Сервер Юк. Заставляем Yandex Cloud Functions работать на F#
В этой статье я расскажу, как засунуть F# в Yandex Cloud Functions. Навыка работы с Serverless у меня нет, так что это будет не компиляция моего опыта, а отчет о вполне успешном эксперименте.
Судя по всему, разработчики Yandex Cloud Functions считают, что dotnet = C#
.
Поэтому документация для dotnet
написана только c позиции C#-разработчика.
О том, что делать F#-разрабу — ни слова.
Однако это не означает, что это невозможно в принципе.
Это интересно:
Yandex Cloud Functions сам Yandex сокращает до YcFunctions. Мы так и не поняли, как это произносить («ЫыыК», «ЮююК», «Яяяк», «ИгреК»). Однако сочетание «юк» в устах татарина не просто звук, а слово, в переводе означающее «нет». В преимущественно тюркской команде это стало мемом, особенно в свете ServerLess, который у нас теперь иначе, как Сервер Юк, не называют.
Так что, если у кого-то есть проблемы с «голосом в голове», то он может взять на вооружение ЮкFunctions
, Юк Функции
и т.д.
Интерфейс YcFunctions позволяет редактировать .cs
файлы прямо на сайте в их псевдопроекте, либо скармливать свой проект в виде архива.
Также в виде архива можно загружать пачки готовых .dll
. Именно этой «дырой в системе» я и воспользовался. Мы соберем F#-проект в .dll
и скормим их YcFunctions.
Что делаем?
В нашей команде некоторые юзают TogglTrack. Это очень простой трекер времени. Его не выбирали по совокупности достоинств, просто так сложилось исторически. Актуальной версии клиентского API под dotnet он не имеет, но при этом используется в нескольких внутренних ботах и приложениях. Часть функций в них пересекается.
Например, часто случается, что останавливаешь трекер, а потом 20 минут почему-то продолжаешь заниматься той же самой задачей. В этом случае хочется растянуть последнюю запись до текущего момента. Чтобы избавиться от повторений (довольно разномастных), я решил вынести эту функцию в отдельный наномикросервис. Обычно для подобных задач в нашей команде поднимается скрипт на Suave
и Fable.Remoting
. Однако из любопытства мне захотелось запихнуть его во что-нибудь ещё более простое.
В сети полно информации об известных провайдерах бессерверных вычислений от Microsoft, Google и Amazon. Её достаточно даже с позиции F#-only разраба. Но так как с их оплатой сейчас головняк, а потенциально хотелось бы иметь ресурс применимый для наших провинциальных клиентов, то мы естественным образом смотрим в сторону ЮкFunctions. Хоть у них и есть аналоги в РФ, но они, мягко говоря, не горят желанием работать с рядовым разрабом, если за ним не стоит компания.
Первый шаг
Выдать "Hello World"
при помощи ЮкФункций можно с полпинка. Через интерфейс сайта можно выбрать вашу платформу, после чего откроется минимально готовый проект на C#. Его можно сразу же запустить или подправить в том же окне. Однако ничего серьёзного там сделать нельзя.
Для начала я создал пустую C#-библиотеку, перенёс туда код с сайта, собрал его, упаковал в архив и закинул обратно, дабы убедиться, что ничего скрытого там нет. После этого я добавил в решение F#-проект, сослался на него и вызвал Say.hello
функцию в теле C#-хендлера:
namespace FSharpDomain
module Say =
let hello name =
sprintf "Hello %s" name
public class Handler
{
public Response FunctionHandler(Request request)
{
return new Response(200, FSharpDomain.Say.hello(request.body));
}
}
После загрузки в облако YcFunctions отработал без проблем. Далее я заменил C#-типы на аналогичные F#-рекорды:
type Request = {
httpMethod : string
body : string
}
type Response = {
StatusCode : int
Body : string
}
public class Handler
{
public FSharpDomain.Yc.Response FunctionHandler(FSharpDomain.Yc.Request request)
{
return new FSharpDomain.Yc.Response(200, FSharpDomain.Say.hello(request.body));
}
}
И сериализатор YcFunctions смог их переварить.
Затем в .dll
был собран проект, написанный только на F#.
Обращаю ваше внимание на то, что в интерфейсе YcFunctions нужно подправить точку входа функции на TogglTrackFunction.Handler
:
Это полное имя типа, но первое слово до точки должно совпадать с именем .dll
.
Немного неудобно, но терпимо:
type Handler () =
member this.FunctionHandler (request : Request) =
{
StatusCode = 200
Body = FSharpDomain.Say.hello request.body
}
И — о чудо! Сервис спокойно съел его, то есть мы можем работать только с F# кодом.
Дальше я попытался сделать FunctionHandler
асинхронной функцией, и для этого завернул тело функции в билдер task
. Тут Юкфункции дали сбой: они не поняли, что метод стал асинхронным. В результате они моментально возвращали пустое тело. Я пока не понял с чем это связано и как это лечить, но скорее всего C# добавляет какую-то метаинформацию к асинхронным методам, которую не добавляет F#. Из-за этого рефлексия YcFunctions не справляется с задачей. В моем случае можно грубо вызвать синхронное выполнение для асинхронных задач. В будущем, если проблема не решится — можно вернуть C# проект и дергать F# из него. Однако перед этим я бы разобрался с механизмом реакции на увеличение нагрузки.
Канон
Здесь можно подвести промежуточный итог и зафиксировать код минимальной версии функции на F#:
type Request = {
httpMethod : string
body : string
}
type Response = {
StatusCode : int
Body : string
}
type Handler () =
member this.FunctionHandler (request : Request) : Response = ...
Представленный тип Request
отображает не все возможные поля. Полный их перечень можно найти здесь.
На этом «обязательная» часть заканчивается. И дальше будет описание примера, которое писалось с прицелом на начинающих или скучающих F#-разрабов.
Упрощение процесса сборки
Для того, чтобы не заниматься сборкой и архивированием решения после каждого изменения вручную, я написал небольшой скрипт для interactive
, который будет делать это за нас в полуавтоматическом режиме:
let src = System.IO.Path.Combine(__SOURCE_DIRECTORY__, "..")
let published = System.IO.Path.Combine(src, """bin\Release\net6.0\publish""")
let zip = System.IO.Path.ChangeExtension(published, "zip")
System.Diagnostics.ProcessStartInfo(
"dotnet"
, $"""publish --configuration Release --version-suffix {System.DateTime.UtcNow.ToString "yyyyMMdd-HHmmss"}"""
, WorkingDirectory = src
)
|> System.Diagnostics.Process.Start
|> fun p -> p.WaitForExit()
if System.IO.File.Exists zip then
System.IO.File.Delete zip
System.IO.Compression.ZipFile.CreateFromDirectory(published, zip)
Вообще, вроде, у YcFunctions есть gRPC API для загрузки полученного архива «прямо из кода», но я им пока плотно не занимался.
Сторонний код
В коде будут использоваться четыре сторонних пакета:
Thoth.Json.Net
— сериализатор, который пришёл к нам из мираFable
. У него комфортный API и вменяемые дефолты.Hopac
— библиотека для работы с асинхронщиной, которая используется нами вместоasync
иTask
. В рамках задачи какой-либо особой необходимости в нем нет, просто я уже привык.Http.fs
— обертка надHttpClient
, ориентированная на F#. Из плюсов иммутабельные реквесты и дружба с(|>)
и сHopac
.FsToolkit.ErrorHandling.JobResult
— пакет, который даст нам билдерjobResult
. Подавляющая часть наших действий будет происходить в его контексте. Стоит уточнить, что будет использоваться версия2.10.0
, потому что все последующие завязаны на версиюHopac 0.5.1
, которая по нашему опыту иногда стреляет.
DTO для коммуникации с TogglTrack
я спер из статьи по кодогену ув. Kleidemos, которая то ли выйдет, то ли нет в ближайшем будущем. Главное, что я воспользовался «внутренней информацией» и получил Thoth
-friendly DTO из генератора. Их потребуется всего две штуки: для получения списка и обновления TimeEntry
(запись времени).
Приступаем к функции
Первым делом выдадим наружу системную информацию. Swagger здесь прикручивать избыточно. Но в перспективе можно выдать информацию о типах в виде исходного .fs
файла, мы так делаем в скриптах. Здесь ограничимся версией запускаемой .dll
.
Я не буду повторять стандартную REST структуру, а буду работать с ЮкФункцией, как с актором без состояния. Мы будем ждать на вход сообщение в виде DU и реагировать соответствующим образом в респонсе. Так будут выглядеть сообщение на вход (Command
) и ответ на запрос версии (VersionResponse
):
type VersionResponse = {
Version : string
}
type Command =
| Version
Здесь нет связки вход-выход (как в Fable.Remoting
). Я вроде знаю, как ее сделать, но это будет слишком большое отклонение от темы.
Целиком код обработчика будет выглядеть так:
module Handle =
let fromCommand cmd = jobResult {
match cmd with
| Command.Version ->
let version =
System.Reflection.Assembly.GetCallingAssembly()
.GetCustomAttributes(typeof, false)
|> Array.exactlyOne
|> unbox
return Response.ok {
VersionResponse.Version = version.InformationalVersion
}
let fromRequestBody request = jobResult {
let! command =
Thoth.Json.Net.Decode.Auto.fromString request
return! fromCommand command
}
type Handler () =
member this.FunctionHandler (request : Request) = run ^ job {
match! Handle.fromRequestBody request.body with
| Error err ->
return {
StatusCode = 400
Body = err
}
| Ok response ->
return response
}
Здесь в одном месте мы принимаем запрос, десериализуем его тело и формируем ответ.
В теории Яндекс поддерживает автоматическую десериализацию, и мы могли бы принимать сразу Command
. Однако практика отучила нас от встроенных сериализаторов. Если этот процесс и автоматизируется, то это происходит на нашей территории. В следующих примерах я опущу десериализацию и проблемы с ней. И буду описывать реакцию только на валидные запросы.
Мнение старейшин о присутствие DU в DTO
Из-за ограниченной поддержки DU другими языками их обычно не используют в REST API, а также в сходных средах, типа баз данных и т. п.
Это оправданная стратегия при работе с публичными контрактами.
Однако в случаях, когда F# находится на обоих концах системы, это ограничение генерирует слишком много шума.
К тому же API с использованием DU развивается в быту сильно быстрее, чем классическое.
Поэтому на практике, в таких случаях дешевле держать два API.
Одно обычное и одно условно «внутреннее» для F#.
Термин закавычен, потому что в действительности нет особой необходимости прятать его от внешнего мира, кроме как по требованию бизнес-логики.
Если проект не испытывает проблем с перфом, то обычное API реализуется как надстройка над внутренним.
Делать это там же или через отдельное приложение, решается по ситуации.
Может показаться, что DU вместо путей даёт какие-то серьёзные преимущества при работе с акторной моделью, но это не так.
При необходимости актор тривиально спрячется за любым фасадом.
Конкретно здесь предпочтение DU было дано а) по инерции, как стандартный способ взаимодействия слишком отдалённых ресурсов и б) как способ, который не так часто встречается в литературе.
Отсутствие местного аналога AsyncReplyChannel
серьёзно смазывает эффект, ибо вынуждает руками типизировать ответы на клиенте, так что этот вопрос придётся осветить в будущем отдельно.
В остальном общий ход повествования похож на то, что требуется нам на стадии разработки (с поправкой на масштаб проблем).
Закругляя, скажу, что «магия» контрактов, типа контроллеров в ASP.NET
и привязок в WPF
, вырабатывает привычку к иррациональной сакрализации подобных схем.
В результате при столкновении с проблемой вместо быстрой выработки альтернативного решения устраиваются многомесячные танцы с бубном в попытке вернуть расположение древних богов.
С моей точки зрения, столь малые функции предпочтительнее затачивать под конкретный клиент, как на уровне инфраструктуры, так и на уровне бизнес-логики.
Тащить сюда что-то потяжелее можно, особенно, если вы не дружите со скриптами, но это должен быть осознанный шаг.
Последний TimeEntry
Сначала добудем список TimeEntry
. Для его получения нужны логин и пароль пользователя (TogglCredentials
). Функция у нас не привязана к конкретному пользователю, поэтому эту информацию надо получить из соответствующего кейса Command
:
type TogglCredentials = {
Username : string
Password : string
}
type TimeEntry = {
Description : string option
Duration : int
Start : System.DateTime
Id : int64
ProjectId : int option
}
type LastTimeEntryResponse = TimeEntry
type Command =
| Version
| GetLastTimeEntryV1 of TogglCredentials
Наличие суффикса версии в GetLastTimeEntryV1
вызвано соображениями обратной совместимости.
Мы применяем этот механизм при соединении модулей, которые могут развиваться слишком независимо, чтобы клиенты своевременно получали обновления.
В REST аналогичную проблему решают через соответствующий токен в пути, но у нас нет ни необходимости, ни желания перевыпускать всё API (по крайней мере, пока не достигнем GetLastTimeEntryV9
).
Будь мы серьёзными ребятами, мы бы создали полноценный клиент для TogglTrack
, но в нашем случае TogglCredentials
живёт доли секунды, поэтому ограничимся TypeExtensions
-методом над логин-паролем. Получение списка будет выглядеть как-то так:
let appJson =
ContentType.create("application", "json")
|> Client.RequestHeader.ContentType
type TogglCredentials with
member this.GetTimeEntries =
// HttpFs
Client.Request.createUrl Client.HttpMethod.Get "https://api.track.toggl.com/api/v9/me/time_entries"
|> Client.Request.setHeader appJson
|> Client.Request.basicAuthentication this.Username this.Password
|> Client.getResponse
// Hopac
|> Job.bind ^ Client.Response.readBodyAsString
// Thoth.Json.Net
|> Job.map ^ Thoth.Json.Net.Decode.fromString(
Decode.Auto.generateDecoder(
extra = Extra.withInt64 Extra.empty
)
)
// FsToolkit.ErrorHandling.JobResult
member this.GetLastTimeEntry = jobResult {
match! this.GetTimeEntries with
| last :: _ -> return last
| [] -> return! Error "List of time entries is empty."
}
В этом случае обработчик GetLastTimeEntryV1
сведётся к такому коду.
| Command.GetLastTimeEntryV1 credentials ->
let! last = credentials.GetLastTimeEntry
return Response.ok {
LastTimeEntryResponse.Description = last.description
Duration = last.duration
Start = last.start
ProjectId = last.project_id
Id = last.id
}
С этого момента придётся увеличить таймаут этой функции до трех секунд, в противном случае она будет возвращать ошибку.
Если запустить тот же метод локально, то в самом худшем случае он уложится в 800 мс, а на практике раза в 2–3 быстрее.
С чем связана такая большая разница, мне пока не ясно.
Обновление TimeEntry
Добавим ещё один кейс в Command
:
type Command =
| Version
| GetLastTimeEntryV1 of TogglCredentials
| ExtendUpToDateV1 of TogglCredentials
type ExtendUpToDateResponse = TimeEntry
И ещё один метод в TogglCredentials
. Он практически идентичен предыдущему и представляет интерес только из-за заполненного responseBody
и прокидывания ответа в результат:
type TogglCredentials with
member this.UpdateTimeEntry
(workspaceId : int)
(timeEntryId : int64)
(updateTimeEntry : Toggl.Api.TimeEntries.UpdateTimeEntry.Request.MainItem) = jobResult {
let! response =
$"https://api.track.toggl.com/api/v9/workspaces/{workspaceId}/time_entries/{timeEntryId}"
|> Client.Request.createUrl Client.HttpMethod.Put
|> Client.Request.setHeader appJson
|> Client.Request.bodyString (
Thoth.Json.Net.Encode.Auto.toString(
updateTimeEntry
, extra = Extra.withInt64 Extra.empty
)
)
|> Client.Request.basicAuthentication this.Username this.Password
|> Client.getResponse
let! body = Client.Response.readBodyAsString response
if response.statusCode >= 300 then
do! Error body
}
Для удобства добавим метод, преобразовывающий одну ДТОшку в другую:
type Toggl.Api.TimeEntries.GetTimeEntries.Response.MainItem with
member this.AsUpdate () : Toggl.Api.TimeEntries.UpdateTimeEntry.Request.MainItem = {
billable = this.billable
created_with = None
description = this.description
duration = this.duration
duronly = None
project_id = this.project_id
start = this.start
start_date = None
stop = this.stop
tag_action = ""
tag_ids = this.tag_ids
tags = this.tags
task_id = this.task_id
user_id = this.user_id
workspace_id = this.workspace_id
}
В качестве ответа получим обновленную запись:
| Ok (Command.ExtendUpToDateV1 credentials) ->
let! last = credentials.GetLastTimeEntry
if last.duration < 0 then
do! Error "Time Entry is not finished"
let updateTimeEntry =
let utcNow = System.DateTime.UtcNow
{ last.AsUpdate() with
duration = int (utcNow.Subtract last.start).TotalSeconds
stop = Some utcNow
}
do! credentials.UpdateTimeEntry last.workspace_id last.id updateTimeEntry
return Response.ok {
ExtendUpToDateResponse.Description = last.description
Duration = last.duration
Start = last.start
ProjectId = last.project_id
Id = last.id
}
Поддержка эволюции
В TogglTrack
есть два варианта авторизации:
«Очевидный» через логин и пароль.
«Хитрый» через токен. Его можно перевыпустить, не изменяя пароля, при этом обладатели старого токена потеряют доступ.
Старую пару логин-пароль мы выносим в отдельный тип FullCredentials
, а TogglCredentials
превращаем в DU из двух кейсов:
type FullCredentials = {
Username : string
Password : string
}
type TogglCredentials =
| Full of FullCredentials
| Token of string
Старые команды на уровне рефлексии ожидают рекорд с полями Username
и Password
. Мы заменим их тип на FullCredentials
, чтобы не ломать совместимость. А для новых версий продублируем ещё два кейса от нового TogglCredentials
:
type Command =
| Version
| GetLastTimeEntryV1 of FullCredentials
| GetLastTimeEntryV2 of TogglCredentials
| ExtendUpToDateV1 of FullCredentials
| ExtendUpToDateV2 of TogglCredentials
Аутентификацию в Client
придётся переписать. Мы добавим метод Authenticate
, который снабжает Client.Request
нужным заголовком.
После чего воспользуемся им в остальных методах.
type TogglCredentials with
member this.Authenticate =
match this with
| TogglCredentials.Full credentials -> credentials.Username, credentials.Password
| TogglCredentials.Token token -> token, "api_token"
||> Client.Request.basicAuthentication
member this.GetTimeEntries =
Client.Request.createUrl Client.HttpMethod.Get "https://api.track.toggl.com/api/v9/me/time_entries"
|> Client.Request.setHeader appJson
|> this.Authenticate
|> Client.getResponse
|> Job.bind ^ Client.Response.readBodyAsString
|> Job.map ^ Thoth.Json.Net.Decode.fromString(
Decode.Auto.generateDecoder(
extra = Extra.withInt64 Extra.empty
)
)
// this.UpdateTimeEntry модифицируется по аналогии.
Благодаря активному шаблону можно свести две версии одной команды к одному обработчику.
На практике это не всегда возможно, но здесь разница между версиями заканчивается на входных данных.
let (|FromFull|) (credentials : FullCredentials) = Full credentials
| Command.GetLastTimeEntryV1 (FromFull credentials)
| Command.GetLastTimeEntryV2 credentials ->
...
| Ok (Command.ExtendUpToDateV1 (FromFull credentials))
| Ok (Command.ExtendUpToDateV2 credentials) ->
На этом код сервера исчерпан.
Найти его можно здесь.
Клиентский код
Больше всего на клиент оказывает влияние пользовательский интерфейс, но скриптовый вариант на базе того же Http.fs
лежит в Scripts/RequestScript.fsx
.
Чисто в качестве примера:
let sendCommandRaw (command : TogglTrackFunction.Command) =
Client.Request.createUrl Client.HttpMethod.Post UserSecrets.url
|> Client.Request.setHeader appJson
|> Client.Request.bodyString ^ Encode.Auto.toString command
|> Client.getResponse
|> Job.bind Client.Response.readBodyAsString
let sendCommand<'response> command job {
let! response = sendCommandRaw command
return Decode.Auto.fromString<'response>(
response
, extra = Extra.withInt64 Extra.empty
)
}
let getVersion =
sendCommand TogglTrackFunction.Command.Version
let extendUpToDateV1 =
TogglTrackFunction.Command.ExtendUpToDateV1 {
Username = UserSecrets.username
Password = UserSecrets.password
}
|> sendCommand
let extendUpToDateV2 =
TogglTrackFunction.Command.ExtendUpToDateV2 UserSecrets.credentials
|> sendCommand
run extendUpToDateV2
Итог
В целом этот дилетантский опыт с YcFunctions мне понравился. Вряд ли в ближайшем будущем я буду использовать его в качестве ядра сервера, но сам сервис оказался чрезвычайно удобным для прототипирования быстрорастворимых задач с неопределённой полезностью.
Конкретно у нас YcFunctions
метят в зону обитания скриптов на Suave
и Fable.Remoting
. Они могут быть проще в разработке и хостинге, а с учётом бесплатного лимита и дешевле до поры.
С другой стороны, они находятся слишком далеко от основных ресурсов и несколько уязвимы на стадии прогрева.
Но последнее ощущается общей проблемой F#-либ.