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

Комментарии (3)


  1. nik_the_spirit
    08.11.2023 11:27

    Загрузка готовых dll не «дыра в системе». Она вроде описана даже в документации в разделе Ручная поставка зависимостей.


    1. kleidemos
      08.11.2023 11:27

      В оригинале данное словосочетание также закавычено. Я уверен, что оно было применено в контексте ложного тезиса "dotnet = C#". Например указанный раздел документации находится в главе "Сборка и управление зависимостями функции на С#"..


    1. StrigoEnTurbano Автор
      08.11.2023 11:27

      "Дыра в системе" - это не про саму возможность загружать dll, а про то, что в dll можно засунуть проект на F# и скормить её YandexFunctions.