JSON победил как формат обмена, потому что его не нужно объяснять: открыл — и видно, что внутри. Ровно из этого свойства растут все проблемы, которые с ним случаются. Формат, который читается без описания, обычно и передают без описания — а потом выясняется, что стороны понимали его по-разному.
Формат без схемы — это не контракт
Контракт — это обещание: какие поля будут, какого типа, что обязательно, что может отсутствовать. JSON сам по себе не содержит ничего из этого. Он описывает, как записаны данные, а не какие данные записаны.
Пока обе стороны пишет одна команда, разницы не видно: договорённость живёт в головах и в коде, и этого хватает. Разница проявляется в момент, когда сторон становится две, или когда между ними появляется время — версия, выпущенная полгода назад, разговаривает с версией, выпущенной вчера.
Отсюда рабочее определение: контракт существует, только если он записан отдельно от кода обеих сторон. Всё остальное — совпадение привычек, которое держится, пока людей мало.
Тупик: проверять на приёме
Первое, что пробуют, когда приходит неожиданный ответ, — добавить проверки в код приёмной стороны. Поле отсутствует — подставим значение по умолчанию. Тип не тот — приведём. Пришёл лишний ключ — проигнорируем.
Это не помогает по двум причинам. Первая: проверки расползаются. Через полгода обработка ответа занимает больше кода, чем полезная работа с ним, и каждая ветка написана под случай, который однажды видели.
Вторая, более важная: такие проверки скрывают факт расхождения. Отправляющая сторона не узнаёт, что нарушила договорённость, — принимающая молча приспособилась. Ошибка перестаёт быть событием и становится фоном, а через год никто уже не знает, какое поведение правильное.
Второй частый ход — договориться устно и записать в задачу. Работает ровно до того момента, когда задачу закроют и она уйдёт из виду.
Где JSON подводит на ровном месте
Числа. Спецификация не различает целые и дробные и не задаёт точность. Большой идентификатор, отправленный числом, на принимающей стороне может потерять младшие разряды — и это не ошибка обработчика, это допустимое поведение. Идентификаторы поэтому передают строкой, даже когда они выглядят как числа.
Отсутствие и пустота. Поля нет, поле есть со значением «ничего», поле есть с пустой строкой — три разные ситуации, которые регулярно смешивают. «Адрес не указан» и «адрес удалён» — разные факты, и если контракт их не различает, различать их будет случайный код.
Порядок. Порядок ключей в объекте не гарантирован, порядок элементов массива гарантирован. Код, полагающийся на первое, работает годами и ломается при смене библиотеки на отправляющей стороне.
Даты. В формате нет типа для времени. Каждый передаёт как умеет: строкой в одном из форматов, числом секунд, числом миллисекунд. Смешение этих способов внутри одного обмена — обычное дело, и обнаруживается оно смещением на десятилетия.
Кодировка и экранирование. Обычно работает само, но текст с символами вне основного набора умеет ломать наивные обработчики на обеих сторонах.
Дробные значения денег. Двоичное представление дробей не хранит десятичные суммы точно, и сложение цен через такой тип даёт копеечные расхождения, которые всплывают в сверке, а не в разработке. Деньги передают либо строкой, либо целым числом наименьших единиц, и это решение принимают один раз на весь обмен, а не для каждого поля отдельно.
Что делать вместо проверок в коде
Записать схему отдельным файлом. Не в комментарии и не в описании задачи, а рядом с кодом, в виде, который умеет читать программа. Тогда договорённость перестаёт быть устной и её можно проверить машинально.
Проверять на обеих сторонах. Отправляющая — перед отправкой, принимающая — на приёме. Это кажется избыточным, но ловит разные ошибки: отправляющая проверка находит ошибку у автора изменения, принимающая — у автора вызова.
Падать громко, а не приспосабливаться. Ответ, нарушивший схему, должен приводить к явной ошибке с указанием, какое поле и чем не устроило. Молчаливое исправление откладывает разбирательство на неопределённый срок и обычно перекладывает его на другого человека.
Различать необязательное и отсутствующее осознанно. Для каждого поля отвечать на вопрос: что означает его отсутствие. Если ответа нет, поле должно быть обязательным.
Не расширять смысл существующих полей. Соблазн передать новую сущность в старом поле, «пока не сделаем нормально», приводит к тому, что поле начинает означать две вещи, и обе стороны понимают его по-разному.
Держать примеры рядом со схемой. Схема описывает форму, пример показывает смысл. Пара реальных ответов — обычный и граничный — снимает больше вопросов, чем страница описаний, и заодно служит проверкой: если пример перестал проходить схему, разошлось что-то одно, и это видно сразу.
Записывать, кто потребители. Контракт без списка тех, кто им пользуется, нельзя менять осознанно: непонятно, кого сломает удаление поля. Список из трёх строк рядом со схемой превращает опасную правку в проверяемую.
Как менять контракт, не ломая
Правило простое и почти всегда достаточное: добавлять можно, убирать и переименовывать — нельзя.
Новое необязательное поле не ломает старых потребителей, потому что они его не читают. Удалённое поле ломает всех, кто на него рассчитывал, а узнают они об этом в момент выкатки. Переименование — это удаление и добавление одновременно, то есть худший случай.
Если поле всё же надо убрать, порядок обратный привычному: сначала перестают читать, потом перестают писать. Между этими шагами проходит столько времени, сколько живёт самый старый потребитель, и знать это время — отдельная задача, которую стоит решить до, а не после.
Отдельный случай — изменение смысла поля при сохранении имени и типа. Формально ничего не сломалось: схема прежняя, проверки проходят. Фактически сломалось всё, что зависело от прежнего смысла, и ни одна автоматическая проверка этого не заметит. Такое изменение по последствиям равно удалению поля и требует такого же обращения — нового имени, а не тихой подмены.
Позиция редакции
Мы считаем, что схема обмена должна существовать как файл и проверяться автоматически на обеих сторонах, а нарушение — приводить к явной ошибке. Практический вывод: если у вас нет файла, описывающего ответ, у вас нет контракта — есть привычка, и она сломается при первом же расхождении версий.
Мы неправы, если обе стороны обмена выпускаются одновременно одной командой и никогда не расходятся по версиям: для внутреннего вызова внутри одного приложения формальная схема — лишний слой, который переживёт свою пользу.