Поддержание обратной совместимости: о ватерлинии айсберга
Прежде, чем начинать разговор о принципах проектирования расширяемого API, следует обсудить гигиенический минимум. Огромное количество проблем не случилось бы, если бы разработчики API чуть ответственнее подходили к обозначению зоны своей ответственности.
1. Предоставляйте минимальный объём функциональности
В любой момент времени ваше API подобно айсбергу: у него есть видимая (документированная) часть и невидимая — недокументированная. В хорошем API эти две части соотносятся друг с другом примерно как надводная и подводная часть настоящего айсберга, 1 к 10. Почему так? Из двух очевидных соображений.
-
Компьютеры существуют, чтобы сложные вещи делать просто, не наоборот. Код, который напишут разработчики поверх вашего API, должен в простых и лаконичных выражениях описывать решение сложной проблемы. Если разработчики вынуждены писать больше кода на вашем API, чем вы написали сами — что-то здесь пошло не так. Возможно, такой API просто не нужен.
-
Изъятие функциональности из API невозможно без серьёзных потерь. Если вы пообещали предоставлять какую-то функциональность — вам теперь придётся предоставлять её «вечно» (до окончания поддержки этой мажорной версии API). Объявление функциональности неподдерживаемой очень сложный и чреватый потенциальными конфликтами с потребителем процесс.
Правило №1 самое простое: если какую-то функциональность можно не выставлять наружу — значит, выставлять её не надо. Можно сформулировать и так: каждая сущность, каждое поле, каждый метод в публичном API — это продуктовое решение. Должны существовать веские причины, по которым та или иная сущность документирована.
2. Избегайте серых зон и недосказанности
Ваши обязательства по поддержанию функциональности должны быть оговорены настолько четко, насколько это возможно. Особенно это касается тех сред и платформ, где нет способа нативно ограничить доступ к недокументированной функциональности. К сожалению, разработчики часто считают, что, если они «нашли» какую-то непубличную особенность, то они могут ей пользоваться — а производитель API, соответственно, обязан её поддерживать. Поэтому политика компании относительно таких «находок» должна быть явно сформулирована. Тогда в случае несанкционированного использования скрытой функциональности вы по крайней мере сможете сослаться на документацию и быть формально правы в глазах комьюнити.
Однако достаточно часто разработчики API сами легитимизируют такие серые зоны, например:
-
отдают недокументированные поля в ответах эндпойнтов;
-
используют непубличную функциональность в примерах кода — в документации, в ответ на обращения пользователей, в выступлениях на конференциях и т.д.;
Нельзя взять обязательства наполовину. Или вы гарантируете работу этого кода всегда, или не подавайте никаких намеков на то, что такая функциональность существует.
3. Фиксируйте неявные договорённости
Третий принцип гораздо менее явный. Посмотрите внимательно на код, который предлагаете написать разработчикам: нет ли в нём каких-то условностей, которые считаются очевидными, но нигде не зафиксированы при этом?
Пример 1. Рассмотрим SDK работы с заказами.
// Создаёт заказ
let order = api.createOrder();
// Получает статус заказа
let status = api.getStatus(order.id);
Предположим, что в какой-то момент при масштабировании вашего сервиса вы пришли к асинхронной репликации базы данных и разрешили чтение из реплики. Это приведёт к тому, что после создания заказа следующее обращение к его статусу по id может вернуть 404
, если пришлось на асинхронную реплику, до которой ещё не дошли последние изменения из мастера. Фактически, вы сменили политику консистентности со strong на eventual.
К чему это приведёт? К тому, что код выше перестанет работать. Разработчик создал заказ, пытается получить его статус — и получает ошибку. Очень тяжело предсказать, какую реакцию на эту ошибку предусмотрят разработчики — с большой вероятностью никакую.
Вы можете сказать: «Позвольте, но мы нигде и не обещали строгую консистентность!» — и это будет, конечно, неправдой. Вы можете так сказать если, и только если, вы действительно в документации метода createOrder
явно описали нестрогую консистентность, а все ваши примеры использования SDK написаны как-то так:
let order = api.createOrder();
let status;
while (true) {
try {
status = api.getStatus(order.id);
} catch (e) {
if (e.httpStatusCode != 404 || timeoutExceeded()) {
break;
}
}
}
if (status) {
…
}
Мы полагаем, что можно не уточнять, что писать код, подобный вышеприведённому ни в коем случае нельзя. Уж если вы действительно предоставляете нестрого консистентное API, то либо операция createOrder
в SDK должна быть асинхронной и возвращать результат только по готовности всех реплик, либо политика перезапросов должна быть скрыта внутри операции getStatus
.
Если же нестрогая консистентность не была описана с самого начала — вы не можете внести такие изменения в API. Это эффективный слом обратной совместимости, который к тому же приведёт к огромным проблемам ваших потребителей, поскольку проблема будет воспроизводиться случайным образом.
Пример 2. Представьте себе следующий код:
let resolve;
let promise = new Promise(
function (innerResolve) {
resolve = innerResolve;
}
);
resolve();
Этот код полагается на то, что callback-функция, переданная в new Promise
будет выполнена синхронно, и переменная resolve
будет инициализирована к моменту вызова resolve()
. Однако это конвенция абсолютно ниоткуда не следует: ничто в сигнатуре конструктора new Promise
не указывает на синхронный вызов callback-а.
Разработчики языка, конечно, могут позволить себе такие фокусы. Однако вы как разработчик API — не можете. Вы должны как минимум задокументировать это поведение и подобрать сигнатуры так, чтобы оно было очевидно; но вообще хорошим советом будет вообще избегать таких конвенций, поскольку они банально неочевидны при прочтении кода, использующего ваше API. Ну и конечно же ни при каких обстоятельствах вы не можете изменить это поведение с синхронного на асинхронное.
Пример 3. Представьте, что вы предоставляете API для анимаций, в котором есть две независимые функции:
// Анимирует ширину некоторого объекта
// от первого значения до второго
// за указанное время
object.animateWidth('100px', '500px', '1s');
// Наблюдает за изменением размеров объекта
object.observe('widthchange', observerFunction);
Возникает вопрос: с какой частотой и в каких точках будет вызываться observerFunction
? Допустим, в первой версии SDK вы эмулировали анимацию пошагово с частотой 10 кадров в секунду — тогда observerFunction будет вызвана 10 раз и получит значения ‘140px’, ‘180px’ и т.д. вплоть до ‘500px’. Но затем в новой версии API вы решили воспользоваться системными функциями для обеих операций — и теперь вы попросту не знаете, когда и с какой частотой будет вызвана observerFunction
.
Даже просто изменение частоты вызовов вполне может сделать чей-то код неработающим — например, если обработчик выполняет на каждом шаге тяжелые вычисления, и разработчик не предусмотрел никакого ограничения частоты выполнения, полагаясь на то, что ваш SDK вызывает его обработчик всего лишь 10 раз в секунду. А вот если, например, observerFunction
перестанет вызываться с финальным значением ‘500px’ вследствие каких-то особенностей системных алгоритмов — чей-то код вы сломаете абсолютно точно.
В данном случае следует задокументировать конкретный контракт — как и когда вызывается callback — и придерживаться его даже при смене нижележащей технологии.
Пример 4. Представьте, что потребитель совершает заказ, которые проходит через вполне определённую цепочку преобразований:
GET /v1/orders/{id}/events/history
→
{
"event_history": [
{
"iso_datetime": "2020-12-29T00:35:00+03:00",
"new_status": "created"
},
{
"iso_datetime": "2020-12-29T00:35:10+03:00",
"new_status": "payment_approved"
},
{
"iso_datetime": "2020-12-29T00:35:20+03:00",
"new_status": "preparing_started"
},
{
"iso_datetime": "2020-12-29T00:35:30+03:00",
"new_status": "ready"
}
]
}
Допустим, в какой-то момент вы решили надёжным клиентам с хорошей историей заказов предоставлять кофе «в кредит», не дожидаясь подтверждения платежа. Т.е. заказ перейдёт в статус “preparing_started”, а может и “ready”, вообще без события “payment_approved”. Вам может показаться, что это изменение является обратно-совместимым — в самом деле, вы же и не обещали никакого конкретного порядка событий. Но это, конечно, не так.
Предположим, что у разработчика (вероятно, бизнес-партнера вашей компании) написан какой-то код, выполняющий какую-то полезную бизнес функцию поверх этих событий — например, аналитика по затратам и доходам. Вполне логично ожидать, что этот код будет оперировать какой-то машиной состояний, которая будет переходить в то или иное состояние в зависимости от получения или неполучения события. Аналитический код наверняка сломается вследствие изменения порядка событий. В лучшем случае разработчик увидит какие-то исключения и будет вынужден разбираться с причиной; в худшем случае партнер будет оперировать неправильной статистикой неопределённое время, пока не найдёт в ней ошибку.
Правильным решением было бы во-первых, изначально задокументировать порядок событий и допустимые состояния; во-вторых, продолжать генерировать событие “payment_approved” перед “preparing_started” (если вы приняли решение исполнять такой заказ — значит, по сути, подтвердили платёж) и добавить расширенную информацию о платеже.
Этот пример подводит нас к ещё к одному правилу.
4. Продуктовая логика тоже должна быть обратно совместимой
Такие критичные вещи, как граф переходов между статусами, порядок событий и возможные причины тех или иных изменений — должны быть документированы. Далеко не все детали бизнес-логики можно выразить в форме контрактов на эндпойнты, а некоторые вещи нельзя выразить вовсе.
Представьте, что в один прекрасный день вы заводите специальный номер телефона, по которому клиент может позвонить в колл-центр и отменить заказ. Вы даже можете сделать это технически обратно-совместимым образом, добавив новых необязательных полей в сущность «заказ». Но конечный потребитель может просто знать нужный номер телефона, и позвонить по нему, даже если приложение его не показало. При этом код бизнес-аналитика партнера всё так же может сломаться или начать показывать погоду на Марсе, т.к. он был написан когда-то, ничего не зная о возможности отменить заказ, сделанный в приложении партнера, каким-то иным образом, не через самого партнёра же.
Технически корректным решением в данной ситуации могло бы быть добавление параметра «разрешено отменять через колл-центр» в функцию создания заказа — и, соответственно, запрет операторам колл-центра отменять заказы, если флаг не был указан при их создании. Но это в свою очередь плохое решение с точки зрения продукта. «Хорошее» решение здесь только одно — изначально предусмотреть возможность внешних отмен в API; если же вы её не предвидели — остаётся воспользоваться «блокнотом душевного спокойствия», речь о котором пойдёт в разделе III.
Это черновик будущей главы книги о разработке API. Работа ведётся на Github. Англоязычный вариант этой же главы опубликован на medium. Я буду признателен, если вы пошарите его на реддит — я сам не могу согласно политике платформы.