Когда-либо давал. F#-сервер из скрипта на примере локального брокера сообщений

3bc55ed8a0db28e6480fb30c4e605d06.jpg

В ходе разработки на F# поднимать локальные web-серверы приходится гораздо чаще, чем это принято на C#. Связано это с большим количеством нехарактерных для C# активностей. То, что в C# делают плагины для IDE, у нас делают скрипты, причём их сферы ответственности пересекаются где-то наполовину. Если не понимать этого аспекта, то можно навечно увязнуть в ситуации перманентного нытья о недостаточной поддержке F# со стороны MS.

В этой статье я расскажу про устойчивую комбинацию из Suave, Fable.Remoting и Hopac, которая может стать для вас молотком универсальным решением для реализации локальных служебных серверов. Я не буду углубляться в особенности бизнес-логики таких скриптов или в их взаимосвязи, об этом постепенно расскажет @Kleidemos (он также был задействован в особо мутных частях статьи).

В рамках статьи нас интересует:

  • Как запускать серверы из скриптов, избегая полноценных проектов (Suave).

  • Как комфортно распространять контракт API от сервера к клиенту (Fable.Remoting).

  • Как управлять одним ресурсом из нескольких источников через актор (Hopac).

  • Как «моментально» получать информацию об изменении наблюдаемого ресурса.

Если описать это в более «физических» терминах, то нас интересует: как на Hopac написать актор вокруг ресурса, засунуть его в Fable.Remoting и Suave и как ко всему этому прикрутить long polling.

Однако в качестве точки опоры необходима какая-то предметная задача, и для неё была выбрана нарочито упрощённая локальная очередь сообщений. Каждый умеет писать if-ы, поиск по словарю, сохранение в БД и т. д., поэтому большую часть этих пунктов я даже не добавлю в код. Но всё же очередь будет иметь некоторые нестандартные особенности.

Что конкретно будем делать?

Нам нужно сделать сервер, который будет держать очередь (обычно расположенную между двумя другими скриптами). В данную очередь можно отправлять новые сообщения и получать их по запросу. При этом предполагается, что потребитель (consumer или «обработчик») может часто «умирать» и перезапускаться. Поэтому сервер должен хранить последнее отправленное сообщение на случай повторного запроса. То есть сообщение под номером N будет удаляться только после отправки потребителю сообщения N + 1.

В скриптах для серверов мы используем Suave, так как это чуть ли не единственный сервер, не требующий полноценного приложения для своей работы (как, например, ASP.NET). В Suave есть собственный синтаксис для описания путей и методов, но он нам не понадобится, потому что всю инфраструктурную часть API мы отдадим Fable.Remoting. Это набор библиотек, которые обеспечивают условно бесшовную типобезопасную связь между сервером и клиентом. В нашем случае на стороне сервера используется (с оговорками) адаптер Fable.Remoting.Suave, а на стороне клиента — Fable.Remoting.DotnetClient.

Замечания о коде

Я адепт «крышечки». Этот оператор экономит некоторое количество скобочек за счёт игр с приоритетом выполнения. Для того чтобы код работал, вам потребуется определить:

let inline (^) f x = f x

Скрипт клиента работает на:

#r "nuget: Fable.Remoting.DotnetClient, 3.31.0"
#r "nuget: Hopac, 0.5.0"

Скрипт сервера работает на:

#r "nuget: Fable.Remoting.Suave, 4.36.0"
#r "nuget: Hopac, 0.5.0"
#r "nuget: FSharpx.Collections, 3.1.0"

Контракт API в Fable.Remoting

Описание контракта для Fable.Remoting напоминает описание контроллеров для ASP.NET MVC. Но только в MVC пишется конкретный тип контроллера с встроенной имплементацией методов, и сам контроллер вряд ли в каком-то виде появится на клиенте. А в Fable.Remoting описывается контракт в виде рекорда, все поля которого должны быть функциями вида .. -> 'a Async, после чего этот рекорд будет использоваться и там, и там.

На сервере данный контракт надо будет реализовать в экземпляре самостоятельно, после чего его можно будет встроить в конкретную платформу при помощи соответствующего адаптера. В случае Suave мы получим нужный WebPart так:

