Как стать автором
Обновить

Реализация REST API-метода с пагинацией на примере JSON-Schema и OpenAPI

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров10K

Данный текст преимущественно ориентирован на начинающих системных аналитиков, а также всех, кто интересуется проектированием 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‑канале: Записки системного аналитика

Теги:
Хабы:
Всего голосов 16: ↑10 и ↓6+5
Комментарии16

Публикации

Истории

Работа

Ближайшие события