GraphQL
GraphQL — язык запросов к API и среда выполнения этих запросов на сервере.
Клиент указывает, какие поля ему нужны, а сервер возвращает данные соответствующей формы.
Запрос:
query {
user(id: "42") {
id
name
email
}
}Ответ:
{
"data": {
"user": {
"id": "42",
"name": "Анна",
"email": "anna@example.com"
}
}
}Если клиенту не нужен email, поле можно не запрашивать:
query {
user(id: "42") {
id
name
}
}Ответ:
{
"data": {
"user": {
"id": "42",
"name": "Анна"
}
}
}GraphQL определяет:
- схему API;
- доступные типы;
- операции чтения и изменения;
- структуру запросов;
- формат результата;
- правила проверки запросов;
- механизм выполнения через резолверы.
GraphQL не является:
- базой данных;
- ORM;
- транспортным протоколом;
- автоматической заменой бизнес-логики;
- готовым механизмом аутентификации;
- гарантией высокой производительности.
Типичная архитектура:
Клиент
↓ GraphQL document
GraphQL-сервер
↓ проверка по схеме
Резолверы
├── база данных
├── REST API
├── другой GraphQL API
├── Redis
└── внешние сервисыGraphQL чаще всего работает поверх HTTP через один endpoint:
POST /graphqlОднако GraphQL не привязан исключительно к HTTP. Операции также могут передаваться через WebSocket и другие механизмы.
Содержание
- Схема GraphQL
- Скалярные типы
- Объектные типы
- Nullable и Non-Null
- Списки
- Enum
- Input-типы
- Interface
- Union
- Корневые типы
- Запросы
- Переменные
- Алиасы
- Фрагменты
- Директивы
- Мутации
- Запрос и мутация по HTTP
- Ответ GraphQL
- Резолверы
- Default resolver
- Асинхронные резолверы
- Разделение ответственности
- Контекст и аутентификация
- Авторизация
- Ошибки в резолверах
- Проблема N+1
- DataLoader
- Пагинация
- Apollo Server
- Полный пример схемы
- Контекст Apollo Server
- Аналоги Apollo Server
- Apollo Client
- Observability
- GraphQL и REST
- Когда GraphQL действительно нужен
Схема GraphQL
Схема GraphQL описывает публичный контракт API:
- доступные типы;
- поля каждого типа;
- аргументы полей;
- типы возвращаемых значений;
- операции чтения;
- операции изменения;
- связи между объектами.
Схема обычно записывается на языке SDL — Schema Definition Language.
Пример:
type User {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
}
enum UserRole {
USER
EDITOR
ADMIN
}
type Query {
user(id: ID!): User
users(limit: Int = 20): [User!]!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
publishPost(id: ID!): PublishPostPayload!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type PublishPostPayload {
post: Post
errors: [UserError!]!
}
type UserError {
code: String!
message: String!
field: String
}Такая схема сообщает клиенту:
- какие операции существуют;
- какие аргументы обязательны;
- какие поля можно получить;
- где значение может быть
null; - какие перечисления допустимы;
- как выглядит результат мутации.
Скалярные типы
GraphQL содержит несколько встроенных скалярных типов.
| Тип | Назначение |
|---|---|
Int |
32-битное целое число |
Float |
Число с плавающей точкой |
String |
Строка |
Boolean |
Логическое значение |
ID |
Идентификатор |
Пример:
type Product {
id: ID!
name: String!
price: Float!
quantity: Int!
available: Boolean!
}ID сериализуется похожим образом на строку, но семантически обозначает идентификатор:
id: ID!Клиенту не следует выполнять арифметику с ID, даже если сервер использует числовой идентификатор.
Пользовательские скаляры
Встроенных типов недостаточно для дат, времени, денежных значений и некоторых других данных.
Можно объявить пользовательский скаляр:
scalar DateTime
scalar JSON
scalar DecimalПрименение:
type Order {
id: ID!
createdAt: DateTime!
metadata: JSON
total: Decimal!
}Сервер должен реализовать сериализацию и разбор такого значения.
Упрощённый скаляр даты:
import {
GraphQLScalarType,
Kind,
} from "graphql";
const dateTimeScalar =
new GraphQLScalarType({
name: "DateTime",
description:
"Дата и время в формате ISO 8601",
serialize(value) {
if (value instanceof Date) {
return value.toISOString();
}
const date = new Date(value);
if (
Number.isNaN(date.getTime())
) {
throw new TypeError(
"Некорректное значение DateTime",
);
}
return date.toISOString();
},
parseValue(value) {
if (typeof value !== "string") {
throw new TypeError(
"DateTime должен быть строкой",
);
}
const date = new Date(value);
if (
Number.isNaN(date.getTime())
) {
throw new TypeError(
"Некорректное значение DateTime",
);
}
return date;
},
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
return null;
}
const date = new Date(ast.value);
if (
Number.isNaN(date.getTime())
) {
return null;
}
return date;
},
});Подключение к резолверам:
const resolvers = {
DateTime: dateTimeScalar,
};Для стандартных форматов полезнее использовать проверенную библиотечную реализацию, чем писать сложные скаляры самостоятельно.
Объектные типы
Объектный тип содержит поля:
type User {
id: ID!
name: String!
email: String!
}Клиент может запросить любое разрешённое сочетание полей:
query {
user(id: "42") {
id
name
}
}Объектный тип не обязательно совпадает со структурой таблицы базы данных.
Например, GraphQL-тип:
type User {
id: ID!
displayName: String!
avatarUrl: String
}может собираться из:
- нескольких таблиц;
- внешнего API;
- вычисляемых значений;
- файлового хранилища.
Схема должна описывать публичную предметную модель, а не автоматически раскрывать внутреннюю структуру базы.
Nullable и Non-Null
По умолчанию поля GraphQL могут иметь значение null.
type User {
middleName: String
}Поле обязательно помечается восклицательным знаком:
type User {
id: ID!
name: String!
}Различие:
String — строка или null
String! — обязательно строкаЕсли поле объявлено как обязательное:
name: String!но резолвер вернул null, GraphQL зарегистрирует ошибку выполнения.
Null propagation
Рассмотрим схему:
type User {
id: ID!
profile: Profile!
}
type Profile {
displayName: String!
}Если displayName неожиданно равен null, ошибка распространяется до ближайшего nullable-поля.
Если user в Query допускает null:
type Query {
user(id: ID!): User
}результат может стать таким:
{
"data": {
"user": null
},
"errors": [
{
"message": "Cannot return null for non-nullable field Profile.displayName"
}
]
}Поэтому не следует ставить ! механически у каждого поля. Non-null является обещанием API, которое сервер должен выполнять.
Списки
Список обозначается квадратными скобками:
users: [User]Возможные варианты:
[User]
[User!]
[User]!
[User!]!Их смысл:
| Тип | Что допускается |
|---|---|
[User] |
Сам список, его элементы и отдельные значения могут быть null |
[User!] |
Список может быть null, элементы — нет |
[User]! |
Список обязателен, но отдельные элементы могут быть null |
[User!]! |
Список и все его элементы обязательны |
Часто коллекции объявляются так:
users: [User!]!Если элементов нет, сервер возвращает пустой массив:
{
"data": {
"users": []
}
}а не null.
Enum
Enum ограничивает значение заранее известным набором вариантов:
enum OrderStatus {
CREATED
PAID
SHIPPED
CANCELLED
}Использование:
type Order {
id: ID!
status: OrderStatus!
}Фильтр:
type Query {
orders(
status: OrderStatus
): [Order!]!
}Запрос:
query {
orders(status: PAID) {
id
status
}
}Enum безопаснее произвольной строки:
status: StringСхема заранее сообщает клиенту допустимые значения.
Input-типы
Объектные типы результата нельзя напрямую использовать как входные данные.
Для аргументов создаются input-типы:
input CreateProductInput {
name: String!
price: Float!
categoryId: ID!
}Мутация:
type Mutation {
createProduct(
input: CreateProductInput!
): CreateProductPayload!
}Входной объект:
input UpdateProductInput {
name: String
price: Float
categoryId: ID
}Здесь поля необязательны, потому что обновление может быть частичным.
Следует определить семантику:
- отсутствующее поле — не изменять значение;
null— очистить значение;- конкретное значение — установить его.
Например:
input UpdateProfileInput {
displayName: String
middleName: String
}Переменные:
{
"input": {
"displayName": "Анна"
}
}могут означать, что middleName изменять не нужно.
А:
{
"input": {
"middleName": null
}
}может означать явное удаление отчества.
Эта семантика должна быть задокументирована.
Interface
Interface задаёт общий набор полей для нескольких типов.
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String!
}
type Product implements Node {
id: ID!
name: String!
price: Float!
}Запрос:
query {
node(id: "42") {
id
__typename
}
}Поле __typename сообщает конкретный тип результата:
{
"data": {
"node": {
"id": "42",
"__typename": "User"
}
}
}Запрос полей конкретного типа выполняется через inline fragment:
query {
node(id: "42") {
id
__typename
... on User {
name
}
... on Product {
name
price
}
}
}Серверу может потребоваться определить реальный тип:
const resolvers = {
Node: {
__resolveType(value) {
if ("email" in value) {
return "User";
}
if ("price" in value) {
return "Product";
}
return null;
},
},
};Union
Union объединяет несколько типов, у которых необязательно есть общие поля.
union SearchResult =
User
| Product
| ArticleЗапрос:
query {
search(text: "graphql") {
__typename
... on User {
id
name
}
... on Product {
id
name
price
}
... on Article {
id
title
}
}
}Резолвер типа:
const resolvers = {
SearchResult: {
__resolveType(value) {
if (value.email) {
return "User";
}
if (
typeof value.price ===
"number"
) {
return "Product";
}
if (value.title) {
return "Article";
}
return null;
},
},
};Корневые типы
Основные корневые типы:
type Query
type Mutation
type SubscriptionQuery используется для чтения:
type Query {
user(id: ID!): User
}Mutation используется для изменения состояния:
type Mutation {
createUser(
input: CreateUserInput!
): CreateUserPayload!
}Subscription используется для событий в реальном времени:
type Subscription {
messageCreated(
roomId: ID!
): Message!
}Для базового API обязательным является только Query.
Запросы
Query — операция чтения данных.
Простой запрос:
query {
products {
id
name
price
}
}Именованный запрос:
query GetProducts {
products {
id
name
price
}
}Именованные операции предпочтительнее анонимных, потому что имя можно использовать:
- в логах;
- в трассировке;
- в метриках;
- при анализе производительности;
- в persisted queries.
Аргументы
query {
product(id: "815") {
id
name
price
}
}Аргументы могут находиться у любого поля:
query {
user(id: "42") {
posts(
published: true
limit: 10
) {
id
title
}
}
}Схема:
type User {
id: ID!
posts(
published: Boolean
limit: Int = 20
): [Post!]!
}Переменные
Не следует собирать GraphQL-запрос конкатенацией строк.
Нежелательно:
const query = `
query {
user(id: "${userId}") {
id
name
}
}
`;Предпочтительно использовать переменные:
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
}
}Отдельный объект переменных:
{
"userId": "42"
}HTTP-запрос:
{
"query": "query GetUser($userId: ID!) { user(id: $userId) { id name } }",
"variables": {
"userId": "42"
},
"operationName": "GetUser"
}Переменные:
- отделяют данные от документа;
- упрощают повторное использование;
- корректно обрабатывают специальные символы;
- лучше поддерживаются клиентскими библиотеками;
- помогают persisted queries.
Переменные всё равно должны проверяться бизнес-логикой сервера.
Алиасы
Алиасы позволяют запросить одно поле несколько раз с разными аргументами:
query {
firstUser: user(id: "42") {
id
name
}
secondUser: user(id: "81") {
id
name
}
}Ответ:
{
"data": {
"firstUser": {
"id": "42",
"name": "Анна"
},
"secondUser": {
"id": "81",
"name": "Иван"
}
}
}Алиасы также позволяют изменить имя поля в результате:
query {
displayTitle: applicationName
}Фрагменты
Fragment позволяет повторно использовать набор полей.
fragment UserSummary on User {
id
name
avatarUrl
}Использование:
query GetArticle($articleId: ID!) {
article(id: $articleId) {
id
title
author {
...UserSummary
}
reviewers {
...UserSummary
}
}
}Фрагменты особенно полезны в компонентном frontend-приложении, где каждый компонент объявляет необходимые ему поля.
Директивы
GraphQL поддерживает директивы, изменяющие выполнение или описание схемы.
Встроенные директивы:
@include
@skip
@deprecatedПример:
query GetUser(
$userId: ID!
$withEmail: Boolean!
) {
user(id: $userId) {
id
name
email @include(
if: $withEmail
)
}
}Пропуск поля:
email @skip(if: $hideEmail)Устаревшее поле схемы:
type User {
fullName: String
@deprecated(
reason: "Используйте displayName"
)
displayName: String!
}Мутации
Mutation — операция, которая изменяет состояние системы.
Пример схемы:
input CreateUserInput {
name: String!
email: String!
password: String!
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type Mutation {
createUser(
input: CreateUserInput!
): CreateUserPayload!
}Операция:
mutation CreateUser(
$input: CreateUserInput!
) {
createUser(input: $input) {
user {
id
name
email
}
errors {
code
field
message
}
}
}Переменные:
{
"input": {
"name": "Анна",
"email": "anna@example.com",
"password": "long-secure-password"
}
}Успешный результат:
{
"data": {
"createUser": {
"user": {
"id": "42",
"name": "Анна",
"email": "anna@example.com"
},
"errors": []
}
}
}Ошибка валидации:
{
"data": {
"createUser": {
"user": null,
"errors": [
{
"code": "EMAIL_INVALID",
"field": "email",
"message": "Некорректный email"
}
]
}
}
}Payload мутации
Мутация может возвращать объект напрямую:
type Mutation {
createUser(
input: CreateUserInput!
): User!
}Но отдельный payload обычно легче расширять:
type CreateUserPayload {
user: User
errors: [UserError!]!
clientMutationId: String
}Позже можно добавить:
type CreateUserPayload {
user: User
errors: [UserError!]!
confirmationRequired: Boolean!
}без изменения корневой формы мутации.
Именование мутаций
Хорошие названия отражают бизнес-действие:
createUser
updateProfile
publishArticle
cancelOrder
markNotificationAsReadМенее информативные варианты:
execute
process
change
updateData
performActionGraphQL не требует, чтобы все изменения выглядели как универсальный CRUD.
Предметная мутация:
cancelOrder(id: ID!): CancelOrderPayload!часто понятнее универсального обновления:
updateOrder(
id: ID!
input: {
status: CANCELLED
}
): Order!Предметная операция позволяет централизованно проверить правила отмены заказа.
Запрос и мутация по HTTP
Типичный GraphQL-запрос:
POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer access-tokenТело:
{
"query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
"variables": {
"id": "42"
},
"operationName": "GetUser"
}Query иногда передаётся через GET:
GET /graphql?query=...Это может упростить HTTP-кэширование, но URL имеет ограничения длины и не должен содержать чувствительные данные.
Мутации должны передаваться через POST, потому что они изменяют состояние.
Ответ GraphQL
Обычный успешный ответ:
{
"data": {
"user": {
"id": "42",
"name": "Анна"
}
}
}Ответ с ошибкой:
{
"data": {
"user": null
},
"errors": [
{
"message": "Пользователь не найден",
"path": ["user"],
"extensions": {
"code": "NOT_FOUND"
}
}
]
}GraphQL поддерживает частичный результат:
{
"data": {
"user": {
"id": "42",
"name": "Анна",
"privateNotes": null
}
},
"errors": [
{
"message": "Недостаточно прав",
"path": [
"user",
"privateNotes"
],
"extensions": {
"code": "FORBIDDEN"
}
}
]
}Клиент должен учитывать, что в одном ответе могут одновременно присутствовать:
data
errorsHTTP-статусы GraphQL
GraphQL-ответ с ошибкой выполнения часто возвращается с HTTP-статусом 200, если сам GraphQL-запрос был корректно принят и выполнен:
HTTP/1.1 200 OK
Content-Type: application/json{
"data": {
"user": null
},
"errors": [
{
"message": "Пользователь не найден"
}
]
}Транспортные и синтаксические ошибки могут использовать другие статусы:
400 — некорректный GraphQL-документ или запрос
401 — не пройдена аутентификация на уровне endpoint
405 — неподдерживаемый HTTP-метод
500 — внутренняя ошибка инфраструктурыКлиент не должен полагаться только на response.ok. Нужно также анализировать поле errors.
Резолверы
Resolver — функция, которая получает значение конкретного поля.
Схема:
type Query {
user(id: ID!): User
}Резолвер:
const resolvers = {
Query: {
user(
parent,
args,
context,
info,
) {
return userRepository.findById(
args.id,
);
},
},
};Резолверы организуются по типам:
const resolvers = {
Query: {
user() {},
users() {},
},
Mutation: {
createUser() {},
},
User: {
posts() {},
},
};Аргументы резолвера
Функция получает четыре основных аргумента:
function resolver(
parent,
args,
context,
info,
) {
// ...
}parent
Результат родительского резолвера.
Схема:
type User {
id: ID!
posts: [Post!]!
}Резолвер:
const resolvers = {
User: {
posts(parent) {
return postRepository
.findByAuthorId(
parent.id,
);
},
},
};Здесь parent — объект пользователя:
{
id: "42",
name: "Анна"
}args
Аргументы поля:
user(id: ID!): Useruser(parent, args) {
return userRepository.findById(
args.id,
);
}Для запроса:
user(id: "42")значение args будет примерно таким:
{
id: "42",
}context
Контекст, общий для выполнения одной GraphQL-операции.
В нём обычно находятся:
- текущий пользователь;
- репозитории;
- DataLoader;
- логгер;
- request ID;
- сервисы;
- параметры запроса.
user(parent, args, context) {
context.logger.info(
{
userId: args.id,
},
"User requested",
);
return context.repositories
.userRepository
.findById(args.id);
}Контекст нельзя делать глобальным объектом с данными конкретного пользователя. Новый контекст создаётся для каждого запроса.
info
Содержит техническую информацию:
- текущий тип;
- имя поля;
- AST запроса;
- путь выполнения;
- схему;
- выбранные поля.
function resolver(
parent,
args,
context,
info,
) {
console.log(info.fieldName);
}info используется инструментами трассировки, оптимизации и анализа запроса. В обычной бизнес-логике он требуется редко.
Default resolver
Если отдельный резолвер поля не объявлен, GraphQL обычно пытается взять одноимённое свойство из родительского объекта.
Схема:
type User {
id: ID!
name: String!
}Корневой резолвер:
const resolvers = {
Query: {
user() {
return {
id: "42",
name: "Анна",
};
},
},
};Отдельные резолверы для User.id и User.name не нужны.
Если имя поля GraphQL отличается от внутреннего свойства, нужен резолвер:
type User {
displayName: String!
}const resolvers = {
User: {
displayName(parent) {
return parent.display_name;
},
},
};Асинхронные резолверы
Резолвер может возвращать обычное значение или Promise.
const resolvers = {
Query: {
async user(
parent,
{ id },
context,
) {
return context.repositories
.userRepository
.findById(id);
},
},
};GraphQL дождётся выполнения Promise.
Поля одного уровня могут выполняться параллельно, если между ними нет зависимости.
Запрос:
query {
currentUser {
id
}
products {
id
}
}Резолверы currentUser и products могут выполняться независимо.
Мутации верхнего уровня выполняются последовательно в порядке документа:
mutation {
first: updateUser(
id: "42"
input: {
name: "Анна"
}
) {
user {
id
}
}
second: publishArticle(
id: "81"
) {
article {
id
}
}
}Но вложенные поля результата могут выполняться параллельно.
Разделение ответственности
Резолвер не должен содержать всю бизнес-логику.
Нежелательно:
const resolvers = {
Mutation: {
async createOrder(
parent,
{ input },
context,
) {
const user =
await context.database.query(
"SELECT * FROM users WHERE id = ?",
[context.user.id],
);
let total = 0;
for (const item of input.items) {
total +=
item.price *
item.quantity;
}
await context.database.query(
"INSERT INTO orders ...",
);
await context.emailClient.send({
to: user.email,
});
return {
order: {
total,
},
};
},
},
};Предпочтительно использовать резолвер как адаптер:
const resolvers = {
Mutation: {
async createOrder(
parent,
{ input },
context,
) {
return context.useCases
.createOrder
.execute({
currentUser:
context.currentUser,
input,
});
},
},
};Бизнес-логика находится в отдельном сценарии:
class CreateOrder {
constructor({
orderRepository,
productRepository,
}) {
this.orderRepository =
orderRepository;
this.productRepository =
productRepository;
}
async execute({
currentUser,
input,
}) {
// Валидация, авторизация,
// расчёты и сохранение
}
}Контекст и аутентификация
Контекст создаётся при каждом запросе:
async function createContext({
request,
}) {
const token =
extractBearerToken(request);
const currentUser = token
? await tokenService
.verifyAccessToken(token)
: null;
return {
currentUser,
repositories,
services,
logger:
logger.child({
requestId:
request.headers.get(
"x-request-id",
),
}),
};
}Проверка аутентификации:
function requireAuthentication(
context,
) {
if (!context.currentUser) {
throw new GraphQLError(
"Требуется аутентификация",
{
extensions: {
code:
"UNAUTHENTICATED",
},
},
);
}
return context.currentUser;
}Использование:
const resolvers = {
Query: {
profile(
parent,
args,
context,
) {
const currentUser =
requireAuthentication(
context,
);
return context.repositories
.userRepository
.findById(
currentUser.id,
);
},
},
};Авторизация
GraphQL endpoint не должен считаться полностью разрешённым после одной общей проверки.
Права проверяются для конкретной операции и ресурса.
const resolvers = {
Mutation: {
async deleteUser(
parent,
{ id },
context,
) {
const currentUser =
requireAuthentication(
context,
);
if (
!currentUser.permissions
.includes(
"user:delete",
)
) {
throw new GraphQLError(
"Недостаточно прав",
{
extensions: {
code: "FORBIDDEN",
},
},
);
}
return context.services
.userService
.deleteUser(id);
},
},
};Для поля с чувствительными данными может потребоваться отдельная проверка:
const resolvers = {
User: {
email(
user,
args,
context,
) {
const currentUser =
requireAuthentication(
context,
);
const canRead =
currentUser.id === user.id ||
currentUser.permissions
.includes(
"user:email:read:any",
);
if (!canRead) {
throw new GraphQLError(
"Недостаточно прав",
{
extensions: {
code: "FORBIDDEN",
},
},
);
}
return user.email;
},
},
};Скрытие поля во frontend не является авторизацией.
Ошибки в резолверах
Техническая ошибка:
throw new GraphQLError(
"Не удалось выполнить операцию",
{
extensions: {
code: "INTERNAL_SERVER_ERROR",
},
},
);Ошибка отсутствия аутентификации:
throw new GraphQLError(
"Требуется аутентификация",
{
extensions: {
code: "UNAUTHENTICATED",
},
},
);Недостаточно прав:
throw new GraphQLError(
"Недостаточно прав",
{
extensions: {
code: "FORBIDDEN",
},
},
);Не следует отправлять клиенту:
- SQL-запрос;
- stack trace;
- строку подключения;
- внутреннее имя сервера;
- токен;
- конфигурацию;
- подробности стороннего сервиса.
Внутренняя ошибка записывается в лог с requestId, а клиенту возвращается безопасное сообщение.
Проблема N+1
Одна из основных проблем GraphQL — N+1 queries.
Запрос:
query {
posts {
id
title
author {
id
name
}
}
}Наивная реализация:
const resolvers = {
Query: {
posts() {
return postRepository
.findAll();
},
},
Post: {
author(post) {
return userRepository
.findById(
post.authorId,
);
},
},
};Если найдено 100 публикаций:
1 запрос для posts
+
100 запросов для author
=
101 запросЭто и есть N+1.
DataLoader
DataLoader группирует несколько загрузок в один batch и кэширует результаты в пределах запроса.
Установка:
npm install dataloaderСоздание loader:
import DataLoader from "dataloader";
function createUserLoader(
userRepository,
) {
return new DataLoader(
async (userIds) => {
const users =
await userRepository
.findByIds(userIds);
const usersById =
new Map(
users.map((user) => [
user.id,
user,
]),
);
return userIds.map(
(userId) =>
usersById.get(userId) ??
null,
);
},
);
}Контекст:
function createContext() {
return {
loaders: {
userById:
createUserLoader(
userRepository,
),
},
};
}Резолвер:
const resolvers = {
Post: {
author(
post,
args,
context,
) {
return context.loaders
.userById
.load(post.authorId);
},
},
};Теперь запросы могут быть объединены:
SELECT *
FROM users
WHERE id IN (...)Порядок результатов DataLoader
Batch-функция должна вернуть массив:
- той же длины;
- в том же порядке, что и входные ключи.
Вход:
["42", "7", "81"]Результат должен соответствовать:
[
user42,
user7,
user81,
]Даже если база вернула пользователей в другом порядке.
DataLoader создаётся на запрос
Нельзя бездумно использовать один глобальный DataLoader для всех пользователей:
const globalUserLoader =
new DataLoader(...);Его внутренний кэш может:
- жить слишком долго;
- возвращать устаревшие данные;
- смешивать контексты доступа;
- увеличивать память;
- создавать риск утечки данных.
Обычно DataLoader создаётся для каждой GraphQL-операции:
async function context() {
return {
loaders: {
userById:
createUserLoader(
userRepository,
),
},
};
}Пагинация
Нельзя без ограничения возвращать всю таблицу:
type Query {
users: [User!]!
}Для небольших данных можно использовать offset-пагинацию:
type Query {
users(
page: Int = 1
limit: Int = 20
): UserPage!
}
type UserPage {
items: [User!]!
pageInfo: PageInfo!
}
type PageInfo {
page: Int!
limit: Int!
total: Int!
pages: Int!
}Для часто изменяемых наборов обычно надёжнее курсорная пагинация:
type Query {
users(
first: Int = 20
after: String
): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: ConnectionPageInfo!
}
type UserEdge {
cursor: String!
node: User!
}
type ConnectionPageInfo {
hasNextPage: Boolean!
endCursor: String
}Запрос:
query GetUsers(
$first: Int!
$after: String
) {
users(
first: $first
after: $after
) {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
endCursor
}
}
}Сервер должен ограничивать максимальный размер страницы:
function normalizePageSize(first) {
if (!Number.isInteger(first)) {
return 20;
}
return Math.min(
Math.max(first, 1),
100,
);
}Apollo Server
Apollo Server — GraphQL-сервер для JavaScript и Node.js.
Установка базовых пакетов:
npm install @apollo/server graphqlМинимальный сервер
import {
ApolloServer,
} from "@apollo/server";
import {
startStandaloneServer,
} from "@apollo/server/standalone";
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
users: [User!]!
}
`;
const users = [
{
id: "1",
name: "Анна",
email: "anna@example.com",
},
{
id: "2",
name: "Иван",
email: "ivan@example.com",
},
];
const resolvers = {
Query: {
user(parent, { id }) {
return (
users.find(
(user) => user.id === id,
) ?? null
);
},
users() {
return users;
},
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } =
await startStandaloneServer(
server,
{
listen: {
port: 4000,
},
context: async ({
req,
}) => {
return {
authorization:
req.headers.authorization,
};
},
},
);
console.log(
`GraphQL server: ${url}`,
);Endpoint будет доступен примерно по адресу:
http://localhost:4000/Для полноценного приложения Apollo Server обычно интегрируют с HTTP-фреймворком и общей инфраструктурой middleware.
Полный пример схемы
const typeDefs = `#graphql
scalar DateTime
enum UserRole {
USER
EDITOR
ADMIN
}
type User {
id: ID!
name: String!
email: String!
role: UserRole!
createdAt: DateTime!
posts(
first: Int = 20
): [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
createdAt: DateTime!
}
input CreatePostInput {
title: String!
content: String!
}
input UpdatePostInput {
title: String
content: String
}
type UserError {
code: String!
field: String
message: String!
}
type CreatePostPayload {
post: Post
errors: [UserError!]!
}
type Query {
currentUser: User
post(id: ID!): Post
posts(
first: Int = 20
after: String
): [Post!]!
}
type Mutation {
createPost(
input: CreatePostInput!
): CreatePostPayload!
updatePost(
id: ID!
input: UpdatePostInput!
): Post!
publishPost(
id: ID!
): Post!
}
`;Резолверы
import {
GraphQLError,
} from "graphql";
const resolvers = {
DateTime: dateTimeScalar,
Query: {
currentUser(
parent,
args,
context,
) {
if (!context.currentUser) {
return null;
}
return context.repositories
.userRepository
.findById(
context.currentUser.id,
);
},
post(
parent,
{ id },
context,
) {
return context.repositories
.postRepository
.findById(id);
},
posts(
parent,
{ first, after },
context,
) {
const limit =
Math.min(
Math.max(first, 1),
100,
);
return context.repositories
.postRepository
.findPage({
limit,
after,
});
},
},
Mutation: {
async createPost(
parent,
{ input },
context,
) {
if (!context.currentUser) {
throw new GraphQLError(
"Требуется аутентификация",
{
extensions: {
code:
"UNAUTHENTICATED",
},
},
);
}
try {
const post =
await context.useCases
.createPost
.execute({
currentUser:
context.currentUser,
input,
});
return {
post,
errors: [],
};
} catch (error) {
if (
error.name ===
"ValidationError"
) {
return {
post: null,
errors:
error.details,
};
}
throw error;
}
},
updatePost(
parent,
{ id, input },
context,
) {
if (!context.currentUser) {
throw new GraphQLError(
"Требуется аутентификация",
{
extensions: {
code:
"UNAUTHENTICATED",
},
},
);
}
return context.useCases
.updatePost
.execute({
currentUser:
context.currentUser,
postId: id,
input,
});
},
publishPost(
parent,
{ id },
context,
) {
if (!context.currentUser) {
throw new GraphQLError(
"Требуется аутентификация",
{
extensions: {
code:
"UNAUTHENTICATED",
},
},
);
}
return context.useCases
.publishPost
.execute({
currentUser:
context.currentUser,
postId: id,
});
},
},
User: {
posts(
user,
{ first },
context,
) {
const limit =
Math.min(
Math.max(first, 1),
100,
);
return context.repositories
.postRepository
.findByAuthorId(
user.id,
{
limit,
},
);
},
},
Post: {
author(
post,
args,
context,
) {
return context.loaders
.userById
.load(post.authorId);
},
},
};Контекст Apollo Server
async function createGraphQLContext({
req,
}) {
const authorization =
req.headers.authorization;
let currentUser = null;
if (authorization) {
const [scheme, token] =
authorization.split(" ");
if (
scheme?.toLowerCase() ===
"bearer" &&
token
) {
try {
currentUser =
await tokenService
.verifyAccessToken(
token,
);
} catch {
currentUser = null;
}
}
}
return {
currentUser,
repositories,
useCases,
loaders: {
userById:
createUserLoader(
repositories
.userRepository,
),
},
logger:
logger.child({
requestId:
req.headers[
"x-request-id"
],
}),
};
}Контекст:
- создаётся для каждой операции;
- не должен содержать секреты, доступные резолверам без необходимости;
- не должен переиспользовать request-scoped DataLoader между запросами;
- должен собираться в одном понятном месте.
Аналоги Apollo Server
Для JavaScript также существуют другие GraphQL-серверы и инструменты:
- GraphQL Yoga;
- Mercurius для Fastify;
- Envelop;
- стандартный пакет
graphql; - серверные интеграции различных фреймворков.
Выбор зависит от:
- используемого HTTP-фреймворка;
- требований к производительности;
- плагинов;
- subscriptions;
- федерации;
- инструментирования;
- способа развёртывания.
Бизнес-логика не должна быть жёстко привязана к Apollo. Резолверы должны вызывать прикладные сервисы, которые можно использовать и из другого транспорта.
Apollo Client
Apollo Client — клиентская библиотека для выполнения GraphQL-операций и управления нормализованным кэшем.
Она предоставляет:
- отправку запросов;
- мутации;
- кэширование;
- повторное использование данных;
- обработку loading и errors;
- обновление интерфейса;
- pagination;
- optimistic updates;
- интеграцию с UI-фреймворками.
Установка базовых пакетов:
npm install \
@apollo/client \
graphqlСоздание клиента
import {
ApolloClient,
HttpLink,
InMemoryCache,
} from "@apollo/client";
const client =
new ApolloClient({
link: new HttpLink({
uri:
"https://api.example.com/graphql",
credentials:
"include",
}),
cache:
new InMemoryCache(),
});Apollo Client с Bearer-токеном
import {
ApolloClient,
HttpLink,
InMemoryCache,
} from "@apollo/client";
import {
setContext,
} from "@apollo/client/link/context";
const httpLink = new HttpLink({
uri: "https://api.example.com/graphql",
});
const authLink = setContext(
(_, previousContext) => {
const accessToken = getAccessToken();
return {
headers: {
...previousContext.headers,
authorization: accessToken
? `Bearer ${accessToken}`
: "",
},
};
},
);
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});Функция getAccessToken() должна возвращать актуальный access-токен или null:
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}Тогда после входа токен можно сохранить в памяти:
setAccessToken(loginResult.accessToken);При каждом GraphQL-запросе authLink добавит заголовок:
Authorization: Bearer access-tokenЕсли токен отсутствует, заголовок Authorization лучше вообще не добавлять:
const authLink = setContext(
(_, previousContext) => {
const accessToken = getAccessToken();
return {
headers: {
...previousContext.headers,
...(accessToken
? {
authorization:
`Bearer ${accessToken}`,
}
: {}),
},
};
},
);Вариант с cookie
Если аутентификация основана на HttpOnly cookie, authLink обычно не нужен. Достаточно разрешить браузеру отправлять cookie:
import {
ApolloClient,
HttpLink,
InMemoryCache,
} from "@apollo/client";
const httpLink = new HttpLink({
uri: "https://api.example.com/graphql",
credentials: "include",
});
const client = new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
});При междоменном запросе сервер должен корректно настроить CORS:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: trueПри использовании credentials нельзя указывать:
Access-Control-Allow-Origin: *Observability
Для GraphQL полезно собирать:
- имя операции;
- тип операции;
- длительность;
- число ошибок;
- сложность;
- глубину;
- медленные резолверы;
- количество обращений к базе;
- DataLoader batch size;
- cache hit rate;
- размер результата.
Структурированный лог:
logger.info(
{
operationName:
"GetUserProfile",
operationType:
"query",
durationMs: 84,
requestId,
userId:
currentUser?.id,
errorCount: 0,
},
"GraphQL operation completed",
);Не следует использовать в labels метрик:
- полный текст запроса;
- переменные;
- user ID;
- email;
- request ID.
Подходящие labels:
operation_name
operation_type
result
serviceПри этом число возможных operation_name должно быть контролируемым. Именованные и persisted operations упрощают мониторинг.
GraphQL и REST
GraphQL и REST решают похожую задачу — предоставляют интерфейс для работы с данными, — но используют разные модели.
Модель endpoint
REST:
GET /users/42
GET /users/42/posts
POST /posts
PATCH /posts/815
DELETE /posts/815GraphQL:
POST /graphqlВ GraphQL конкретная операция находится в теле запроса:
query GetUser {
user(id: "42") {
id
name
}
}Получение связанных данных
REST может потребовать несколько запросов:
GET /users/42
GET /users/42/posts
GET /posts/815/commentsGraphQL может получить связанные данные одной операцией:
query {
user(id: "42") {
id
name
posts(first: 10) {
id
title
comments(first: 5) {
id
text
}
}
}
}Это уменьшает число сетевых запросов, но не гарантирует уменьшение числа запросов к базе. Без DataLoader и оптимизации сервер может создать N+1.
Overfetching
REST endpoint может возвращать больше данных, чем нужно:
{
"id": "42",
"name": "Анна",
"email": "anna@example.com",
"role": "editor",
"settings": {},
"createdAt": "...",
"updatedAt": "..."
}Если экрану нужны только:
id
nameостальные поля являются overfetching.
GraphQL позволяет запросить только необходимое:
query {
user(id: "42") {
id
name
}
}Underfetching
В REST один endpoint может не содержать все нужные связи, поэтому клиент делает дополнительные запросы.
GraphQL позволяет описать требуемый граф данных одной операцией.
Но слишком большой запрос может стать дорогим, поэтому необходимы ограничения сложности и пагинация.
Версионирование
REST часто использует версию в URL:
/api/v1/users
/api/v2/usersGraphQL обычно развивает одну схему:
- Добавляет новые поля.
- Помечает старые через
@deprecated. - Собирает статистику использования.
- Удаляет поле после миграции клиентов.
Пример:
type User {
fullName: String
@deprecated(
reason: "Используйте displayName"
)
displayName: String!
}Это не означает, что GraphQL вообще не требует версионирования. Несовместимые изменения всё равно нужно управляемо внедрять.
HTTP-семантика
REST естественно использует:
- HTTP-методы;
- status codes;
- HTTP cache;
- ETag;
- CDN;
- стандартные инструменты наблюдаемости.
GraphQL переносит значительную часть семантики внутрь тела:
operation
fields
arguments
errorsПоэтому инфраструктура может видеть только:
POST /graphqlбез дополнительной GraphQL-инструментации.
Кэширование
REST-ресурс:
GET /products/815легко кэшировать по URL.
GraphQL-запросы могут иметь:
- разный набор полей;
- разные переменные;
- одинаковый endpoint;
- персонализированные результаты.
Apollo Client решает часть задачи через нормализованный клиентский кэш. Для CDN часто используются persisted queries или GET-запросы.
Загрузка файлов
REST естественно работает с:
multipart/form-dataGraphQL не имеет встроенного стандартного бинарного типа для загрузки файлов.
Практичный подход:
- Через GraphQL получить разрешение или подписанный URL.
- Загрузить файл напрямую в объектное хранилище.
- Через GraphQL подтвердить или связать файл с сущностью.
Пример:
mutation {
createUploadUrl(
input: {
fileName: "avatar.png"
contentType: "image/png"
}
) {
uploadUrl
fileId
}
}Сам файл загружается отдельным HTTP-запросом.
Ошибки
REST:
HTTP/1.1 404 Not FoundGraphQL:
{
"data": {
"user": null
},
"errors": [
{
"message": "Пользователь не найден",
"extensions": {
"code": "NOT_FOUND"
}
}
]
}GraphQL поддерживает частичный результат, но клиентская обработка становится сложнее.
Когда GraphQL действительно нужен
GraphQL особенно полезен, если одновременно присутствуют несколько условий.
Много разных клиентов
Например:
Web-приложение
Мобильное приложение
Панель администратора
Партнёрский интерфейсКаждому клиенту требуется свой набор полей и связей.
Web:
user {
id
name
avatarUrl
}Административный интерфейс:
user {
id
name
email
role
status
createdAt
}GraphQL позволяет использовать одну схему без отдельного endpoint для каждого представления.
Сложный связанный граф данных
Например:
Проекты
├── участники
├── задачи
│ ├── исполнитель
│ ├── комментарии
│ └── вложения
└── событияЕсли экраны запрашивают разные комбинации связанных сущностей, GraphQL может значительно упростить клиент.
Частые изменения требований frontend
Frontend-команды могут выбирать новые поля без создания отдельного REST endpoint:
query {
project(id: "42") {
id
name
taskCount
activeMembers {
id
name
}
}
}Серверу всё равно может потребоваться добавить новые поля в схему и резолверы, но клиент сам определяет композицию запроса.
Медленные или ограниченные сети
Одна GraphQL-операция может заменить несколько последовательных HTTP-запросов.
Это полезно для:
- мобильных приложений;
- сложных экранов;
- сетей с высокой задержкой;
- агрегирующих BFF-сервисов.
Нужен строгий исследуемый контракт
GraphQL-схема предоставляет:
- типы;
- документацию;
- introspection;
- проверку запросов;
- автодополнение;
- генерацию типов;
- инструменты анализа.
Это улучшает взаимодействие frontend и backend-команд.
GraphQL как API aggregation layer
GraphQL-сервер может объединять:
User Service
Order Service
Catalog Service
Payment Service
REST APIs
DatabasesКлиент видит единую предметную схему:
query {
currentUser {
id
orders {
id
products {
id
name
}
}
}
}Но GraphQL не устраняет сложности распределённой системы. Серверу всё равно нужно обрабатывать:
- тайм-ауты;
- частичные отказы;
- авторизацию;
- N+1 между сервисами;
- кэшированиGetUser($id: ID!) { user(id: $id) { id name } }
Mutation:
```graphql
mutation CreateUser(
$input: CreateUserInput!
) {
createUser(input: $input) {
user {
id
name
}
errors {
code
field
message
}
}
}Резолвер:
const resolvers = {
Query: {
user(
parent,
{ id },
context,
) {
return context.repositories
.userRepository
.findById(id);
},
},
};Аргументы резолвера:
parent — результат родительского поля
args — аргументы GraphQL-поля
context — пользователь, сервисы, loaders, logger
info — информация о выполнении и ASTApollo Server:
const server =
new ApolloServer({
typeDefs,
resolvers,
});Apollo Client:
const client =
new ApolloClient({
link: httpLink,
cache:
new InMemoryCache(),
});Основные правила:
- проектируйте схему как публичную предметную модель;
- используйте именованные операции и переменные;
- не помещайте бизнес-логику в резолверы;
- создавайте контекст отдельно для каждого запроса;
- проверяйте аутентификацию и авторизацию на сервере;
- защищайте чувствительные поля отдельно;
- используйте DataLoader против N+1;
- создавайте DataLoader на каждый запрос;
- ограничивайте размер страниц;
- ограничивайте глубину и сложность операций;
- учитывайте стоимость запроса при rate limiting;
- используйте стабильные коды ошибок;
- маскируйте внутренние исключения;
- собирайте метрики по имени операции;
- не используйте уникальные значения как labels;
- настраивайте клиентский кэш осознанно;
- применяйте persisted queries для стабильных публичных клиентов;
- используйте отдельные HTTP-маршруты для файлов, health-check, metrics и webhooks;
- выбирайте GraphQL только тогда, когда гибкость запросов окупает серверную и клиентскую сложность.