let webApp = // : WebPart 
    Remoting.createApi()
    // createApi : unit -> ITopicApi
    |> Remoting.fromValue (createApi()) 
    |> Remoting.buildWebPart

Аналоги есть для Giraffe и Saturn, но если взглянуть на их устройство, то становится ясно, что адаптер можно написать самостоятельно за вечер.

На клиенте рекорд создаётся автоматически на основе адреса и типа:

let client = // : string ITopicApi
    Remoting.createApi "http://127.0.0.1:8080"
    |> Remoting.buildProxy>

После чего Fable.Remoting.DotnetClient при любом вызове client.Add будет самостоятельно бегать на сервер и разруливать инфраструктурный шум независимо от нас.

Fable.Remoting имеет мало настроек, не относящихся непосредственно к рекорду с API. Можно включить бинарную сериализацию, чуть иначе построить пути, и, наверное, на этом всё. Так как эти настройки недоступны в виде метадаты, их необходимо будет продублировать не только на сервере, но и на всех клиентах. Настроить детали сериализации конкретных типов нельзя (предположительно из-за особенностей хранения типов в JS). Так что если такая необходимость возникает, то лучше выстроить дополнительный слой с DTO своими силами.

Контракт очереди/топика

Тут надо определиться с терминологией. В процессе объяснения мы будем касаться трех очередей:

  • глобальной очереди, которой посвящена статья,

  • очереди как структуры данных,

  • очереди как явление в MailBox и Ch в Hopac.

Чтобы в них не запутаться, глобальную очередь решено было называть топиком. Если я понимаю правильно, это не совсем верное наименование, но в рамках одной статьи это простительно. Очередь как структура данных будет встречаться крайне редко, и за фактом ее появления можно будет проследить. Остальные очереди будут касаться Hopac-а.

Весь контракт будет описан одним маленьким файлом.

Message — хранимое в топике сообщение, содержит только свой идентификатор и обобщённый контент.TimeStamp и другие полезные вещи могут быть добавлены по аналогии, поэтому их мы опустим:

type MessageId = int

type 'content Message = {
    Content : 'content
    Id : MessageId
}

Потребитель может запросить либо следующий элемент топика, либо попросить повторить последнюю посылку.

type ConsumerRequest = 
    | Repeat
    | Next

Мы пишем очередь как смычку между активно разрабатываемыми скриптами, поэтому Repeat нужен в первую очередь обработчику, что столкнулся с проблемой на последнем сообщении, умер, был исправлен и только что поднят снова. Однако прикручивать к обработчику информацию о том, умер он ранее, или это его первая (ре)инкарнация — это ещё солидный кусок логики, поэтому все обработчики будут начинать общение с Repeat. Считайте это вариантом приветствия, отсутствие которого может в некоторых случаях трактоваться как подозрительная активность (ниже). В более серьёзных версиях очередей Next имеет смысл увязать с идентификаторами сообщения, а весь запрос с идентификатором consumer-а, чтобы точно понимать, кто и что хотел запросить. Это добавит несколько дополнительных предикатов и словарей, но не повлияет радикально на суть происходящего, так что эту опцию мы также опустим.

Если серверу есть что ответить, то он вернет Ready с искомым сообщением. В противном случае сервер попытается в течение минуты дождаться нового сообщения и передать его дальше. Но если время истечет, а нового месседжа так и не появится, то сервер вернет Pending. В особом случае, если топик убили в момент ожидания, сервер вернет Shutdown:

type 'content Reply =
    | Ready of 'content Message
    | Pending
    | Shutdown

Предполагается, что после Pending обработчик вернется с новым запросом. Сценарий после Shutdown сильно сложнее и полностью зависит от конкретной реакции на смерть топика.

Наконец сам контракт для Fable.Remoting поддерживает методы добавления/получения сообщения и отключения сервера:

type 'content ITopicApi = {
    Add : 'content -> MessageId Async
    TryGet : ConsumerRequest -> 'content Reply Async
    Shutdown : unit -> unit Async
}

Реализация топика

