В данном цикле я хочу поговорить об одном из вариантов представления REST-клиента. Но я буду обсуждать частное (REST), чтобы использовать его как точку опоры для перехода к общему — проблеме проекций внешних контрактов. В первых двух частях я сосредоточусь на синтаксисе и «архитектурных» ходах, а потом поговорю о генераторах кода. Сложность будет расти с каждой частью, но предлагаемые подходы даже в рамках одной части можно применять независимо. Например, сейчас мы поговорим про DTO (Data Transfer Object), в следующий раз — про перенос методов и их иерархии. Это близкие модули, но их взаимное влияние осознанно будет сведено к минимуму.

Ранее я накатал большой цикл по локальному F#-кодогену и высказал намерение периодически возвращаться к генераторам на примере каких-то узконаправленных задачек. Данный цикл — пробный шар в этом направлении. Он логически вытекает из последних двух частей цикла, так что осилившие «Большой код» смогут посмотреть на уже знакомые концепции немного под другим углом.

Теоретически REST-клиенты относятся к рутинным промышленным задачам, которые должны решаться стандартными C#-генераторами (они не радуют, но терпимо). Однако мне как-то особо не везёт, и периодически я сталкиваюсь с сервисами, контракты которых описаны только в документации. Иногда отсутствие Swagger обусловлено ленью разрабов, иногда особенностями предметной области. А ещё бывают ситуации, когда удобоваримый контракт есть, но он не везде соответствует действительности. К тому же надо учитывать вариативность и нечёткость правил при проектировании REST API, что в том числе приводит к индивидуальным подходам к обработке и представлению ошибок.

Даже между содержательной частью сервиса и его API всегда имеется ощутимое расстояние. Между API и клиентом оно тоже есть, но с учётом перечисленных факторов оно значительно возрастает. Чем оно больше, тем разнообразнее подходы при реализации клиента. У меня нет претензий к самому факту наличия бизнес-логики в клиенте, но мне часто не нравится её реализация. Из-за этого я предпочитаю, чтобы «умная» версия клиента дублировалась ещё одной, которая отказывается от попыток задавить задачу интеллектом. Её цель — механически отображать действительность, полностью отказавшись от какой-либо ответственности. К сожалению, большинство разрабов воспринимает приложения не как череду проекций из ввода в результат, а как задачку на минимизацию произвольно выбранного показателя. Наличие альтернативных [под]клиентов этой задачке явно противоречит.

REST и JSON позиционируются как человеко-читаемые стандарты. Это хорошая черта, которую, по моему мнению, слишком часто игнорируют. Моей целью было оставить в клиенте только то, что было оставлено самими авторами API, и исключить все предпосылки к индивидуальным трактовкам зелёных занавесок. Сделать это в общепринятых рамках оказалось нелегко, так что они тоже были отброшены. Код, что появится дальше по тексту, будет необычным, а для кого-то даже страшным. Он такой не потому, что используется в кодогене, а потому что в таком виде он может быть результатом кодогена. Таким образом, человеческая интерпретация API, обычно значительно предшествующая кодогену, перемещается в стадию непосредственного употребления API. Это идеальное решение, если мы идём в набег на сервис, но и полноценному освоению оно не помешает.

DTO и его содержимое

Модель, используемая в бизнес-логике, редко совпадает с той, что используется при общении через сеть (а также интероп, БД, сейвы и т. д.). Когда я был юн и писал на C#, в моей ойкумене считалось кошерным переиспользовать один и тот же тип в максимально возможном числе случаев. Для этого тип (реже экземпляр) нашпиговывался тонной замечаний в виде атрибутов и добивок сериализаторов. В теории такой подход должен был приводить к простоте и надёжности, однако на практике скрещивание защищённых моделей и незащищённых сетевых контрактов порождает либо сложные, либо дырявые решения. Я всё ещё вынужден сталкиваться с обоими вариантами. Будучи лишь пользователем таких либ, я мало беспокоюсь об их сложности, так как за толщей абстракций я её не вижу. Но моя работа может встать намертво, если в такие решения залетает баг. Мы можем всей командой сидеть на нескольких центнерах свежих данных и ждать, пока другая организация разберётся с устаревшим предикатом.

