JSON Toolkit

Ключевые моменты проектирования контрактов данных JSON API

Главное: Шесть вещей очень трудно изменить после выпуска: оболочки ответов, форматы ошибок, форма пагинации, представление ID и времени, семантика null против отсутствия и эволюция версий.

Средний~13 мин чтенияОпубликовано 2026-10-30Обновлено 2026-10-30
Обложка ключевых моментов проектирования контрактов данных JSON API

Главное: закрепите в контракте оболочку ответа, формат ошибок, представление ID и времени и семантику null-против-отсутствия, прежде чем спорить о пагинации и деталях именования; всегда оборачивайте верхний уровень в оболочку data, чтобы пагинацию и трассировочные ID можно было добавить позже, не ломая существующих вызывающих.

В дизайне API нет стандартных ответов, но есть решения, которые «трудно отменить после выбора», и их лучше продумать до написания первой строки кода.

1. Оборачивать ответ или нет

Три стиля:

// Стиль A: голый ответ
{ "id": 1, "name": "张三" }

// Стиль B: оболочка data
{ "data": { "id": 1, "name": "张三" } }

// Стиль C: data + метаданные
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
Стиль Плюсы Минусы
Голый ответ Проще всего, на один уровень меньше для фронтенда Нельзя прикрепить метаданные; массив верхнего уровня несёт риски перехвата JSON и эволюции
Оболочка data Легко добавить meta / errors / links Ещё один уровень вложенности, фронтенд должен писать res.data.data
data + метаданные Лучшая наблюдаемость Требуется строгое соглашение о том, какие поля принадлежат meta

Рекомендация: используйте оболочку data единообразно и для списков, и для деталей, и никогда не делайте верхний уровень массивом. Так добавление пагинации или трассировочных ID позже никогда не ломает контракт.

2. Формат ошибок: копируйте RFC 9457

Изобретение собственной структуры ошибок — огромный источник стоимости сотрудничества. RFC 9457 (заменяет RFC 7807) определяет application/problem+json:

{
  "type": "https://bbdrug.com/problems/insufficient-balance",
  "title": "余额不足",
  "status": 422,
  "detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
  "instance": "/accounts/A1001/payments/p-889",
  "balance": "12.00",
  "required": "30.00"
}
Поле Значение Обязательное
type Стабильный URI типа проблемы Рекомендуется
title Краткая человекочитаемая сводка Рекомендуется
status HTTP-код статуса Рекомендуется
detail Конкретное объяснение того, что произошло в этот раз Необязательное
instance Экземпляр ресурса, с которым возникла проблема Необязательное
Поля расширения Произвольные бизнес-поля Необязательные

Поля расширения (например balance / required выше) разрешены спецификацией, и фронтенд может использовать их для точных сообщений вместо парсинга текста detail.

3. Пагинация: курсор лучше offset

// Пагинация по offset
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }

// Пагинация по курсору
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
Аспект Пагинация по offset Пагинация по курсору
Сложность реализации Низкая (LIMIT/OFFSET) Средняя (нужен стабильный ключ сортировки)
Производительность на глубоких страницах Плохая (чем больше offset, тем медленнее) Хорошая
Когда данные меняются Дубликаты или пропущенные элементы Стабильно
Переход на страницу Возможен Невозможен
Нужен общий счётчик Требует дополнительного подсчёта Не требуется

Рекомендация: используйте пагинацию по offset для административных таблиц (им нужны переходы по страницам и итоги), а пагинацию по курсору — для бесконечных лент, обращённых к пользователю.

4. ID и время

4.1 Всегда используйте строки для ID

Обоснование рассмотрено в статье о типах: на стороне JavaScript значения выше 2^53 молча теряют точность. Даже если текущий ID — маленькое автоинкрементное целое, использование строк оставляет место для перехода на snowflake ID позже.

{ "id": "1000000000000000001" }

4.2 Используйте строки RFC 3339 для времени

{
  "createdAt": "2026-10-30T10:41:45+08:00",
  "updatedAt": "2026-10-30T02:41:45Z"
}
  • Не используйте числовые метки времени в миллисекундах: они трудночитаемы, и единицу (секунды/миллисекунды) часто путают;
  • Указывайте часовой пояс явно: либо с Z, либо со смещением; никогда не используйте «локальное время без часового пояса»;
  • Именуйте поля единообразно как xxxAt, чтобы с первого взгляда было очевидно, что это точка во времени.

5. null и отсутствие

Вы должны выбрать одно и записать это в документацию:

