Как стать автором
Обновить

Humane API REST Protocol

Время на прочтение22 мин
Количество просмотров6K
Автор оригинала: Jin

Здравствуйте, меня зовут Дмитрий Карловский и я… как скульптор, отрезаю всё лишнее, чтобы оставить лишь самую мякотку, которая в наиболее лаконичной и практичной форме решает широкий круг задач. Вот лишь несколько спроектированных мною вещей:


  • MarkedText — стройный легковесный язык разметки текста (убийца MarkDown).
  • Tree — структурированный формат представления данных (убийца JSON и XML).

На этот же раз мы спроектируем удобный клиент-серверный API, призванный убрать кровавую пелену с глаз фронтендеров и стальные мозоли с пальцев бэкендеров..


HARP OData GraphQL
Architecture ✅REST ✅REST ❌RPC
Common uri query string compatible ⭕Back ✅Full
Single line query
Pseudo-static compatible ⭕Back ⭕Partial
Same model of request and response
File name compatible
Web Tools Friendly
Data filtering ⭕Unspec
Data sorting ⭕Unspec
Data slicing ⭕Unspec
Data aggregation ⭕Unspec
Deep fetch
Limited logic
Metadata query
Idempotent requests ✅Full ⭕Partial ❌Undef
Normalized response

Application Programming Interface


Архитектурно можно выделить три основных подхода: RPC, REST и протоколы синхронизации. Разберём их подробнее..


Remote Procedure Call


Тут мы сначала выбираем какую процедуру вызвать. Потом передаём на сервер её имя и аргументы. Сервер её выполняет и возвращает результат.


Известные примеры RPC протоколов:



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


Другой особенностью является невозможность кеширования запросов на прокси, так как они не имеют информации о том, что и как можно кешировать.


REpresentational State Transfer


Тут идея в том, чтобы выделить объекты с которыми можно взаимодействовать (ресурсы), стандартизировать их идентификаторы (URI) и процедуры для взаимодействия с ними (методы). Число типов ресурсов получается таким образом в несколько раз меньше, чем число процедур в случае RPC, что существенно проще в использовании человеком.


Известные примеры REST протоколов:



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


Synchronization protocols


Тут на уровне протокола вообще нет методов, а узлы сети просто обмениваются дельтами внесённых локально изменений. Эти виды протоколов характерны для децентрализованных систем, поддерживающих работу в оффлайне. Известные представители данного типа протоколов… мне не известны. Но сейчас я разрабатываю один из таких, который вскоре перевернёт весь мир. Но пока что мы остановимся на чём-то более традиционном — REST..


Architecture


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


Pseudo-Static


Важно отметить, что REST — это совсем не про URI похожие на пути к файлам вида:


/users/jin/chats/123/messages/456/likes.json
/organizations/hyoo/chats/123/messages/456/likes.json

Эти две ссылки фактически ссылаются на один и тот же ресурс. То есть первая же проблема с ними — выбор каноничной ссылки из множества вариантов.


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


Следующая проблема — раздутие размера из-за избыточной информации, что приводит либо к переносу на несколько строк в случайных местах, либо вообще к обрезанию.


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


Резюмируя: URI должен содержать лишь минимально необходимую информацию для идентификации самого ресурса, но не его положение в той или иной иерархии.


Create Read Update Delete


Не менее важно отметить, что REST не только и не столько про CRUD, не смотря на то, что CRUD хорошо выражается через основные HTTP методы:


  • CreatePOST
  • ReadGET
  • UpdatePUT/PATCH
  • DeleteDELETE

У CRUD тем не менее есть ряд существенных недостатков..


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


Удаление ресурса нарушает ссылочную целостность. И если в рамках нашей системы мы можем обновить или удалить все ссылки, то внешние системы так и продолжат ссылаться в никуда. Поэтому предпочтительнее ресурсы не удалять полностью, а лишь помечать скрытыми.


Таким образом для нашего протокола хватит лишь двух HTTP-методов:


  • GET для чтения.
  • PATCH для обновления.

