Когда-либо давал. F#-сервер из скрипта на примере локального брокера сообщений
В ходе разработки на 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
на всю систему.
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
:
Сам актор начнёт работать только после вызова следующего кода:
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