Подход Выражение «нет значения» Выражение «не изменять»
Подход A Значение поля равно null Опустить поле в запросе
Подход B Поля не существует Опустить поле в запросе

Проблема подхода A в том, что «опущено» и «отправлено как null» означают разное в PATCH, что легко неверно истолковать; проблема подхода B в том, что JSON.stringify во фронтенде отбрасывает поля со значением undefined, что случайно совпадает с семантикой.

Практическая рекомендация:

  • Используйте подход A для тел ответов (null ясно означает отсутствие значения, и фронтенду не нужно различать undefined);
  • Используйте подход B для тел запросов (особенно PATCH), в сочетании с Merge Patch или JSON Patch, чтобы сделать семантику явной.

6. Именование и структура полей

Решение Частые варианты Оговорка
Стиль именования camelCase (экосистема JS) / snake_case (Python, базы данных) Единообразно по проекту, не смешивать
Значения перечислений Строчные с подчёркиваниями, "pending_review" Не используйте числа; они трудночитаемы
Булевы поля isActive / enabled Не используйте status: 0/1
Поля массивов Существительные во множественном числе, items Возвращайте [] для пустых массивов, никогда null
Глубина вложенности Не более 3 уровней Глубже — разделите или предложите параметр expand

Возврат [] вместо null для пустых массивов важен: data.items.map(...) во фронтенде не упадёт из-за null.

7. Стратегия эволюции

Изменение Ломает совместимость Как обрабатывать
Добавить необязательное поле Нет Выпускайте напрямую
Добавить обязательное поле Да Добавьте значение по умолчанию или выпускайте по версии
Удалить поле Да Сначала пометьте устаревшим, наблюдайте за вызывающими, затем удалите
Изменить тип поля Да Добавьте новое имя поля и двойную запись во время перехода
Изменить набор значений перечисления Зависит Клиент должен иметь запасной вариант для неизвестных значений
Изменить семантику поля Да Трактуйте как новое поле

Три правила дисциплины:

  1. Клиенты должны быть терпимы к неизвестным полям (не загоняйте себя в угол через additionalProperties: false);
  2. Клиенты должны иметь запасную ветку для неизвестных значений перечислений;
  3. Держите устаревшие поля как минимум одну мажорную версию и помечайте их Deprecation в заголовках ответа или документации.

8. Полный пример

{
  "data": [
    {
      "id": "1001",
      "name": "JSON 权威指南",
      "price": "89.50",
      "tags": ["json", "spec"],
      "publishedAt": "2026-09-01T00:00:00+08:00",
      "author": null
    }
  ],
  "page": { "nextCursor": null, "hasMore": false },
  "meta": { "requestId": "req-7f3a", "tookMs": 12 }
}

Этот ответ отражает все соглашения выше: оболочка data, строковые ID, суммы как строки, метки времени с часовым поясом, null означает отсутствие, пустой массив, пагинация по курсору и meta для информации наблюдаемости.

9. Контрольный список

  • [ ] Верхний уровень не массив; используйте оболочку data единообразно;
  • [ ] Ответы об ошибках следуют RFC 9457 и включают стабильный type;
  • [ ] Форма пагинации соответствует бизнес-сценарию;
  • [ ] ID — строки, время использует RFC 3339;
  • [ ] Семантика null и отсутствия записана в документацию;
  • [ ] Стиль именования единообразен по проекту, перечисления используют строчные строки;
  • [ ] Клиент имеет запасные варианты для неизвестных полей и неизвестных значений перечислений;
  • [ ] Ломающие изменения проходят через версионирование или переход с двойной записью.

Вопросы и ответы

Как проектировать эволюционируемый JSON API?

Оборачивайте списки и детали единообразно в оболочку data и никогда не делайте верхний уровень массивом, чтобы пагинацию и трассировочные ID можно было добавить, не ломая контракт. Используйте строковые ID и метки времени RFC 3339 и запишите семантику null-против-отсутствия в документацию. Держите новые поля необязательными и семантически независимыми и давайте клиентам запасные варианты для неизвестных полей и значений перечислений.

Почему не изобретать собственный формат ошибок?

Изобретение собственной структуры ошибок — огромный источник стоимости сотрудничества. Копируйте application/problem+json по RFC 9457, идентифицируйте проблемы стабильным type и допускайте поля расширения вроде balance и required, чтобы фронтенд мог строить точные сообщения без парсинга текста detail.

Должна ли каждая конечная точка использовать пагинацию по курсору?

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