Постановка задачи
Вам необходимо спроектировать и реализовать GraphQL API, который обрабатывает сложные требования к данным, сохраняет производительность при масштабировании и обеспечивает отличный developer experience.
Архитектура GraphQL
┌─────────────────────────────────────────────────────────────────────────┐
│ Clients │
│ Web App │ Mobile App │ Partner API │ Internal Tools │
└───────────┬──────────────┬──────────────┬──────────────┬────────────────┘
│ │ │ │
└──────────────┴──────────────┴──────────────┘
│
┌─────────────▼─────────────┐
│ API Gateway │
│ Rate Limit │ Auth │ Cache│
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ GraphQL Server │
│ Schema │ Resolvers │
│ Validation │ Auth │
└─────────────┬─────────────┘
│
┌────────────┬───────────┼───────────┬────────────┐
│ │ │ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ DB │ │ Cache │ │ Search │ │ API │ │ Queue │
│ Primary │ │ Redis │ │ Elastic│ │ External│ │ RabbitMQ│
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
Структура проекта
/graphql-api
├── src/
│ ├── index.js # Точка входа сервера
│ ├── schema/
│ │ ├── index.js # Композиция схемы
│ │ ├── typeDefs/ # Определения типов
│ │ │ ├── user.graphql
│ │ │ ├── product.graphql
│ │ │ └── order.graphql
│ │ └── resolvers/ # Реализация resolver
│ │ ├── user.js
│ │ ├── product.js
│ │ └── order.js
│ ├── datasources/ # Слой доступа к данным
│ │ ├── database.js
│ │ ├── cache.js
│ │ └── externalApi.js
│ ├── directives/ # Пользовательские директивы
│ │ ├── auth.js
│ │ └── rateLimit.js
│ ├── middleware/ # Middleware Express
│ │ ├── auth.js
│ │ └── logging.js
│ └── utils/
│ ├── dataloader.js
│ └── validation.js
├── tests/
└── package.json
Проектирование схемы
Определения типов
# schema/typeDefs/user.graphql
type User {
id: ID!
email: String!
name: String!
avatar: String
role: UserRole!
orders(first: Int = 10, after: String): OrderConnection!
createdAt: DateTime!
updatedAt: DateTime!
}
enum UserRole {
ADMIN
CUSTOMER
VENDOR
}
type Query {
me: User @auth
user(id: ID!): User @auth(requires: ADMIN)
users(
first: Int = 20
after: String
filter: UserFilter
orderBy: UserOrderBy
): UserConnection! @auth(requires: ADMIN)
}
type Mutation {
updateProfile(input: UpdateProfileInput!): User! @auth
changePassword(input: ChangePasswordInput!): Boolean! @auth
}
input UpdateProfileInput {
name: String
avatar: String
}
input UserFilter {
role: UserRole
searchTerm: String
createdAfter: DateTime
}
enum UserOrderBy {
CREATED_AT_ASC
CREATED_AT_DESC
NAME_ASC
NAME_DESC
}
# Relay-style pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Пользовательские скаляры
# schema/typeDefs/scalars.graphql
scalar DateTime
scalar JSON
scalar Email
scalar URL
scalar UUID
// schema/scalars.js
import { GraphQLScalarType, Kind } from 'graphql';
export const DateTimeScalar = new GraphQLScalarType({
name: 'DateTime',
description: 'ISO-8601 formatted date-time string',
serialize(value) {
return value instanceof Date ? value.toISOString() : value;
},
parseValue(value) {
return new Date(value);
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
return null;
},
});
Resolvers с DataLoader
Решение проблемы N+1
// src/utils/dataloader.js
import DataLoader from 'dataloader';
import { db } from './database';
export function createLoaders() {
return {
userById: new DataLoader(async (ids) => {
const users = await db.users.findMany({
where: { id: { in: ids } }
});
// Должно возвращаться в том же порядке, что и запрошенные ids
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
}),
ordersByUserId: new DataLoader(async (userIds) => {
const orders = await db.orders.findMany({
where: { userId: { in: userIds } }
});
// Группировать по userId
const ordersByUser = new Map();
orders.forEach(order => {
const existing = ordersByUser.get(order.userId) || [];
ordersByUser.set(order.userId, [...existing, order]);
});
return userIds.map(id => ordersByUser.get(id) || []);
}),
productById: new DataLoader(async (ids) => {
const products = await db.products.findMany({
where: { id: { in: ids } }
});
const productMap = new Map(products.map(p => [p.id, p]));
return ids.map(id => productMap.get(id) || null);
}),
};
}
// src/index.js
import { ApolloServer } from '@apollo/server';
import { createLoaders } from './utils/dataloader';
const server = new ApolloServer({
typeDefs,
resolvers,
});
// Создавайте новые loaders для каждого запроса
const context = async ({ req }) => ({
user: req.user,
loaders: createLoaders(),
});
Реализация resolver
// src/schema/resolvers/user.js
export const userResolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) throw new AuthenticationError('Not authenticated');
return user;
},
user: async (_, { id }, { loaders }) => {
return loaders.userById.load(id);
},
users: async (_, { first, after, filter, orderBy }, { db }) => {
const cursor = after ? decodeCursor(after) : null;
const where = buildUserFilter(filter);
const order = buildOrderBy(orderBy);
const users = await db.users.findMany({
where: {
...where,
...(cursor && { id: { gt: cursor } }),
},
orderBy: order,
take: first + 1, // Получить на один элемент больше, чтобы проверить hasNextPage
});
const hasNextPage = users.length > first;
const edges = users.slice(0, first).map(user => ({
cursor: encodeCursor(user.id),
node: user,
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!cursor,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: () => db.users.count({ where }),
};
},
},
User: {
// Field resolver с использованием DataLoader
orders: async (user, { first, after }, { loaders }) => {
const orders = await loaders.ordersByUserId.load(user.id);
return paginateArray(orders, first, after);
},
},
Mutation: {
updateProfile: async (_, { input }, { user, db }) => {
if (!user) throw new AuthenticationError('Not authenticated');
return db.users.update({
where: { id: user.id },
data: input,
});
},
},
};
Аутентификация и авторизация
Директива auth
// src/directives/auth.js
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
import { AuthenticationError, ForbiddenError } from 'apollo-server-express';
export function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
const { requires } = authDirective;
fieldConfig.resolve = async function (source, args, context, info) {
if (!context.user) {
throw new AuthenticationError('Must be logged in');
}
if (requires && context.user.role !== requires) {
throw new ForbiddenError('Insufficient permissions');
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
});
}
// Определение директивы схемы
const authDirectiveTypeDef = `
directive @auth(requires: UserRole) on FIELD_DEFINITION
`;
Аутентификация в context
// src/middleware/auth.js
import jwt from 'jsonwebtoken';
export async function getUser(req) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return null;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await db.users.findUnique({ where: { id: decoded.userId } });
return user;
} catch (error) {
return null;
}
}
// В настройке сервера
const context = async ({ req }) => ({
user: await getUser(req),
loaders: createLoaders(),
});
Rate limiting и сложность запросов
Анализ сложности запросов
// src/plugins/complexityPlugin.js
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';
export const complexityPlugin = {
requestDidStart: () => ({
didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
const maxComplexity = 1000;
if (complexity > maxComplexity) {
throw new Error(
`Query is too complex: ${complexity}. Maximum allowed: ${maxComplexity}`
);
}
console.log('Query Complexity:', complexity);
},
}),
};
Ограничение глубины
// src/validation/depthLimit.js
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(10)],
});
Стратегии кэширования
Кэширование ответов
// Кэширование Apollo Server
import responseCachePlugin from '@apollo/server-plugin-response-cache';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
responseCachePlugin({
sessionId: (context) => context.user?.id || null,
}),
],
});
// Подсказки кэша в resolvers
const resolvers = {
Query: {
products: async (_, args, context, info) => {
info.cacheControl.setCacheHint({ maxAge: 300 }); // 5 минут
return fetchProducts(args);
},
},
Product: {
__resolveReference(product, { loaders }) {
return loaders.productById.load(product.id);
},
},
};
Слой кэширования Redis
// src/datasources/cache.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export const cache = {
async get(key) {
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
},
async set(key, value, ttlSeconds = 300) {
await redis.setex(key, ttlSeconds, JSON.stringify(value));
},
async invalidate(pattern) {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
},
};
// Использование в resolver
const resolvers = {
Query: {
product: async (_, { id }, { loaders, cache }) => {
const cacheKey = `product:${id}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const product = await loaders.productById.load(id);
await cache.set(cacheKey, product, 600);
return product;
},
},
};
Обработка ошибок
// src/utils/errors.js
import { GraphQLError } from 'graphql';
export class ValidationError extends GraphQLError {
constructor(message, field) {
super(message, {
extensions: {
code: 'VALIDATION_ERROR',
field,
},
});
}
}
export class NotFoundError extends GraphQLError {
constructor(resource) {
super(`${resource} not found`, {
extensions: {
code: 'NOT_FOUND',
resource,
},
});
}
}
// Форматтер ошибок
const formatError = (error) => {
// Логируйте полную ошибку внутри системы
console.error(error);
// Санитизируйте для клиента
if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return {
message: 'Internal server error',
extensions: {
code: 'INTERNAL_SERVER_ERROR',
},
};
}
return error;
};
const server = new ApolloServer({
typeDefs,
resolvers,
formatError,
});
Тестирование GraphQL API
// tests/user.test.js
import { createTestClient } from 'apollo-server-testing';
import { gql } from 'apollo-server-express';
import { createServer } from '../src/server';
describe('User queries', () => {
let query, mutate;
beforeAll(async () => {
const server = await createServer();
const client = createTestClient(server);
query = client.query;
mutate = client.mutate;
});
it('fetches current user', async () => {
const ME_QUERY = gql`
query {
me {
id
email
name
}
}
`;
const res = await query({
query: ME_QUERY,
context: { user: { id: '1', email: '[email protected]', name: 'Test' } },
});
expect(res.errors).toBeUndefined();
expect(res.data.me.email).toBe('[email protected]');
});
it('requires authentication for me query', async () => {
const res = await query({
query: gql`query { me { id } }`,
context: { user: null },
});
expect(res.errors[0].extensions.code).toBe('UNAUTHENTICATED');
});
});
Принципы проектирования API уровня Senior
REST vs GraphQL: когда выбирать что
| Фактор | REST | GraphQL |
|---|
| Несколько ресурсов за один вызов | Плохо (over/under-fetching) | Отлично |
| Простые CRUD-операции | Отлично | Избыточно |
| Кэширование | Просто (HTTP caching) | Сложно (нужно кастомное) |
| Загрузка файлов | Нативно | Требуются обходные решения |
| Real-time | Polling/WebSockets | Subscriptions встроены |
Правило Senior: Используйте REST для простых, хорошо определённых ресурсов. Используйте GraphQL, когда у клиентов сложные, вариативные потребности в данных.
Идемпотентность для mutations
Сетевые сбои случаются. Если клиент отправляет mutation для платежа и получает timeout, успешно ли она выполнилась?
Решение: ключи идемпотентности
type Mutation {
processPayment(
input: PaymentInput!
idempotencyKey: String!
): PaymentResult!
}
// Реализация сервера
async processPayment(_, { input, idempotencyKey }, { redis, db }) {
// Проверить, видели ли мы уже этот ключ
const cached = await redis.get(`idempotency:${idempotencyKey}`);
if (cached) {
return JSON.parse(cached); // Вернуть сохранённый результат
}
// Обработать платёж
const result = await processStripePayment(input);
// Кэшировать результат на 24 часа
await redis.setex(
`idempotency:${idempotencyKey}`,
86400,
JSON.stringify(result)
);
return result;
}
Стратегия версионирования
API развиваются. Подход GraphQL:
- Аддитивные изменения: добавляйте новые поля, не удаляйте старые.
- Deprecation: помечайте поля как
@deprecated перед удалением. - Переименование поля: добавьте новое поле, объявите старое deprecated, удалите после периода миграции.
type User {
id: ID!
name: String! @deprecated(reason: "Use fullName instead")
fullName: String!
}
Правило Senior: Хорошо спроектированному GraphQL API редко требуется версионирование. Гибкий язык запросов позволяет клиентам получать ровно то, что им нужно.
Структурированные контракты ошибок
Не возвращайте только общие ошибки. Предоставляйте машиночитаемые коды ошибок:
{
"errors": [
{
"message": "The card balance is too low.",
"extensions": {
"code": "INSUFFICIENT_FUNDS",
"requestId": "req_12345",
"docsUrl": "https://api.example.com/errors/insufficient_funds"
}
}
]
}
Чек-лист GraphQL API
Проектирование схемы
- [ ] Пагинация в стиле Relay для списков
- [ ] Пользовательские скаляры для валидации
- [ ] Input-типы для mutations
- [ ] Описательные типы ошибок с кодами
- [ ] Документация схемы
- [ ] Стратегия deprecation для эволюции полей
Производительность
- [ ] DataLoader для предотвращения N+1
- [ ] Лимиты сложности запросов
- [ ] Лимиты глубины запросов
- [ ] Кэширование ответов
- [ ] Persistent queries (production)
- [ ] Batching для связанных запросов
Безопасность
- [ ] Middleware аутентификации
- [ ] Директивы авторизации
- [ ] Rate limiting на операцию
- [ ] Валидация входных данных
- [ ] Whitelisting запросов (production)
- [ ] Ключи идемпотентности для mutations
Связанные статьи Wiki