Важно отметить, что ресурс может быть довольно большим, поэтому важны механизмы как частичного чтения (fetch plan), так и частичного обновления (PATCH, но не PUT).


Real Time


Подход HTTP с запросом/ответом плохо подходит для современных приложений, которым нужно в реальном времени реагировать на изменения, не заваливая сервер запросами вида "а не изменилось ли что?". Для таких приложений необходимо поднимать двустороннее WebSocket соединение. А чтобы не повторять одну и ту же логику дважды, HTTP запросы можно делать через него:



В дополнение к стандартным GET и PATCH, при соединении по WebSocket стоит поддержать ещё пару методов:


  • WATCH — это то же самое, что и GET, но дополнительно подписывает клиента на обновления ресурса.
  • FORGET — просто отписывает от обновлений.

Keys


При выборе способа идентификации сущности можно выделить два основных подхода:


  • Натуральный ключ, формирующийся из свойств сущности. Но он не эффективен, не гарантирует уникальность и может динамически меняться, что создаёт множество проблем.
  • Суррогатный ключ, генерирующийся автоматически. Он эффективен, неизменен и гарантированно уникален.

Ключевое свойство тут — неизменность. Идентификатор не должен меняться со временем, иначе его сложно назвать идентификатором. Поэтому нам подойдёт любой неизменный ключ. И зачастую натурального неизменного ключа попросту не удаётся придумать. Поэтому как правило это должен быть именно суррогатный.


Model


Как правило, прикладная область представляет из себя граф, где узлами выступают несколько десятков типов сущностей, а рёбрами — несколько сотен типов отношений между ними.


Часть этих сущностей и отношений представлена в базе данных в явном виде, что характерно для рёбер в графовых СУБД. Часть — в неявном, что характерно для отношений в реляционных СУБД. А часть может быть виртуальными, вычисляемыми на лету.


Кстати, на тему графовых СУБД у меня есть пара интересных статей:



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


Итак, опишем наиболее простую, но гибкую модель:


  • Entity — документ, хранящий различные данные.
  • Type — тип сущности, который определяет какие у неё есть свойства и какого они типа.
  • ID — суррогатный идентификатор сущности, уникальный в рамках её типа.
  • URI — уникальный идентификатор сущности в рамках всего API, представляющий из себя ссылку относительно базового URI API.
  • Сущности могут содержать URI других сущностей в качестве значений свойств, что позволяет им образовывать граф.

Fetch Plan


Часто при реализации REST API ресурс возвращается целиком. Хороший анти пример — поиск через GitHub API, выдача которого запросто может весить полтора мегабайта вместо требуемых для отображения меню пары килобайт. Это типичная проблема, называемая overfetch.


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


Так же тут можно заметить, что если один и тот же пользователь встречается в нескольких местах, то одни и те же его данные дублируются многократно. В моей практике был курьёзный случай с менеджером задач, где каждая задача находилась в нескольких папках, те в нескольких других, и так далее до корня. И когда приложение при старте запрашивало дерево папок, то вместо десятка килобайт данных, оно получало ответ в десятки мегабайт. А это мало того, что нагружало сервер и сеть, так ещё и Internet Explorer просто падал при попытке распарсить столь большой JSON.


Последняя проблема является следствием денормализации данных, не являющихся по своей сути ориентированным деревом. Особую пикантность этой ситуации придавало то, что сервер получал данные из базы в нормальной форме, но для выдачи клиенту производил денормализацию. А клиент, получая данные в денормализованной форме, делал обратную нормализацию, чтобы избавиться от дубликатов.


Отчасти поэтому GitHub со временем перешёл на более модный GraphGL, который решает первые две проблемы, но не последнюю. Мы же решим их все. А значит нам нужно следующее:


  • Partial Fetch — указание в запросе какие именно поля надо выгружать.
  • Recursive Fetch — если в поле находится ссылка на другой ресурс, то указание в запросе, какие его свойства надо выгружать.
  • Filtered Fetch — указание ограничений как для непосредственной выдачи, так и для загружаемых рекурсивно коллекций.
  • Normalized Output — возвращение в ответе небольшого среза графа в нормальной форме без дублирования.