Справедливости ради надо сказать, что REST сложно спроецировать так, чтобы проекцию нельзя было обойти, так как REST почти целиком состоит из широко известных технологий. Проекцию на таких технологиях несложно заменить. Максимум, с чем я сталкивался — это непроходимый тупик из-за недокументированного алгоритма авторизации/аутентификации. Но мне трудно считать этот случай типовым.

В F# предпочтителен иной подход. Мы воспроизводим контракт (DTO) в виде преимущественно алгебраических типов, скармливаем его в максимально дубовый сериализатор, после чего ручками (или не совсем) определяем процедуры преобразования DTO в модели и обратно. Наиболее опасный момент здесь — это степень дубовости сериализатора, так как она недискретна. По понятным причинам у нас нет никакого желания заморачиваться с преобразованием массивов в списки или null в option. По PersistentVector можно поспорить, как и по форматам Guid или дат, особенно, когда последние выражены числом.

Имена полей мы предпочитаем заимствовать из протокола напрямую, без адаптаций к PascalCase или без уклонения от конфликтов с ключевыми словами. С именами enum сложнее, если они передаются строкой. У меня есть домены, где десериализация не должна падать, когда встречает неизвестный клиенту вариант. Клиент должен остаться работоспособным и иногда передавать эти данные далее без полноценного понимания, с чем он имеет дело. В этом случае DTO работает со строкой, а модель с различными вариациями на тему Known of <EnumLike> | Unknown of string.

Хорошей иллюстрацией можно считать Fable.Remoting (рекомендую статью товарища, где пакет разбирается подробнее). Это очень простая либа, которая позволяет определять контракт сервера на F# без скатывания в детали протоколов и сериализации. После чего данный контракт можно типобезопасно использовать на произвольных клиентах или серверах (в узком смысле слова, т. е. Suave, Giraffe и т. д.). В Fable.Remoting есть несколько настроек общего характера, но там вообще нельзя переопределить имена полей или конвертеры данных. Встроенный сериализатор целиком и полностью полагается на описательную силу алгебраических типов, а всё, что выше этого, перекладывается на пользователя.

Хочу сразу указать на ограниченность подобного подхода. Алгебраические типы есть не везде, в некоторых случаях они присутствуют не в полном объёме. Если сервер не поддерживает типы-суммы (DU), велика вероятность, что типы-произведения (рекорды) будут испорчены наследованием. Плодить рекорды с десятком option полей, чтобы сожрать всё, а потом проматчить варианты, можно, но не нужно. Проще, вооружившись, разобрать протокол самостоятельно. У Thoth.Json есть Decode.oneOf для сумм и Decode.map2/3/4/.. для произведений. Функции принадлежат либе, но их концепция универсальна, и с ними надо ознакомиться, даже если вам предстоит работать с бинарными протоколами.

Имена DTO

Действуя вышеописанным образом, можно многое сделать без пауз и лишних раздумий, но не всё. По странному стечению обстоятельств большинство встреченных мною авторов локальных протоколов не дают типам официальные имена. Повезёт, если тип используется часто или фигурирует в какой-нибудь коллекции, но даже в этом случае он может упоминаться в PDF под типом из таблицы 4.F14.1. На самом деле по меркам пустошей это шикарное описание, ибо можно вспомнить, что делает метод 4.F14. А вот вспомнить, где находится таблица 37 в древнем .docx, физически невозможно. Кстати, имён методов тоже может не быть, либо они могут быть в виде сложносочинённых предложений или псевдокода сигнатур. Замечу ещё, что я нормально отношусь к именам на русском, если предметная область слишком тяжела для перевода, но у кого-то этот пункт может вызывать проблемы.

По канонам DDD надо осознанно выбирать названия. Имена типов должны отражать их суть применительно к их домену, и при смене домена должны меняться имена типов и/или их содержимое (т. е. сами типы). Я согласен с этим утверждением, но хочу заметить, что DTO на самом деле имеют опосредованное отношение к своим моделям. Для них куда более важным фактором является протокол, к которому они относятся. Это их домен, который по возможности необходимо сужать до конкретного контракта, метода, команды или запроса в этом протоколе.

Если API проектировал я, то вероятность, что MyModel похож на «свой» MyModelDTO, очень высока, но неизвестно, как долго это родство будет поддерживаться в процессе развития системы, ведь внешние контакты не могут эволюционировать с той же скоростью. Если API чужое (а то и вовсе из категории «необязательный квестов»), то привязка MyModel к MyModelDTO будет оправдываться лишь тем, что на остальные типы MyModel похож ещё меньше. Из-за этого натягивать нейминг нашего домена на внешний контракт можно лишь с очень большой степенью условности. То есть, когда я вижу MyModelDTO, я вижу DTO для типа MyModel, а должен видеть тип, который лежит по такому-то пути в запросе / ответе такого-то метода такого-то API.