Фаза, когда сервер получил запрос от консьюмера, который он пока не может удовлетворить, предполагает параллельное ожидание нового сообщения от продюсера. Это наверняка можно сделать на стандартных примитивах синхронизации, но в нашей обители привыкли к Hopac, а на нём эта задача выражается в виде череды предсказуемых операций.

Для тех, кто не знаком с Hopac

Hopac — это большая библиотека, и если вы с ней вообще не знакомы, то дальнейший код будет полон магии. Для его восприятия необходимо понимать устройство альтернатив (Alt). Исчерпывающе они разбирались вот в этом цикле статей (часть 2 и часть 3). Он был написан специально, чтобы нам не приходилось подробно объяснять читателю устройство Alt где-либо ещё.

Но сокращённо можно описать их следующим образом:

  • 'a Alt является наследником 'a Job.

  • Внутри себя Alt делится на три фазы:

    • Подготовка и проверка условий выполнения.

    • Коммит, который может сработать, если условия удовлетворены.

    • Последствия, которые могут наступить только после коммита.

  • Несколько Alt можно противопоставить друг другу при помощи Alt.choose (и получить новый Alt). В этом случае только один из Alt сможет произвести коммит и разыграть последствия.

  • Расположение альтернатив внутри Alt.choose важно, так как между двумя готовыми альтернативами будет выбрана та, что находится выше по списку.

Операторы (^=>), (^->), (^=>.) и т. д. цепочкой добавляют последствия в Alt (что-то вроде Alt.mapFun и Alt.mapJob). Эти операторы в связке с Alt.choose можно воспринимать как альтернативный match .. with с ->, где на каждое возможное событие слева предполагается своя реакция справа.

Реализация ITopicApi

В начале определим три точки входа для нашего контракта:

// unit IVar
let shutdown = IVar()

// Mailbox<'a * (MessageId -> unit Job)>
let producers = Mailbox()

// Mailbox unit Job)>
let consumers = Mailbox()

Они нужны для непосредственной реализации методов API:

// Mailbox<'input, 'output -> unit Job> -> 'input -> 'output Async
let exchangeWith mailbox input = Job.toAsync ^ job {
    let reply = IVar()
    do! Mailbox.send mailbox (input, IVar.tryFill reply)
    return! reply
}

{
    Shutdown = fun () -> 
        IVar.tryFill shutdown ()
        |> Job.toAsync 
    Add = exchangeWith producers
    TryGet = exchangeWith consumers
}

С Shutdown все довольно просто — в данной функции достаточно заполнить ячейку shutdown, а все зависимые от этого события участники среагируют самостоятельно. Операции Add и TryGet требуют обмена и используют почти стандартный для Hopac механизм. В актор посылаются входная информация и функция, в которую актор должен загрузить свой ответ. Эта функция заполнит ячейку reply, а оттуда её содержимое будет передано наружу. Таким образом итоговая функция приобретёт вид 'input -> 'output Async.

Ждём первое сообщение

При запуске топик пуст, поэтому на старте его актор будет вести себя немного иным образом. У него будут три действительно важных альтернативы:

let waitFirst = Alt.choose [
    shutdown ^=>. Job.abort ()
    producers ^=> ...
    consumers ^=> ...
]

shutdown приведёт к смерти актора (Poison Pill).

producers создаст первое сообщение и триггернёт ожидающего (если есть). После чего сервер перейдёт в основной режим работы, имея на руках «текущее» сообщение, его идентификатор и пустую очередь:

producers ^=> fun (content, reply) -> job {
    let current = {
        Message.Id = 1
        Content = content
    }
    do! asJob ^ reply current.Id
    do! tryFixPendedConsumer
    do! Job.iterateServer (current, current.Id, Deque.empty) ^ fun (current, lastId, queue) -> 
            waitNext current lastId queue
        |> Job.start
    return! Job.abort()
}

Очередь последующих сообщений здесь выражена типом 'a Deque, который был взят из FSharpx.Collections. Это иммутабельная очередь, а значит её можно безболезненно передавать между акторами, когда такая потребность возникает. Бонусом идут готовые шаблоны распознавания, которые сильно упрощают жизнь.

consumers отправит консьюмеров ждать новое сообщение через pendConsumer, если они запрашивают Repeat, и Shutdown для остальных:

