|
|
|
@@ -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-параметров) тоже допустим.
|