
В этой статье я расскажу, как засунуть 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<System.Reflection.AssemblyInformationalVersionAttribute>, false)
|> Array.exactlyOne
|> unbox<System.Reflection.AssemblyInformationalVersionAttribute>
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<Toggl.Api.TimeEntries.GetTimeEntries.Response.Main>(
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<Toggl.Api.TimeEntries.GetTimeEntries.Response.Main>(
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.VersionResponse> TogglTrackFunction.Command.Version
let extendUpToDateV1 =
TogglTrackFunction.Command.ExtendUpToDateV1 {
Username = UserSecrets.username
Password = UserSecrets.password
}
|> sendCommand<TogglTrackFunction.ExtendUpToDateResponse>
let extendUpToDateV2 =
TogglTrackFunction.Command.ExtendUpToDateV2 UserSecrets.credentials
|> sendCommand<TogglTrackFunction.ExtendUpToDateResponse>
run extendUpToDateV2
Итог
В целом этот дилетантский опыт с YcFunctions мне понравился. Вряд ли в ближайшем будущем я буду использовать его в качестве ядра сервера, но сам сервис оказался чрезвычайно удобным для прототипирования быстрорастворимых задач с неопределённой полезностью.
Конкретно у нас YcFunctions метят в зону обитания скриптов на Suave и Fable.Remoting. Они могут быть проще в разработке и хостинге, а с учётом бесплатного лимита и дешевле до поры.
С другой стороны, они находятся слишком далеко от основных ресурсов и несколько уязвимы на стадии прогрева.
Но последнее ощущается общей проблемой F#-либ.
