В прошлой части мы проецировали внешние контракты в 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

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