В прошлой части мы проецировали внешние контракты в DTO на примере REST. В этой будем проецировать методы контрактов в нечто, что позволит вызывать их вот так:
let! issues = gitflic.project.["kleidemos"].["myFirstProject"].issue.GET(limit = 24)
Имитация пути метода вызывает наибольший интерес, однако её реализации будет предшествовать ряд самодостаточных стадий.
Объекты запросов вместо обращения к сети
Вызов HttpClient
является кульминацией метода, так что формат взаимодействия с ним может определить всю структуру нашего клиента. Меня такая связь угнетает, поэтому я избегаю её до последнего и могу вовсе отказаться включать её в пакет. А ещё недавно я подсел на Godot
, андроид-версия которого не даёт работать с сетью в обход механизмов движка. Из-за этого данная практика приобрела принудительный характер даже по отношению к моему легаси.
Вместо обращения к сети каждый метод будет создавать объект запроса, который будет интерпретироваться по месту использования неким внешним актором (в широком смысле). Этот актор должен идти в комплекте с генерализованной обвязкой, которая не попадает в глубь актора, но обеспечивает типизацию API снаружи. Комплекс из актора и его обвязки я по историческим причинам буду называть раннером, но этот нейминг нельзя заимствовать некритично. Обращаю внимание, что нас интересует объект запроса с позиции раннера, а не HttpClient
или актора. И в первом приближении он может выглядеть так:
type 'output Request = {
Url : string
HttpMethod : string
Content : string
Headers : (string * string) list
}
'output
в данном случае лишь указывает на возвращаемый тип. Его можно овеществить, добавив функцию десериализации в рекорд. Но лучше выкинуть и функцию, и Content
, так как из-за этих двух полей сериализация становится частью клиента, а она может потянуть за собой тонну дополнительных настроек и потенциальный бег к морю. Раз мы рассчитываем на запуск запроса внешним игроком, то этот же игрок может быть назначен ответственным за [де]сериализацию:
type Request<'input, 'output> = {
Url : string
HttpMethod : string
Headers : (string * string) list
Input : 'input
}
Таким образом мы только что определили монаду, для которой можно задать zero, map и т. д.
Это может выглядеть симпатично, но далее надо задаться вопросом, зачем здесь Input
. Из-за него запрос нельзя создать без экземпляра соответствующего типа, но при этом нас это поле не интересует ни как источник данных, ни как результат вычислений. То есть все операции над ним будут производится сообразно целям и желаниям пользователя. Если выкинуть это поле, мы сохраним информацию о входе и выходе, при этом раннер всё ещё сможет интерпретировать запрос:
type Request<'input, 'output> = {
Url : string
HttpMethod : string
Headers : (string * string) list
}
По тем же соображениям необходимо подрезать Url
, ибо если он будет включать адрес домена, порт и т. д., то вся эта информация потребуется на этапе создания Request
. Это снова увеличит контекст без ощутимой для нас пользы. Данная информация должна принадлежать раннеру, который применит её на этапе запуска, а мы можем ограничиться относительным путём:
type Request<'input, 'output> = {
Path : string
HttpMethod : string
Headers : (string * string) list
}
Наконец я волен выделить Query
-параметры из Path
. Слить их воедино проблем не составит, а вот извлекать их оттуда придётся с бубном:
type Request<'input, 'output> = {
Path : string
Query : (string * string) list
HttpMethod : string
Headers : (string * string) list
}
with
member this.PathAndQuery : string = ...
Возможно, кого-то заинтересует, почему Query
и Headers
были заданы списком, а не Map
, который обеспечил бы примитивную защиту от дубликатов, быть может, ускорил поиск и т. д. Это допустимый шаг, если он продиктован соображениями, отличными от расшаривания валидации. Следует понимать, что наш Request
хоть и позиционируется как объект на вход в систему, в действительности является объектом на выходе из системы, нашей системы. Раннер же относится к внешним сущностям, которые находятся за пределами ответственности нашей библиотеки, а значит, и валидация его входных данных будет лежать на нём. Мы просто не в состоянии её заменить. У нас всё ещё нет права генерировать мусор, но наша задача теперь сводится лишь к производству хорошо читаемого объекта. Поэтому гипотетическое разбиение Path
на токены даст больший эффект, чем провалидированные типы.
Получившийся Request
никак не затрагивает вопросы авторизации / аутентификации. Во-первых, этот процесс всегда слишком индивидуален, чтобы описывать его здесь вслепую. Во-вторых, он гораздо легче решается на стороне раннера. От нас требуется реализовать общеупотребимые элементы авторизации в каком-нибудь параллельном закутке клиента. Опираясь на эту реализацию и на довольно проницаемую структуру запроса, раннер сможет нужным образом дополнить запрос при загрузке в HttpClient
(и его аналоги).
Методы как объекты
Далее нас интересует процесс создания реквестов. Потенциально он может быть методом клиента, принимать несколько параметров (в том числе опциональных) и возвращать Request<'input, 'output>
:
val GetProjectIssues: ownerAlias: string * projectAlias: string * ?page: int * ?size: int -> Request<unit, _>
Это нормальный ход, и сам по себе он дефектов не имеет. Но если между клиентом и Request
-ом окажется ещё один тип, который отображает конкретный метод нашего API, то за него можно будет зацепиться и накидать более сложную функциональность:
val GetProjectIssues: ownerAlias: string * projectAlias: string * ?page: int * ?size: int -> GetProjectIssues
Это всего лишь дополнительный узел, в который мы будем попадать так же, как и до этого, но шаг преобразования в Request<'input, 'output>
придётся делать явным образом. Так как все необходимые параметры к этому моменту у нас уже есть, то это преобразование можно унифицировать с позиции пользователя. По классике это должна быть очередная фабрика:
type IRequestFactory<'input, 'output> =
abstract member Create : unit -> Request<'input, 'output>
Однако наш запрос является иммутабельным рекордом со структурной эквивалентностью, то есть его экземпляр нельзя изменить, но можно сравнить с другими. Отсюда возникает вопрос, насколько нормальной будет ситуация, когда многократный вызов Create
будет возвращать неидентичные запросы. Этот вопрос не имеет правильного ответа из-за недостатка контекста. Но мне известен конечный пункт наших изысканий, и он предполагает идемпотентные операции, то есть запросы будут одинаковыми. Семантика фабрик никак это свойство не отражает, поэтому я предпочитаю имена погрубее и поближе к сути:
type IAsRequest<'input, 'output> =
// Делать `AsRequest` свойством или методом -- дело внутренних установок.
abstract member AsRequest : unit -> Request<'input, 'output>
Глядя на пример из начала статьи, может показаться рациональным выстроить иерархию типов-методов через наследование. С наследованием вычисление Path
пишется попроще и правится каскадом, но, по существу, на этом преимущества наследования заканчиваются. При кодогене нам эта подстраховка не особо нужна, и мы больше выиграем от рекордов, у которых нет наследования, но есть {with}
-синтаксис, шаблоны и эквивалентность.
На самом деле это не взаимоисключающий выбор, так как если у вас есть кодоген, то можно добиться record like
поведения и от обычного типа. Вопрос лишь в том, что дешевле сделать.
До этой статьи я понятия не имел, что из uri
, url
, path
и т. д. чем является, и в моих планах эту информацию забыть. Но независимо от формата записи, строка в качестве базового элемента сборки — это источник проблем. Строка так же удобна для взаимодействия с внешней средой, как удобен json
при определённых условиях. Не считая инфраструктурных доменов, json
почти всегда перед работой преобразуется в полноценные объекты. В редких случаях в JObject
или ExpandoObject
. Добавление или замена свойств в json
-строке через regex
-ы и прочие операции со string
— допустимы для шаржей и, быть может, для высшей лиги байтолюбов (не шарю, просто допускаю возможность), но не для большинства реальных приложений.
С Uri
надо действовать аналогичным образом. Uri
— это не просто фарш, который нельзя провернуть назад. Это фарш, который обваляли в панировке и подвергли термической обработке, то есть это готовые котлеты. В них не надо засовывать забытые компоненты в надежде всё ещё раз перемолоть и зажарить. Так что везде, где предполагается дополнение или коррекция пути, все внутренние элементы должны быть представлены полноценными объектами.
Говоря конкретно, это означает, что объект метода, который продуцирует Request
, должен вмещать все обязательные и необязательные параметры:
type GetProjectIssues = {
ownerAlias : string
projectAlias : string
page : int option
size : int option
}
А Path
и Query
будут генерироваться на ходу.
with
interface IAsRequest<unit, response> with
override this.AsRequest () = {
Path = $"/project/{this.userAlias}/{this.projectAlias}/issue"
Query = [
let inline (!) key value = [
match value with
| Some value -> key, string value
| None -> ()
]
yield! !"page" this.page
yield! !"size" this.size
]
HttpMethod = "GET"
HttpHeaders = []
}
Другие потенциальные параметры, типа credentials
или адреса сайта, были отброшены в прошлом параграфе. Так что теперь у нас есть полное описание метода, которое доступно для чтения, сравнения и with
-модификации. Первое актуально для UI, второе — для кэширования, а последнее — для рекурсивной постраничной загрузки.
Здесь может остаться только один вопрос. Раз уж у нас появился отдельный объект на каждый метод, то почему Request
остаётся отдельным рекордом, а не интерфейсом, прикреплённым непосредственно к методу-объекту? Поведение рекорда (а также DU) нельзя изменить, за счёт чего рекорд может принудительно разрывать домены там, где надо явно обозначить границы ответственности двух разных систем. Интерфейс же просто образует связь без дополнительных ограничений. Не могу сказать, что данная принудиловка была здесь строго необходима, скорее, она просто себя хорошо зарекомендовала и стала стратегией по умолчанию.
Имитация иерархии
Те, кто читал 4 часть "Большого кода", уже видел, как можно отображать графы в типы при помощи type extensions
. Наш граф сводится к дереву, что значительно проще, так как предполагает движение лишь в одну сторону. Обратный ход возможен, но он железно предопределён для каждой конкретной ноды.
Нам потребуется создать по типу на каждую нетерминальную ноду в древе, включая её корень, независимо от того, есть ли в ней индивидуальные данные или нет:
type GitFlic = ...
type project = ...
type ``project {userAlias}`` = ...
Рекорды без полей синтаксически невозможны, так что ноды без данных надо симулировать либо через DU, либо через структуры, либо через singleton
:
[<RequireQualifiedAccess>]
type project = Instance
// Для справки:
// Такая структура имеет свойство эквивалентности.
// project() = project() // = true
type project = struct end
type ``project`` private () =
static member val instance = ``project`` ()
Все эти типы будут храниться в отдельном пространстве имён, которое мы вряд ли будем открывать отдельно. В данном случае нам важна лишь внешняя читаемость, должно быть понятно, о чём идёт речь лишь по упоминанию в сигнатуре (в том числе у переменных). Нейминг кейсов DU немного строже, чем нейминг типов, что в совокупности с их вложенностью будет скорее сбивать нас, так что я предпочитаю использовать пустую структуру или singleton
. Оба случая предполагают нетипичный «конструктор», но пользователю клиента он не понадобится.
Ноды с данными элементарны:
type ``project {userAlias}`` = {
userAlias : string
}
Экономить на типах не надо, даже если их данные совпадают:
type ``project {userAlias} {projectAlias}`` = {
userAlias : string
projectAlias : string
}
type ``project {userAlias} {projectAlias} issue`` = {
userAlias : string
projectAlias : string
}
Если мы ведём речь о древовидном API, то мы не можем отсечь ноду от её потомков, если у неё не будет отдельного типа. Отсутствие отдельных типов может приводить как к бесконечным циклам, так и к проглатыванию сегментов пути. Оба варианта также создают предпосылки к разночтению при совпадении имён в различных частях дерева:
gitflic.project.["kleidemos"].["myFirstProject"].issue.issue.issue.issue
gitflic.project.["kleidemos"].["myFirstProject"].["someId"].GET
Очевидно, что при таком подходе методы (терминальные) и нетерминальные ноды по своей структуре отличаются лишь тем, что первые реализуют интерфейс IAsRequest
. Хранить их из-за этого вместе или порознь — вопрос удобства навигации:
type ``project {userAlias} {projectAlias} issue {localId} GET`` = {
ownerAlias : string
projectAlias : string
page : int option
size : int option
}
with
interface IAsRequest<...
Далее каждой нетерминальной ноде надо добавить свойства ведущие к её дочерним нодам:
type GitFlic with
member this.project = project.instance
type ``project {userAlias} {projectAlias}`` with
// Типов с таким набором полей несколько,
// но явное указание типа результата позволяет компилятору понять,
// о чём идёт речь.
member this.issue : ``project {userAlias} {projectAlias} issue`` = {
userAlias = this.userAlias
projectAlias : this.projectAlias
}
Если переход требует параметризации, то нужен индексатор. Для тех, кто не знал или забыл, определение (безымянного) индексатора в F# выглядят вот так:
type project with
member this.Item
with get userAlias : ``project {userAlias}`` = {
userAlias = userAlias
}
// project().["userAlias"]
В одной из последних версий F# научился работать с индексаторами без точки перед квадратной скобкой, но это нововведение создаёт предпосылки к разночтению. Отныне видя запись f[x]
, я не могу быть уверенным, что это вызов функции на списке из одного элемента. Мне это очень не нравится, доводы авторов фичи мне кажутся сомнительными, а вся акция выглядит как внезапное C#-like обострение. Поэтому в своих проектах и статьях я избегаю упрощённой записи, так как со списками мы работаем многократно чаще, чем с индексаторами.
Часть завершающих переходов к методам API (терминальным нодам) требует опциональные параметры, что может быть выражено только через методы класса. В целях унификации предпочтительно использовать тот же подход и для непараметризуемых случаев:
type ``project {userAlias} {projectAlias} issue`` with
member this.GET (?page : int, ?size : int)
: ``project {userAlias} {projectAlias} issue GET`` = {
userAlias = this.userAlias
projectAlias = this.projectAlias
page = page
size = size
}
Опционально ко всем нодам можно добавить свойство для перехода к верхней части дерева:
type ``project {userAlias} {projectAlias} issue GET`` with
member this.Parent =
: ``project {userAlias} {projectAlias} issue`` = {
userAlias = this.userAlias
projectAlias = this.projectAlias
}
Но так как все параметры из пути доступны для чтения непосредственно, то зачастую проще пройти всю цепочку от gitflic
до другого метода перекидывая значения из имеющегося рекорда в путь.
В конце всего этого необходимо вытащить GitFlic.instance
в качестве глобальной «переменной»:
[<AutoOpen>]
module Auto =
let gitflic = Routes.GitFlic.instance
gitflic.project.["kleidemos"].["myfirstproject"].GET()
Раннер
Раннер — вещь сугубо индивидуальная, но минимальный пример привести можно. Вот так может выглядеть основная процедура отработки запроса на базе HttpClient
и Hopac
:
module Runner =
open Thoth.Json.Net
type Config = ...
let run (client : HttpClient) config (input : 'input) (request : Request<'input, 'output>) : 'output Job = job {
use content = new StringContent(
if typeof<'input> <> typeof<unit>
then Encode.Auto.toString input
else ""
)
content.Headers.ContentType <- Headers.MediaTypeHeaderValue.Parse "application/json"
use msg = new HttpRequestMessage(
HttpMethod.Parse request.HttpMethod
, config.ApiAddress + request.PathAndQuery
, Content = content
)
for key, value in request.Headers do
msg.Headers.Add(key, value)
do let auth = config.AuthorizationHeader
msg.Headers.Add(auth.Name, auth.Value)
let! response = client.SendAsync msg
if typeof<'output> = typeof<unit> then
return unbox<'output> ()
else
let! respContent = response.Content.ReadAsStringAsync()
return
respContent
|> Decode.Auto.unsafeFromString
}
Здесь никак не отображены коды ошибок и т. п., но в F# такие вещи лучше решать с учётом специфики проекта и пожеланий команды. Скорее всего обработка кодов будет размещена здесь же, но не факт.
Стоит обратить внимание на то, как ведёт себя unit
в 'input
и 'output
. В обоих направления экземпляр данного типа должен трактоваться как пустое тело. В случае запроса мы помещаем пустую строку в Content
, хотя идеологически лучше (но вербознее) вообще не задавать msg.Content
. А в случае ответа мы вместо чтения и десериализации сразу возвращаем ()
(экземпляр unit
).
На базе функции run
можно определить объект раннера и его основные методы:
module Runner =
...
type Main = {
HttpClient : HttpClient
Config : Config
}
let create client config : Main = ...
type Main with
// `request` и `input` в зависимости от предпочтительного варианта использования можно менять местами
member this.Run (input : 'input) (request : Request<'input, 'output>) : 'output Job = ...
member this.RunAsRequest (input : 'input) (preRequest : IAsRequest<'input, 'output>) : 'output Job = ...
Затем имеет смысл расширить Request
и IAsRequest
:
// После модуля Runner (а не "в")
[<AutoOpen>]
module RunnerAuto =
type Request<'input, 'output> with
member this.Run (input : 'input) (runner : Runner.Main) : 'output Job = ...
type IAsRequest<'input, 'output> with
member this.Run (input : 'input) (runner : Runner.Main) : 'output Job = ...
Я имею обыкновение забывать имена свободно болтающихся объектов и функций, поэтому могу продублировать рутовый gitflic
и прикрепить его к раннеру:
module Runner =
type Main with
member this.api = Routes.GitFlic.instance
runner.api.project.["kleidemos"].["myfirstproject"].GET()
Билдер
Если вам нужно глубокое понимание билдеров (они же Computation Expressions
или CE
), то здесь есть перевод большого цикла, посвящённого конкретно этой теме. Я же веду речь про бытовое использование, поэтому буду пояснять лишь то, что влияет на принимаемые решения.
На самом деле нам часто хватает явного вызова раннеров через пайпы или type extensions
, но при желании можно совместить раннер и билдер. Мы обычно особо не заморачиваемся и создаём свой билдер на основе уже существующего асинхронного. Например, вот так выглядит начало раннер-билдера на основе Hopac.JobBuilder
:
module Runner =
type JobBuilder (httpClient, serverConfig) =
inherit Hopac.JobBuilder()
// Применяется для разруливаня return! к Request<unit, 'output>
member this.ReturnFrom (request : Request<unit, 'output>) =
run httpClient serverConfig () request
// Для let! к Request<unit, 'output>
member this.Bind (request : Request<unit, 'output>, f : 'output -> 'y Job) = job {
let! output = this.ReturnFrom request
return! f output
}
У данного билдера есть особенности. Во-первых, его нельзя создать без параметров:
JobBuilder(clinet, config) {
...
}
Но это не является серьёзной проблемой, так как обычно он идёт в обозе Runner.Main
:
module Runner =
type Main with
member this.job = JobBuilder(this.HttpClient, this.Config)
И пользователь создаёт его так:
runner.job {
...
}
Во-вторых, он является наследником Hopac.JobBuilder
, поэтому он может работать с Job
, Task
, Async
и Observable
. Более того, он может работать как чистый предок и никак не сталкиваться с нашим Request<unit, 'output>
:
runner.job {
do! timeOutMillis 1000
return 42
}
Некоторых это может смущать, но я считаю такую обратную совместимость преимуществом, так как на практике код может эволюционировать окольными путями, и было бы неудобно принудительно менять билдер только из-за того, что REST-вызов покинул скоуп. Семантика, конечно, пострадает, но её исправление удобнее отложить до момента коммита, когда ключевые решения уже будут приняты.
Пока в данном билдере определена обработка let!
, do!
и return!
для Request<unit, 'output>
. Но в F# интерфейсы реализуются эксплицитно, из-за этого метод AsRequest
будет закрыт для вызова, пока мы не скастим тип к интерфейсу:
runner.job {
do! timeOutMillis 100
let! project = (runner.api.project.["kleidemos"].["myfirstproject"].GET() :> IAsRequest<_,_>).AsRequest()
return project.language
}
Это довольно вербозная запись, так что имеет смысл расширить билдер ещё на два метода:
// Аналогичная пара для IAsRequest<unit, 'output>
member this.ReturnFrom (preRequest : IAsRequest<unit, 'output>) =
run httpClient serverConfig () (preRequest.AsRequest())
member this.Bind (preRequest : IAsRequest<unit, 'output>, f : 'output -> 'y Job) = job {
let! output = this.ReturnFrom preRequest
return! f output
}
В результате чего можно будет писать так:
runner.job {
do! timeOutMillis 100
let! project = runner.api.project.["kleidemos"].["myfirstproject"].GET()
return project.language
}
Теперь осталось разобраться с запросами, у которых есть 'input
отличный от ()
. Нас, наверное, устроило бы обращение вида:
runner.job {
let! project = gitflic.project.POST() {
title = "created-via-api"
isPrivate = true
alias = "created-via-api"
ownerAlias = "kleidemos"
ownerAliasType = "USER"
language = "F#"
description = "Created via api."
}
return project.id
}
Но билдер может влиять на интерпретацию кода строго в определённых узлах. Добраться до отрезка .POST() {
он не может. Нам нужно передать request
и input
на вход в билдер, что можно сделать через тупл:
let! project =
gitflic.project.POST()
, {
title = "created-via-api"
...
}
Проблема этого варианта в слабой связанности параметров кортежа. Компилятор сначала посмотрит на то, что мы создали, сравнит с имеющимися перегрузками и лишь потом укажет на нашу ошибку. При такой последовательности тип второго элемента придётся указывать руками. Вместо этого лучше, чтобы первый параметр явно определял второй посредством метода. Ещё лучше, если этот метод будет возвращать специально предназначенную структуру:
module Runner =
...
type Send<'input, 'output> = {
Request : Request<'input, 'output>
Input : 'input
}
[<AutoOpen>]
module RunnerAuto =
type IAsRequest<'input, 'output> with
member this.Send input : Runner.Send<'input, 'output> = {
Request = this.AsRequest()
Input = input
}
Несмотря на то, что интерфейсы в F# эксплицитные, расширения на них имплицитны. Поэтому обращение gitflic.project.POST().Send
корректно и подсказывается IDE.
Остаётся добавить ещё пару методов в билдер:
// Аналогичная пара для Send<unit, 'output>
member this.ReturnFrom (send : Send<'input, 'output>) =
run httpClient serverConfig send.Input send.Request
member this.Bind (send : Send<'input, 'output>, f : 'output -> 'y Job) = job {
let! output = this.ReturnFrom send
return! f output
}
И мы сможем писать так:
runner.job {
let! project = gitflic.project.POST().Send {
title = "created-via-api"
...
}
return project.id
}
Оригинальный Hopac.JobBuilder
написан в немного устаревшей манере, которая ничего не знает о механизме Source
, из-за чего каждый исходный тип потребовал от нас по 2 дополнительных метода. Но если бы билдер был написан так, как надо, то нам было бы достаточно добавлять по функции преобразования в Job
для каждого типа из списка (Request
, IAsRequest
, Send
), после чего компилятор доделывал остальные преобразования автоматически. Заинтересовавшимся предлагаю пройти в исходники FsToolkit.ErrorHandling
(а также в сопутствующие треды), где есть несколько билдеров, построенных именно по этой схеме. Тем, кто предполагает часто расширять JobBuilder
, я рекомендую задуматься замене его на собственную версию, язык это позволяет.
Промежуточное заключение
В этот раз мы спроецировали древо методов REST API, а потом подружили проекцию с билдером. В реальности для проекции потребуется генератор кода, но усечённый результат генерации можно увидеть здесь. Самого генератора там нет, его я оставил для следующей части. В ней я дам генератор, разберу подробно наиболее интересные моменты, а также покажу какие ещё проекции древа методов могут оказаться нам полезны.
Автор статьи @kleidemos
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS