Заметка о способах, которыми программы запрашивают друг у друга данные и действия: ресурсы в REST, граф в GraphQL, удалённые процедуры в RPC и gRPC. Про сам протокол HTTP, поверх которого работает большинство из них, — в заметке про протоколы.
REST (англ. Representational State Transfer) — архитектурный стиль, описанный Роем Филдингом в его диссертации (Fielding, глава 5). Это не протокол и не формат, а набор ограничений: система, которая им следует, называется RESTful.
Филдинг собирает стиль из последовательности ограничений:
- Клиент-сервер — интерфейс отделён от хранения данных.
- Отсутствие состояния (англ.
stateless) — каждый запрос клиента должен содержать всю информацию, нужную для его понимания: сервер не хранит контекст между запросами. - Кэширование — ответ должен быть явно или неявно помечен как кэшируемый или некэшируемый.
- Единообразный интерфейс (англ.
uniform interface) — центральная черта, которая отличает REST от других сетевых стилей. - Многоуровневая система — клиент не знает, говорит ли он с сервером напрямую или через посредников.
- Код по требованию — необязательное ограничение: сервер может передать клиенту исполняемый код.
Единообразный интерфейс Филдинг раскладывает на четыре ограничения: идентификация ресурсов; работа с ресурсами через их представления; самоописывающие сообщения; гипермедиа как двигатель состояния приложения.
В REST всё строится вокруг ресурсов — сущностей, у которых есть адрес: /users, /users/17, /users/17/orders. Клиент получает не сам ресурс, а его представление — обычно JSON.
Действие над ресурсом задаётся методом HTTP. Спецификация HTTP делит методы по двум свойствам (RFC 9110): метод безопасный, если его смысл — по сути только чтение; метод идемпотентный, если несколько одинаковых запросов имеют тот же задуманный эффект, что и один.
| Метод | Что делает с ресурсом | Безопасный | Идемпотентный |
|---|---|---|---|
GET |
получить | да | да |
POST |
создать или выполнить действие | нет | нет |
PUT |
заменить целиком | нет | да |
PATCH |
изменить частично | нет | нет |
DELETE |
удалить | нет | да |
Безопасность и идемпотентность GET, PUT, DELETE — по RFC 9110, PATCH не является ни безопасным, ни идемпотентным по RFC 5789 (RFC 5789).
Эти свойства — не формальность. Идемпотентный запрос можно безопасно повторить при обрыве связи; безопасный — закэшировать и отдать из кэша. На этом и держится главное практическое преимущество REST — кэширование средствами самого HTTP.
- Форма запроса на клиенте.
GET /route?field=value&anotherField=anotherValue/* axios */
axios.get('/route', {
params: {
field: 'value',
anotherField: 'anotherValue',
},
});- Форма запроса на сервере.
GET /route- Получение данных из запроса на сервере.
/* Express */
app.get('/route', (request, response) => {
const { field, anotherField } = request.query;
});Передача массива arr [1, 3, 7] с клиента.
GET /route?arr[]=1&arr[]=3&arr[]=7Передача объекта obj { foo: '1', bar: '7' } с клиента.
GET /route?obj[foo]=1&obj[bar]=7Важно: запись с квадратными скобками — не стандарт HTTP, а соглашение библиотеки qs, которая умеет разбирать вложенные объекты и массивы, указанные через [] (qs). В Express 5 парсер строки запроса по умолчанию сменили с расширенного (extended) на простой (simple) (руководство по переходу на Express 5) — поэтому вложенные объекты так по умолчанию больше не разбираются: расширенный разбор нужно включать явно настройкой query parser.
- Форма запроса на клиенте.
POST /route
Content-Type: application/json
{ "field": "value", "anotherField": "anotherValue" }/* axios */
axios.post('/route', {
field: 'value',
anotherField: 'anotherValue',
});- Форма запроса на сервере.
POST /route- Получение данных из запроса на сервере.
/* Express */
app.post('/route', (request, response) => {
const { field, anotherField } = request.body;
});- Форма запроса на клиенте.
GET /route/paramValue/* axios */
axios.get(`/route/${paramValue}`);- Форма запроса на сервере.
GET /route/:param- Получение данных из запроса на сервере.
/* Express */
app.get('/route/:param', (request, response) => {
const { param } = request.params;
});Пример отправки нескольких параметров.
PATCH /users/17/name
PATCH /users/:id/:fieldGraphQL — язык запросов (query language) для API.
Клиент посылает запрос (query) к сервису GraphQL и получает ответ в виде JSON-объекта по указанной в запросе схеме.
Например, клиент может послать запрос.
query {
currentUser {
id
username
role
}
}Если сервер позволяет получить данные по заданной выше схеме, по клиенту придёт ответ, соответствующий этой схеме.
{
"data": {
"currentUser": {
"id": "1",
"username": "Notes",
"role": "Admin"
}
}
}Спецификация выделяет три типа операций (спецификация GraphQL): query — чтение, mutation — запись, за которой следует чтение, и subscription — долгоживущий запрос, который получает данные в ответ на события.
Запрос query отвечает за получение данных, он не может их изменять (аналог GET-запроса в REST).
query {
users {
id
username
}
}Запрос mutation отвечает за изменение данных (объединяет в себе возможности POST, PUT, DELETE и других в REST).
mutation {
logOut
}Подписка subscription нужна, когда данные должен присылать сервер: клиент подписывается один раз, а сервер отправляет новые данные при каждом событии — например, новом сообщении в чате.
subscription {
messageAdded {
id
text
}
}Все поля, которые может получить клиент, должны быть описаны в типе (type).
Типы Query и Mutation являются типами по умолчанию. В них должны быть описаны все возможные запросы.
type Query {
currentUser: User
}
type Mutation {
logOut: Boolean
}schema {
query: Query
mutation: Mutation
}Могут создаваться пользовательские типы.
type User {
id: ID
username: String
role: String
image: String
}Если схема в запросе с клиента не удовлетворяет схеме на сервере, то возникнет валидационная ошибка. Например, поле age отсутствует в типе User.
{
"data": null,
"errors": [
{
"...": "...",
"message": "Validation error of type FieldUndefined: Field 'age' in type 'User' is undefined"
}
]
}GraphQL не позволяет создавать динамические объекты в качестве type.
Примеры динамических объектов.
const commentsMap = {
123: {
id: "123",
message: "Hello"
},
456: {
id: "456",
message: "Hi"
}
};const reportsInfoMap = {
spam: ["id1", "id2"],
flood: [],
bullying: ["id3"]
}const flags = {
isReviewed: true,
isVisited: true,
/* ... */
}Есть два решения, как это можно обойти
- Передавать объекты текстовый как JSON и указывать встроенный тип
String(не лучшая валидация) или подключить библиотеку, которая имеет скалярный тип JSON (например, эту). Например, AWS AppSync имеет встроенный типAWSJSON.
type Res {
commentsMap: String
reportsMap: JSON
flags: AWSJSON
}- Представить объекты в виде массивов (предпочтительный вариант).
type Comment {
id: ID
message: String
}
type ReportsInfo {
name: String
values: [ID]
}
type Flag {
name: String
value: Boolean
}
type Res {
commentsMap: [Comment]
reportsMap: [ReportsInfo]
flags: [Flag]
}query Users {
users {
id
username
role
}
}query User($userId: ID!) {
user(userId: $userId) {
id
username
role
}
}Variables
{
"userId": "auth0|72d45e398924235638341891"
}Вход в систему меняет состояние — создаёт сессию, — поэтому это mutation, а не query.
mutation Login($credentialsInput: Credentials) {
login(credentials: $credentialsInput) {
auth_token
}
}Variables
{
"credentialsInput": {
"username": "admin",
"password": "admin"
}
}- Строгая типизация. Конкретная схема, полностью описывающая, как можно работать с данными.
type User {
id: ID!
username: String!
}
type Article {
id: ID!
title: String!
description: String
author: User!
}
type Query {
articles: [Article]
article(id: ID!): Article
}- Клиент всегда запрашивает только то, что ему нужно. Он не может получить те поля, которые не запрашивал, ровно как и те, которые не предусмотрены описанной схемой.
Следующий запрос вернёт articles, содержащие только поля id и title. Все эти поля должны присутствовать в схеме запроса на бэкенде (описана выше), иначе будет ошибка.
query {
articles {
id
title
}
}- Один запрос может объединять в себе несколько различных ресурсов.
Следующий запрос объединяет в себе информацию, собранную из Articles и Users.
query {
articles {
id
title
description
author {
id
username
}
}
}GraphQL не привязан к HTTP, но обычно работает поверх него, через один адрес. По руководству на graphql.org, сервер принимает POST для query и mutation и может принимать GET для query (graphql.org). Отсюда важное следствие для кэширования — см. сравнение.
Удалённым вызовом процедур (англ. Remote Procedure Call, RPC) называют подход, при котором программа вызывает функцию на другой машине так же, как вызывала бы локальную. В статье 1984 года, с которой началось широкое применение RPC, он описан как удобная парадигма связи между программами по сети (Birrell, Nelson, Implementing Remote Procedure Calls).
Главное отличие от REST — в том, что называется. В REST адрес называет ресурс (существительное): POST /users. В RPC вызов называет действие (глагол): createUser(...).
Самый простой пример — JSON-RPC 2.0: протокол удалённого вызова процедур без состояния (спецификация JSON-RPC). Он не зависит от транспорта: по спецификации, его можно использовать внутри одного процесса, через сокеты, поверх HTTP или в любой другой среде обмена сообщениями.
{ "jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1 }{ "jsonrpc": "2.0", "result": 19, "id": 1 }Поле id связывает ответ с запросом. Запрос без id называется уведомлением: клиент не ждёт на него ответа.
У удобства RPC есть обратная сторона: сетевой вызов только выглядит как локальный. Он может не дойти, зависнуть или выполниться дважды — поэтому таймауты и идемпотентность нужны здесь не меньше, чем в любом сетевом взаимодействии.
gRPC — фреймворк удалённого вызова процедур, в котором клиент может напрямую вызвать метод серверного приложения на другой машине так, будто это локальный объект (документация gRPC).
Две вещи отличают его от «RPC вообще».
Первая — контракт. Сервис описывается заранее в файле .proto на языке Protocol Buffers — независимом от языка и платформы механизме сериализации структурированных данных (документация Protocol Buffers). По этому описанию генерируется код и клиента, и сервера — на любом из поддерживаемых языков.
syntax = "proto3";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}Сообщения передаются не текстом, а в двоичном виде: числа после полей — это номера, по которым поля кодируются, а не значения.
Вторая — транспорт и потоки. gRPC работает поверх кадров HTTP/2 (протокол gRPC поверх HTTP/2) и поддерживает четыре вида методов (документация gRPC):
| Вид | Как устроен | В .proto |
|---|---|---|
| Унарный | один запрос — один ответ, как обычный вызов функции | rpc Get (Req) returns (Res); |
| Серверный поток | один запрос — поток ответов | rpc List (Req) returns (stream Res); |
| Клиентский поток | поток запросов — один ответ | rpc Upload (stream Req) returns (Res); |
| Двунаправленный | обе стороны шлют последовательности сообщений | rpc Chat (stream Req) returns (stream Res); |
Ограничение для веба: браузер не может обратиться к gRPC-сервису напрямую. Для этого есть gRPC-Web, и его клиенты подключаются к сервисам через специальный прокси — по умолчанию это Envoy (gRPC-Web).
| Признак | REST | GraphQL | JSON-RPC | gRPC |
|---|---|---|---|---|
| Что называет запрос | ресурс | нужные поля графа | процедуру | процедуру |
| Транспорт | HTTP | обычно HTTP, один адрес | любой | HTTP/2 |
| Формат | любой, обычно JSON | JSON | JSON | Protocol Buffers, двоичный |
| Контракт | не обязателен | схема обязательна | нет | .proto обязателен |
| Кэширование средствами HTTP | естественное для GET |
затруднено: запросы обычно POST |
нет | нет |
| Поток от сервера | вне модели | подписки | нет | четыре вида методов |
| Из браузера | да | да | да | через gRPC-Web и прокси |
Грубые ориентиры:
- REST — публичные API, простые операции над сущностями, всё, что выигрывает от кэширования и понятности.
- GraphQL — много разных клиентов с разными потребностями в данных: каждый берёт ровно то, что ему нужно, одним запросом.
- JSON-RPC — небольшой набор действий, где ресурсная модель была бы натяжкой.
- gRPC — взаимодействие сервисов между собой: строгий контракт, компактный двоичный формат и потоки.
Эти подходы не исключают друг друга. Частая схема: сервисы внутри системы общаются по gRPC, а наружу смотрит REST или GraphQL — потому что их понимает браузер и любой клиент без генерации кода.