Данный текст преимущественно ориентирован на начинающих системных аналитиков, а также всех, кто интересуется проектированием IT-систем.

Постановка задачи

Представим, что есть компания, продающая домашних питомцев. И вот ее менеджеры обзванивают потенциальных клиентов и по 10-15 минут уговаривают их купить пушистиков, рассказывая о том, какие они милые, добрые и умные.

Фото пушистика
Фото пушистика

Мы - в роли обычного системного аналитика, работающего в этой компании над бэкендом систем, отвечающих за продажи. И вот к нам приходит наш владелец продукта (PO) и просит сделать так, чтобы сотрудники аналитического отдела могли видеть у себя в интерфейсе CRM-системы запись о каждом обращении потенциального клиента и содержание его диалога с менеджером по продажам в текстовом виде.

Опустим нюансы, связанные с реализацией того, как мы получаем этот диалог, обрабатываем его и храним (в том числе персональные данные) - это не относится к сути данной статьи, допустим, что с этим у нас все в порядке и все уже реализовано.

В итоге наша задача в этом случае сводится к банальному получению массива записей из базы данных и отображению его на фронтенде. Но есть подводный камень, о котором иногда забывают многие, особенно начинающие аналитики (да и разработчики, чего уж греха таить).

В чем заключается подводный камень?

Допустим, что наша компания средней руки и у нас целых 100 менеджеров. Каждый из них совершает за день в среднем 30 звонков по 10 минут. Итого получаем 3000 диалогов в день. Каждый 10-ти минутный диалог в текстовом виде будет "весить" в среднем 10 Кб, и за день таких диалогов наберется на 30 Мб.

В этом случае, при попытке сделать запрос в БД и извлечь все записи хотя бы за один день (а их, напомню на 30 Мб объема), мы получим, как минимум, длительное время отклика (технические нюансы про блокировки в БД, опять же, опустим), которое точно не будет радовать наших пользователей, когда они каждый раз будут обновлять или заходить на страницу, и никак не ускорит процесс их работы.

В этом случае на помощь приходит простой подход - пагинация. Это значит следующее:

  1. На фронтенд мы будем выводить записи не полностью, а постранично - "пачками" по несколько штук, получая каждую пачку отдельным запросом на сервер.

  2. В реализации бэкенда соответственно это будет 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.
recordsPerPage (integer) - количество записей на странице. Параметр обязательный, передается в query.
(Например: GET api/v1/dialogs?page=3&recordsPerPage=5)

Назначение

Получение диалогов клиента с менеджером из БД
ак правило, еще здесь указывается название таблицы с интересующими нас записями и имя схемы БД, в которой она находится).

Ограничения

1. В случае внутренней ошибки возвращать ответ 500 Internal Server Error.
2. В случае ошибки валидации параметров запроса возвращать ответ 400 Bad Request.

Логика

При получении запроса на эндпоинт необходимо осуществить валидацию его параметров.
Если валидация успешна, необходимо выполнить запрос на получение X записей из БД, пропустив предыдущие Y записей.

P.S. X=recordsPerPage; Y=X*(page-1).
P.P.S. Тут вообще можно написать SQL-запрос типа SELECT * FROM dialogs OFFSET Y LIMIT X, чтобы разработчики крепче вас любили :)

Спецификация (формат данных)

Если пользуемся OpenAPI - указываем ссылку на спецификацию в swagger (или другом аналогичном инструменте).
Если пользуемся JSON-схемой, прикладываем файл со схемой запроса и ответа.

Ниже приведена иллюстрация взаимодействия для привлечения внимания :)

Метод REST API
Метод REST API

Можно отправить тот же самый запрос и методом POST (для наглядности вместе с еще одним атрибутом - пусть им будет date).

Обычно такой подход используется, если запрос имеет большое количество параметров, так как при использовании метода GET можно столкнуться с ограничением длины URL-строки в 2048 символов, и параметры будут потеряны при передаче на сервер. Плюс к этому, если передаваемые данные чувствительны, то безопаснее будет передавать их в теле (requestBody) POST-запроса.

В этом случае, в описании метода выше изменятся только следующие строки:

Метод и URL

POST api/v1/dialogs

Параметры запроса

page (integer) - номер страницы. Параметр обязательный, передается в requestBody.
recordsPerPage (integer) - количество записей на странице. Параметр обязательный, передается в requestBody.
date (string) - дата диалога. Параметр необязательный, передается в 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
GET
GET

Представление метода POST в Swagger
POST
POST

На этом все. Формат данных и метод описаны, можно согласовывать требования, заводить задачу, оценивать сроки и отдавать в разработку.