consumers ^=> fun (request, reply) -> job {
    match request with
    | Repeat -> do! pendConsumer request reply
    | Next -> do! asJob ^ reply Reply.Shutdown
}

Предположительно, если консьюмеры запрашивают Next, то они не получили сообщение о смерти предыдущей версии топика. Если сетевой адрес топика выбирается динамически, то есть вероятность, что новый и старый топики относятся к совершенно различным данным. Fable.Remoting не пересылает сам контракт по сети, только данные. Входные данные у разнотипных топиков имеют одинаковую природу, а значит можно неосторожно передать сообщение, которое на другом конце «провода» не может быть интерпретировано. Исходя из этого, имеет смысл в ответ на Next отвечать Shutdown. Это должно привести к перенастройке потребителя. Не самое красивое решение, но оно является следствием упрощения на прошлом этапе. В более развитых системах у сервера есть понимание, кто его запрашивает, а значит неизвестный консьюмер будет отсечён более проработанным образом.

Основной режим работы

waitNext отвечает за основной цикл актора. Выйти из него можно только через смерть по shutdown. Состояние его выражено через три параметра current : Message, lastId : MessageId и queue : Message Deque.

let waitNext current lastId queue = Alt.choose [
    // Позволит перейти на новую итерацию с неизменным состоянием актора.
    let proceed = current, lastId, queue
        
    fixedPendedConsumers ^=> ...
    shutdown ^=>. Job.abort ()
    producers ^=> ...
    consumers ^=> ...
]

Первую позицию в списке альтернатив занимает fixedPendedConsumers:

// Mailbox unit Job)>
let fixedPendedConsumers = Mailbox()

Этот примитив нельзя зацепить напрямую из внешнего источника, он только для внутреннего использования актора. Сигнатурно это полная копия consumers. Оба ящика будут содержать одни и те же элементы, но на разных стадиях цикла. В fixedPendedConsumers будут находиться только те консьюмеры, что не смогли сразу получить сообщение, отстояли очередь, дождались прихода нового сообщения и теперь готовы его получить:

fixedPendedConsumers ^=> fun (request, reply) -> job {
    match request, queue with 
    | Next, Deque.Cons (next, queue) ->
        do! asJob ^ reply ^ Reply.Ready next
        return next, lastId, queue
    | _ ->
        do! asJob ^ reply ^ Reply.Ready current
        return proceed
}

Обработка producers изменилась несильно. Теперь новое сообщение добавляется в конец существующей очереди:

producers ^=> fun (content, reply) -> job {
    let lastId = lastId + 1
    let last = {
        Message.Id = lastId
        Content = content
    }
    do! asJob ^ reply last.Id
    do! tryFixPendedConsumer
    return current, lastId, queue.Conj last
} 

Обработка consumers теперь может выдать Ready, если позволит состояние очереди. Причём ответ на Next ещё и изменит состояние актора. В противном случае новый потребитель опять уйдёт в pendConsumer:

consumers ^=> fun (request, reply) -> job {
    match request, queue with 
    | Repeat, _ ->
        do! asJob ^ reply ^ Reply.Ready current
        return proceed
    | Next, Deque.Cons (next, queue) ->
        do! asJob ^ reply ^ Reply.Ready next
        return next, lastId, queue
    | Next, _ ->
        do! pendConsumer request reply
        return proceed
}

Ожидание нового сообщения

Если запрошенного сообщения ещё нет в топике, то запрос от консьюмера переходит в режим ожидания. В этой фазе запрос как бы встаёт в очередь, из которой может выйти в любой момент (например, по таймауту), но только пока в топик не придёт нужное сообщение. Перед тем как получить это сообщение, запрос должен потерять свободу выхода.

Для реализации этого алгоритма не надо делать одну очередь запросов с мутирующим поведением элементов. Надо разделить её на две очереди с разным устройством. Вторую очередь мы уже встречали под именем fixedPendedConsumers. Для реализации первой очереди со свободным выходом в Hopac используется 'a Ch.

let tryFindPendedConsumer, trySubmitPendedConsumer =
    let pended = Ch ()
    Ch.take pended
    , fun request reply -> 
        Ch.give pended (request, reply)