В комплекте с json идёт механизм JSON Path. Понятия не имею, какими документами он определяется, но в быту с ним сталкивался каждый. Было бы хорошо, если бы мы могли механически переиспользовать его для нужд именования. Например, если у нас есть такой json:

{
    "id": "2739de5e-9ec0-4bac-86d2-0b639b46a875",
    "title": "Пример комментария",
    "status": {
        "id": "OPEN",
        "title": "Новая"
    },
    "assignedUsers": [
        {
            "id": "6736240a-139d-4e62-be4f-cab026562172",
            "username": "exampleuser"
        }
    ]
}

То на F# мы бы хотели увидеть приблизительно такие типы:

// Объект лежащий в свойстве `status` объекта `root`.
type ``$.status`` = {
    id : string
    title : string
}

// Объект лежащий в списке в свойстве `assignedUsers` объекта `root`.
type ``$.assignedUsers[]`` = {
    id : System.Guid
    username : string
}

// Сам `root`.
type ``$`` = {
    id : System.Guid
    title : string
    status : ``$.status``
    assignedUsers : ``$.assignedUsers[]`` list
}

Идея понятна, но она наталкивается на технические ограничения. F# имеет более строгие правила именования для типов, кейсов, модулей и пространств, чем для обычных member-ов. Он не пропустит $, точки и [].

  • $ на самом деле нам не особо подходит, так как объект root может быть либо запросом, либо ответом. У методов эти компоненты могут присутствовать и в паре, и по одному (а также вовсе отсутствовать). Это значит, что нам надо будет маркировать их дополнительным словом (или уровнем иерархии) request или response. В таком случае проще заменить доллары на нужный маркер.

  • Точки конкретно здесь бьют по читаемости, и мне кажется, что их лучше заменить пробелами и без замечаний от компилятора.

  • [] — наиболее дискуссионный момент. Я волюнтаристски остановился на знаке -, так как он напоминает мне синтаксис списков markdown.

В итоге получится такой код:

// Объект лежащий в свойстве `status` объекта `response`.
type ``response status`` = {
    id : string
    title : string
}

// Объект лежащий в списке в свойстве `assignedUsers` объекта `response`.
type ``response assignedUsers -`` = {
    id : System.Guid
    username : string
}

// Сам `response`.
type response = {
    id : System.Guid
    title : string
    status : ``response status``
    assignedUsers : ``response assignedUsers -`` list
}

Этот код компилируется. Упреждая вопрос, имена вида a b - c - - d, - и - a компилятор тоже одобрит.

Группировка типов

Всякий протокол подразумевает некоторую онтологию команд, которая в REST имеет ярко выраженную древовидную форму. В железячных протоколах с этим и проще, и сложнее (4.F14), но в любом случае, мы можем вычленить некую терминальную структуру — лист дерева. Для REST это будет метод, который будет характеризоваться Route и HttpMethod. Метод можно использовать как место размещения всех задействованных типов из предыдущего параграфа. В контексте F# это значит, что каждому методу будет соответствовать свой модуль (который может вкладываться в другие модули или пространства).

Очевидно, что некоторые типы в разных методах будут идентичны. Особенно это касается Get-запросов, так как многие из них — это лишь квери с разным набором условий. Я предпочитаю в таких ситуациях использовать алиасы (псевдонимы типов или модулей). В общепринятой схеме мы определяем тип где-то выше по иерархии и ссылаемся на него в методах:

type Issue = ...

module GetIssueById =
    type response = Issue
    
module GetProjectIssues =
    type response = Issue list

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

module GetIssueById =
    type response = ...

module GetProjectIssues =
    type response = GetIssueById.response list

В GetProjectIssues у нас определён response без response -, но на response - удобно опираться в коде.

Если я пилю элементарную страницу со списком ишуев, то мне хочется ссылаться на элементы списка так:

let itemView (item : GetProjectIssues.``response -``) ... = ...

А не так:

let itemView (item : GetIssuesById.response) ... = ...

Ибо меня мало интересует имя типа вне контекста источника данных. Для этих целей можно определить дополнительные алиасы:

module GetProjectIssues =
    type response = GetIssueById.response list

    // алиасы для удобства
    type ``response -`` = GetIssueById.response
    type ``response - status`` = GetIssueById.``response status``
    // ...

Здесь я сознательно оставил определение response нетронутым. Тултипы IDE обычно не показывают содержимое исходного типа алиасов, информация исчерпывается записью вида type A = B.C. Если требуется увидеть исходник (например, для сборки рекорда), то надо проскакать по всей цепочке «наследования» туда и обратно, что даже с хоткеями занимает несколько секунд. Из-за этого я избегаю алиасов на редкие алиасы, если у нас нет необходимости подстраховать себя в рукописном коде. В сгенерированном коде такой проблемы нет, так как корректность графа можно гарантировать на уровне алгоритма.

Структурная группировка

Иногда у нас нет необходимости в полной библиотеке сервиса. Особенно в тех случаях, когда в двух-трёх бесполезных методах происходит какая-то адская пляска, понять которую можно только после консультаций со специалистом. Мне проще исключить эти методы из перечня, чем дёргать лишний раз человека. Возможен обратный ход, когда я беру только те методы, что собираюсь использовать. Оба варианта ломают общую иерархию, что может приводить к очень странной конструкции алиасов. В этом случае можно подумать об анонимных рекордах. Они имеют структурную эквивалентность в широком смысле и могут быть определены так:

// Объект лежащий в свойстве `status` объекта `root`.
type ``response status`` = {|
    id : string
    title : string
|}

// Объект лежащий в списке в свойстве `assignedUsers` объекта `root`.
type ``response assignedUsers -`` = {|
    id : System.Guid
    username : string
|}

// Сам `root`.
type response = {|
    id : System.Guid
    title : string
    status : ``response status``
    assignedUsers : ``response assignedUsers -`` list
|}

При такой записи response status является алиасом на тип:

{|
    id : string
    title : string
|}

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

По меркам F# это экстремальный подход. Теряются некоторые преимущества вывода типов, отламывается частичный pattern matching, хуже работают подсказки IDE, нельзя быстро насаживать расширения, и иногда доводить рекорды до корректного состояния надо руками по памяти.

Ситуацию усугубляет социальная нетипичность подхода, а значит, и отсутствие должной подготовки. Я не знаю ни одного новичка, который бы озаботился устройством механизма вывода типов компилятора. У меня был случай, когда человек реализовал модуль с 47 упоминаниями одного типа (не считая других), хотя месяц спустя рефакторинг показал, что обязательными были лишь 9. Меня бы этот факт вообще не волновал, если бы код работал как надо, но это было не так. У задачи были все предпосылки к тотальному решению, но они не были реализованы из-за попыток не плодить типы. Чрезмерность была порождена не рвением и не желанием перестраховаться, а серьёзным недопониманием, из-за чего компилятор без явных указаний от разраба постоянно уходил в неверном направлении. Это порождало боль и желание её избежать, что било по проекту.

Сейчас я таких пробелов в воспитании не допускаю, но я буду вынужден проводить повторный инструктаж перед подобным применением анонимных рекордов, ибо их «синтаксическое» поведение пока ещё недостаточно проработано (правки вносились в недавних версиях языка, но я ожидаю продолжения в перспективе). Частично тему может закрыть эта статья. Но я всё равно рекомендую поиграть в REPL-е с выводом непримитивных типов, ибо F#-исту в любом случае необходимо грамотно протаскивать контекст в ХМ (алгоритм вывода типов Хиндли-Милнера). Чтобы срезать углы без последствий, надо знать об особенностях крышечки (которая let inline (^) f x = f x), пайпов (в том числе двойных, тройных и обратных), конвейеров, явных и неявных yield (включая yield!), let! и т. п.

Номинальные типы удобны при рваных локальных клиентах, но есть ряд ограничений при передаче анонимных рекордов между сборками. Их можно свести к общему правилу — чем больше дистанция между объявлением и использованием, тем выше шанс, что компилятор остановит сборку. Конкретнее я сказать не могу, так как знаю об ограничениях очень мало, а централизованной справки по ним не существует в природе. Мы активно используем анонимные рекорды, но в последние годы я перестал сталкиваться с соответствующими жалобами компилятора, из-за чего был уверен, что его команда эту проблему постепенно закрывает. Однако мой источник в кремле компиляторе буквально только что сказал, что конкретно бинарную совместимость анонимных рекордов они принципиально не трогают.

