Данный текст преимущественно ориентирован на начинающих системных аналитиков, а также всех, кто интересуется проектированием IT-систем.
Постановка задачи
Представим, что есть компания, продающая домашних питомцев. И вот ее менеджеры обзванивают потенциальных клиентов и по 10-15 минут уговаривают их купить пушистиков, рассказывая о том, какие они милые, добрые и умные.
Мы - в роли обычного системного аналитика, работающего в этой компании над бэкендом систем, отвечающих за продажи. И вот к нам приходит наш владелец продукта (PO) и просит сделать так, чтобы сотрудники аналитического отдела могли видеть у себя в интерфейсе CRM-системы запись о каждом обращении потенциального клиента и содержание его диалога с менеджером по продажам в текстовом виде.
Опустим нюансы, связанные с реализацией того, как мы получаем этот диалог, обрабатываем его и храним (в том числе персональные данные) - это не относится к сути данной статьи, допустим, что с этим у нас все в порядке и все уже реализовано.
В итоге наша задача в этом случае сводится к банальному получению массива записей из базы данных и отображению его на фронтенде. Но есть подводный камень, о котором иногда забывают многие, особенно начинающие аналитики (да и разработчики, чего уж греха таить).
В чем заключается подводный камень?
Допустим, что наша компания средней руки и у нас целых 100 менеджеров. Каждый из них совершает за день в среднем 30 звонков по 10 минут. Итого получаем 3000 диалогов в день. Каждый 10-ти минутный диалог в текстовом виде будет "весить" в среднем 10 Кб, и за день таких диалогов наберется на 30 Мб.
В этом случае, при попытке сделать запрос в БД и извлечь все записи хотя бы за один день (а их, напомню на 30 Мб объема), мы получим, как минимум, длительное время отклика (технические нюансы про блокировки в БД, опять же, опустим), которое точно не будет радовать наших пользователей, когда они каждый раз будут обновлять или заходить на страницу, и никак не ускорит процесс их работы.
В этом случае на помощь приходит простой подход - пагинация. Это значит следующее:
На фронтенд мы будем выводить записи не полностью, а постранично - "пачками" по несколько штук, получая каждую пачку отдельным запросом на сервер.
В реализации бэкенда соответственно это будет REST-контроллер, обрабатывающий входящий запрос с параметрами, которые указывают на какой странице мы находимся (page) и количество записей на одну страницу (recordsPerPage).
Важно: пагинация относится к одной из best practiсes проектирования API и используется не только при взаимодействии "фронтенд-бэкенд", но и при взаимодействии "бэкенд-бэкенд".
Как реализовать это в коде мы оставим на откуп нашим доблестным разработчикам, а так как у нас в команде принят API-first подход, то сначала нам придется спроектировать для этого метод REST API. Проектировать будем на примере JSON-схемы и OpenAPI.
Вспомним, что у нас в БД уже лежит диалог клиента с менеджером. Идем смотреть какие же там поля. Допустим, их там всего пять:
id (integer)
- идентификатор записи в БД;managerId (integer)
- идентификатор сотрудника;clientPhoneNumber (string)
- номер телефона клиента;dialog (string)
- запись диалога в текстовом виде;date (string)
- дата диалога.
Теперь нам необходимо определить формат ответа, формат запроса, HTTP-метод и URL, на который мы будем отправлять запрос.
Особой разницы с чего начинать проектирование нет, я обычно начинаю с метода.
Описание метода
Существует несколько best practices проектирования REST API. Список не исчерпывающий, а построенный на основе моего личного опыта.
Использование существительных вместо глаголов в названиях эндпоинтов. То есть в нашем случае для получения диалогов вместо названия "getDialogs" мы будем использовать просто "dialogs".
Использовать множественное число в названиях эндпоинтов, только тогда, когда это необходимо. Если ваш метод возвращает массив объектов (пусть даже из одного объекта), то использование множественного числа оправдано.
Использовать версионирование API. В нашем случае это /v1/.
Использовать пагинацию.
Использовать HTTP-коды ответов. Метод должен возвращать понятные и не вводящие в заблуждение коды ответов. Например - нежелательно оборачивать случившуюся ошибку сервера (500 Internal Server Error) в тело ответа 200 OK (и такое бывает).
Ниже приведен шаблон описания REST API-метода. Будем использовать GET метод для запроса данных.
Метод и URL | GET api/v1/dialogs |
Параметры запроса | page (integer) - номер страницы. Параметр обязательный, передается в query. |
Назначение | Получение диалогов клиента с менеджером из БД |
Ограничения | 1. В случае внутренней ошибки возвращать ответ 500 Internal Server Error. |
Логика | При получении запроса на эндпоинт необходимо осуществить валидацию его параметров. |
Спецификация (формат данных) | Если пользуемся OpenAPI - указываем ссылку на спецификацию в swagger (или другом аналогичном инструменте). |
Ниже приведена иллюстрация взаимодействия для привлечения внимания :)
Можно отправить тот же самый запрос и методом POST (для наглядности вместе с еще одним атрибутом - пусть им будет date
).
Обычно такой подход используется, если запрос имеет большое количество параметров, так как при использовании метода GET можно столкнуться с ограничением длины URL-строки в 2048 символов, и параметры будут потеряны при передаче на сервер. Плюс к этому, если передаваемые данные чувствительны, то безопаснее будет передавать их в теле (requestBody) POST-запроса.
В этом случае, в описании метода выше изменятся только следующие строки:
Метод и URL | POST api/v1/dialogs |
Параметры запроса | page (integer) - номер страницы. Параметр обязательный, передается в requestBody. |
Метод спроектировали, переходим к форматам данных.
JSON-схема
JSON-схема для описания формата данных в ответе будет выглядеть следующим образом:
JSON-схема ответа
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "dialog_response",
"type": "object",
"properties": {
"dialogs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"managerId": {
"type": "integer"
},
"clientPhoneNumber": {
"type": "string"
},
"dialog": {
"type": "string"
},
"date": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "managerId", "clientPhoneNumber", "dialog", "date"]
}
},
"pagination": {
"type": "object",
"properties": {
"pageIndex": {
"type": "integer"
},
"recordsPerPage": {
"type": "integer"
},
"totalRecords": {
"type": "integer"
}
},
"required": ["pageIndex", "recordsPerPage"]
}
}
}
dialogs
– массив объектов, представляющих каждую запись данных (каждый диалог в нашем случае).pagination
– объект, который содержит свойства для определения текущего номера страницы (pageIndex
), количества записей на странице (recordsPerPage
) и общего количества записей (totalRecords
).
JSON-схема для описания формата данных запроса (нужна только для метода POST) будет выглядеть следующим образом:
JSON-схема запроса
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "dialog_request",
"type": "object",
"properties": {
"pagination": {
"type": "object",
"properties": {
"pageIndex": {
"type": "integer"
},
"recordsPerPage": {
"type": "integer"
}
},
"required": ["pageIndex", "recordsPerPage"]
},
"date" : {
"type": "string",
"format": "date-time"
}
}
}
-
OpenAPI
Давайте посмотрим, как это будет выглядеть в формате спецификации OpenAPI на языке YAML.
OpenAPI спецификация для метода GET
openapi: 3.0.3
info:
title: Petstore - OpenAPI 3.0
version: 1.0.1
servers:
- url: https://petstore.com/api/v1
tags:
- name: Dialogs
description: Получение диалогов
paths:
/dialogs:
get:
tags:
- Dialogs
summary: Поиск диалогов
description: Массив диалогов
parameters:
- name: page
in: query
description: Номер страницы для пагинации
required: true
schema:
type: string
- name: recordsPerPage
in: query
description: Количество записей на странице
required: true
schema:
type: string
responses:
'200':
description: Успешный запрос
content:
application/json:
schema:
type: object
properties:
dialogs:
type: array
description: Массив диалогов
items:
$ref: '#/components/schemas/Dialog'
pagination:
$ref: '#/components/schemas/Pagination'
'400':
description: Некорректный формат запроса
'500':
description: Внутренняя ошибка сервера
components:
schemas:
Dialog:
type: object
description: диалог сотрудника с клиентом
properties:
id:
type: integer
description: идентификатор записи в БД
example: 10
managerId:
type: integer
description: идентификатор сотрудника
example: 123456
clientPhoneNumber:
type: string
description: Номер телефона клиента
example: +7999888***
dialog:
type: string
description: Диалог клиента с сотрудником
example: "blablablabla"
date:
type: string
format: datetime
required: [id, managerId, clientPhoneNumber, dialog, date]
Pagination:
type: object
description: объект пагинации
properties:
pageIndex:
type: integer
example: 1
recordsPerPage:
type: integer
example: 5
totalRecords:
type: integer
example: 5000
required: [pageIndex, recordsPerPage]
OpenAPI спецификация для метода POST
openapi: 3.0.3
info:
title: Petstore - OpenAPI 3.0
version: 1.0.1
servers:
- url: https://petstore.com/api/v1
tags:
- name: Dialogs
description: Получение диалогов
paths:
/dialogs:
post:
tags:
- Dialogs
summary: Поиск диалогов
description: Массив диалогов
requestBody:
content:
application/json:
schema:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
date:
type: string
format: datetime
responses:
'200':
description: Успешный запрос
content:
application/json:
schema:
type: object
properties:
dialogs:
type: array
description: Массив диалогов
items:
$ref: '#/components/schemas/Dialog'
pagination:
allOf:
- $ref: '#/components/schemas/Pagination'
- type: object
properties:
totalRecords:
type: integer
example: 5000
description: Количество записей всего
'400':
description: Некорректный формат запроса
'500':
description: Внутренняя ошибка сервера
components:
schemas:
Dialog:
type: object
description: диалог сотрудника с клиентом
properties:
id:
type: integer
description: идентификатор записи в БД
example: 10
managerId:
type: integer
description: идентификатор сотрудника
example: 123456
clientPhoneNumber:
type: string
description: Номер телефона клиента
example: +7999888***
dialog:
type: string
description: Диалога клиента с сотрудником
example: "blablablabla"
date:
type: string
format: datetime
required: [id, managerId, clientPhoneNumber, dialog, date]
Pagination:
type: object
description: объект пагинации
properties:
pageIndex:
type: integer
example: 1
recordsPerPage:
type: integer
example: 5
required: [pageIndex, recordsPerPage]
Обратите внимание, что в спецификации мы уже указываем не только схему данных, но и URL с методом и параметрами запроса, что позволяет более наглядно вести и удобнее читать документацию.
Можете скопировать эти YAML сюда, чтобы самостоятельно поизучать и посмотреть реализацию в Swagger. Или увидеть представление спроектированных методов в интерфейсе Swagger на изображениях ниже .
Представление метода GET в Swagger
Представление метода POST в Swagger
На этом все. Формат данных и метод описаны, можно согласовывать требования, заводить задачу, оценивать сроки и отдавать в разработку.
Подробнее ознакомиться с проектированием REST API, форматами OpenAPI и JSON-Schema вы можете в моей статье на Хабре.
Какие еще бывают реализации пагинации
Рассмотренный выше способ пагинации называется offset-based pagination. Он прост в реализации и эффективен, для небольших объемов запрашиваемой из БД информации, так как с увеличением номера страницы возрастает время запроса данных в БД, а также возникает проблема с консистентностью данных в ответе, в случае с часто обновляемыми данными.
Более производительный и устраняющий проблемы с консистентностью данных метод называется cursor-based pagination, когда вместо offset используется cursor - указатель на место в памяти, содержащее результаты select запроса к БД. Поэтому курсор вернёт только те данные, которые существовали на момент начала транзакции в базе.
Еще один из методов называется keyset pagination (seek method). Основная его идея - использовать комбинацию ключей для итерационного поиска следующих записей в отсортированном массиве.
Единственный недостаток этих методов - нельзя перейти на определенную страницу.
Рекомендую ознакомиться с замечательной статьей со сравнением offset pagination и keyset pagination.
А также с этой статьей о способах реализации пагинации в PostgreSQL.
В этой статье я постарался максимально емко изложить принцип проектирования метода REST API с пагинацией, предназначенного для запроса данных из БД на примере JSON-схемы и спецификации OpenAPI.
Эту и другие статьи по системному анализу и IT‑архитектуре, вы сможете найти в моем небольшом уютном Telegram‑канале: Записки системного аналитика