Query


Итак, ближе к делу, пришла пора разработать язык запросов ко графу в рамках REST архитектуры..


Applications


Прежде всего надо определиться где и как будут использоваться запросы:


  • В коде приложения в виде литерала прописан URI.
  • Через специальный DSL формируется URI с подстановкой динамических данных.
  • Разработчик может просто открыть URI в браузере, чтобы посмотреть что возвращает сервер.
  • В сетевом логе клиента разработчик может найти интересующий его запрос, чтобы посмотреть подробности.
  • В сетевом логе сервера запросы тоже часто выводятся в одну строку с минимальными подробностями.
  • Выдача может быть сохранена в виде файла. Хорошо бы иметь сам запрос в качестве его имени.
  • Тот же формат может быть использован и для адресов страниц для пользователей.
  • URI может быть отправлен в чате, комментарии, социальной сети и тд.
  • URI может выводиться в XML в том числе в виде идентификатора узла.

Special Symbols


Так как запрос может содержать пользовательские данные, то в них придётся экранировать все спецсимволы. В формате URL уже есть ряд стандартных спец символов, поддерживаемых любыми инструментами:


: @ / $ + ; , ? & = #

Они экранируются при использовании encodURIComponent, но не при использовании encodeURI, то есть с этими символами мы точно не получим неожиданного экранирования. Однако, с некоторыми из них всё же есть проблемы:


  • : / ? — не допустимы в именах файлов.
  • : — не допустим в начале пути URL.
  • / — ряд инструментов показывает лишь последний сегмент пути после него, что порой не информативно.
  • ? # — ряд инструментов экранирует множественные вхождения этих символов в URL.
  • # — всё, что после этого символа, браузер на сервер не передаёт.
  • & — требует неуклюжего экранирования в XML: &.

Таким образом, ряд допустимых спецсимволов сокращается до:


@ $ + ; , =

Так как нам надо делать глубокие выборки, то нам нужны те или иные формы скобок. Однако, эти символы на роль скобок совершенно не подходят. Давайте проанализируем какие виды наглядных скобок вообще есть:


  • () — не экранируются в пользовательских данных стандартными инструментами (encodeURIComponent), так что совсем не подходят.
  • <> — экранируются при отображении в Chrome Dev Tools, что резко снижает наглядность. Не допустимы в именах файлов. Требует неуклюжего экранирования в XML.
  • {} — экранируются при использовании до ? в Chrome, но если размещать запрос после ?, то всё хорошо.
  • [] — не экранируется ни в адресной строке браузеров, ни в их логах запросов. Вообще супер!

Так что дополним допустимое множество спецсимволов квадратными скобками:


@ $ + ; , = [ ]

Отдельно стоит отметить символы, которые не экранируются в пользовательских данных:


~ ! * ( ) _ - ' .

Их допустимо использовать, но лишь в тех местах, где синтаксически не может быть пользовательских данных. Впритык к ним такие символы тоже лучше не использовать, чтобы визуально они не сливались.


Syntax


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


Чтобы полностью идентифицировать сущность нам надо указать её тип и идентификатор. Нет ничего естественнее, чем соединить их через =:


person=jin

Как можно заметить, это не полный URI, а его сокращённая форма. Если базовый URI API https://example.org/, то полный URI сущности получится такой:


https://example.org/person=jin

Теперь, если в выдаче по этой ссылке мы получим, например article=123, то такой URI тоже правильно отрезолвится в:


https://example.org/article=123

Относительные URI хороши не только тем, что они короткие, но и тем, что мы можем работать с одним и тем же графом через разные API Enpoints, что очень полезно, например, при переезде API.


Что если нам нужен не один пользователь, а все? Просто убираем идентификатор и получаем всю коллекцию:


