Обеспечение обратной совместимости в распределенных системах
По мере того, как наша жизнь становится более распределенной, также есть программное обеспечение, на которое мы полагаемся. То, что мы видим как единый пользовательский интерфейс, обычно питается от серии подключенных служб, каждая из которых имеет определенную работу.
Рассмотрим Netflix. На главной странице мы видим смесь контента: ранее просмотренные шоу, популярные новые названия, управление учетными записями и многое другое.
Но этот экран не генерируется netflix.exe работая где-то на ПК. По состоянию на 2017 год он был оснащен более чем 700 индивидуальными услугами. Это означает, что начальный экран на самом деле представляет собой просто совокупность сотен микросервисов, работающих вместе. Одна служба для управления функциями учетной записи, другая для вынесения рекомендаций и т. д.
Переход к распределенным архитектурам приносит много преимуществ: более легкое тестирование, меньшие развертываемые блоки, более слабая развязка, меньшие поверхности отказа, чтобы назвать несколько. Но это также приносит свой собственный набор проблем.
Одним из них является поддержание обратной совместимости между компонентами. Другими словами, как набор услуг может развиваться вместе таким образом, чтобы не нарушать систему? Сервисы могут работать только вместе, если все они согласны на различные контракты: как обмениваться данными и как выглядит формат данных. Нарушение даже одного контракта может привести к хаосу в вашей системе.
Но как разработчики, мы знаем, что изменение-это единственная константа. Технологические и бизнес-потребности неизбежно меняются с течением времени, и так же должны меняться и наши услуги. Это может происходить различными способами: веб-интерфейсы API, обмен сообщениями, такие как JMS или Kafka, и даже в хранилищах данных.
Ниже мы рассмотрим некоторые рекомендации по созданию распределенных систем, которые позволяют нам изменять службы и интерфейсы таким образом, упрощая обновление.
Web APIs
RESTful web API - это один из основных способов взаимодействия распределенных систем. Это всего лишь базовая клиент-серверная модель: служба A (клиент) отправляет запрос на службу B (сервер). Сервер выполняет некоторую работу и отправляет ответ, указывающий на успех или неудачу.
Со временем web API могут потребоваться изменения. Независимо от того, идет ли речь о смене бизнес-приоритетов или новых стратегиях, мы должны с самого первого дня принять, что наши API, скорее всего, будут изменены.
Давайте рассмотрим некоторые способы, которыми мы можем сделать наши веб-API обратно совместимыми.
Принцип надежности
Чтобы создать веб-интерфейсы API, которые легко эволюционируют, следуйте принципу надежности, обобщенному как "будьте консервативны в том, что вы делаете, будьте либеральны в том, что вы принимаете.”
В контексте веб-API этот принцип может применяться несколькими способами:
- Каждая конечная точка API должна иметь небольшую конкретную цель, которая следует только за одной из операций CRUD. Клиенты должны отвечать за объединение нескольких вызовов по мере необходимости.
- Серверы должны обмениваться ожидаемыми форматами сообщений и схемами и придерживаться их.
- Новые или неизвестные поля в теле сообщения не должны вызывать ошибок в API, они должны просто игнорироваться.
Управление версиями
Управление версиями API позволяет нам поддерживать различные функциональные возможности для одного и того же ресурса.
Например, рассмотрим приложение блога, которое предлагает API для управления его основными данными, такими как пользователи, сообщения в блоге, категории и т. д. Предположим, что первая итерация имеет конечную точку, которая создает пользователя со следующими данными: имя, адрес электронной почты и пароль. Шесть месяцев спустя мы решаем, что теперь каждая учетная запись должна включать роль (администратор, редактор, автор и т. д.). Что мы должны делать с существующим API?
По сути, у нас есть два варианта:
- Обновите пользовательский API, чтобы требовать роль с каждым запросом.
- Одновременно поддерживайте старые и новые пользовательские API.
С опцией 1 мы обновляем код, и любой запрос, который не включает новый параметр, отклоняется как неверный запрос. Это легко реализовать, но это также нарушает существующие пользователи API.
С вариантом 2 мы реализуем новый API, а также обновляем исходный API, чтобы обеспечить некоторое разумное значение по умолчанию для нового параметра роли. Хотя это определенно больше работы для нас, мы не нарушаем никаких существующих пользователей API.
Следующий вопрос заключается в том, как мы делаем версию API? Эта дискуссия продолжается уже много лет, и нет ни одного правильного ответа. Многое будет зависеть от вашего стека технологий, но в целом, есть три основных способа реализации управления версиями API:
URL
Это самый простой и наиболее распространенный способ и он может быть достигнут с помощью любого пути:
POST /v2/blog/users
Или с помощью параметров запроса:
POST /blog/users?v=2
URL-адреса удобны, потому что они являются обязательной частью каждого запроса, поэтому ваши потребители должны иметь дело с ними. Большинство платформ регистрируют URL-адреса с каждым запросом, поэтому легко отслеживать, какие потребители используют те или иные версии.
Заголовки
Это можно сделать с помощью настраиваемого имени заголовка, понятного вашим службам:
API-Version: 2
Или мы можем захватить заголовок "Accept", чтобы включить пользовательские расширения:
Accept: application/vnd.mycompany.v2+json
Использование заголовков для управления версиями больше соответствует практике RESTful. В конце концов, URL должен представлять ресурс, а не какую-то его версию. Кроме того, заголовки уже отлично справляются с передачей того, что по сути является метаданными между клиентами и серверами, поэтому добавление версии кажется хорошим выбором.
С другой стороны, заголовки громоздки для работы с некоторыми фреймворками, более трудны для тестирования и нецелесообразны для входа в систему для каждого запроса. Некоторые интернет-прокси могут удалять неизвестные заголовки, что означает, что мы потеряем наш пользовательский заголовок, прежде чем он достигнет службы.
Тело сообщения
Мы могли бы обернуть тело сообщения с некоторыми метаданными, которые включают версию:
{
metadata: {
version: 2
},
message: {
name: “John Doe”,
email: “john@stackoverflow.com”,
password: “P@assword123”,
role: “editor”
}
}
С точки зрения RESTful, это нарушает идею о том, что тела сообщений являются представлениями ресурсов, а не версией ресурса. Мы также должны обернуть все наши доменные объекты в общий класс-оболочку, что не очень удобно—если этот класс-оболочка когда-либо должен измениться, все наши API потенциально должны измениться вместе с ним.
Одна последняя мысль о версионировании: рассмотрите возможность использования чего-то помимо простой схемы подсчета (v1, v2 и т. д.). Вы можете предоставить пользователям еще несколько контекстов, используя формат даты (например, "201911") или даже семантическое управление версиями.
Документация
Когда мы выпускаем библиотеки на GitHub или Maven, мы предоставляем журналы изменений и документацию. Наши веб-интерфейсы API не должны отличаться.
Журналы изменений необходимы для того, чтобы позволить потребителям API принимать обоснованные решения о том, как и когда они должны обновить своих клиентов. Как минимум, журналы изменений API должны включать следующее:
- Версия и дата вступления в силу
- Критические изменения, с которыми придется иметь дело потребителям
- Новые функции, которые могут быть дополнительно использованы, но не требуют каких-либо обновлений со стороны потребителей
- Исправления и изменения в существующих API, которые не требуют от потребителей ничего менять
- Уведомления об устаревании, которые запланированы для дальнейшей работы
Эта последняя часть имеет решающее значение для того, чтобы сделать наши API эволюционирующими. Удаление конечной точки явно не является обратно совместимым, поэтому вместо этого мы должны запретить их. Это означает, что мы продолжаем поддерживать его в течение фиксированного периода времени и позволяем нашим потребителям время для изменения их кода вместо неожиданного взлома.
Служба обмена сообщениями
Службы обмена сообщениями, такие как JMS и Kafka, - это еще один способ подключения распределенных систем. Мы обычно не получаем немедленную обратную связь о том, принял ли потребитель сообщение или нет.
Из-за этого мы должны быть осторожны при обновлении издателя, либо потребителя. Существует несколько стратегий, которые мы можем принять, чтобы предотвратить критические изменения при обновлении наших приложений обмена сообщениями.
Обновление потребителей в первую очередь
Рекомендуется сначала обновить потребительские приложения. Это дает нам возможность обрабатывать новые форматы сообщений, прежде чем мы фактически начнем их публиковать.
Здесь также применяется принцип устойчивости. Производители всегда должны отправлять минимально необходимую полезную нагрузку, а потребители должны потреблять только те поля, которые им небезразличны, и игнорировать все остальное.
Создание новых тем и очередей
Если тела сообщений существенно изменяются или мы полностью вводим новый тип сообщения, мы должны использовать новую тему или очередь. Это позволяет нам публиковать сообщения, не беспокоясь о том, что потребители могут быть не готовы их потреблять. Сообщения будут стоять в очереди в брокерах, и мы можем свободно развернуть нового или обновленного потребителя, когда захотим.
Использование заголовков и фильтров
Большинство шин сообщений предлагают заголовки сообщений. Так же, как и заголовки HTTP, это отличный способ передать метаданные, не загрязняя полезную нагрузку сообщения. Мы можем использовать это в своих интересах несколькими способами. Как и в случае с веб-API, мы можем публиковать сообщения с информацией о версии в заголовке.
Со стороны потребителя мы можем фильтровать сообщения, соответствующие известным нам версиям, игнорируя другие.
Хранилища данных
В настоящей архитектуре микрослужб хранилища данных не являются общими ресурсами. Каждая служба владеет своими данными и контролирует доступ к ним.
Однако в реальном мире это происходит не так часто. Большинство систем представляют собой смесь устаревшего и современного кода, где все получают доступ к хранилищам данных, используя свои собственные методы доступа.
Итак, как мы можем развивать хранилища данных обратно совместимым образом? Поскольку большинство хранилищ данных являются либо реляционными, либо NoSQL-базами данных, мы рассмотрим каждую из них отдельно.
Реляционная база данных
Реляционные базы данных, такие как Oracle, MySQL и PostgreSQL, имеют несколько характеристик, которые могут сделать их обновление сложной задачей:
- Таблицы имеют очень строгие схемы и будут отклонять данные, которые не совсем соответствуют
- Таблицы могут иметь ограничения внешнего ключа между собой
Изменения в реляционных базах данных можно разделить на три категории.
Добавление новых таблиц
Это, как правило, безопасно сделать и не будет нарушать любые существующие приложения. Мы должны избегать создания ограничений внешнего ключа в существующих таблицах, но в противном случае беспокоиться не о чем.
Добавление новых столбцов
Всегда добавляйте новые столбцы в конец таблиц. Если столбец не допускает значения null, мы должны включить разумное значение по умолчанию для существующих строк.
Кроме того, запросы в наших приложениях всегда должны использовать именованные столбцы вместо числовых индексов. Это самый безопасный способ убедиться, что новые столбцы не нарушают существующие запросы.
Удаление столбцов или таблиц
Эти типы обновлений представляют наибольший риск для обратной совместимости. Нет никакого хорошего способа убедиться, что таблица или столбец существуют, прежде чем запрашивать его. Подслушанная проверка таблицы перед каждым запросом просто не стоит того.
Если это возможно, запросы к базе данных должны корректно обрабатывать сбои. Предполагая, что удаляемая таблица или столбец не являются критическими или частью какой-либо более крупной транзакции, запрос должен продолжать выполнение, если это возможно.
Однако это не будет работать в большинстве случаев. Скорее всего, каждый столбец или таблица в схеме важны, и его неожиданное исчезновение сломает ваши запросы.
Поэтому наиболее практичным подходом к удалению столбцов и таблиц является первое обновление вызывающего их кода. Это означает обновление каждого запроса, ссылающегося на рассматриваемую таблицу, и изменение его поведения. Как только все эти обычаи исчезнут, можно будет безопасно удалить его из базы данных.
Базы данных NoSQL
Такие хранилища данных NoSQL, как MongoDB, ElasticSearch и Cassandra, имеют иные ограничения, чем их реляционные аналоги.
Основное отличие состоит в том, что вместо строк данных, которые все должны соответствовать схеме, документы внутри базы данных NoSQL не имеют такого ограничения. Это означает, что наши приложения уже привыкли иметь дело с документами, которые не имеют единой схемы.
У нас есть дополнительное преимущество в том, что большинство баз данных NoSQL не допускают ограничений между коллекциями, как это делают реляционные базы данных.
В этом контексте добавление новых коллекций и полей обычно не вызывает беспокойства. Здесь снова принцип надежности является нашим руководством: только сохраняйте необходимые поля и игнорируйте любые поля, которые нам не нужны при чтении документа.
С другой стороны, удаление полей и коллекций должно следовать тем же рекомендациям, что и реляционные базы данных. Если это возможно, наши запросы должны корректно обрабатывать сбои и продолжать выполнение. Кроме того, мы должны сначала обновить все запросы, а затем обновить само хранилище данных.
Развертывание программного обеспечения
Независимо от того, какой технический стек мы используем, есть определенные методы, которые мы можем включить в наш жизненный цикл программного обеспечения, которые помогают устранить или свести к минимуму проблемы совместимости.
Имейте в виду, что большинство из них работает только при двух условиях:
- Совершенно новые программные проекты.
- Зрелые организации по разработке программного обеспечения готовы выделить необходимые ресурсы для обучения инструментарию.
Если ваша организация не вписывается в одну из этих категорий, вы вряд ли добьетесь успеха в реализации любого из этих процессов.
Кроме того, ни одна из приведенных ниже практик не должна быть серебряной пулей, которая решит все проблемы развертывания. Вполне возможно, что ни один или многие из них не будут применимы к вашей организации. Оцените, как каждый из них может помочь вам, а может и не помочь.
Canary deployment
Сanary deployment, также известное как развертывание blue/green, red/black или purple/red, представляет собой идею выпуска новой версии приложения и позволяет только небольшому проценту трафика достичь его.
Цель состоит в том, чтобы протестировать новые версии приложений с реальным трафиком, минимизируя при этом последствия любых проблем, которые могут возникнуть. Если новое приложение работает должным образом, то остальные экземпляры могут быть обновлены. Если что-то пойдет не так, один экземпляр может быть возвращен, и только небольшая часть трафика будет затронута.
Это работает только для кластерных служб, где мы запускаем несколько экземпляров. Приложения, работающие как синглеты, не могут быть протестированы таким образом.
Кроме того, для canary deployments требуются сложные сервисные сетки для работы. Большинство архитектур микросервисов уже используют некоторые типы обнаружения служб, но не все они созданы равными. Без сервисной сетки, которая обеспечивает мелкозернистый контроль над потоком трафика, canary deployment невозможно.
Наконец, canary deployment - это не ответ на все обновления. Они не работают со службами, которые разворачиваются в первый раз. Если базовая модель данных изменяется вместе со службой, может оказаться невозможным одновременное выполнение нескольких версий приложения.
The three Ns
The three Ns относятся к идее, что приложение должно поддерживать три версии каждой службы, с которой оно взаимодействует:
- Предыдущая версия (N-1)
- Текущая версия (N)
- Следующая версия (N+1)
Так что же именно это означает? Это действительно просто сводится к тому, чтобы не предполагать, что наши услуги будут модернизированы в каком-либо конкретном порядке.
В качестве примера рассмотрим две службы, A и B, где A делает спокойные звонки на B.
Если нам нужно внести изменения в A, мы не должны предполагать, что B будет обновлен до или после A, или даже вообще. Изменения, которые мы вносим в A или B, должны стоять сами по себе.
И что произойдет, если B придется откатиться назад? Мы не должны возвращать все свои зависимые услуги в этом случае.
Для ясности: принцип трех Ns нелегко достичь, особенно при работе с устаревшими монолитными приложениями. Однако это не так уж и невозможно.
Это требует планирования и предвидения, и оно не придет без растущих болей и неудач на этом пути. Это обычно требует масштабного сдвига в мышлении разработчиков до точки, когда каждый разработчик и команда должны задавать два вопроса, прежде чем они выпустят какой-либо новый код:
- Какие службы использует мой код и какие службы используют мой код?
- Что произойдет, если мой код должен быть возвращен, и каков мой план отката?
На первый вопрос может быть нелегко ответить, но есть много инструментов, которые могут помочь. От статического анализа исходного кода до более сложных инструментов, таких как Zipkin, график зависимостей может помочь вам понять, как взаимодействуют службы.
Второй вопрос должен быть простым для ответа: что изменилось за пределами кода? Это может быть база данных, файлы конфигурации и т. д. Мы должны иметь план отката этих изменений, а не просто скомпилированный код.
Если вы готовы приложить усилия, достижение трех Ns - это отличный способ обеспечить обратную совместимость во всей распределенной системе.
Переключатели характеристик
Еще один отличный способ защитить сервисы от критических изменений - это использование переключателей функций.
Переключатель функций - это часть конфигурации, которую приложения могут использовать, чтобы определить, включена ли определенная функция или нет. Это позволяет нам выпускать новый код, но избегать его фактического выполнения до тех пор, пока мы не выберем его. Кроме того, мы можем быстро отключить новую функциональность, если мы обнаружим проблему с ним.
Существует множество инструментов, которые можно использовать для реализации переключателей функций, таких как rollout.io и Optimizely. Независимо от того, какой инструмент мы используем, есть определенные характеристики, которые мы должны искать.
Быстрый
Реализация функциональных переключателей обычно означает добавление большого количества кода, как показано ниже, в наши приложения:
if(newFeatureEnabled()) {
// do new stuff
}
Поэтому проверка состояния переключателя функций должна быть быстрой. Мы не должны полагаться на чтение из базы данных или удаленного файла каждый раз, когда нам нужно проверить это состояние переключения, так как это может очень быстро ухудшить наше приложение.
В идеале, переключаемое состояние должно быть загружено во время запуска приложения и кэшироваться внутри, с некоторым механизмом для обновления этого внутреннего состояния по мере необходимости (messaging bus, JMX, API и т. д.).
Распределенный
Поскольку мы имеем дело с распределенными системами, вполне вероятно, что переключение функций должно быть доступно для нескольких приложений. Поэтому состояние переключателя должно быть распределено таким образом, чтобы каждое приложение видело одно и то же состояние вместе с любыми изменениями.
Элементарный
Изменение состояния переключателя должно быть одной операцией. Если нам нужно обновить несколько источников конфигурации, мы увеличиваем вероятность того, что приложения получат другой вид переключения.
Переключатели имеют тенденцию накапливаться с течением времени в коде. Хотя влияние на производительность проверки большого количества переключателей может быть незначительным, они могут быстро превратиться в технический долг и потребовать периодической очистки. Обязательно планируйте время, чтобы вернуться к ним и очистке по мере необходимости.
Двигайтесь быстро, но не ломайте вещи
В нашем постоянно меняющемся распределенном мире существует множество способов взаимодействия приложений и служб. Что означает, что есть много способов сломать их, поскольку они неизбежно развиваются.
Приведенные выше советы и идеи являются лишь отправной точкой и не охватывают все способы, которыми могут разговаривать наши системы. Такие вещи, как распределенные кэши и транзакции, также могут создавать препятствия для создания обратно совместимого программного обеспечения.
Существуют также другие протоколы, такие как web sockets или gRPC, которые имеют свои собственные функции, которые мы можем использовать для быстрого обновления наших систем.
По мере того, как мы удаляемся от монолитов и переходим к микрослужбам, нам нужно убедиться, что мы фокусируемся столько же на эволюционности наших систем, сколько и на функциональности.