HTTP API refactoring & architecture chapter started

This commit is contained in:
Sergey Konstantinov
2023-02-15 00:14:07 +02:00
parent 7b12b9e541
commit 52377d64e1
5 changed files with 254 additions and 135 deletions
@@ -16,8 +16,9 @@ HTTP появился изначально для передачи размеч
В рамках настоящего раздела мы поговорим о дизайне сетевых API, обладающих следующими характеристиками:
* протоколом взаимодействия является HTTP версий 1.1 и выше;
* форматом данных является JSON (за исключением эндпойнтов, специально предназначенных для передачи файлов в других форматах);
* в качестве идентификаторов ресурсов используется URL в соответствии с описанной в стандарте семантикой;
* семантика вызовов HTTP-эндпойнтов соответствует спецификации, и стандарт нигде не нарушается специально.
* форматом данных является JSON (за исключением эндпойнтов, специально предназначенных для передачи данных, как правило, файлов, в других форматах);
* в качестве идентификаторов ресурсов используется URL в соответствии со стандартом;
* семантика вызовов HTTP-эндпойнтов соответствует спецификации
* никакие из веб-стандартов нигде не нарушается специально.
Такое API мы будем для краткости называть просто «HTTP API» или «JSON-over-HTTP API». Мы понимаем, что такое использование терминологически некорректно, но писать каждый раз «JSON-over-HTTP эндпойнты, утилизирующие семантику, описанную в стандартах HTTP и URL» не представляется возможным.
@@ -1,76 +1,184 @@
### Преимущества и недостатки HTTP API
### Составляющие протокола HTTP и их семантика
После теоретических экскурсов вернёмся, наконец, к практическим вопросам. У читателя может возникнуть резонный вопрос — а почему вообще существует такая дихотомия: какие-то API полагаются на стандартную семантику HTTP, а какие-то полностью от неё отказываются в пользу новоизобретённых стандартов. Например, если мы посмотрим на [формат ответа в JSON-RPC](https://www.jsonrpc.org/specification#response_object), то мы обнаружим, что он легко мог бы быть заменён на стандартные средства протокола HTTP. Вместо
Третье важное подготовительное упражнение, которое мы должны сделать — это дать описание формата HTTP-запросов и ответов и прояснить базовые понятия. Многое из написанного ниже может показаться читателю самоочевидным, но, увы, специфика протокола такова, что даже базовые сведения о нём, без которых мы не сможем двигаться дальше, разбросаны по обширной и фрагментированной документации. Ниже мы попытаемся дать структурированный обзор протокола в том объёме, который необходим нам для проектирования HTTP API.
В описании семантики и формата протокола мы будем руководствоваться свежевышедшим [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), который заменил аж девять предыдущих спецификаций, описывавших разные аспекты технологии (при этом большое количество различной дополнительной функциональности всё ещё покрывается отдельными стандартами, например, метод `PATCH` так и не вошёл в основной RFC и регулируется [RFC 5789](https://www.rfc-editor.org/rfc/rfc5789)).
HTTP-запрос представляет собой применение определённого глагола к URL с указанием версии протокола и передачей дополнительной мета-информации в заголовках и, возможно, каких-то данных в теле запроса:
```
200 OK
POST /v1/orders HTTP/2.0
Host: our-api-host.tld
Content-Type: application/json
{
"jsonrpc": "2.0",
"id",
"error": {
"code": -32600,
"message": "Invalid request"
}
"coffee_machine_id": 123,
"currency_code": "MNT",
"price": "10.23",
"recipe": "lungo",
"offer_id": 321,
"volume": "800ml"
}
```
сервер мог бы ответить просто `400 Bad Request` (с передачей идентификатора запроса, ну скажем, в заголовке `X-Request-ID`). Тем не менее, разработчики протокола посчитали нужным разработать свой собственный формат.
Ответом на HTTP-запрос будет являться конструкция, состоящая из статус-кода ответа, сообщения, заголовков и, возможно, тела ответа.
Такая ситуация (не только конкретно с JSON-RPC, а почти со всеми высокоуровневыми протоколами поверх HTTP) сложилась по множеству причин, включая разнообразные исторические (например, невозможность использовать многие возможности HTTP из ранних реализаций `XMLHttpRequest` в браузерах). Однако, нужно отметить одно принципиальное различие между использованием протоколов уровня приложения (как в нашем примере с JSON-RPC) и чистого HTTP: если ошибка `400 Bad Request` является прозрачной для практически любого сетевого агента, то ошибка в собственном формате JSON-RPC таковой не является — во-первых, потому что понять её может только агент с поддержкой JSON-RPC, а, во-вторых, что более важно, в JSON-RPC статус запроса *не является метаинформацией*. Протокол HTTP позволяет прочитать такие детали, как метод и URL запроса, статус операции, заголовки запроса и ответа, *не читая тело запроса целиком*. Для большинства протоколов более высокого уровня это не так: даже если агент и обладает поддержкой протокола, ему необходимо прочитать и разобрать тело ответа.
```
201 Created
Location: /v1/orders/123
Content-Type: application/json
Иными словами, **при разработке HTTP мы предоставляем возможность промежуточным агентам читать метаданные запросов и ответов**, и именно это является ключевым свойством технологии, которую мы для краткости называем «HTTP API».
{
"id": 123
}
```
Каким образом эта самая возможность читать метаданные нам может быть полезна? Современный стек взаимодействия между клиентом и сервером является (как и предсказывал Филдинг) многослойным. Мы можем выделить множество агентов разного уровня, которые, так или иначе, обрабатывают сетевые запросы и ответы:
* разработчик пишет код поверх какого-то фреймворка, который отправляет запросы;
* фреймворк базируется на API языка программирования, которое, в свою очередь, обращается к API операционной системы;
* запрос доходит до сервера, возможно, через промежуточные HTTP-прокси;
* сервер, в свою очередь, тоже представляет собой несколько слоёв абстракции в виде фреймворка, языка программирования и ОС;
* перед конечным сервером, как правило, находится веб-сервер, проксирующий запрос, а зачастую и не один;
* в современных облачных архитектурах HTTP-запрос, прежде чем дойти до конечного обработчика, пройдёт через несколько абстракций в виде прокси и гейтвеев.
Главное преимущество, которое вам предоставляет следование букве стандарта HTTP — возможность положиться на то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — без необходимости писать какой-то дополнительный код.
##### URL
Если попытаться сформулировать главный принцип разработки HTTP API, то мы получим примерно следующее: **лучше бы ты разрабатывал API так, чтобы промежуточные агенты могли читать и интерпретировать мета-информацию запроса и ответа**.
URL — единица адресации в HTTP API (некоторые евангелисты технологии даже используют термин «пространство URL» как синоним для Мировой паутины). Предполагается, что API в парадигме REST должно использовать систему адресов столь же гранулярную, как и предметная область; иными словами, у любых сущностей, которыми мы можем манипулировать независимо, должен быть свой URL.
В отличие от большинства альтернативных технологий, основной импульс развития которых исходит от какой-то одной крупной IT-компании (Facebook, Google, Apache Software Foundation), инструменты для HTTP API разрабатываются множеством различных компаний и коллабораций. Соответственно, вместо гомогенного, но ограниченного в возможностях фреймворка, для HTTP API мы имеем множество самых разных инструментов, таких как:
* различные прокси и API-гейтвеи (nginx, Envoy);
* различные форматы описания спецификаций (в первую очередь, OpenAPI) и связанные инструменты для работы со спецификациями (Redoc, Swagger UI) и кодогенерации;
* ПО для разработчиков, позволяющее удобным образом разрабатывать и отлаживать клиенты API (Postman, Insomnia) и так далее.
Формат URL регулируется [отдельным стандартом](https://url.spec.whatwg.org/), который развивает независимое сообщество Web Hypertext Application Technology Working Group (WHATWG). Считается, что концепция URL (вместе с понятием универсального имени ресурса, URN) составляет более общую сущность URI (универсальный идентификатор ресурса). (Разница между URL и URN заключается в том, что URL позволяет *найти* некоторый ресурс в рамках некоторого протокола доступа, в то время как URN — «внутреннее» имя объекта, которое само по себе никак не помогает получить к нему доступ.)
Конечно, большинство этих инструментов применимы и для работы с API, реализующими альтернативные парадигмы. Однако именно способность промежуточных агентов считывать метаданные HTTP запросов позволяет легко строить сложные конвейеры типа экспортировать access-логи nginx в prometheus и из коробки получить удобные мониторинги статус-кодов ответов в Grafana.
URL принято раскладывать на составляющие, каждая из которых имеет свою семантику (и может отсутствовать):
* схема (scheme) — протокол обращения (в нашем случае всегда `https:`);
* хост (host; домен или IP-адрес), к которому обращён запрос — самая крупная единица адресации;
* домен может включать в себя поддомены;
* порт (port);
* путь (path) — часть URL между доменным именем (с портом) и символами `?`, `#` или концом строки
* путь традиционно считается способом выразить строгую иерархию сущностей; если сущность `leaf` полностью вложена в сущность `root`, этот факт отражают в виде path `/root/leaf`;
* путь принято разбивать по символу `/` и работать с каждой частью как отдельным токеном — но стандарт этого, вообще говоря, не предписывает;
* пути с символом `/` и без символа `/` в конце (скажем `/root/leaf` и `/root/leaf/`) с точки зрения стандарта являются разными (и URL, отличающиеся только наличием/отсутствием слэша, считаются разными URL), хотя практически нам неизвестны аргументы в пользу того, чтобы не считать такие пути эквивалентными;
* пути могут содержать секции `.` и/или `..`, которые предлагается трактовать по аналогии с такими же символами в путях на файловой системе (и, соответственно, считать URL `/root/leaf`, `/root/./leaf`, `/root/branch/../leaf` эквивалентными), но стандарт этого вновь не предписывает;
* запрос (query) — часть URL после знака `?` до знака `#` или конца строки;
* query принято раскладывать на пары `ключ=значение`, разделённые символом `&`; следует вновь иметь в виду, что стандарт не предписывает query строго соответствовать этому формату;
* фрагмент (fragment; также якорь, anchor) — часть URL после знака `#`;
* фрагмент традиционно рассматривается как адресация внутри запрошенного документа, поэтому многими агентами опускается при выполнении запроса;
* два URL с различными значениями фрагмента могут считаться одинаковыми — а могут не считаться, зависит от контекста.
Главным недостатком HTTP API является то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — даже если вы их об этом не просили. Более того, так как стандарты HTTP являются сложными, концепция REST — непонятной, а разработчики программного обеспечения — неидеальными, то промежуточные агенты могут трактовать метаданные запроса *неправильно*. Особенно это касается каких-то экзотических и сложных в имплементации стандартов.
В HTTP-запросах, как правило (но не обязательно) схема, хост и порт опускаются (и считаются совпадающими с параметрами соединения).
#### Вопросы производительности
**NB**: в стандарте также перечислены разнообразные исторические наслоения (например, передача логинов и паролей в URL или использование не-UTF кодировки), которые нам в рамках вопросов дизайна API неинтересны. Также стандарт содержит правила сериализации, нормализации и сравнения URL, которые в целом полезно знать разработчику HTTP API.
В пользу многих современных альтернатив HTTP API — таких как GraphQL, gRPC, Apache Thrift — часто приводят аргумент о низкой производительности JSON-over-HTTP API по сравнению с рассматриваемой технологией; конкретнее, называются следующие проблемы:
##### Заголовки
1. Избыточность формата:
* в JSON необходимо всякий раз передавать имена всех полей, даже если передаётся массив из большого количества одинаковых объектов;
* большое количество технических символов — кавычек, скобок, запятых — и необходимость экранировать служебные символы в строках.
2. HTTP API в ответ на запрос к ресурсу возвращает представление ресурса целиком, хотя клиенту могут быть интересны только отдельные поля.
3. Низкая производительность операций сериализации и десериализации данных.
4. Передача бинарных данных требует дополнительного кодирования, например, через Base64.
5. Низкая производительность самого протокола HTTP (в частности, невозможность мультиплексирования нескольких запросов и ответов по одному соединению).
Заголовки — это *метаинформация*, привязанная к запросу или ответу. Она может описывать какие-то свойства передаваемых данных (например, `Content-Length`), дополнительные сведения о клиенте или сервере (`User-Agent`, `Date`), или просто содержать поля, не относящиеся непосредственно к смыслу запроса/ответа (например, `Authorization`).
Будем здесь честны: большинство существующих имплементаций HTTP API действительно страдают от указанных проблем. Тем не менее, мы берём на себя смелость заявить, что все эти проблемы большей частью надуманы, и их решению не уделяют большого внимания потому, что указанные накладные расходы не являются сколько-нибудь заметными для большинства вендоров API. В частности:
Важное свойство заголовков — это возможность считывать их до того, как получено тело сообщения. Таким образом, заголовки могут, во-первых, сами по себе влиять на обработку запроса или ответа, и ими можно относительно легко манипулировать при проксировании — и многие сетевые агенты действительно это делают, добавляя или модифицируя заголовки по своему усмотрению (в частности, современные веб-браузеры добавляют к запросам целую коллекцию заголовков: `User-Agent`, `Origin`, `Accept-Language`, `Connection`, `Referer`, `Sec-Fetch-*` и так далее, а современное ПО веб-серверов, в свою очередь, автоматически добавляет или модифицирует такие заголовки как `X-Powered-By`, `Date`, `Content-Length`, `Content-Encoding`, `X-Forwarded-For`).
1. Если мы говорим об избыточности формата, то необходимо сделать важную оговорку: всё вышесказанное верно, если мы не применяем сжатие. [Сравнения показывают](https://nilsmagnus.github.io/post/proto-json-sizes/), что использование gzip очень сильно нивелирует разницу в размере JSON документов относительно альтернативных бинарных форматов (а есть ещё и специально предназначенные для текстовых данных архиваторы, например, brotli).
Подобное вольное обращение с заголовками создаёт определённые проблемы, если ваш API предусматривает передачу дополнительных полей метаданных, поскольку придуманные вами имена полей могут случайно совпасть с какими-то из существующих стандартных имён (или ещё хуже — в будущем появится новое стандартное поле, совпадающее с вашим). Долгое время во избежание подобных коллизий использовался префикс `X-`; уже более 10 лет как эта практика объявлена устаревшей и не рекомендуется к использованию (см. подробный разбор вопроса в [RFC 6648](https://www.rfc-editor.org/rfc/rfc6648)), однако отказа от этого префикса по факту не произошло (и многие широко распространённые нестандартные заголовки, например, `X-Forwarded-For`, его всё ещё содержат). Таким образом, использование префикса `X-` вероятность коллизий снижает, но не устраняет. Тот же RFC вполне разумно предлагает использовать вместо `X-` префикс в виде имени компании. (Мы со своей стороны склонны рекомендовать использовать оба префикса в формате `X-ApiName-Field`; префикс `X-` для читабельности [чтобы отличать специальные заголовки от стандартных], а префикс с именем компании или API — чтобы не произошло коллизий с каким-нибудь другим нестандартным префиксом.)
2. Вообще говоря, если такая нужда появляется, то и в рамках HTTP API вполне можно регулировать список возвращаемых полей ответа, это вполне соответствует духу и букве стандарта. Однако, мы должны заметить, что экономия трафика на возврате частичных состояний (которую мы рассматривали подробно в главе «Частичные обновления») очень редко бывает оправдана.
Помимо прочего заголовки используются как управляющие конструкции — это т.н. «content negotiation», т.е. договорённость клиента и сервера о формате ответа (через заголовки `Accept*`) и условные запросы, позволяющие сэкономить трафик на возврате ответа целиком или частично (через заголовки `If-*`-заголовки, такие как `If-Range`, `If-Modified-Since` и так далее).
3. Если использовать стандартные десериализаторы JSON, разница по сравнению с бинарными форматами может оказаться действительно очень большой. Если, однако, эти накладные расходы являются проблемой, стоит обратиться к альтернативным десериализаторам — в частности, [simdjson](https://github.com/simdjson/simdjson). Благодаря оптимизированному низкоуровневому коду simdjson показывает отличную производительность, которой уж точно хватит для обычного HTTP API.
##### HTTP-глаголы
4. Вообще говоря, парадигма HTTP API подразумевает, что для бинарных данных (такие как изображения или видеофайлы) предоставляются отдельные эндпойнты. Передача бинарных данных в теле JSON-ответа необходима только в случаях, когда отдельный запрос за ними представляет собой проблему с точки зрения производительности. Такой проблемы фактически не существует в server-2-server взаимодействии, и даже в мобильных сетях она уже далеко не так остра.
Важнейшая составляющая HTTP запроса — это глагол (метод), описывающий операцию, применяемую к ресурсу. При разработке HTTP API, как правило, стремятся к тому, чтобы сигнатура `ГЛАГОЛ /url` была максимально читабельна и соответствовала семантике операции над ресурсом.
5. Протокол HTTP 1.1 действительно неидеален с точки зрения мультиплексирования запросов. Однако альтернативные парадигмы организации API для решения этой проблемы опираются… на HTTP версии 2.0. Разумеется, и HTTP API можно построить поверх версии 2.0.
RFC 9110 описывает восемь глаголов (`GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `CONNECT`, `OPTIONS` и `TRACE`), из которых нас как разработчиков API интересует первые четыре. `CONNECT`, `OPTIONS` и `TRACE` — технические методы, которые очень редко используются в HTTP API (за исключением `OPTIONS`, который необходимо реализовать, если необходим доступ к API из браузера). Теоретически, `HEAD` (метод получения *только метаданных*, то есть заголовков, ресурса) мог бы быть весьма полезен в HTTP API, но по неизвестным нам причинам практически в этом смысле не используется.
На всякий случай ещё раз уточним: JSON-over-HTTP API *действительно* проигрывает с точки зрения производительности современным бинарным протоколам. Мы, однако, берём на себя смелость утверждать, что производительность хорошо спроектированного и оптимизированного HTTP API достаточна для абсолютного большинства предметных областей, и реальный выигрыш от перехода на альтернативные протоколы окажется незначителен.
Помимо RFC 9110, множество других RFC предлагают использовать дополнительные HTTP-глаголы (такие, например, как `COPY`, `LOCK`, `SEARCH` — полный список можно найти [здесь](http://www.iana.org/assignments/http-methods/http-methods.xhtml)), однако из всего разнообразия предложенных стандартов лишь один имеет широкое хождение — метод `PATCH`. Причины такого положения дел довольно тривиальны — этих пяти методов (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) достаточно для почти любого HTTP API.
#### Преимущества и недостатки формата JSON
HTTP-глагол определяет два важных свойства HTTP-вызова:
* его семантику (что представляет собой операция);
* его побочные действия, а именно:
* является ли запрос модифицирующим (и можно ли кэшировать ответ);
* является ли запрос идемпотентным.
Как нетрудно заметить, большинство претензий, предъявляемых к концепции HTTP API, относятся вовсе не к HTTP, а к использованию формата JSON. В самом деле, ничто не мешает разработать API, которое будет использовать любой бинарный формат вместо JSON (включая тот же Protobuf), и тогда разница между Protobuf-over-HTTP API и gRPC сведётся только к (не)использованию подробных URL, статус-кодов и заголовков запросов и ответов (и вытекающей отсюда (не)возможности использовать то или иное стандартное программное обеспечение «из коробки»).
| Глагол | Семантика | Модифицирующий | Идемпотентный | Имеет тело |
|--------|----------------------------------|----------------|---------------|------------|
| GET | Возвращает представление ресурса | нет | да | нет |
| PUT | Заменяет (полностью перезаписывает) ресурс согласно данным, переданным в теле запроса | да | да | да |
| DELETE | Удаляет ресурс | да | да | нет |
| POST | Обрабатывает запрос в соответствии со своим внутренним устройством | да | нет | да |
| PATCH | Модифицирует (частично перезаписывает) ресурс согласно данным, переданным в теле запроса | да | нет | да |
Однако, во многих случаях (включая настоящую книгу) разработчики предпочитают текстовый JSON бинарным Protobuf (Flatbuffers, Thrift, Avro и т.д.) по очень простой причине: JSON очень легко и удобно читать. Во-первых, он текстовый и не требует дополнительной расшифровки; во-вторых, имена полей включены в сам файл. Если сообщение в формате protobuf невозможно прочитать без .proto-файла, то по JSON-документу почти всегда можно попытаться понять, что за данные в нём описаны. В совокупности с тем, что при разработке HTTP API мы также стараемся следовать стандартной семантике всей остальной обвязки, в итоге мы получаем API, запросы и ответы к которому (по крайней мере в теории) удобно читаются и интуитивно понятны.
Важное свойство модифицирующих идемпотентных глаголов — это то, что **URL запроса является его ключом идемпотентности**. `PUT /url` полностью перезаписывает ресурс так, что `GET /url` возвращает представление, соответствующее переданном в `PUT /url` телу (в случае JSON-over-HTTP API это, как правило, просто означает, что `GET /url` возвращает в точности тот же контент, чтобы передан в `PUT /url`). `DELETE /url` обязан удалить указанный ресурс — так, что `GET /url` должен вернуть `404` или `410`.
Помимо человекочитаемости у JSON есть ещё одно важное преимущество: он максимально формален. В нём нет никаких конструкций, которые могут быть по-разном истолкованы в разных архитектурах (с точностью до ограничений на длины чисел и строк), и при этом он удобно ложится в нативные структуры данных почти любого языка программирования. С этой точки зрения у нас фактически не было никакого другого выбора, какой ещё формат данных мы могли бы использовать при написании примеров кода для этой книги. В случае же разработки API менее общего назначения, мы рекомендуем подходить к выбору формата по тому же принципу: взвесить накладные расходы на подготовку и внедрение инструментов чтения бинарных форматов и расшифровки бинарных протоколов против накладных расходов на неоптимальную передачу данных и использование разнородного ПО при разработке API.
Идемпотентность и симметричность методов `GET` / `PUT` / `DELETE` влечёт за собой запрет `GET` и `DELETE` иметь тело запроса (поскольку этому телу невозможно приписать никакой осмысленной роли). Однако (по-видимому в связи с тем, что многие разработчики попросту не знают семантику этих методов) распространённое ПО веб-серверов обычно разрешает этим методам иметь тело запроса и транслирует его дальше к коду обработки эндпойнта (использование этой практики мы решительно не рекомендуем).
Достаточно очевидным образом ответы на модифицирующие запросы не кэшируются (хотя при определённых условиях закэшированный ответ метода `POST` может быть использован при последующем `GET`-запросе) и, таким образом, повторный `POST` / `PUT` / `DELETE` / `PATCH` запрос обязательно будет доставлен до конечного сервера (ни один промежуточный агент не имеет права ответить из кэша). В случае `GET`-запроса это, вообще говоря, неверно — гарантией может служить только наличие в ответе директив кэширования `no-store` или `no-cache`.
Один из самых частых антипаттернов разработки HTTP API — это использование HTTP-глаголов в нарушение их семантики:
* размещение модифицирующих операций за `GET`
* промежуточные агенты могут ответить на такой запрос из кэша, если какая-то из директив кэширования отсутствует, либо, напротив, повторить запрос при получении сетевого таймаута;
* некоторые агенты считают себя вправе переходить по таким ссылкам без явного волеизъявления пользователя или разработчика; например, социальные сети и мессенджеры выполняют такие вызовы для генерации оформления ссылки, если пользователь пытается ей поделиться;
* размещение неидемпотентных операций за идемпотентными методами `PUT` / `DELETE`
* хотя промежуточные агенты редко автоматически повторяют модифицирующие запросы, тем не менее это легко может сделать используемый разработчиком клиента или сервера фреймворк
* обычно эта ошибка сочетается с наличием у запроса тела (чтобы всё-таки отличать, что конкретно нужно перезаписать или удалить), что является само по себе проблемой, так как любой сетевой агент вправе это тело проигнорировать;
* несоблюдение требования симметричности операций `GET` / `PUT` / `DELETE` (т.е., например, после выполнения `DELETE /url` операция `GET /url` продолжает возвращать какие-то данные).
При разработке HTTP API часто возникает и обратная проблема: если метод по умолчанию модифицирующий или неидемпотентный, означает ли это, что операция *обязана* быть модифицирующей / идемпотентной? Двойственность смысловой нагрузки глаголов (семантика vs побочные действия) порождает неопределённость в вопросах организации таких API. Рассмотрим, например, ресурс `/v1/search`, осуществляющий поиск предложений кофе в нашем учебном API. С каким глаголом мы должны к нему обращаться?
* С одной стороны, `GET /v1/search` позволяет явно продекларировать, что никаких посторонних эффектов у этого запроса нет (никакие данные не перезаписываются) и результаты его можно кэшировать (при условии, что все значимые параметры передаются в URL, т.е. в виде path и query-параметров).
* С другой стороны, согласно семантике операции, `GET /v1/search` должен возвращать *представление ресурса* `search`. Но разве результаты поиска являются представлением ресурса-поисковика? Смысл операции «поиск» гораздо точнее описывается фразой «обработка запроса в соответствии с внутренней семантикой ресурса», т.е. соответствует методу `POST`. Кроме того, можем ли мы вообще говорить о кэшировании поисковых запросов? Страница результатов поиска формируется динамически из множества источников, и повторный запрос с той же поисковой фразой почти наверняка выдаст другой список результатов.
Иными словами, для любых операций, результат которых представляет собой результат работы какого-то алгоритма (например, список релевантных предложений по запросу) мы всегда будем сталкиваться с выбором, что важнее: семантика глагола или отсутствие побочных эффектов. Автор этой книги склоняется к мнению, что семантика важнее (и вообще использование метода `GET` в HTTP API осмысленно, если и только если ответ можно сопроводить параметрами кэширования), но не навязывает его.
#### ### Статус-коды
Статус-код — это численное машиночитаемое описание результата операции. Все статус-коды делятся на пять больших групп:
* `1xx` — информационные (фактически, какое-то хождение имеет разве что `100 Continue`);
* `2xx` — коды успеха операции;
* `3xx` — коды перенаправлений (индицируют необходимость выполнения дополнительных действий, чтобы считать операцию успешной);
* `4xx` — клиентские ошибки;
* `5xx` — серверные ошибки.
**NB**: разделение на группы по первой цифре кода имеет очень важное практическое значение. В случае, если возвращаемый сервером код ошибки `xyz` неизвестен клиенту, согласно спецификации клиент обязан выполнить то действие, которое выполнил бы при получении ошибки `x00`.
В основе технологии статус-кодов лежит понятное желание сделать ошибки машиночитаемыми, так, чтобы все промежуточные агенты могли понять, что конкретно произошло с запросом. Номенклатура статус-кодов HTTP действительно весьма подробно описывает почти любые проблемы, которые могут случиться с HTTP-запросом: недопустимые значения `Accept-*`-заголовков, отсутствующий `Content-Length`, неподдерживаемый HTTP-метод, слишком длинный URI и так далее.
Однако следует отметить, что большинство HTTP-ошибок специфичны именно для протокола HTTP, т.е. описывают исключительные ситуации, возникающие при попытке использовать различные возможности протокола. Поэтому большинство RPC-фреймворков либо полностью игнорирует номенклатуру статус-кодов (используя только `200 OK`), либо полагается очень ограниченный их набор. В самом деле, если ваш API не использует возможности протокола, вам достаточно трёх ошибок:
* `400` для персистентных ошибок (если просто повторить запрос — ошибка никуда не денется);
* `404` для статуса неопределённости (повтор запроса может дать другой результат);
* `500` для проблем на стороне сервера плюс заголовок `Retry-After`, чтобы дать понять клиенту, когда обратиться с повторным запросом.
**NB**: обратите внимание на проблему дизайна спецификации. По умолчанию все `4xx` коды не кэшируются, за исключением: `404`, `405`, `410`, `414`. Мы не сомневаемся, что это было сделано из благих намерений, но подозреваем, что множество людей, знающих об этих тонкостях, примерно совпадает с множеством редакторов спецификации HTTP.
К сожалению, для описаний ошибок, возникающих в бизнес-логике, номенклатура статус-кодов HTTP совершенно недостаточна и вынуждает либо использовать статус-коды в нарушение стандарта, либо обогащать ответ дополнительной информацией об ошибке. Авторы спецификации, будучи в курсе этой проблемы, добавили следующую фразу: ‘The response message will usually contain a representation that explains the status’. Мы с ними, разумеется, полностью согласны, но не можем не отметить, что эта фраза противоречит парадигме REST: другие агенты в многоуровневой системе не могут понять, что же там «объясняет» представление ошибки, и сама ошибка становится для них непрозрачной. Проблемы имплементации системы ошибок в HTTP API мы обсудим подробнее в главе 6.
#### Важное замечание о кэшировании
Кэширование — исключительно важная часть любой современной микросервисной архитектуры, и велик соблазн управлять им на уровне протокола — благо, стандарт предоставляет весьма функциональную и продвинутую функциональность работы с кэшами. Однако автор этой книги должен предостеречь читателя: если вы планируете имплементировать такую логику, прочитайте стандарт очень внимательно. Неверное толкование тех или иных параметров кэширования может приводить к крайне неприятным ситуациям; например, в практике автора был случай, когда случайное удаление настроек для определённой географической области (эндпойнт начал возвращать `404`) привело к неработоспособности сервиса на протяжении нескольких часов, поскольку разработчики протокола не учли, что статус `404` по умолчанию кэшируется, и клиенты просто не запрашивают новую версию настроек, пока не истечёт время жизни кэша.
#### Важное замечание о консистентности
Один и тот же параметр в разных ситуациях может находиться в разных частях запроса. Скажем, идентификатор партнёра, совершающего запрос, может быть передан:
* в имени поддомена `<partner-id>.domain.tld`;
* как часть пути `/v1/<partner-id>/orders`;
* как query-параметр `/v1/orders?partner_id=<partner-id>`;
* как заголовок
```
GET /v1/orders
X-ApiName-Partner-Id: <partner-id>
```
* как поле в теле запроса
```
POST /v1/orders/retrieve
{
"partner_id": <partner-id>
}
```
Возможны и более экзотические варианты: размещение параметра в схеме запроса или `Content-Type` ответа.
Однако при перемещении параметра между различными составляющими запроса мы столкнёмся с тремя неприятными явлениями:
* некоторые виды параметров чувствительны к регистру (путь, query-параметры, имена полей в JSON), некоторые нет (домен, имена заголовков);
* при этом со *значениями* заголовков и вовсе неразбериха: часть из них по стандарту обязательно нечувствительна к регистру (в частности, `Content-Type`), а часть, напротив, обязательно чувствительна (например, `ETag`);
* наборы допустимых символов и правила экранирования также различны для разных частей запроса
* для path, например, стандарта экранирования символов `/`, `?` и `#` нет совсем;
* для разных частей запросов используется разный кейсинг:
* `kebab-case` для домена, заголовков и пути;
* `snake_case` для query-параметров;
* `snake_case` или `camelCase` для тела запроса;
При этом использование и `snake_case`, и `camelCase` для доменного имени невозможно, знак подчеркивания в доменных именах недопустим, а заглавные буквы будут приведены к строчным.
Чисто теоретически возможно использование `kebab-case` во всех случаях, но в большинстве языков программирования имена переменных и полей объектов в `kebab-case` недопустимы, что приведёт к неудобству работы с таким API.
Короче говоря, ситуация с кейсингом настолько плоха и запутанна, что консистентного и удобного решения попросту нет. В этой книге мы придерживаемся следующего правила: токены даются в том кейсинге, который является общепринятым для той секции запроса, в которой находится токен; если положение токена меняется, то меняется и кейсинг. (Мы далеки от того, чтобы рекомендовать этот подход всюду; наша общая рекомендация, скорее — не умножать энтропию и пытаться минимизировать такого рода коллизии.)
**NB**. Вообще говоря, JSON исходно — это JavaScript Object Notation, а в языке JavaScript кейсинг по умолчанию - `camelCase`. Мы, тем не менее, позволим себе утверждать, что JSON уже перестал быть форматом данных, привязанным к JavaScript, и давно используется для организации взаимодействия агентов, реализованных на любых языках программирования. Использование `snake_case` по крайней мере позволяет легко перебрасывать параметр из query в тело и обратно, что, обычно, является наиболее частотным кейсом при разработке HTTP API. Впрочем, обратный вариант (использование `camelCase` в именах query-параметров) тоже допустим.
@@ -1,77 +1,76 @@
### Семантика протокола HTTP
### Преимущества и недостатки HTTP API
Итак, после трёх вступительных глав с прояснением основных терминов и понятий (такова, увы, цена популярности технологии) мы перейдём, наконец, непосредственно к вопросам дизайна HTTP API.
В описании семантики и формата протокола мы будем руководствоваться свежевышедшим [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), который заменил аж девять предыдущих RFC, описывавших разные аспекты технологии (правда, большое количество различной дополнительной функциональсти всё ещё покрывается отдельными стандартами, например, метод `PATCH` так и не вошёл в основной стандарт и всё ещё регулируется [RFC 5789](https://www.rfc-editor.org/rfc/rfc5789)).
HTTP-запрос представляет собой применение определённого глагола к URL с указанием версии протокола и передачей дополнительной мета-информации в заголовках и, возможно, каких-то данных в теле запроса:
У читателя может возникнуть резонный вопрос — а почему вообще существует такая дихотомия: какие-то API полагаются на стандартную семантику HTTP, а какие-то полностью от неё отказываются в пользу новоизобретённых стандартов. Например, если мы посмотрим на [формат ответа в JSON-RPC](https://www.jsonrpc.org/specification#response_object), то мы обнаружим, что он легко мог бы быть заменён на стандартные средства протокола HTTP. Вместо
```
POST /v1/orders HTTP/2.0
Host: our-api-host.tld
Content-Type: application/json
200 OK
{
"coffee_machine_id": 123,
"currency_code": "MNT",
"price": "10.23",
"recipe": "lungo",
"offer_id": 321,
"volume": "800ml"
"jsonrpc": "2.0",
"id",
"error": {
"code": -32600,
"message": "Invalid request"
}
}
```
Ответом на HTTP-запрос будет являться конструкция, состоящая из статус-кода ответа, сообщения, заголовков и, возможно, тела ответа.
сервер мог бы ответить просто `400 Bad Request` (с передачей идентификатора запроса, ну скажем, в заголовке `X-Request-ID`). Тем не менее, разработчики протокола посчитали нужным разработать свой собственный формат.
```
201 Created
Location: /v1/orders/123
Content-Type: application/json
Такая ситуация (не только конкретно с JSON-RPC, а почти со всеми высокоуровневыми протоколами поверх HTTP) сложилась по множеству причин, включая разнообразные исторические (например, невозможность использовать многие возможности HTTP из ранних реализаций `XMLHttpRequest` в браузерах). Однако, нужно отметить одно принципиальное различие между использованием протоколов уровня приложения (как в нашем примере с JSON-RPC) и чистого HTTP: если ошибка `400 Bad Request` является прозрачной для практически любого сетевого агента, то ошибка в собственном формате JSON-RPC таковой не является — во-первых, потому что понять её может только агент с поддержкой JSON-RPC, а, во-вторых, что более важно, в JSON-RPC статус запроса *не является метаинформацией*. Протокол HTTP позволяет прочитать такие детали, как метод и URL запроса, статус операции, заголовки запроса и ответа, *не читая тело запроса целиком*. Для большинства протоколов более высокого уровня, включая JSON-RPC, это не так: даже если агент и обладает поддержкой протокола, ему необходимо прочитать и разобрать тело ответа.
{
"id": 123
}
```
Каким образом эта самая возможность читать метаданные нам может быть полезна? Современный стек взаимодействия между клиентом и сервером является (как и предсказывал Филдинг) многослойным. Мы можем выделить множество агентов разного уровня, которые, так или иначе, обрабатывают сетевые запросы и ответы:
* разработчик пишет код поверх какого-то фреймворка, который отправляет запросы;
* фреймворк базируется на API языка программирования, которое, в свою очередь, обращается к API операционной системы;
* запрос доходит до сервера, возможно, через промежуточные HTTP-прокси;
* сервер, в свою очередь, тоже представляет собой несколько слоёв абстракции в виде фреймворка, языка программирования и ОС;
* перед конечным сервером, как правило, находится веб-сервер, проксирующий запрос, а зачастую и не один;
* в современных облачных архитектурах HTTP-запрос, прежде чем дойти до конечного обработчика, пройдёт через несколько абстракций в виде прокси и гейтвеев.
Главное преимущество, которое предоставляет следование букве стандарта HTTP — возможность положиться на то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — без необходимости писать какой-то дополнительный код.
#### Важное замечание о консистентности
Если попытаться сформулировать главный принцип разработки HTTP API, то мы получим примерно следующее: **лучше бы ты разрабатывал API так, чтобы промежуточные агенты могли читать и интерпретировать мета-информацию запроса и ответа**.
Один и тот же параметр в разных ситуациях может находиться в разных частях запроса. Скажем, идентификатор партнёра, совершающего запрос, может быть передан:
* в имени поддомена `<partner-id>.domain.tld`;
* как часть пути `/v1/<partner-id>/orders`;
* как query-параметр `/v1/orders?partner_id=<partner-id>`;
* как заголовок
В отличие от большинства альтернативных технологий, основной импульс развития которых исходит от какой-то одной крупной IT-компании (Facebook, Google, Apache Software Foundation), инструменты для HTTP API разрабатываются множеством различных компаний и коллабораций. Соответственно, вместо гомогенного, но ограниченного в возможностях фреймворка, для HTTP API мы имеем множество самых разных инструментов, таких как:
* различные прокси и API-гейтвеи (nginx, Envoy);
* различные форматы описания спецификаций (в первую очередь, OpenAPI) и связанные инструменты для работы со спецификациями (Redoc, Swagger UI) и кодогенерации;
* ПО для разработчиков, позволяющее удобным образом разрабатывать и отлаживать клиенты API (Postman, Insomnia) и так далее.
```
GET /v1/orders
X-ApiName-Partner-Id: <partner-id>
```
Конечно, большинство этих инструментов применимы и для работы с API, реализующими альтернативные парадигмы. Однако именно способность промежуточных агентов считывать метаданные HTTP запросов позволяет легко строить сложные конвейеры типа экспортировать access-логи nginx в prometheus и из коробки получить удобные мониторинги статус-кодов ответов в Grafana.
* как поле в теле запроса
Главным недостатком HTTP API является то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — даже если вы их об этом не просили. Более того, так как стандарты HTTP являются сложными, концепция REST — непонятной, а разработчики программного обеспечения — неидеальными, то промежуточные агенты могут трактовать метаданные запроса *неправильно*. Особенно это касается каких-то экзотических и сложных в имплементации стандартов.
```
POST /v1/orders/retrieve
{
"partner_id": <partner-id>
}
```
#### Вопросы производительности
Возможны и более экзотические варианты: размещение параметра в схеме запроса или `Content-Type` ответа.
В пользу многих современных альтернатив HTTP API — таких как GraphQL, gRPC, Apache Thrift — часто приводят аргумент о низкой производительности JSON-over-HTTP API по сравнению с рассматриваемой технологией; конкретнее, называются следующие проблемы:
Однако при перемещении параметра между различными составляющими запроса мы столкнёмся с тремя неприятными явлениями:
* некоторые виды параметров чувствительны к регистру (путь, query-параметры, имена полей в JSON), некоторые нет (домен, имена заголовков);
* при этом со *значениями* заголовков и вовсе неразбериха: часть из них по стандарту обязательно нечувствительна к регистру (в частности, `Content-Type`), а часть, напротив, обязательно чувствительна (например, `ETag`);
* наборы допустимых символов и правила экранирования также различны для разных частей запроса
* для path, например, стандарта экранирования символов `/`, `?` и `#` нет совсем;
* для разных частей запросов используется разный кейсинг:
* `kebab-case` для домена, заголовков и пути;
* `snake_case` для query-параметров;
* `snake_case` или `camelCase` для тела запроса;
1. Избыточность формата:
* в JSON необходимо всякий раз передавать имена всех полей, даже если передаётся массив из большого количества одинаковых объектов;
* большое количество технических символов — кавычек, скобок, запятых — и необходимость экранировать служебные символы в строках.
2. HTTP API в ответ на запрос к ресурсу возвращает представление ресурса целиком, хотя клиенту могут быть интересны только отдельные поля.
3. Низкая производительность операций сериализации и десериализации данных.
4. Передача бинарных данных требует дополнительного кодирования, например, через Base64.
5. Низкая производительность самого протокола HTTP (в частности, невозможность мультиплексирования нескольких запросов и ответов по одному соединению).
При этом использование и `snake_case`, и `camelCase` для доменного имени невозможно, знак подчеркивания в доменных именах недопустим, а заглавные буквы будут приведены к строчным.
Будем здесь честны: большинство существующих имплементаций HTTP API действительно страдают от указанных проблем. Тем не менее, мы берём на себя смелость заявить, что все эти проблемы большей частью надуманы, и их решению не уделяют большого внимания потому, что указанные накладные расходы не являются сколько-нибудь заметными для большинства вендоров API. В частности:
Чисто теоретически возможно использование `kebab-case` во всех случаях, но в большинстве языков программирования имена переменных в `kebab-case` недопустимы, что приведёт к неудобству работы с таким API.
1. Если мы говорим об избыточности формата, то необходимо сделать важную оговорку: всё вышесказанное верно, если мы не применяем сжатие. [Сравнения показывают](https://nilsmagnus.github.io/post/proto-json-sizes/), что использование gzip практически нивелирует разницу в размере JSON документов относительно альтернативных бинарных форматов (а есть ещё и специально предназначенные для текстовых данных архиваторы, например, brotli).
Короче говоря, ситуация с кейсингом настолько плоха и запутанна, что консистентного и удобного решения попросту нет. В этой книге мы придерживаемся следующего правила: токены даются в том кейсинге, который является общепринятым для той секции запроса, в которой находится токен; если положение токена меняется, то меняется и кейсинг. (Мы далеки от того, чтобы рекомендовать этот подход всюду; наша общая рекомендация, скорее — не умножать энтропию и пытаться минимизировать такого рода коллизии.)
2. Вообще говоря, если такая нужда появляется, то и в рамках HTTP API вполне можно регулировать список возвращаемых полей ответа, это вполне соответствует духу и букве стандарта. Однако, мы должны заметить, что экономия трафика на возврате частичных состояний (которую мы рассматривали подробно в главе «Частичные обновления») очень редко бывает оправдана.
**NB**. Вообще говоря, JSON исходно — это JavaScript Object Notation, а в языке JavaScript кейсинг по умолчанию - `camelCase`. Мы, тем не менее, позволим себе утверждать, что JSON уже перестал быть форматом данных, привязанным к JavaScript, и давно используется для организации взаимодействия агентов, реализованных на любых языках программирования. Использование `snake_case` по крайней мере позволяет легко перебрасывать параметр из query в тело и обратно, что, обычно, является наиболее частотным кейсом при разработке HTTP API.
3. Если использовать стандартные десериализаторы JSON, разница по сравнению с бинарными форматами может оказаться действительно очень большой. Если, однако, эти накладные расходы являются проблемой, стоит обратиться к альтернативным десериализаторам — в частности, [simdjson](https://github.com/simdjson/simdjson). Благодаря оптимизированному низкоуровневому коду simdjson показывает отличную производительность, которой может не хватить только совсем уж экзотическим API.
4. Вообще говоря, парадигма HTTP API подразумевает, что для бинарных данных (такие как изображения или видеофайлы) предоставляются отдельные эндпойнты. Передача бинарных данных в теле JSON-ответа необходима только в случаях, когда отдельный запрос за ними представляет собой проблему с точки зрения производительности. Такой проблемы фактически не существует в server-2-server взаимодействии, и даже в мобильных сетях она уже далеко не так остра.
5. Протокол HTTP 1.1 действительно неидеален с точки зрения мультиплексирования запросов. Однако альтернативные парадигмы организации API для решения этой проблемы опираются… на HTTP версии 2.0. Разумеется, и HTTP API можно построить поверх версии 2.0.
На всякий случай ещё раз уточним: JSON-over-HTTP API *действительно* проигрывает с точки зрения производительности современным бинарным протоколам. Мы, однако, берём на себя смелость утверждать, что производительность хорошо спроектированного и оптимизированного HTTP API достаточна для абсолютного большинства предметных областей, и реальный выигрыш от перехода на альтернативные протоколы окажется незначителен.
#### Преимущества и недостатки формата JSON
Как нетрудно заметить, большинство претензий, предъявляемых к концепции HTTP API, относятся вовсе не к HTTP, а к использованию формата JSON. В самом деле, ничто не мешает разработать API, которое будет использовать любой бинарный формат вместо JSON (включая тот же Protobuf), и тогда разница между Protobuf-over-HTTP API и gRPC сведётся только к (не)использованию подробных URL, статус-кодов и заголовков запросов и ответов (и вытекающей отсюда (не)возможности использовать то или иное стандартное программное обеспечение «из коробки»).
Однако, во многих случаях (включая настоящую книгу) разработчики предпочитают текстовый JSON бинарным Protobuf (Flatbuffers, Thrift, Avro и т.д.) по очень простой причине: JSON очень легко и удобно читать. Во-первых, он текстовый и не требует дополнительной расшифровки; во-вторых, имена полей включены в сам файл. Если сообщение в формате protobuf невозможно прочитать без .proto-файла, то по JSON-документу почти всегда можно попытаться понять, что за данные в нём описаны. В совокупности с тем, что при разработке HTTP API мы также стараемся следовать стандартной семантике всей остальной обвязки, в итоге мы получаем API, запросы и ответы к которому (по крайней мере в теории) удобно читаются и интуитивно понятны.
Помимо человекочитаемости у JSON есть ещё одно важное преимущество: он максимально формален. В нём нет никаких конструкций, которые могут быть по-разном истолкованы в разных архитектурах (с точностью до ограничений на длины чисел и строк), и при этом он удобно ложится в нативные структуры данных почти любого языка программирования. С этой точки зрения у нас фактически не было никакого другого выбора, какой ещё формат данных мы могли бы использовать при написании примеров кода для этой книги. В случае же разработки API менее общего назначения, мы рекомендуем подходить к выбору формата по тому же принципу: взвесить накладные расходы на подготовку и внедрение инструментов чтения бинарных форматов и расшифровки бинарных протоколов против накладных расходов на неоптимальную передачу данных и использование разнородного ПО при разработке API.
@@ -1,25 +1,45 @@
### URL
### Принципы организации HTTP API
URL — единица адресации в HTTP API (некоторые евангелисты технологии даже используют термин «пространство URL» как синоним для Мировой паутины). Предполагается, что API в парадигме REST должно использовать систему адресов столь же гранулярную, как и предметная область; иными словами, у любых сущностей, которыми мы можем манипулировать независимо, должен быть свой URL.
Перейдём теперь к конкретике: как разрабатывать HTTP API так, чтобы извлекать выгоду из следования протоколу. Представим себе обычную операцию получения последних заказов пользователя. Например, мы можем поступить следующим образом:
Формат URL регулируется [отдельным стандартом](https://url.spec.whatwg.org/), который развивает независимое сообщество Web Hypertext Application Technology Working Group (WHATWG). Считается, что концепция URL (вместе с понятием универсального имени ресурса, URN) составляет более общую сущность URI (универсальный идентификатор ресурса). Разница между URL и URN заключается в том, что URL позволяет *найти* некоторый ресурс в рамках некоторого протокола доступа, в то время как URN — «внутреннее» имя объекта, которое само по себе никак не помогает получить к нему доступ.
```
GET /orders
Authorization: Bearer <token>
```
URL принято раскладывать на составляющие, каждая из которых имеет свою семантику (и может отсутствовать):
* схема — протокол обращения (в нашем случае всегда `https:`);
* хост (домен или IP-адрес), к которому обращён запрос — самая крупная единица адресации;
* домен может включать в себя поддомены;
* порт;
* путь — часть URL между доменным именем (с портом) и символами `?`, `#` или концом строки
* путь традиционно считается способом выразить строгую иерархию сущностей; если сущность `leaf` полностью вложена в сущность `root`, этот факт отражают в виде path `/root/leaf`;
* путь принято разбивать по символу `/` и работать с каждой частью как отдельным токеном — но стандарт этого вообще-то не предписывает;
* пути с символом `/` и без символа `/` в конце (скажем `/root/leaf` и `/root/leaf/`) вообще говоря являются разными (и URL, отличающиеся только наличием/отсутствием слэша, считаются разными URL), хотя практически нам неизвестны аргументы в пользу того, чтобы не считать такие пути эквивалентными;
* пути могут содержать секции `.` и/или `..`, которые предлагается трактовать аналогичным символам в путях на файловой системе (и, соответственно, считать URL `/root/leaf`, `/root/./leaf`, `/root/branch/../leaf` эквивалентными), но стандарт этого вновь не предписывает;
* query — часть URL после знака `?` до знака `#` или конца строки;
* query принято раскладывать на пары ключ=значение, разделённые символом `&`; следует вновь иметь в виду, что стандарт не предписывает query строго соответствовать этому формату;
* фрагмент (якорь) — часть URL после знака `#`;
* фрагмент традиционно рассматривается как адресация внутри запрошенного документа, поэтому многими агентами опускается при выполнении запроса;
* два URL с различными значениями фрагмента могут считаться одинаковыми — а могут не считаться, зависит от контекста.
Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя `user_id`, обратится к базе данных и вернёт отсортированный по времени создания список заказов.
В HTTP-запросах, как правило (но не обязательно) схема, хост и порт опускаются (и считаются совпадающими с параметрами соединения).
Пока вопросы производительности нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей, мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится, и принимаем решение декомпозировать единый бэкенд на четыре микросервиса:
* сервис A, проверяющий авторизационные токены;
* сервис B, хранящий текущие заказы пользователя;
* сервис C, хранящий завершённые заказы пользователя;
* сервис-гейтвей D, который маршрутизирует запросы между другими микросервисами.
Таким образом, запрос будет проходить по следующему пути:
* гейтвей D получит запрос и отправит его в сервисы B и C;
* сервисы B и C обратятся к сервису A, проверят токен, и вернут данные по запросу;
* сервис D скомбинирует ответы сервисов B и C и вернёт их пользователю.
Нетрудно заметить, что мы тем самым создаём нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов, поскольку самостоятельно выяснить идентификатор пользователя они не могут. Каким образом мы можем решить проблему? Очевидно, сделав так, чтобы `user_id` уже был включён в запрос:
* гейтвей D получает запрос и через сервис A меняет токен на `user_id`
* гейтвей D обращается к сервису B
```
GET /ongoing-orders
X-OurApi-User-Id: <user id>
```
и к сервису C
```
GET /finished-orders
X-OurApi-User-Id: <user id>
```
**NB**: альтернативно мы могли бы закодировать имя пользователя в самом токене согласно, например, [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519).
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификация пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он сформулирован в терминах предметной области сервиса (в данном случае — сервис истории заказов естественным образом хранит идентификатор пользователя как неизменяемый параметр заказа), а не клиента, осуществляющего вызов. Этот принцип скрывается под буквой S в аббревиатура REST: «stateless».
Пойдём теперь чуть дальше и подметим, что список завершённых заказов пользователя меняется очень редко. Нет никакой нужды каждый раз получать его заново — мы могли бы закэшировать его на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
* перед обращением в сервис C составить ключ и обратиться к кэшу;
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису C и сохранить полученные данные в кэш.
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D, получив запрос `GET /orders`, обратится к ресурсу `service-c.tld/<user-id>/finished-orders` и получит данные либо из кэша, либо непосредственно из сервиса.
**NB**: в стандарте также перечислены разнообразные исторические наслоения (например, передача логинов и паролей в URL или использование не-UTF кодировки), которые нам в рамках вопросов дизайна API неинтересны. Также стандарт содержит правила сериализации, нормализации и сравнения URL, которые в целом полезно знать разработчику HTTP API.
@@ -1,9 +0,0 @@
### Заголовки
Заголовки — это *метаинформация*, привязанная к запросу или ответу. Она может описывать какие-то свойства передаваемых данных (например, `Content-Length`), дополнительные сведения о клиенте или сервере (`User-Agent`, `Date`), или просто содержать поля, не относящиеся непосредственно к смыслу запроса/ответа (например, `Authorization`).
Важное свойство заголовков — это возможность считывать их до того, как получено тело сообщения. Таким образом, заголовки могут, во-первых, сами по себе влиять на обработку запроса или ответа, и ими можно относительно легко манипулировать при проксировании — и многие сетевые агенты действительно это делают, добавляя или модифицируя заголовки по своему усмотрению (в частности, современные веб-браузеры добавляют к запросам целую коллекцию заголовков: `User-Agent`, `Origin`, `Accept-Language`, `Connection`, `Referer`, `Sec-Fetch-*` и так далее, а современное ПО веб-серверов, в свою очередь, автоматически добавляет или модифицирует такие заголовки как `X-Powered-By`, `Date`, `Content-Length`, `Content-Encoding`, `X-Forwarded-For`).
Подобное вольное обращение с заголовками создаёт определённые проблемы, если ваш API предусматривает передачу дополнительных полей метаданных, поскольку придуманные вами имена полей могут случайно совпасть с какими-то из существующих стандартных имён (или ещё хуже — в будущем появится новое стандартное поле, совпадающее с вашим). Долгое время во избежание подобных коллизий использовался префикс `X-`; уже более 10 лет как эта практика объявлена устаревшей и не рекомендуется к использованию (см. подробный разбор вопроса в [RFC 6648](https://www.rfc-editor.org/rfc/rfc6648)), однако отказа от этого префикса по факту не произошло (и многие нестандартные, но широко распространённые заголовки, например, `X-Forwarded-For`, его всё ещё содержат). Таким образом, использование префикса `X-` вероятность коллизий снижает, но не устраняет. Тот же RFC вполне разумно предлагает использовать вместо `X-` префикс в виде имени компании. (Мы со своей стороны склонны рекомендовать использовать оба префикса в формате `X-ApiName-Field`; префикс `X-` для читабельности [чтобы отличать специальные заголовки от стандартных], а префикс с именем компании или API — чтобы не произошло коллизий с каким-нибудь другим нестандартным префиксом.)
Помимо прочего заголовки используются как управляющие конструкции — это т.н. «content negotiation», т.е. договорённость клиента и сервера о формате ответа (через заголовки `Accept*`) и условные запросы, позволяющие сэкономить трафик на возврате ответа целиком или частично (через заголовки `If-*`-заголовки, такие как `If-Range`, `If-Modified-Since` и так далее).