person

Да, в общем случае, имя — это не тип, а имя коллекции или поля. Воспользуемся ;, чтобы выбрать сразу несколько коллекций:


person;article;section

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


person[name;age];article[title;content]

Скобки можно использовать рекурсивно, чтобы делать глубокие выборки:


person=jin[name;age;friend[name;age]]

Тут мы выбрали имена и возраста конкретного пользователя и всех его друзей.


Предикат после имени в общем случае является не указанием ID, а произвольным фильтром. Просто для сущностей он интерпретируется как фильтр по ID. Для примера, загрузим не всех пользователей, а только девушек:


person[name;phone;sex=female]

Тут важно отметить, что фильтрация по какому-либо полю обычно сопряжена с загрузкой этого поля. Поэтому для каждой девушки тут будет выдано не только имя и номер телефона, но и пол. Это может показаться избыточным в данном примере. Но только до тех пор, пока мы не узнаем, что под female может скрываться и trap, и было бы не плохо по выдаче это распознать. Так что клиенту лучше не строить гипотез касательно фактических значений полей, а просто получать их от сервера.


Предикат может быть как позитивным, так и негативным. Так что оставим лишь незамужних девушек, используя !=:


person[name;phone;sex=female;status!=married]

В качестве значения можно указать не только конкретное значение, но и диапазон, используя @. Диапазоны могут быть следующих видов:


  • Закрытый с нижней границей: lo@
  • Закрытый с верхней границей: @hi
  • Закрытый с обеими границами: lo@hi
  • Закрытый с совпадающими границами: val@val или просто val

Да, любое одиночное значение — это на самом деле диапазон. Уточним, что нас интересуют лишь взрослые девушки:


person[name;phone;sex=female;status!=married;age=18@]

А диапазон может быть не один, а несколько, разделённых ,. Так что добавим, что помолвленные девушки нас тоже не интересуют:


person[name;phone;sex=female;status!=married,engaged;age=18@]

Выдачу мы можем сразу отсортировать по загружаемым полям, используя префикс + для сортировки по возрастанию и - для сортировки по убыванию. Для примера, выведем в начало молодых девушек, с максимальным числом талантов:


person[name;phone;sex=female;status!=married,engaged;-skills;+age=18@]

Приоритет сортировки полей определяется расположением их в запросе. Кто первый встал — того и тапки.


Как и в случае с фильтрами, сортировки тоже приводят к автоматической загрузке соответствующих полей, что позволяет сохранять URI коротким.


У каждой сущности есть обобщённые поля, начинающиеся с _, через которые можно, например, получать агрегированную информацию, вместо детальной. Если вместо числового поля с числом талантов у нас есть лишь поле со ссылками на сущности описывающие эти таланты, но мы не хотим их все загружать, то можем просто получить их число, используя функцию _len:


person[name;phone;sex=female;status!=married,engaged;-_len[skill];+age=18@]

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


person[name;phone;sex=female;status!=married,engaged;-_len[skill[kind=kids]];+age=18@]

Другие агрегационные функции: _sum, _min, _max. И этот список будет расширяться. Каждая функция сама определяет сколько и каких параметров ей надо передавать.


Если же мы хотим получить не весь список, а, например, лишь первые 20, то можем воспользоваться другим обобщённым полем — _num, которое содержит номер сущности в конкретном списке (сам номер при этом не возвращается):


person[_num=0@20;name;phone;sex=female;status!=married,engaged;-_len[skill[kind=kids]];+age=18@]

Вот и весь язык запросов. Как видите, весьма короткий запрос позволяет довольно точно указать, что мы хотим. Если в последнем URI вы узнали себя — срочно пишите мне телеграмы. А с теми кто остался мы продолжаем..


Back Compatibility


Символ ; для разделения параметров выбран из соображений удобочитаемости и универсальности. Однако, не сложно заметить, что если парсер будет поддерживать также и &, то его можно будет использовать и для для обычных QueryString. Это позволяет, плавно мигрировать с QueryString на HARP:


search=harp&offset=0&limit=10

Но и это ещё не всё, добавив / с той же семантикой, мы сможем разбирать и pathname:


users/jin/comments=123

А добавив ещё и ? с #, можем всё это комбинировать:


users/jin/comments?since=2022-08-04#scrollTop=9000

TypeScript API


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



Используются они так:


const harp = $hyoo_harp_from_string( 'person[+age=18@;+name;article[title];_num=20@29]' )
// {
//     person: {
//         age: {
//             '+': true,
//             '=': [[ '18', '' ]],
//         },
//         name: {
//             '+': true,
//         },
//         article: {
//             title: {},
//         },
//         _num: {
//             '=': [[ '20', '29' ]],
//         },
//     },
// }

const ADULT = [ 18, '' ]
const page = 2
const nums = [ page * 10, ( page + 1 ) * 10 - 1 ]

const uri = $hyoo_harp_to_string({
    person: {
        age: {
            '+': true,
            '=': [ ADULT ],
        },
        name: {
            '+': true,
        },
        article: {
            title: {},
        },
        _num: {
            '=': [ nums ],
        },
    },
})
// person[+age=18@;+name;article[title];_num=20@29]

Эти функции слабо типизированы. В том смысле, что ничего не знают про структуру графа. Но мы можем объявить схему, используя, например, $hyoo_harp_scheme, основанном на $mol_data:


const Str = $mol_data_optional( $hyoo_harp_scheme( {}, String ) )
const Int = $mol_data_optional( $hyoo_harp_scheme( {}, Number ) )

const Article = $hyoo_harp_scheme({
    title: Str,
    content: Int,
})

const Person = $hyoo_harp_scheme({
    name: Str,
    age: Int,
    article: $mol_data_optional( Article ),
})

Теперь мы можем собирать URI и тайп чекер будет гарантировать, что мы нигде не ошибёмся в именах, и даже будет подсказывать нам при вводе:


// person[name;age;article[title]]
const query = Person.build({
    person: {
        name: {},
        age: {},
        article: {
            title: {},
        },
    },
})

И наоборот, полученный от клиента URI мы легко можем распарсить, получив строго типизированный JSON:


const query = Person.parse( 'person[+age=18@;+name;article[title];_num=20@29]' )

const article_fetch1 = Object.keys( query.person.article ) // ❌ article may be absent
const article_fetch2 = Object.keys( query.person.article ?? {} ) // ✅
const follower_fetch = Object.keys( query.follower ?? {} ) // ❌ Person don't have follower

Наконец, даже если у нас уже есть какое-то JSON представление запроса, то мы можем его статикодинамически провалидировать:


const person1 = Person({}) // ✅
const person2 = Person({ name: {} }) // ✅
const person3 = Person({ title: {} }) // ❌ compile-time error: Person don't have title
const person4 = Person({ _num: [[ Math.PI ]] }) // ❌ run-time error: isn't integer
const person5: typeof Person.Value = person2 // ✅ same type

Осталось научиться генерировать TS схему из её декларативного описания, чтобы не приходилось её заново описывать для каждого языка отдельно.


Response


Так как модель прикладной области представляет из себя граф, а в ответе нужно возвращать её подграф, то нам нужна возможность представления графа в виде дерева без дублирования. Для этого разделим представление графа на 4 уровня:


  • Type — Определяет типы хранящихся в них сущностей. Это важно для языков со статической типизацией, чтобы использовались соответствующие структуры данных для обработки ответа.
  • ID — Идентифицируют сущность в рамках типа.
  • Field — Имя поля сущности.
  • Value — Значение поля, тип которого определяется схемой и именем поля.

В качестве значений могут быть URI других сущностей. Именно URI, а не ID, так как в общем случае в одном списке могут идти разные типы сущностей вперемешку.


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


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


Format


