Данный текст преимущественно ориентирован на начинающих системных аналитиков, а также всех, кто интересуется проектированием 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‑канале: Записки системного аналитика
Комментарии (14)
GamerTol
28.09.2024 06:37В "OpenAPI и JSON-Schema вы можете в моей статье на Хабре." Ссылка никуда не ведёт.
sotland
28.09.2024 06:37+1Как пагинация совмещается с фильтрацией? Можно расширить текст на случай наличия простого фильтра.
monzon_8
28.09.2024 06:37Тема пагинации не раскрыта. Как фронтенд узнает, сколько кнопочек вперёд-назад рисовать?
nick_oldman Автор
28.09.2024 06:37Достаточно сделать по одной кнопочке вперёд-назад, на крайний случай третью - двойное вперед >> (к самой последней записи).
Запрашивать из БД общее количество записей и затем делить на количество записей на страницу для подсчёта количество страниц - не самое оптимальное решение, потому что SELECT COUNT(*) это одна из самых «тяжелых» операций.
monzon_8
28.09.2024 06:37SELECT COUNT(*) это одна из самых «тяжелых» операций
а есть какие-то объективные данные на сей счёт? не из головы, а чтобы на замеры посмотреть? это одна из самых тривиальных операций для любой СУБД.
ответьте на такой вопрос. как интерфейс сообщит пользователю, что кликать по кнопке "вперёд" не имеет смысла (т.е. она должна быть как минимум деактивирована) в случае, если мы достигли последней "страницы" в базе согласно лимиту и оффсету и клик по следующей заведомо приведет к пустому ответу БД потому что записей с такими лимитом и оффсетом в базе попросту нет?
nick_oldman Автор
28.09.2024 06:37По вопросу возвращения количества записей в ответе согласен, я изначально ввел всех в заблуждение, т.к. рассказывал про получение записей на UI, а в голове держал взаимодействие back2back, когда нет смысла передавать этот параметр.
Чтобы не городить костыли, действительно целесообразно получать в ответе totalCount. Но если записей слишком много, то смысла пользоваться offset-пагинацией нет, и целесообразнее перейти к реализации с помощью, например, keyset-пагинации.
Спасибо, что внимательно прочитали и обратили внимание, статью отредактировал.
Format-X22
28.09.2024 06:37Похоже пост ради ссылки на канал. Оно в целом не очень плохо, но технический уровень очень плох. Тема конечно простая и даже помечено мол тут всё просто. Но по факту и сам вариант решения имеет проблемы.
Вообще, по хорошему, нужно всегда отдавать объект. По простой причине - очень частно нужны метаданные. Для той же пагинации хорошо бы знать сколько элементов вообще есть в списке всего. Обычно отправляется объект, в нем есть ключ items или подобное, там массив самих данных. Рядом едет ключ pagination, внутри объект с данными пагинации - как минимум ключ total где показывается сколько данных всего. Это важно - как минимум знать какая страница последняя и понимать что кнопку «далее» рисовать не нужно. А в идеале ещё и выводить мол нашлось столько то, где-нибудь внизу таблицы. Также помимо пагинации иногда нужно отправлять ещё какие-нибудь данные и вот с объектом всё выходит прекрасно.
По именованию recordsPerPage вопросы, почему так длинно. Хотя иногда бывает, но можно взять что-то классическое вроде skip и limit. Ну ли page и pageSize. Многословно выходит.
Также не раскрыта тема ленточной пагинации. При интенсивном потоке данных имеет смысл в пагинации по lastId и подобному.
nick_oldman Автор
28.09.2024 06:37Спасибо за подробный комментарий и, что как и пользователь выше, обратили внимание на необходимость наличия ключа total для корректной отрисовки UI, отредактировал статью.
Про ленточную пагинацию изначально не планировал писать в статье, но сейчас привел краткий пример.
scarab
28.09.2024 06:37Предположим, на момент просмотра 1-й страницы в базе имеется 100 записей с id от 1 до 100.
Выводим записи с обратной сортировкой по id, 10 на страницу. В этом случае на первой странице отобразятся записи с 100 по 91 включительно.
Пока менеджер просматривал страницу, в базу добавилось ещё 3 записи с id 101, 102, 103.
Менеджер нажимает кнопку перехода на следующую страницу и... ему отобразится что?
Записи с 90 по 81 включительно? Или с 93 по 84? Так он же уже их просматривал? А если записи добавляются быстрее, чем менеджер просматривает (а в Вашем примере со 100 операторами это вполне реально).
Ну и вишенкой на торте: а на кой чёрт здесь вообще аналитик? Бюджеты пилить? Разработать API должен уметь любой мидл, не говоря уже о сеньоре (как по мне, разработчик, не умеющий в такие вещи, вообще профнепригоден, но я уже привык, что джуны сейчас слегка умнее шимпанзе).
nick_oldman Автор
28.09.2024 06:37Менеджер нажимает кнопку перехода на следующую страницу и... ему отобразится что?
Вы абсолютно правы, в моем примере я и указал, что offset-based пагинация может приводить к неконсистентности часто обновляемых данных и лучше использовать курсоры, так как они вернут результаты, существующие в базе на момент начала транзакции.
Целью статьи вообще было показать, как можно спроектировать простой API-метод, описав его с помощью двух разных подходов, не углубляясь в тонкости реализации логики на бэке или фронте, так как в начале материала я даже отметил, что он ориентирован на начинающих аналитиков.
Ну и вишенкой на торте: а на кой чёрт здесь вообще аналитик?
Последние лет пять большом энтерпрайзе (во всяком случае там, где работал я и где работали мои знакомые) переходят к api first. В связи с этим - огромный спрос на аналитиков, и одна из их задач - это отдать готовую спецификацию разработчику, чтобы тот не тратил своё время на написание и поддержание документации. Бюджет хоть и расходуется, но time to market продукта получает неплохой прирост.
Косвенно, это подтверждается огромнейшим спросом на рынке РФ (возьмите хотя бы hh - там количество вакансий SA уже второе по количеству после разработчиков).
swame
Не хватает фото пушистиков для наглядности.
nick_oldman Автор
Добавил :)