Привет! Меня зовут Сергей Константинов, в Яндексе я руковожу разработкой API Карт. Недавно я поделился опытом поддержки обратной совместимости со своими коллегами. Мой доклад состоял из двух неравных частей. Первая, большая, посвящена тому, как правильно разрабатывать API, чтобы потом не было мучительно больно. Вторая же про то, что делать, если вам нужно что-то рефакторить и не сломать по дороге обратную совместимость.
Если заглянуть в Википедию, то про обратную совместимость там будет написано, что это сохранение интерфейса системы при выпуске новых версий. На самом деле, для конечных пользователей обратная совместимость означает, что код, написанный для предыдущей версии системы, работает функционально так же и в следующей версии.
Для разработчика обратная совместимость в первую очередь подразумевает, что единожды принятое обязательство предоставлять какую-либо функциональность невозможно отменить, исправить или перестать поддерживать.
Зачем нужно брать на себя обязательства? Во-первых, вы экономите время и деньги своим пользователям. Наивно думать, будто бы не поддерживать обратную совместимость дешевле. На самом деле, вы просто размазываете стоимость поддержки по клиентам. Один факап в продакшене может стоить гораздо больше, чем вся разработка всего продукта.
Во-вторых вы поддерживаете свою карму. Сознательный слом обратной совместимости расстраивает пользователей куда больше, чем баги. Людям очень не нравится, когда им явно демонстрируют равнодушие к их проблемам.
В-третьих, обратная совместимость – это конкурентное преимущество. Она подразумевает возможность переходить на более свежие версии без затрат на разработку, гарантию того, что сервис не сломается продакшене.
Обратная совместимость: правильная архитектура
Что можно сделать на этапе проектирования, чтобы потом не было мучительно больно? Тут нужно сделать три предварительных замечания. Во-первых, обратная совместимость не бывает бесплатной. Выстраивание правильной архитектуры влечёт за собой накладные расходы. Вам придётся больше думать, вводить больше сущностей, писать избыточный код.
Во-вторых, прежде чем приступать к разработке, нужно обозначить сферу ответственности, четко прояснить, что именно будет поддерживаться. Сводите к минимуму ситуации, когда какое-то публично доступное API не описано в документации. Никогда не давайте на чтение (и, тем более, на запись) сущности, формат которых не описан.
В-третьих, предполагается, что ваш API спроектирован правильно и структурирован по уровням абстракции.
Предположим, что все это мы поняли и осознали. Пора переходить к правилам, которые которые мы вынесли из нашего почти пятилетноего опыта.
Правило №1: больше интерфейсов
В пределе в вашей публичной документации не должно быть ни одной сигнатуры, принимающей конкретные типы, а не интерфейсы. Исключение может быть сделано для базовых глобальных классов и явно подчинённых компонент.
interface IGeoObject :
IChildOnMap, ICustomizable,
IDomEventEmitter, IParentOnMap {
attribute IEventManager events;
attribute IGeometry geometry;
attribute IOptionManager options;
attribute IDataManager properties;
attribute IDataManager state;
}
Map getMap();
IOverlay getOverlay();
IParentOnMap getParent();
IGeoObject setParent(IParentOnMap parent)
Почему это помогает избежать потери обратной совместимости? Если в сигнатуре заявлен интерфейс, у вас не будет проблем, когда у вас появится вторая (третья, четвёртая) реализация интерфейса. Атомизируется ответственность объектов. Интерфейс не накладывает условий, чем должен быть передаваемый объект: он может быть как наследником стандартного объекта, так и самостоятельной реализацией.
Почему это полезно при проектировании API? Выделение интерфейсов в первую очередь необходимо разработчику для наведения порядка в голове. Если ваш метод принимает в качестве параметра объект с 20 полями и 30 методами, очень рекомендуется задуматься, что конкретно необходимо из этих полей и методов.
В результате применения этого правила вы должны получить на выходе много дробных интерфейсов. Ваши сигнатуры не должны требовать от входного параметра больше 5±2 свойств или методов. Вы получите представление о том, какие свойства ваших объектов важны в контексте общей архитектуры системы, а какие – нет. Как следствие, снизится избыточность интерфейсов
Правило №2: иерархия
Ваши объекты должны быть выстроены в иерархию: кто с кем взаимодействует. Когда на эту иерархию наложатся интерфейсы, которые вы предъявили к своим объектам, вы получите некую иерархию интерфейсов. Теперь самое важное: объект имеет право знать только об объектах соседних уровней.
Почему это помогает избежать потери обратной совместимости? Снижается общая связанность архитектуры, меньше связей – меньше side-эффектов. А при изменении какого-либо объекта вы можете задеть только его соседей по дереву.
Добиться этого очевидными способами можно не всегда. Нужные методы и свойства нужно пробрасывать по цепочке через промежуточные звенья (с учетом уровня абстракции, разумеется!). Тем самым вы автоматически получите набор точек расширения, которые потом могут пригодиться.
Правило №3: контексты
Рассматривайте любую промежуточную ступень иерархии как информационный контекст для нижележащей ступени.
Пример:
Map
= картографический контекст (наблюдаемая область карты + масштаб).
IPane
= контекст позиционирования в клиентских координатах.
ITileContainer
= контекст позиционирования в тайловых координатах.
Ваше дерево объектов можно будет рассматривать как иерархию контекстов. Каждый уровень иерархии должен соответствовать какому-то уровню абстракции.
Почему это помогает избежать потери обратной совместимости? Правильно построенное дерево контекстов практически никогда не изменится при рефакторинге: потоки информации могут появляться, но очень вряд ли пропадут. Правило контекстов позволяет эффективно изолировать уровни иерархии друг от друга.
Это полезно при проектировании API, так как держать в голове информационную схему проекта существенно проще, чем полное дерево. А описание объектов в терминах предоставляемых ими контекстов позволяет правильно выделять уровни абстракции.
Правило №4: consistency
В данном случае я использует термин consistency в парадигме ACID для баз данных. Это означает, что между транзакциями состояние объектов всегда должно быть валидным. Любой объект должен предоставлять полное описание своего состояния в любой момент и полный набор событий, позволяющий отслеживать все изменения своего состояния.
Подобные паттерны нарушают consistency:
obj.name = 'что-то';
// do something
obj.setOptions('что-то');
// do something
obj.update();
В частности, отсюда следует правило: избегайте методов update, build, apply.
Это помогает избежать потери обратной совместимости, т.к. внешний наблюдатель всегда может полностью восстановить состояние и историю объекта по его публичному интерфейсу. Кроме того, такой объект всегда можно подменить или склонировать, не обладая знанием о его внутреннем устройстве.
Когда у вас организовано такое взаимодействие, что есть state объекта и событие о его изменении, номенклатура методов и событий ваших объектов становится менее разнообразной и более консистентной. Вам станет проще выделять интерфейсы и держать все это в голове.
Правило №5: события
Организуйте взаимодействие между объектами с помощью событий, причём в обе стороны.
Рассмотрим два примера, как можно организовать взаимодействие между кнопкой и макетом:
button.onStateChange = function () {
layout.setCaption(state.caption); }
layout.onClick = function () {
button.select(); }
vs
button.onStateChange = function () {
this.fire('statechange'); }
layout.onClick = function () {
this.fire('click') }
Вторая схема взаимодействия получается нативно при соблюдении требования consistency:
- каждый из объектов знает, когда состояние другого объекта изменилось;
- каждый из объектов может полностью выяснить состояние другого.
В первом случае кнопка и макет знают подробности о внутреннем устройстве друг друга, во втором – нет.
Это помогает избежать потери обратной совместимости, т.к. события необязательны к исполнению для обоих объектов: вы легко сможете поддерживать такие реализации обоих объектов, которые реагируют только на часть событий и отображают только часть состояния второго объекта. Если у вас появится третий объект, которому необходимо реагировать на то же действие – у вас не будет проблем.
Если вы верно выполнили и предыдущие четыре шага, у вас получается стандартный паттерн: у вас есть, state, события об его изменении, нижележащий объект, который слушает это событие и реагирует на него каким-то образом. Ваша организация взаимодействия между объектами значительно унифицируется. Взаимодействие между объектами таким образом базируется на общих методах и событиях, а не частных, т.е. будет содержать гораздо меньше специфики конкретных объектов
Правило №6: делегирование
Шестое правило логично вытекает из первых пяти. Вы построили всю систему, у вас есть интерфейсы и события, уровни абстракции. Теперь нужно насколько это возможно перенести всю логику на нижний уровень абстракции. Поскольку чаще всего изменяется реализация и функциональность именно нижнего уровня абстракции (верстка, протоколы взаимодействия, etc), интерфейс к нижнему уровню абстракции должен быть максимально общим.
При таком подходе связи между объектами становятся насколько это возможно абстрактными. Вы сможете безболезненно переписать объекты нижнего уровня абстракции целиком при необходимости
Правило №7: тесты
Пишите тесты на интерфейс.
Правило №8: внешние источники
В абсолютном большинстве случаев самые большие проблемы с сохранением обратной совместимости возникают вследствие несохранения обратной совместимости другими сервисами. Если вы не контролируете смежный сервис (источник данных) – заведите к нему версионируемую обёртку на своей стороне.
Обратная совместимость: рефакторинг
Прежде, чем приступать
Проясните ситуацию:
- Если заявленная функциональность не работала никогда, вы вольны принять любое решение: починить, изменить, выкинуть;
- Если что-то выглядит как баг – это ещё не повод бросаться его чинить;
- Проверьте тесты на интерфейс объекта, который собираетесь рефакторить, и связанных объектов;
- Если тестов нет – напишите их;
- Никогда не начинайте никакой рефакторинг без тестов;
- Тестирование должно включать в себя проверку соответствия поведения старой и новой версии API;
Полтора приёма рефакторинга:
- Если вы всё сделали правильно и взаимодействие объектов сделано по схеме “состояние – событие изменения состояния”, то, часто, вы сможете переписать реализацию, оставив старые поля и методы для обратной совместимости;
- Используйте в интерфейсах необязательные поля, методы и fallback-и – правильно подобранные умолчания позволят вам наращивать функциональность.
От релиза к релизу
Заведите себе блокнотик душевного покоя:
- Если вы неправильно назвали сущность – она будет неправильно называться до следующего мажорного релиза
- Если вы сделали архитектурную ошибку – она будет существовать до следующего мажорного релиза
- Запишите себе проблему в блокнотик и постарайтесь не думать о ней до следующего мажорного релиза
Автор: forgotten