Разным клиентам может быть удобно работать с разными форматами представления данных, поэтому используя Content Negotiation позволим ему выбирать один из следующих:


  • JSON: Accept: application/json (самый популярный)
  • Tree: Accept: application/x-harp.tree (наиболее наглядный)
  • XML: Accept: application/xml (по умолчанию)

Разберём их по подробнее на примере следующего запроса:


person[name;age;article[title;author[name;_len[follower[vip=true]]]]];me

JSON


Начнём с самого популярного сейчас формата, для лучшего понимания:


{
    "_query": {
        "person[name;age;article[title;author[name;_len[follower[vip=true]]]]]": {
            "reply": [
                "person=jin",
            ],
        },
        "me": {
            "reply": [
                "person=jin",
            ],
        },
    },
    "person": {
        "jin": {
            "name": "Jin",
            "age": { "_error": "Access Denied" },
            "article": [
                "article=123",
                "article=456",
            ],
            "_len": {
                "follower[vip=true]": 100500,
            },
        }
    },
    "article": {
        "123": {
            "title": "HARP",
            "author": [
                "person=jin",
            ],
        },
        "456": { "_error": "Corrupted Database" },
    },
}

У JSON, однако, есть множество недостатков, таких как:


  • Многострочный текст вытягивается в одну строку.
  • Много визуального шума.
  • Либо много весит, либо вытягивается в одну строку.

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


Tree


_query
    \person[name;age;article[title;author[name;_len[follower[vip=true]]]]]
        reply \person=jin
    \me
        reply \person=jin
person
    \jin
        name \Jin
        age _error \Access Denied
        article
            \article=123
            \article=456
        _len
            \follower[vip=true]
                100500
article
    \123
        title \HARP
        author \person=jin
    \456
        _error \Corrupted Database

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


XML


<?xml-stylesheet type="text/xsl" href="_harp.xsl"?>
<harp>
    <_query id="person[name;age;article[title;author[name;_len[follower[vip=true]]]]]">
        <reply>person=jin</reply>
    </_query>
    <_query id="me">
        <reply>person=jin</reply>
    </_query>
    <person id="person=jin">
        <name>Jin</name>
        <age _error="Access Denied" />
        <article>article=123</article>
        <article>article=456</article>
        <_len id="person=jin/follower[vip=true]">100500</_len>
    </person>
    <article id="article=123">
        <title>HARP</title>
        <author>person=jin</author>
    </article>
    <article id="article=456" _error="Corrupted Database" />
</harp>

Обратите внимание на подключение XSL шаблона в самом начале. Он нужен для того, чтобы при открытии URI в браузере показывался не голый дамп XML или JSON, а красивая HTML страница с иконками, кнопками и рабочими гиперссылками. Это делает URI полностью самодостаточным: вам не нужно искать где-то актуальную документацию — она доступна ровно там же, где и сами данные, по которым можно легко ходить туда-сюда (HATEOAS на максималках).


Тут я сделал небольшой пример, но можно гораздо, гораздо лучше! Как минимум, что хотелось бы видеть:


  • Быстрое переключение между вариантами представления (UI, Tree, JSON, XML).
  • Все URI являются гиперссылками.
  • Полнотекстовый поиск по выдаче.
  • Древовидное представление со сворачиванием и разворачиванием.
  • Табличное представление сущностей одного типа.
  • Кнопки для быстрого изменения запроса (например, добавить поле в запрос одним кликом).
  • Описания сущностей и полей, взятые из схемы.

Create & Update


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


Например, перешлём немного донатов автору данного опуса, и получим актуальные данные сразу по обоим пользователям:


PATCH /person=jin,john[ballance;transfer]

transfer
    \12345
        from \john
        to \jin
        amount 9000

И в случае успеха будет такой ответ:


person
    \jin
        ballance 100500
        transfer
            \transfer=34567
            \transfer=12345
    \john
        ballance -100500
        transfer
            \transfer=23456
            \transfer=12345

Или создадим одной транзакцией сообщение с голосовалкой:


PATCH /

message
    \12345
        chat \123
        body \Do you like HARP?
        attachment \poll=67890