pended представляет собой чистилище, в котором висят отложенные потребители в ожидании новых сообщений. Это единственный Ch на всю систему.

63dd7eb90936b430171cf3901c949fbc.png

Ch редко обладает самостоятельностью. Куда чаще на него смотрят с двух концов системы, что порождает головную боль с подбором имени. Поэтому для хоть какого-то удобства pended был сразу разбит на два элемента:

  • tryFindPendedConsumer = Ch.take pended — альтернатива, реализация которой означает, что ожидающий консьюмер был захвачен.

  • trySubmitPendedConsumer = Ch.give pended ..  — функция, которую надо вызывать, чтобы выставить потребителя в качестве кандидата на сообщение. Функция вернёт альтернативу, реализация которой означает, что запрос потребителя будет удовлетворён.

trySubmitPendedConsumer внутри pendConsumer противопоставляется таймауту запроса и смерти топика.

let pendConsumer request reply = Job.start ^ Alt.choose [
    shutdown ^=>. reply Reply.Shutdown
    timeOutMillis 60_000 ^=>. reply Reply.Pending
    trySubmitPendedConsumer request reply
]

Причём запускаются данные альтернативы в отдельном потоке. В противном случае pendConsumer перевёл весь актор в синхронное ожидание.

С другой стороны канала есть tryFixPendedConsumer. Он должен найти и зафиксировать свободный pendedConsumer. В коде это выглядит как передача pendedConsumer из pended в fixedPendedConsumers:

let tryFixPendedConsumer = Alt.choose [
    tryFindPendedConsumer ^=> // fun pendedConsumer ->
        Mailbox.send fixedPendedConsumers // pendedConsumer

    Alt.unit()
]

Если pended пуст в момент вызова tryFindPendedConsumer, то tryFixPendedConsumer моментально уйдёт в ветку Alt.unit().

Весь этот параграф с точки зрения исходной задачи обеспечивает работу long polling, но на самом деле описываемый подход имеет куда более широкое применение. Отсюда может быть трудно в это поверить, но связка Ch.take ^=> Mailbox.send при всей своей синтаксической простоте является одним из наиболее удобных и регулярных паттернов Hopac. Она позволяет синхронизировать неконтролируемое скопление внешних игроков и внутренний ресурс подопытного актора только тогда, когда это необходимо. Никакой волокиты с проверкой cancellationToken-ов или ручной фильтрацией протухших элементов с многократными попытками взятия. Только код по делу.

Сумма частей

Последовательность при описании частей актора несколько отличается от их действительного положения в коде. Изменения были произведены с целью упростить повествование. Всё вышеописанное находится в функции createApi<'a> : unit -> 'a ITopicApi:

a769a0e0534c18d3e773b47074c6137d.png

Сам актор начнёт работать только после вызова следующего кода:

Job.foreverServer waitFirst
|> start

В нашем случае гопаковский сервер стартует внутри функции. Однако этот вызов при необходимости может быть вытащен наружу, в том числе и в ITopicApi.

Запуск Suave-сервера параллельно с REPL сессией

startWebServer — стандартная для Suave функция запуска сервера. Она синхронна, и при запуске в REPL она не отпустит вас, пока сервер не будет остановлен извне.
Поэтому вместо синхронной версии лучше использовать startWebServerAsync. Эта функция принимает те же самые SuaveConfig и WebPart, но вместо unit возвращает два Async.

let cts = new System.Threading.CancellationTokenSource()

choose [
    Remoting.createApi()
    |> Remoting.fromValue (createApi())
    |> Remoting.buildWebPart
]
|> startWebServerAsync { defaultConfig with cancellationToken = cts.Token }
||> fun startedData wait ->
    async {
        let! startedData = startedData
        for item in Seq.catOptions startedData do
            printfn "%A" item.binding.endpoint
    }
    |> Async.Start
    Async.Start(
        wait
        , cts.Token
    )

startedData — возвращает массив данных обо всех адресах, на которых был запущен сервер. Вы можете прописать их при запуске в поле bindings у SuaveConfig (если попросить порт = 0, то ось выдаст свободный порт динамически). Эта информация дублируется в логах, но оттуда их нельзя использовать как полноценные объекты. Как правило, порты назначаются динамически и именно отсюда их можно передать в местный менеджер ресурсов (обычно такой же сервер на Suave).