Имена методов

Мы полностью закрыли проблему имён для DTO, но не для методов. В рамках данной статьи я пытаю сервис, в документации которого нет нужных строк, чтобы механически преобразовать их в GetIssueById и GetProjectIssues. Эти названия рождены моей волей, но я предпочёл бы тратить её на что-нибудь поинтереснее.

Path и HttpMethod образуют уникальную пару, которая может сойти за имя. GetIssueById в оригинале выглядит как /project/{userAlias}/{projectAlias}/issue/{localId} GET, что может быть представлено как:

// rec, на случай перекрёстных ссылок.
namespace rec GitFlic

module project =
    // Фигурные скобки разрешены, в отличие от квадратных.
    module ``{userAlias}`` =
        module ``{projectAlias}`` =
            module issue =
                module ``{localId}`` =
                    module GET =
                        type ``response status`` = {|
                            id : string
                            title : string
                        |}

                        type ``response assignedUsers -`` = ...
                        type response = ...

Можно ли воспользоваться такой структурой? Наверное, можно. Я предполагал в этом месте сказать несколько веских «но», чтобы вы отказались от этого подхода, но пришёл к выводу, что у меня нет доводов нужной силы. Все аргументы сводятся к угнетающей необходимости собирать огромное число модулей и типов в одно единственное древо. Так что если древо маленькое и/или неглубокое, то делайте как удобно. А если большое, то подумайте о его разделении через пространства имён.

Я использую аналогичную структуру для похожих задач, но применительно к REST считаю её избыточной, так как в данном случае иерархия описывает не внутреннюю логику сервера, а маршрутизацию (в общем смысле слова) его методов. Их редкие совпадения — дело случая, а не результат целенаправленных усилий. Как следствие, я не вижу смысла в просачивании иерархии путей в определение типов/модулей. От такого больше вреда, чем пользы. Гораздо проще уровнять все методы до:

namespace GitFlic.DTO

module ``project {userAlias} {projectAlias} issue {localId} GET`` =
    type ``response status`` = {|
        id : string
        title : string
    |}

    type ``response assignedUsers -`` = ...
    type response = ...
  • Первый / отбрасывается.

  • Все последующие / заменяются на пробелы.

  • Константные токены и HttpMethod остаются без изменений.

  • Неконстантные токены остаются в фигурных скобках.

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

module ``project {userAlias} {projectAlias} issue GET`` =
    type response = ``project {userAlias} {projectAlias} issue {localId} GET``.response list

    // алиасы для удобства
    type ``response -`` = ``project {userAlias} {projectAlias} issue {localId} GET``.response
    type ``response - status`` = ``project {userAlias} {projectAlias} issue {localId} GET``.``response status``
    // ...

Это избавляет нас от namespace/module rec и убер-файлов на десятки тысяч строк. Небольшие пляски всё равно будут, так как первый файл должен вместить в себя все «проблемные» методы, DTO которых используются в других методах. Но все последующие файлы могут группироваться и упорядочиваться произвольно.

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

Напомню, что F# позволяет создавать алиасы на модули, правда без выхода за пределы скоупа:

module Page

module GetIssueById = GitFlic.DTO.``project {userAlias} {projectAlias} issue {localId} GET``

let label (issue : GetIssueById.response) ... = ...

Промежуточное заключение

Мы обсудили принципы проекции внешних контрактов в DTO-типы на примере REST API. С моей точки зрения, основным преимуществом перечисленных практик является их претензия на тотальность, на полное закрытие вопроса. За границами остаются лишь те случаи, которые при любых подходах пришлось бы разруливать руками.

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

Основной минус — некоторые из предлагаемых решений нетипичны, и на чужой территории такое пришлось бы обсуждать и продавливать. Моей задачей было сэкономить время и ресурсы, уничтожив всякую почву для дискуссий по акциденциям, даже если эти дискуссии намечались только в границах одной головы. Таким образом возникает явный конфликт между целью и средствами её достижения. Решать его предлагаю механически, тупо сравнив энергозатраты.

В следующей части мы разберёмся с методами REST, где вернём «потерянную» иерархию, но в более практичном виде:

let! issues = gitflic.project.["kleidemos"].["myFirstProject"].issue.GET(limit = 24)

Автор статьи @kleidemos


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

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