poll
    \67890
        option
            \Yes
            \No
            \There is no third way

А в ответе будет пусто, так как мы тут ничего не запросили.


Comparison


Что ж, давайте теперь сравним наш гуманный протокол с ближайшими альтернативами..


Architecture


HARP OData GraphQL
Architecture ✅REST ✅REST ❌RPC

REST архитектура предпочтительнее. Не даром именно под неё когда-то и разрабатывался HTTP.


Common Query String


HARP OData GraphQL
Common uri query string compatibile ⭕Back ✅Full

HARP обратно совместим с традиционным представлением HTTP запросов. OData же совместима полностью, но какой ценой..


Вы только сравните OData запрос:


GET /pullRequest?$filter=state%20eq%20closed%20or%20state%20eq%20merged&$orderby=repository%20asc%2CupdateTime%20desc&$select=state%2Crepository%2Fname%2Crepository%2Fprivate%2Crepository%2Fowner%2Fname%2CupdateTime
%2Cauthor%2Fname&$skip=20&$top=10&$format=json

И эквивалентный HARP запрос:


GET /pullRequest[state=closed,merged;+repository[name;private;owner[name]];-updateTime;author[name];_num=20@30]

GraphQL же вообще не совместим, если, конечно, не считать совместимостью засовывание всего запроса в один get-параметр.


Single Line


HARP OData GraphQL
Single line query

GraphQL, конечно, тоже можно в одну строку упаковать, но читать его тогда крайне сложно:


GET /graphq?query=%7B%20request%20%7B%20pullRequests(%20state%3A%20%5B%20closed%2C%20merged%20%5D%2C%20order%3A%20%7B%20repository%3A%20asc%2C%20updateTime%3A%20desc%20%7D%2C%20offset%3A%2020%2C%20limit%3A%2010%20)%20%7B%20id%20state%20updateTime%20repository%20%7B%20name%20private%20owner%20%7B%20id%20name%20%7D%20%7D%20updateTime%20author%20%7B%20id%20name%20%7D%20%7D%20%7D%20%7D

Всё же он ориентирован именно на двухмерное представление и никак иначе:


POST /graphql

{
    request {
        pullRequests(
            state: [ closed, merged ],
            order: { repository: asc, updateTime: desc },
            offset: 20,
            limit: 10,
        ) {
            id
            state
            updateTime
            repository {
                name
                private
                owner {
                    id
                    name
                }
            }
            updateTime
            author {
                id
                name
            }
        }
    }
}

Pseudo Static


HARP OData GraphQL
Pseudo-static compatibile ⭕Back ⭕Partial

HARP обратно совместим. В OData пути используются для идентификации ресурсов:


/service/Categories(ID=1)/Products(ID=1)

GraphQL же тут совсем не при делах.


Request vs Response


HARP OData GraphQL
Same model of request and response

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


File Names


HARP OData GraphQL
File name compatible

Не то чтобы возможность вставлять запросы в имена файлов была всем необходима, но в ряде случаев она здорово упрощает жизнь, а достаётся совсем бесплатно.


cp ./report[day=2022-02-22].json ./archive/year=2022

Web Tools


HARP OData GraphQL
Web Tools Friendly

Возможность не ломать глаза об экранирование в разных местах использования URI экономит не очень много времени и нервов, но делает это часто.


Collection Manipulations


HARP OData GraphQL
Data filtering
Data sorting
Data slicing
Data aggregation

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


Limitations


HARP OData GraphQL
Limited filtering logic

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


contains(User/Name, '(aTeam)') and (User/Articles add User/Comments) ge 10

Круто, конечно, но поди разберись: этот запрос можно СУБД отдавать, или он положит её на лопатки, а чинить потом мне в 2 часа ночи?


Metadata


HARP OData GraphQL
Metadata query

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


  • Связанные сущности.
  • Схема данных.
  • Права на действия.
  • Документацию.

