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


  • 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.

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


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


  1. karambaso
    02.08.2022 12:41
    +7

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

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

    Смешно вот что - автор серьёзно считате, что он сумел "выделить три основных подхода". Вместо одного RPC, он всё тот же RPC не увидел в своих двух оставшихся "основных подходах". То есть одно и то же, но с мелкими вариациями, автор выдаёт за кардинально различающиеся подходы.

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

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

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


    1. AcckiyGerman
      02.08.2022 12:57
      +4

      Просветите нас пожалуйста насчет

      действительно основных подходов

      Желательно, чтобы эти подходы через HTTP работали, мы же про "клиент-серверный" API в WEB среде говорим?


  1. dopusteam
    02.08.2022 12:49
    +8

    MarkedText — стройный легковесный язык разметки текста (убийца MarkDown).

    Tree — структурированный формат представления данных (убийца JSON и XML

    При всем уважении, заявления про убийц вызывают недоверие и к представленному решению


    1. Fodin
      02.08.2022 16:34

      Не удивлюсь, если однажды появится статья про более компактный язык программирования (убийцу PERL).


      1. nin-jin Автор
        02.08.2022 17:03

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


        1. mikhail-edoshin
          02.08.2022 19:01

          Это интересно :)


  1. kozlyuk
    02.08.2022 13:34
    +4

    Не получилось и не могло получиться.
    Любой стандарт содержит компромиссы.
    Стандарт на всю конструкцию API, от транспорта до URI и структуры содержимого, будет содержать слишком много компромиссов, чтобы устроить значительное количество людей.


    Критика REST несостоятельная:


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

    Определяется задачей.
    Вы просто заточили решение под задачу, где все объекты можно глобально идентифицировать.
    В другой задаче /organizations/bifit/employees/dkozlyuk и /organizations/mpei/employees/dkozlyuk —
    это разные ресурсы, даже если они об одном человеке.
    А его глобально он идентифицируется как /people/dkozlyuk,
    причем через Link можно передать эту ссылку.


    Создание ресурса не является идемпотентным.

    Это проблема выражения Create через POST, а не проблема REST.


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

    Где representational state transfer, и где ссылочная челостность в хранилище данные (а где еще помечать как удаленные?).
    Почему это сделано заботой клиентов?
    Что мешает в классическом REST отдавать Gone / Moved Permanently + Link: rel=archive...?


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

    RFC 5789: PATCH is neither safe nor idempotent as defined by [RFC 2616], Section 9.1.
    Из примеров видно, что вы бывший PUT имитируете через PATCH к другому ресурсу, видимо, подразумевая идемпотентность.


    Идея пустить все через websocket — полный ужас:


    • Занимаемся не своим прикладного уровня делом.
    • Если канал один, теряем асинхронность, а если их несколько, зачем в одном канале двусторонний стриминг.

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


    1. nin-jin Автор
      02.08.2022 14:06

      В другой задаче /organizations/bifit/employees/dkozlyuk и /organizations/mpei/employees/dkozlyuk —это разные ресурсы, даже если они об одном человеке.

      Эти ресурсы не о людях, а о работниках:

      _query
          \employee[org;person;salary]
              reply
                  \employee=123
                  \employee=456
          \person[name;employee]
      employee
          \123
              org \org=ACME
              person \person=dkozlyuk
              salary 9000
          \456
              org \org=ECMA
              person \person=dkozlyuk
              salary 100500
      person
          \person=dkozlyuk
              name \Дмитрий Козлюк
              employee
                  \employee=123
                  \employee=456

      Где representational state transfer, и где ссылочная челостность в хранилище

      Речь про ссылочную целостность всей сети, а не отдельного хранилища.

      RFC 5789: PATCH is neither safe nor idempotent as defined by [RFC 2616], Section 9.1.

      A PATCH request can be issued in such a way as to be idempotent, which also helps prevent bad outcomes from collisions between two PATCH requests on the same resource in a similar time frame.

      Из примеров видно, что вы бывший PUT имитируете через PATCH к другому ресурсу, видимо, подразумевая идемпотентность.

      PUT потребовал бы передачу всего графа. Тут же передаётся именно diff.

      Если канал один, теряем асинхронность

      Не теряем. Запросы посылаются независимо от прихода ответов и уведомлений.


      1. kozlyuk
        02.08.2022 15:27
        +2

        Эти ресурсы не о людях, а о работниках

        О том и речь. Альтернативные пути не зарождаются сами. Либо автор API решил, что бывает удобно так и этак, тогда выбирать не нужно + есть Link: rel=canonical. Либо были объективные причины продублировать, например, все ресурсы организации требовалось поместить под одним префиксом для какого-нибудь внешнего проксирования или контроля доступа.


        Речь про ссылочную целостность всей сети, а не отдельного хранилища.

        Весь подход основан на том, что у нас не ресурсы, определяемые адресами, а объекты, которые можно найти. Значит, про ссылочную целостность мы вообще не говорим, с точки зрения модели данных никаких внешних ссылок нет, есть только объекты и поисковые запросы. Прекрасно! Зачем гвоздями прибивать soft delete, который есть деталь реализации? Какие запросы будут и не будут находить объект, который я пометил удаленным?


        "A PATCH request can be issued in such a way as to be idempotent" не позволяет понять про кокнертный запрос, будет ли он таковым. У вас PATCH / является идемпотентным по соглашению? Как серверу отличить повторный запрос на создание от конфликта? Это по меньшей мере пробел в спецификации. Моделировать создание как применение diff'а к пустому объекту заманчиво, но семантически спорно, даже если это можно так изобразить (как git diff).


        Не теряем [асинхронность]. Запросы посылаются независимо от прихода ответов и уведомлений.

        Отлично, то есть канал не один, раз запросы идет асинхронно. Тогда зачем WATCH и FORGET, если можно сделать GET с потенциально бесконечным ответом и PATCH к источнику уведомлений, чтобы их включать и выключать?


        1. mayorovp
          02.08.2022 15:45
          +1

          Отлично, то есть канал не один, раз запросы идет асинхронно.

          Справедливости ради, нет никакой проблемы передать произвольное число асинхронных запросов через один веб-сокет.


        1. nin-jin Автор
          02.08.2022 15:49

          Альтернативные пути не зарождаются сами. Либо автор API решил, что бывает удобно так и этак

          Это 3 разных ресурса, доступных по 3 разным URI. Нет никакого смысла иметь несколько URI для одного ресурса.

          были объективные причины продублировать, например, все ресурсы организации требовалось поместить под одним префиксом для какого-нибудь внешнего проксирования или контроля доступа

          Это реализуется через разные endpoint. Впрочем, при чём тут контроль доступа или проксирование не очень понял.

          У вас PATCH / является идемпотентным по соглашению?

          Конечно.

          Как серверу отличить повторный запрос на создание от конфликта?

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

          Отлично, то есть канал не один, раз запросы идет асинхронно.

          WebSocket канал один. Послал один WATCH и сидишь, спокойно получаешь данные как на текущий момент, так и при каждом их обновлении. FORGET нужен, чтобы перестать их получать.


          1. kozlyuk
            02.08.2022 17:43

            Это 3 разных ресурса, доступных по 3 разным URI.

            Да, неудачные примеры: лайки в статье очевидно один и тот же ресурс, эти очевидно разные. Возьмем удаленное сообщение: /chat/1/message/999 не существует с момента удаления, /user/2/message/999 доступно в архиве. А что я должен получить по запросу message=999?


            Нет никакого смысла иметь несколько URI для одного ресурса.

            /version/1.2, /version/latest


            Это реализуется через разные endpoint.

            Разные endpoint — это не несколько URI для одного ресурса?


            Впрочем, при чём тут контроль доступа или проксирование не очень понял.

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


            Все возможные сущности виртуально существуют и могут быть изменены.

            На самом деле, нет, потому что для идемпотентного PATCH / вы должны особым образом обрабатывать начальное пустое состояние. Иначе замена пустоты на значение не отличается от замены значения на значение. По факту это PUT, только почему-то для него вы пожадничали оставить метод, а для подписок нет. Между тем, промежуточные узлы от PUT выиграли бы, так как о его идемпотентности они знают.


            Про websocket'ы я не в курсе, под каналом понимаю синхронный канал. Вопрос был в необходимости иметь специальные методы. Впрочем, так как нет URI, а только запросы, то специального URI для управления подписками нет, и методы вполне годятся.


            1. nin-jin Автор
              02.08.2022 18:01

              А что я должен получить по запросу message=999?

              _query
                  \message=999[chat[message];author[message]]
                      reply \message=999
              message
                  \999
                      chat \chat=1
                      author \user=2
              chat
                  \1
                      message
              user
                  \2
                      message \message=999
              

              Разные endpoint — это не несколько URI для одного ресурса?

              В HARP сущности идентифицируются относительно endpoint.

              Фильтр по URI записывается тривиально и работает для любой системы, в которой URI соответствуют требованиям.

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

              Иначе замена пустоты на значение не отличается от замены значения на значение.

              Они в модели HARP и не отличаются.


              1. kozlyuk
                02.08.2022 23:18
                -1

                Как сервер понял, что надо формировать ответ для архива, а не для чата (тогда ответ был бы "ничего не найдено")? Ресурсы существуют независимо от ссылок, но в основе REST лежит принцип, что какой ресурс будет по ссылке, определяется при обращении (late binding). HARP это решительно отвергает.


                Иначе замена пустоты на значение не отличается от замены значения на значение.

                Они в модели HARP и не отличаются.

                Отличаются для PATCH /, чтобы он был идемпотентным. В норме два запроса, заменяющих пустые поля объекта заполенными, применить нельзя, потому что второй обнаружит, что ожидаемых пустых полей уже нет (это ожидание может быть выражено как в diff, так и If-None-Match). Замечу, что запросы "установить значения части полей, что бы там ни было раньше" — это RPC, а не state transfer, потому что какой получится state, мы не знаем.


                1. nin-jin Автор
                  02.08.2022 23:47

                  Как сервер понял, что надо формировать ответ для архива, а не для чата (тогда ответ был бы "ничего не найдено")?

                  Так тут и нет никакого архива. Чат пустой. Сообщение лежит по своему URI. При желании можно добавить в него поле deleted, но тут хватило и простого убирания из чата.

                  REST лежит принцип, что какой ресурс будет по ссылке, определяется при обращении (late binding). HARP это решительно отвергает.

                  Без понятия о чём вы, но в REST ничего похожего нет.

                  Отличаются для PATCH /, чтобы он был идемпотентным. 

                  А ничего, что по определению идемпотентности они отличаться не могут в принципе?

                  Замечу, что запросы "установить значения части полей, что бы там ни было раньше" — это RPC, а не state transfer, потому что какой получится state, мы не знаем.

                  Нет, это Last Write Wins стратегия разрешения конфликтов. И какой получится стейт мы как раз знаем. Если вам так будет проще, то это можно считать мультиплексированным PUT, гранулированный до отдельных полей. Но лучше всё же принять, что PATCH - это не "так как в гите".


  1. kpmy
    02.08.2022 22:09
    -1

    Каждому хочется написать свой язык разметки https://habr.com/ru/post/305168/


  1. mikhail-edoshin
    03.08.2022 09:19
    +1

    Мне нравится. Насчет ключей -- я бы наоборот предпочел натуральный ключ там, где он есть. Например, country=ru, airport=svo , book=9780735619678. И по отношению RPC/REST -- RPC предполагает, что мы обращаемся к объектам с уникальным поведением, а REST -- что мы работаем с хранилищем или что есть только один тип объекта «данные» с разными полями, и поведение у него одно -- создать, изменить, удалить. Идея объектов включает в себя идею данных, так что это не разные вещи, а одно -- подмножество другого. Если делать API к системе, которая не просто хранит данные, а как-то себя ведет (атомный реактор), то без концепции вызова методов не обойтись.


    1. nin-jin Автор
      03.08.2022 09:42

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


      1. mikhail-edoshin
        03.08.2022 10:04
        +2

        Это да, метод в любом случае вызывается с помощью какого-то сообщения, и такое сообщение -- это тоже данные, которые мы добавляем, по сути, в журнал сообщений. Но тогда ресурсы уже не должны поддерживать PATCH, потому что данные их будут меняться не любым залетным запросом, а специально обученным методом. GET остается без ограничений. Хотя для методов можно приспособитьPOST -- посылать сообщения непосредственно ресурсу.


        1. nin-jin Автор
          03.08.2022 10:13

          Если вам нужно изменить данные - просто меняйте эти данные. Отдельная задача вам тут не нужна. Исключение - когда изменения должны поддерживать аудит. Тогда на каждое изменение создаётся новая "проводка".


          1. mikhail-edoshin
            03.08.2022 10:59
            +3

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


            1. nin-jin Автор
              03.08.2022 11:08

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


              1. mikhail-edoshin
                03.08.2022 12:31
                +1

                Соглашусь про одно место, но на мой взгляд одно место -- это как раз одна инструкция по изменению объекта, которой мы передаем описание изменения -- что нужно сделать (имя метода) и детали (параметры). Исторически сложилось так, что диспетчеризация в начале инструкции выглядит для нас как десяток методов, но вообще это части одного целого. Параметры тоже нужно проверять, конечно, но это проще, чем сравнивать старое и новое состояние объекта (а в общем случае это будут два множества объектов) и определять, корректно так менять или нет.


                1. nin-jin Автор
                  03.08.2022 13:07

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