wait — это полный жизненный цикл сервера. Пока вы его не стартанете, сервер не запустится. Мы запускаем wait с токеном, так что данный wait завершится либо из-за исключения, либо из-за вызова cts.Cancel(). Оба варианта будут означать остановку сервера. Отключение токена в SuaveConfig почему-то не касается поведения wait, но мы всё равно его туда передаём на всякий случай. Если сервер существует только ради топика, имеет смысл завязать shutdown и токен друг на друга.

Клиент

let client =
    Remoting.createApi "http://127.0.0.1:8080"
    |> Remoting.buildProxy>

client.Add "Hello world"
|> Async.RunSynchronously // : MessageId

async {
    // Может прождать минуту, если топик пуст.
    let! response = // : string Message Reply
        client.TryGet ConsumerRequest.Repeat
    printfn "%A" response
}
|> Async.Start

У клиента, созданного через Fable.Remoting, есть одна неприятная особенность. Async-и, полученные при вызове его методов, мемоизируют свой результат, то есть ведут себя как гопаковские Promise, но не говорят об этом в сигнатуре. Это непривычное поведение, поэтому лучше либо не замыкать, либо не передавать эти Async куда-либо ещё.

// Мемоизированная неявным образом версия.
client.TryGet ConsumerRequest.Next
|> Job.fromAsync

// Правильная немемоизированная версия.
job {
    return! client.TryGet ConsumerRequest.Next
}

// Правильная немемоизированная версия.
async {
    return! client.TryGet ConsumerRequest.Next
}

// Правильно мемоизированная версия.
// : 'a Reply Promise
memo ^ job {
    return! client.TryGet ConsumerRequest.Next
}

Жить с этим можно, но побочным эффектом оказалось, что в API нельзя определять методы с Async «без параметров». То есть придётся преобразовывать 'a Async в unit -> 'a Async.

Потоковый Consumer для гопаководов

Абстрактная обработка топика может выглядеть как-то так:

let iterateTopicViaServer (action : _ -> _ Job) =
    let onShutdown = Job.abort()
    Job.foreverServer ^ job {
        match! client.TryGet ConsumerRequest.Repeat with
        | Reply.Pending -> () 
        | Reply.Ready current ->
            do! action current
            do! Job.start ^ Job.foreverServer ^ job { 
                match! client.TryGet ConsumerRequest.Next with
                | Reply.Pending -> ()
                | Reply.Ready msg -> do! action msg
                | Reply.Shutdown -> do! onShutdown
            }
            do! Job.abort()
        | Reply.Shutdown -> do! onShutdown
    }
    // |> Job.start

Это лишь прообраз обработчика, который можно научить:

  • реагировать на остановку топика;

  • инициировать собственное состояние;

  • и менять его в процессе обработки.

Мы ранее не особо касались темы Hopac.Stream и пока не будем туда углубляться. Но Stream на основе данного топика может быть определён следующим образом:

let iterateTopicViaStreams action =
    job {
        return! client.TryGet ConsumerRequest.Repeat
    }
    |> Stream.indefinitely
    |> Stream.tryPickFun ^ function
        | Reply.Pending -> None
        | reply -> Some reply
    |> Stream.once
    |> Stream.choose
    // Верхняя часть предшествует нижней.
    |> Stream.append <| (
        job {
            return! client.TryGet ConsumerRequest.Next
        }
        |> Stream.indefinitely 
    )
    |> Stream.takeWhileFun ^ function
        | Reply.Shutdown -> false
        | _ -> true
    |> Stream.chooseFun ^ function
        | Reply.Ready msg -> Some msg
        | _ -> None
    |> Stream.consumeJob ^ fun msg -> action msg

Если убрать последнюю строку и передачу action, то данная «функция» создаст 'a Message Stream. Поверх него при помощи модуля Stream можно определить изменение состояния (scan_) и реакцию на смерть топика (doFinalize_ и onClose_). Вариант со Stream погибче и потому предпочтительнее.

Дальше больше