В HARP у нас этот вопрос пока почти не проработан. Разве что в том примере я набросал, как это могло бы выглядеть. Остальные протоколы предоставляют лишь часть из перечисленной мета информации. Пока что этот вопрос больше всего проработан в OData.


Idempotency


HARP OData GraphQL
Idempotent requests

HARP возводит идею идемпотентности в абсолют. OData придерживается более традиционного подхода с CRUD. Ну а GraphQL вообще не про идемпотентность. Куда-то не туда индустрия повернула. Опять.


Normal Form


HARP OData GraphQL
Normalized response

Денормализованная выдача может экспоненциально размножить ваши данные. Это проще один раз увидеть, чем 100 раз услышать. Поэтому возьмём не сложный GraphQL запрос за друзьями друзей:


{
    person('jin') {
        id
        name
        friends {
            id
            name
            friends {
                id
                name
            }
        }
    }
}

И получаем такую портянку:


{
    "person": {
        "id": "jin",
        "name": "Jin",
        "friends": [
            {
                "id": "alice",
                "name": "Alice",
                "friends": [
                    {
                        "id": "bob",
                        "name": "Bob",
                    },
                    {
                        "id": "jin",
                        "name": "Jin",
                    },
                ],
            },
            {
                "id": "bob",
                "name": "Bob",
                "friends": [
                    {
                        "id": "alice",
                        "name": "Alice",
                    },
                    {
                        "id": "jin",
                        "name": "Jin",
                    },
                ],
            },
        ],
    }
}

И это всего лишь на трёх собутыльниках. Что будет твориться со школьным классом на 20 человек, я вам не покажу, чтобы не перегружать магистральные каналы связи.


Не смотря на своё название, модель данных GraphQL на самом деле не граф, а… динамическое дерево. Со всеми отсюда вытекающими последствиями.


Крупная корпорация распиарила свою кривую поделку, а все схавали. И, причмокивая, начали пилить костыли, рассказывая остальным, как правильно её готовить:


  • На сервере получили из базы данные в нормальной форме.
  • Денормализовали их для GQL выдачи.
  • Натравили дедубликатор, получив свой, не GQL формат.
  • Отослали клиенту.
  • На клиенте натравили редубликатор для получения GQL ответа.
  • Обработали таки GQL ответ.
  • А клиенту эта денормализация как кость в горле — он нормализует всё обратно.

Куда-то не туда индустрия повернула. Снова.


А вот что мы получим через HARP:


_query
    \person=jin[name;friend[name;friend[name]]]
        reply \person=jin
person
    \jin
        name \Jin
        friend
            \person=alice
            \person=bob
    \alice
        name \Alice
        friend
            \person=bob
            \person=jin
    \bob
        name \Bob
        friend
            \person=alice
            \person=jin

Да, когда вам будут присылать дампы ответов, вам больше не придётся переспрашивать: а запрос-то какой был? Вот он, тут же в ответе.


Post Scriptum


Как видите, это всё не тянет пока что ни на спецификацию, ни даже на какое-то законченное решение. У меня нет цели убедить вас следовать принятым мной решениям и срочно бросаться инкрементить версию вашего API.


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


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


  • Обсудить со мной этот и другие языки можно в чате lang_idioms.
  • Обсудить TS API лучше в чате mam_mol.
  • Свои размышления на разные темы компьютерных наук я выкладываю на Core Dump.
  • Почти все мои статьи доступны на $hyoo_habhub.
  • Приватные записи почти всех моих перфомансов слиты в этот плейлист.
  • Ну а остальную дичь о разработке я пишу в Twitter.
  • Поблагодарить же меня за исследования и мотивировать на новые свершения можно на Boosty.

Спасибо за внимание. Держите сердце горячим, а задницу холодной!


Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Do you like HARP?
34.55% Yes19
56.36% No31
9.09% There is no third way5
Проголосовали 55 пользователей. Воздержались 18 пользователей.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 21: ↑10 и ↓11-1
Комментарии25

Публикации

Истории

Работа

Ближайшие события