В этой статье я расскажу, как засунуть 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#-либ.