Полученный код топика не является каким-то нерушимым каноном. Я даже думаю, что в таком сверхупрощённом виде он никогда и не использовался. Более сложные топики будут появляться в последующих статьях и не будут разбираться столь подробно (или вообще не будут). Так что каждый разработчик может и должен править приведённый пример под конкретную задачу.

Механизм long polling был представлен и в дальнейшем он не сильно изменится. Склейка API с Hopac также глобально меняться не будет. Больше всего проблем (к счастью, решаемых) доставит совмещение нескольких API на одном сервере.

Мы часто игнорируем некоторые данные в DTO, когда они приходят со стороны сервера. Аналогичным образом мы можем игнорировать методы в API. Fable.Remoting не передаёт контракт по сети, как это может происходить в Orleans или Akka.Remoting. Он использует дактайпинг, чтобы сгенерировать или использовать контракт. Это означает, что клиент может разбить основной контракт на несколько подконтрактов и использовать их независимо. Для этого достаточно, чтобы имена усечённого контракта и используемые методы совпадали с полным контрактом. Из этих соображений Fable.Remoting использует в качестве контракта рекорд с функциями, а не интерфейс. Так что это никакой не F#-way, а просто один из вариантов, который очень удачно лёг на предметную область. К тому же в F# рекорды не обладают наследованием, так что рефлексия не сможет уйти не туда.

Fable.Remoting использует схему /<имя типа>/<имя метода> (т. е. /ITopicApi/Add/). Её можно переопределить через Remoting.withRouteBuilder, который принимает функцию typeName : string -> methodName : string -> string. Это может быть полезно для подконтрактов, если вы в отличие от нас предпочитаете использовать плоские домены, в которых нельзя иметь типы с одинаковыми именами. Однако эта функция просто необходима, когда надо расположить несколько обобщённых API с разной параметризацией. Экземпляры ITopicApi и ITopicApi попытаются сесть на один и тот же путь. Как они его поделят, зависит от конкретного сервера. На Suave все запросы попадут к тому, кто выше по списку в WebPart.choose. Таких вещей лучше избегать через какой-нибудь:

Remoting.createApi()
|> Remoting.fromValue (createApi())
|> Remoting.withRouteBuilder ^ fun _ method -> $"/strings/{method}"

Данную функцию надо будет повторить и на сервере, и на клиенте. Схожего поведения можно добиться через subroute из Giraffe. Он отсутствует в Suave, но его изобретение задержит вас минут на 10. В этом случае надо будет продублировать новый токен в клиенте:

Remoting.createApi "http://127.0.0.1:8080/subroutetoken"
|> Remoting.buildProxy>

Переход в привычные роутинги на Suave или Giraffe может привести к возникновению идентификаторов в пути. Например, topics/{topicId}/{consumerId}/ITopicApi.
Подобный подход потребует некоторой работы, так как экземпляры API (и его WebPart) придётся кешировать самостоятельно. Хоть это и посильная задача, я бы рекомендовал отказаться от идентификаторов в путях. Гораздо проще, когда все параметры передаются строго через методы в API. Этому также способствует сама логика разработки. Приведённые в примере topicId и consumerId гораздо удобнее разруливать в акторе-менеджере написанном на Hopac. Получив полный набор данных, он самостоятельно найдёт (или создаст) нужный топик и направит в него оставшиеся данные и reply. Не надо склеивать WebPart, API и Hopac под немыслимыми углами, если они входят друг в друга по принципу стык-паз.

Итог

Связка из Hopac, Suave и Fable.Remoting позволяет очень быстро и очень легко творить абсолютную дичь, наплевав на все законы физики и логики условно классических межсерверных отношений. Можно делать ровно то, что нужно в конкретной задаче, и возвращаться к интеграциям лишь при необходимости. На Fable.Remoting можно писать в прод, но его не надо противопоставлять классическому API по принципу «или, или». Его основная задача — обеспечить максимальные гибкость и скорость на наиболее динамичном участке разработки, когда в первую очередь важна бизнес-логика, а не интеграция вашего API с каким-нибудь Swagger или JWT. Бонусом идёт модульность. API, написанные под Fable.Remoting, гораздо проще таскать между F#-серверами.

Ссылка на репозиторий.

НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS

© Habrahabr.ru