Подробнее ознакомиться с проектированием 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)


  1. swame
    28.09.2024 06:37

    Не хватает фото пушистиков для наглядности.


    1. nick_oldman Автор
      28.09.2024 06:37

      Добавил :)


  1. GamerTol
    28.09.2024 06:37

    В "OpenAPI и JSON-Schema вы можете в моей статье на Хабре." Ссылка никуда не ведёт.


    1. nick_oldman Автор
      28.09.2024 06:37

      Спасибо, обновил!


      1. Crash13
        28.09.2024 06:37

        .


  1. sotland
    28.09.2024 06:37
    +1

    Как пагинация совмещается с фильтрацией? Можно расширить текст на случай наличия простого фильтра.


  1. monzon_8
    28.09.2024 06:37

    Тема пагинации не раскрыта. Как фронтенд узнает, сколько кнопочек вперёд-назад рисовать?


    1. nick_oldman Автор
      28.09.2024 06:37

      Достаточно сделать по одной кнопочке вперёд-назад, на крайний случай третью - двойное вперед >> (к самой последней записи).

      Запрашивать из БД общее количество записей и затем делить на количество записей на страницу для подсчёта количество страниц - не самое оптимальное решение, потому что SELECT COUNT(*) это одна из самых «тяжелых» операций.


      1. monzon_8
        28.09.2024 06:37

        SELECT COUNT(*) это одна из самых «тяжелых» операций

        а есть какие-то объективные данные на сей счёт? не из головы, а чтобы на замеры посмотреть? это одна из самых тривиальных операций для любой СУБД.

        ответьте на такой вопрос. как интерфейс сообщит пользователю, что кликать по кнопке "вперёд" не имеет смысла (т.е. она должна быть как минимум деактивирована) в случае, если мы достигли последней "страницы" в базе согласно лимиту и оффсету и клик по следующей заведомо приведет к пустому ответу БД потому что записей с такими лимитом и оффсетом в базе попросту нет?


        1. nick_oldman Автор
          28.09.2024 06:37

          По вопросу возвращения количества записей в ответе согласен, я изначально ввел всех в заблуждение, т.к. рассказывал про получение записей на UI, а в голове держал взаимодействие back2back, когда нет смысла передавать этот параметр.
          Чтобы не городить костыли, действительно целесообразно получать в ответе totalCount. Но если записей слишком много, то смысла пользоваться offset-пагинацией нет, и целесообразнее перейти к реализации с помощью, например, keyset-пагинации.
          Спасибо, что внимательно прочитали и обратили внимание, статью отредактировал.


  1. Format-X22
    28.09.2024 06:37

    Похоже пост ради ссылки на канал. Оно в целом не очень плохо, но технический уровень очень плох. Тема конечно простая и даже помечено мол тут всё просто. Но по факту и сам вариант решения имеет проблемы.

    Вообще, по хорошему, нужно всегда отдавать объект. По простой причине - очень частно нужны метаданные. Для той же пагинации хорошо бы знать сколько элементов вообще есть в списке всего. Обычно отправляется объект, в нем есть ключ items или подобное, там массив самих данных. Рядом едет ключ pagination, внутри объект с данными пагинации - как минимум ключ total где показывается сколько данных всего. Это важно - как минимум знать какая страница последняя и понимать что кнопку «далее» рисовать не нужно. А в идеале ещё и выводить мол нашлось столько то, где-нибудь внизу таблицы. Также помимо пагинации иногда нужно отправлять ещё какие-нибудь данные и вот с объектом всё выходит прекрасно.

    По именованию recordsPerPage вопросы, почему так длинно. Хотя иногда бывает, но можно взять что-то классическое вроде skip и limit. Ну ли page и pageSize. Многословно выходит.

    Также не раскрыта тема ленточной пагинации. При интенсивном потоке данных имеет смысл в пагинации по lastId и подобному.


    1. nick_oldman Автор
      28.09.2024 06:37

      Спасибо за подробный комментарий и, что как и пользователь выше, обратили внимание на необходимость наличия ключа total для корректной отрисовки UI, отредактировал статью.

      Про ленточную пагинацию изначально не планировал писать в статье, но сейчас привел краткий пример.


  1. 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 должен уметь любой мидл, не говоря уже о сеньоре (как по мне, разработчик, не умеющий в такие вещи, вообще профнепригоден, но я уже привык, что джуны сейчас слегка умнее шимпанзе).


  1. nick_oldman Автор
    28.09.2024 06:37

    Менеджер нажимает кнопку перехода на следующую страницу и... ему отобразится что?

    Вы абсолютно правы, в моем примере я и указал, что offset-based пагинация может приводить к неконсистентности часто обновляемых данных и лучше использовать курсоры, так как они вернут результаты, существующие в базе на момент начала транзакции.

    Целью статьи вообще было показать, как можно спроектировать простой API-метод, описав его с помощью двух разных подходов, не углубляясь в тонкости реализации логики на бэке или фронте, так как в начале материала я даже отметил, что он ориентирован на начинающих аналитиков.

    Ну и вишенкой на торте: а на кой чёрт здесь вообще аналитик? 

    Последние лет пять большом энтерпрайзе (во всяком случае там, где работал я и где работали мои знакомые) переходят к api first. В связи с этим - огромный спрос на аналитиков, и одна из их задач - это отдать готовую спецификацию разработчику, чтобы тот не тратил своё время на написание и поддержание документации. Бюджет хоть и расходуется, но time to market продукта получает неплохой прирост.

    Косвенно, это подтверждается огромнейшим спросом на рынке РФ (возьмите хотя бы hh - там количество вакансий SA уже второе по количеству после разработчиков).