Компания
376,43
рейтинг
21 сентября 2014 в 12:37

Разработка → Как правильно разрабатывать API с поддержкой обратной совместимости. Семинар в Яндексе

Привет! Меня зовут Сергей Константинов, в Яндексе я руковожу разработкой 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
Яндекс
рейтинг 376,43

Комментарии (24)

  • 0
    Ребята, вы как всегда вовремя! Как раз собирался писать API.
  • 0
    Поделитесь, пожалуйста, ссылочкой на доклад Марата про проектирование систем, о котором в начале видео говорится. Ссылка из доклада (http://study.yandex-team.ru/events/kmb-2/techno/talks/901) наружу не доступна.
    • +1
      Кажется, этот семинар ещё не выкладывали. Можно взамен Макконела почитать )
      • 0
        Уже ) Но разве семинар просто рассказывает о книге? )
        • 0
          Нет, конечно. Просто семинара нет в публичном доступе, а книга есть )
          • +1
            Очень жаль, что нет возможности послушать Марата. Рекомендую все-таки выложить
      • 0
        Очень хочется посмотреть данный доклад, выложите, пожалуйста.
  • +1
    очень странно выглдит совет использовать события — это требует глобального/встраевоемого везде медежера событий с интерфейсовм set/get event data, формат которых не задан через интерфейсы
    • 0
      У нас в API карт так и есть — почти все объекты реализуют IEventEmitter
      Интерфейсы же для передаваемых событием данных, в общем случае, не нужны — все данные должны быть доступны через интерфейс объекта, бросающего события.
      • 0
        Последняя фраза не очень понятна.
        К примеру, есть чат и объект Юзер. У юзера событие — «пришло новое сообщение».
        Как подписчику узнать содержимое сообщения, у юзера должен быть метод типа «покажи последние N сообщений» и подписавшийся на юзера клиент должен в них покопаться и найти новое?
        • 0
          Да.
          Только не совсем ясно, зачем «копаться». Новое = последнее.
          • 0
            Только в полностью синхронных системах. Т.е., пока подписчик не вышел из обработчика события, никто не может добавить сообщение, иначе эта логика сломается.
            • 0
              В асинхронных системах доставать ид сообщения из события вообще неправильно и довольно бессмысленно.

              Тогда поток обработки событий должен хранить ид последнего обработанного сообщения, а по событию обновления запрашивать все сообщения, новее чем этот id.
              • 0
                Мне кажется удобным в самом событии хранить всю необходимую информацию.
  • +1
    Спасибо за труд!

    Хотелось бы больше информации по п. 7: как именно вы поддерживаете версионность тестов относительно версий API, какие лучшие практики совместного рефакторинга самой функциональности API и соответствующих тестов, как лучше анализировать необычное поведение устаревших версий API, когда приходится идти на компромиссы в данных — добавлять фейковую информацию, т.к. новейшее API становится в некоторых процессах уже несовместимым со старым.
    Например, в старой версии были ± лайки, в новой решили сделать только лайк, а дислайк в обязательной форме указания причины дислайка. Что в этом случае было бы по вашим процессам создания/поддержки API и тестов со старым функционалом дислайков? Это просто пример, когда со временем развитие API приводит к несовместимостям в логике. Часто в таких случаях применяют разделение на большие ветки версий 1.0/2.0, интересно было бы услышать сталкивались ли вы с таким в процессе развития и какие решения принимались и на основе чего для разрешения подобных задач.
  • +1
    > как именно вы поддерживаете версионность тестов относительно версий API

    У наших тестировщиков, например, подняты стенды, которые сравнивают результаты тестов текущей версии с предыдущими (в т.ч. с продакшеном).

    > какие лучшие практики совместного рефакторинга самой функциональности API и соответствующих тестов

    Эта тема тянет, как минимум, на пару таких семинаров, но, на самом деле, при правильно определённых интерфейсах можно рефакторить как угодно. Проблемы, как правило, не в самом рефакторинге — а в том, что интерфейс был изначально недоопределён или ошибочен.

    > как лучше анализировать необычное поведение устаревших версий API

    Не понял вопроса.

    > Например, в старой версии были ± лайки, в новой решили сделать только лайк, а дислайк в обязательной форме указания причины дислайка.

    Это эффективный слом обратной совместимости. Прятать его за какие-то фейковые фасады = гримировать труп.

    > Часто в таких случаях применяют разделение на большие ветки версий 1.0/2.0, интересно было бы услышать сталкивались ли вы с таким в процессе развития и какие решения принимались и на основе чего для разрешения подобных задач.

    В данный момент мы поддерживаем одновременно четыре мажорных версии АПИ, и пока не планируем сворачивать поддержку старых версий.

    Наше соглашение примерно следующее: должны поддерживаться (= исправляться любые критические ошибки) не менее двух последних мажорных версий; развивается при этом (добавляется новый функционал) только последняя мажорная; должен быть прямой доступ как минимум к двум минорным версиям в рамках актуальной мажорной.

    Мы выпускаем мажорные версии со следующим расчетом: если проект, использующий наше АПИ, актуален и развивается, то он раз в несколько лет неизбежно будет переписан и сможет встроить обновление до новой мажорной версии в свой продуктовый цикл. В реальности получается темп «одна мажорная версия раз в два-три года», который, кажется, всех устраивает.
    • 0
      Ага, примерно так и предполагал, спасибо что поделились. Похоже что у вас изначально все было сделано «по уму» и не случалось резких поворотов в функциональности внутри одной версии. Это когда сделали, к примеру, фатально кривой RPC (уже не важны причины) для мобильного API, приложение в релизе несколько недель, заметили только сейчас. Да, заниматься поддержкой такой ошибки в итоге — это прятать запах освежителем, чем вынести причину его появления, но вопрос как всегда в цене. Варианта вижу два:

      1) очень плохой — забить на тех, кто остался с необновленным приложением, передающим уже неправильные запросы. Если их в соотношении с остальными меньше определенного порогового уровня. К примеру, за эти две недели скачали 100 человек и не обновились, а всего активных пользователей приложения под миллион.

      2) сделать костыль, для конкретной версии API конкретных приложений с занесением информации что после, к примеру, полугода этот костыль принудительно убрать. Пусть как-то хромает, но мы не нарушим серьезно работоспособность всего приложения и оно будет «хромать».

      Со временем пришел к такому решению: с самой первой релизной версии в приложении при его старте делать RPC, отправляющий версию API (или билд), с которой приложение гарантировано было протестировано. При описанной выше проблемной ситуации, в приложение приходил бы ответ с обязательностью уведомления в виде alert-а (не суть как) о необходимости обновить приложение по причине критических багов. Можно это сделать на уровне всех вызовов API, не суть.

      Я понимаю, что такие ситуации нужно избегать и делать все возможное, чтобы такого не было, но интерес у меня возник именно на тему разрешения/поведения в нестандартных ситуациях, когда что-то сломали и шага назад сделать нельзя.

      В принципе, я ответ уже получил, просто хотел пояснить ранее озвученный мой вопрос. Еще раз спасибо!
      • 0
        Вообще, конечно, информации явно недостаточно, чтобы давать какие-то советы :) Но я бы, вероятно, предпочёл честно сообщить о проблемах и попытаться достучаться до всех пользователей старых версий.
  • –2
    Отличный троллинг с вашей стороны писать такие статьи. Сужу это в первую очередь со стороны человека, который занимался переводом проектов с использование API карт версии 1 на версию 2. Причина перехода была связана с фичами, которые были в 2.0 (кажется поддержка HTTPS). Испытывал кучу нелестных чувств к вам как сторонний разработчик. Имхо, бытует не совсем верное мнение, что в мажорной версии можно полностью сломать обратную совместимость. Это не совсем верно. Если пофиг на пользователей, то да. А если не пофиг, то делать даже в мажорной версии такие изменения нужно точечно и аккуратно. Иначе цена прикручивания новой фичи (поддержки HTTPS) выливается в стоимость полностью переписывания клиентского слоя работавшего с картой. Поверьте, да не устраивает такой вариант — нет. Стороннему разработчику может быть глубоко фиолетово на то, что причина полного изменения всех внешних интерфейсов — лишь обостренное чувство прекрасного у разработчика API. Да, это очень мешает двигаться вперед достаточно быстро, но это неизбежные копромиссы на которые крайне желательно идти, если есть желание быть лицом к пользователям. В коммерческом проекте, такой фокус легко просто не прокатит — крупные заказчики скажут «нет» и вы уйдете думать над менее радикальными изменениями и более плавному их внедрению.
    • +6
      Причиной создания версии 2.0 было вовсе не «обострённое чувство прекрасного» — из обострённых чувств мы обратную совместимость никогда не меняем — а вполне конкретные доводы:

      — нам требовалось поддерживать соврменные технологии (transform & transition) и новые устройства (тач-девайсы, в первую очередь), чего архитектура первой версии не позволяла;
      — нам требовалась гораздо более гибкая и менее связная модульность, нежели это было возможно в рамках версии 1.х
      — нам требовалась глубокая локализация, с различными настройками (например, единиц измерения) для разных стран
      — наконец, нас не устраивало качество клиентских решений — иными словами, вебмастера делали на нашем АПИ неудобные и неюзабельные сайты, и нам очень хотелось, насколько это возможно, с этим побороться со своей стороны.

      Обо всём этом я довольно подробно рассказал в 2012 году на РИТ++ и ещё на нескольких конференциях. Кроме того, версия 2.0 была изначально запущена с подробным гайдом по переходу.

      Ваша боль мне ясна; более того, АПИ 2.1 построено именно так, как вы предлагаете — итеративно с весьма незначительными изменениями. Однако, мы стараемся руководствоваться в первую очередь интересами конечных потребителей АПИ, а они на момент 2011 года явно диктовали потребность в радикальном обновлении архитектуры проеекта.
  • 0
    .
  • 0
    Был доклад на YaC по схожим вопросам.
  • 0
    Как-то работал с очередями сообщении. Если быть точнее — IBM WebSphere MQ.
    Вот где поддержка старых версии идет как нигде и никогда.
    Например, в оттранслированном заголовочном файле из С в Паскаль:

    (****************************************************************)
    (*  MQCNO Structure -- Connect Options                          *)
    (****************************************************************)
    
    type
      MQCNO = record
        StrucId            : MQCHAR4  ;(* Structure identifier *)
        Version            : MQLONG   ;(* Structure version number *)
        Options            : MQLONG   ;(* Options that control the action of MQCONNX *)
        (* Ver:1 *)
        ClientConnOffset   : MQLONG   ;(* Offset of MQCD structure for client connection *)
        ClientConnPtr      : MQPTR    ;(* Address of MQCD structure for client connection *)
        (* Ver:2 *)
        ConnTag            : MQBYTE128;(* Queue-manager connection tag *)
        (* Ver:3 *)
        SSLConfigPtr       : PMQSCO   ;(* Address of MQSCO structure for client connection *)
        SSLConfigOffset    : MQLONG   ;(* Offset of MQSCO structure for client connection *)
        (* Ver:4 *)
        ConnectionId       : MQBYTE24 ;(* Unique Connection Identifier *)
        SecurityParmsOffset: MQLONG   ;(* Offset of MQCSP structure *)
        SecurityParmsPtr   : PMQCSP   ;(* Address of MQCSP structure *)
        (* Ver:5 *)
      end;
      PMQCNO  = ^MQCNO;
      PPMQCNO = ^PMQCNO;
    

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка