- ваша система состоит из множества взаимосвязанных сервисов?
- всё ещё вручную актуализируете код сервисов при изменении публичного API?
- изменения в ваших сервисах часто подрывают работу других, а другие разработчики вас за это ненавидят?
Если ответили да хотя бы один раз, то добро пожаловать!
Термины
Публичные контракты, спецификации – публичные интерфейсы, через которые можно взаимодействовать с сервисом. В тексте означают одно и то же.
О чем статья
Узнаете, как сократить время на разработку веб-сервисов, используя инструменты унифицированного описания контрактов и автоматической генерации кода.
Грамотное использование описанных ниже техник и инструментов позволит быстрее выкатывать новые фичи и не ломать старые.
Как выглядит проблема
Есть система, которая состоит из нескольких сервисов. Эти сервисы закреплены за разными командами.
Сервисы-потребители зависят от сервиса-поставщика.
Система развивается, и однажды сервис-поставщик меняет свои публичные контракты.
Если сервисы-потребители не готовы к изменениям, то система перестает полноценно работать.
Как решить эту проблему
Команда сервиса поставщика сама всё поправит
Так можно поступить, если команда поставщика владеет предметной областью других сервисов и имеет доступ к их git-репозиториям. Это сработает только в небольших проектах, когда зависимых сервисов немного. Это самый дешевый вариант. По возможности, следует воспользоваться им.
Актуализировать код своего сервиса команде потребителя
Почему ломают другие, а чиним мы?
Однако главный вопрос — это как починить свой сервис, как теперь выглядит контракт? Нужно изучать новый код сервиса поставщика или обращаться к их команде. Тратим время на изучение кода и на взаимодействие с другой командой.
Подумать, что сделать чтобы проблема не проявлялась
Самый разумный вариант в долгосрочной перспективе. Рассмотрим его в следующем разделе.
Как не допустить проявления проблемы
Жизненный цикл разработки ПО можно представить тремя этапами: проектирование, реализация и тестирование.
Каждый из этапов нужно расширить следующим образом:
- На этапе проектирования декларативно определяем контракты.
- Во время реализации генерируем серверный и клиентский код по контрактам.
- При тестировании проверяем контракты и стараемся учитывать потребности клиентов (CDC).
Каждый из этапов объясняется далее на примере нашей проблемы.
Как проблема выглядит у нас
Так выглядит наша экосистема.
Кружочки – сервисы, а стрелочки – каналы общения между ними.
Frontend – клиентское web-приложение.
Большинство стрелок ведут в Storage Service. В нем хранятся документы. Это самый важный сервис. Ведь наш продукт – это система электронного документооборота.
Стоит этому сервису поменять свои контракты, система сразу перестанет работать.
Исходники нашей системы преимущественно написаны на c#, но также есть сервисы на Go и Python. В данном контексте неважно, чем занимаются остальные сервисы на рисунке.
В каждом сервисе своя реализация клиента для работы с сервисом хранилищ. При изменении контрактов нужно вручную актуализировать код в каждом проекте.
Хочется уйти от ручной актуализации в сторону автоматической. Это поможет увеличить скорость изменения клиентского кода и сократить количество ошибок. Под ошибками понимаются опечатки в URL, ошибки из-за невнимательности и т.п.
Однако, этим подходом не исправишь ошибки в клиентской бизнес-логике. Скорректировать её можно только вручную.
От проблемы к задаче
В нашем случае требуется реализовать автоматическую генерацию клиентского кода.
При этом нужно учитывать следующее:
- серверная часть – контроллеры уже написаны;
- браузер является клиентом сервиса;
- сервисы взаимодействуют по HTTP;
- генерация должна настраиваться. Например, для поддержки JWT.
Вопросы
В ходе решения задачи встали вопросы:
- какой инструмент выбрать;
- как получить контракты;
- где расположить контракты;
- где расположить код клиента;
- в какой момент выполнять генерацию.
Дальше приводятся ответы на эти вопросы.
Какой инструмент выбрать
Инструменты для работы с контрактами представлены по двум направлениям – RPC и REST.
Под RPC можно понимать просто удаленный вызов, в то время как REST требует соблюдения дополнительных условий на HTTP-глаголы и URL.
Отличия в вызове RPC и REST представлены здесь
RPC – Remote procedure call | REST Representational State Transfer | ||
Методы и процедуры | Ресурсы, HTTP-глаголы и URL | ||
Создание | Restaurant:8080/Orders/PlaceOrder | POST | Restaurant:8080/Orders |
Получение | Restaurant:8080/Orders/GetOrder?OrderNumber=1 | GET | Restaurant:8080/Orders/1 |
Изменение | Restaurant:8080/Orders/UpdateOrder | PUT | Restaurant:8080/Orders/1 |
Инструменты
В таблице представлено сравнение инструментов для работы с REST и RPC.
Свойства | OpenAPI | WSDL | Thrift | gRPC |
Тип | REST | RPC | ||
Платформа | Не зависит | |||
Язык | Не зависит | |||
Последовательность разработки* | code first, spec first | code first, spec first | spec first | code first, spec first |
Транспортный протокол | HTTP/1.1 | любой (REST требует HTTP) | собственный | HTTP/2 |
Вид | спецификация | фреймворк | ||
Комментарий | Низкий порог вхождения, много документации | Избыточность XML, тянет за собой SOAP и т.п. | Высокий порог вхождения, мало документации | Средний порог вхождения, лучше документация |
Spec first — сначала определяем контракты, потом по ним получаем клиентскую часть и серверную часть. Удобно в начале разработки, когда кода еще нет.
Вывод
WSDL не подходит из-за своей избыточности.
Apache Thrift слишком экзотический и непростой в освоении.
GRPC требует net Core 3.0 и net Standard 2.1. На момент анализа использовался net Core 2.2 и net Standard 2.0. Нет поддержки GRPC в браузере из коробки, требуется внедрять дополнительное решение. GRPC использует бинарную сериализацию Protobuf и HTTP/2. Из-за этого сужается круг утилит для тестирования API типа Postman и т.п. Нагрузочное тестирование через какой-нибудь JMeter может потребовать дополнительных усилий. Не подходит, переход на GRPC требует много ресурсов.
OpenAPI не требует дополнительных обновлений. Подкупает обилие инструментов, поддерживающих работу с REST и этой спецификацией. Выбираем его.
Инструменты для работы с OpenAPI
В таблице представлено сравнение инструментов для работы с OpenAPI.
Инструменты | swashbuckle | NSwag | OpenAPITools |
Поддерживаемые версии спецификации | Могут генерировать спецификацию в формате OpenApi v2, v3 | ||
Поддержка code first | Есть | Есть | Нет |
Поддерживаемые языки сервера | Нет | C# | Много |
Поддерживаемые языки клиентов | Нет | C#, TypeScript, AngularJS, Angular (v2+), window.fetch API | Много |
Настройки генерации | Нет | Есть | Есть |
Вид | Nuget пакет | Nuget пакет + отдельная утилита | Отдельная утилита |
Swashbuckle не подходит, т.к. позволяет получить только спецификацию. Чтобы сгенерировать клиентский код нужно воспользоваться дополнительным решением.
OpenApiTools интересный инструмент с кучей настроек, но он не поддерживает code first. Его преимущество – это способность генерировать серверный код на множестве языков.
NSwag удобен тем, что это Nuget пакет. Его легко подключить при сборке проекта. Поддерживает всё, что нам нужно: code first подход и генерацию клиентского кода на c#. Выбираем его.
Где расположить контракты. Как осуществить доступ сервисов к контрактам
Здесь представлены решения по организации хранения контрактов. Решения перечислены в порядке увеличения их сложности.
- папка проекта сервиса поставщика – самый простой вариант. Если нужно обкатать подход, то выбрать его.
- общая папка – допустимый вариант, если нужные проекты находятся в одном репозитории. В долгосрочной перспективе будет сложно поддерживать целостность контрактов в папке. Для этого может потребоваться дополнительный инструмент, чтобы учитывать разные версии контрактов и т.п.
- отдельный репозиторий для спецификаций – если проекты находятся в разных репозиториях, то контракты следует вынести в общедоступное место. Недостатки такие же, как и общей папки.
- через API сервиса (swagger.ui, swaggerhub) – отдельный сервис, который занимается управлением спецификациями.
Мы решили использовать самый простой вариант – хранить контракты в папке проекта сервиса-поставщика. На данном этапе нам этого достаточно, так зачем платить больше?
В какой момент выполнять генерацию
Теперь нужно определиться, в какой момент выполнять генерацию кода.
Если бы контракты располагались в общем доступе, сервисы-потребители могли бы получать контракты и сами генерировать код по необходимости.
Мы решили расположить контракты в папке с проектом сервиса-поставщика. Значит, генерацию можно сделать после сборки самого проекта сервиса поставщика.
Где расположить код клиента
Клиентский код будет сгенерирован по контрактам. Осталось выяснить, где его расположить.
Кажется хорошей идеей расположить клиентский код в отдельном проекте StorageServiceClientProxy. Каждый проект сможет подключить себе эту сборку.
Преимущества этого решения:
- клиентский код близко к своему сервису и постоянно в актуальном состоянии;
- потребители могут использовать ссылку на проект в рамках одного репозитория.
Недостатки:
- не сработает, если потребуется сгенерировать клиента в другой части системы, Например, другой репозиторий. Решается использованием, как минимум, общей папки для контрактов;
- потребители должны быть написаны на том же языке. Если нужен клиент на другом языке, нужно использовать OpenApiTools.
Прикручиваем NSwag
Атрибуты контроллеров
Нужно подсказать NSwag, как сгенерировать правильную спецификацию по нашим контроллерам.
Для этого необходимо расставить атрибуты.
[Microsoft.AspNetCore.Mvc.Routing.Route("[controller]")] // часть url
[Microsoft.AspNetCore.Mvc.ApiController] // контроллер участвует в генерации спецификации
public class DescriptionController : ControllerBase {
[NSwag.Annotations.OpenApiOperation("GetDescription")] // название метода в спецификации
[Microsoft.AspNetCore.Mvc.ProducesResponseType(typeof(ConversionDescription), 200)] // метод может вернуть 200 и тип
[Microsoft.AspNetCore.Mvc.ProducesResponseType(401)] // метод может вернуть 401
[Microsoft.AspNetCore.Mvc.ProducesResponseType(403)] // метод может вернуть 403
[Microsoft.AspNetCore.Mvc.HttpGet("{pluginName}/{binaryDataId}")] // часть url
public ActionResult<ConversionDescription> GetDescription(string pluginName, Guid binaryDataId) {
// код...
}
По умолчанию NSwag не может сгенерировать правильную спецификацию для MIME типа application/octet-stream. Например, такое может произойти, когда передаются файлы. Чтобы исправить это, нужно написать свой атрибут и процессор для создания спецификации.
[Microsoft.AspNetCore.Mvc.Route("[controller]")]
[Microsoft.AspNetCore.Mvc.ApiController]
public class FileController : ControllerBase {
[NSwag.Annotations.OpenApiOperation("SaveFile")]
[Microsoft.AspNetCore.Mvc.ProducesResponseType(401)]
[Microsoft.AspNetCore.Mvc.ProducesResponseType(403)]
[Microsoft.AspNetCore.Mvc.HttpPost("{pluginName}/{binaryDataId}/{fileName}")]
[OurNamespace.FileUploadOperation] // самописный атрибут
public async Task SaveFile() { // код... }
Процессор для генерации спецификации для файловых операций
Идея заключается в том, что можно написать свой атрибут и процессор для обработки этого атрибута.
Вешаем атрибут на контроллер, и, когда NSwag встретит его, он обработает его с использованием нашего процессора.
Чтобы это реализовать, NSwag предоставляет классы OpenApiOperationProcessorAttribute и IOperationProcessor.
В нашем проекте мы сделали своих наследников:
- FileUploadOperationAttribute: OpenApiOperationProcessorAttribute
- FileUploadOperationProcessor: IOperationProcessor
Подробнее про использование процессоров тут
Конфигурация NSwag для генерации спецификации и кода
В конфиге 3 основные секции:
- runtime – указывается .net runtime. Например, NetCore22;
- documentGenerator – описывается, как сгенерировать спецификацию;
- codeGenerators – определяется, как сгенерировать код по спецификации.
NSwag содержит кучу настроек, что сначала запутывает.
Для удобства можно воспользоваться NSwag Studio. С помощью неё можно в режиме реального времени смотреть, как влияют различные настройки на результат генерации кода или спецификации. После этого выбранные настройки вручную отразить в конфигурационном файле.
Подробнее про настройку конфига тут
Генерируем спецификацию и клиентский код при сборке проекта сервиса-поставщика
Чтобы после сборки проекта сервиса-поставщика генерировалась спецификация и код, сделали следующее:
- Создали WebApi проект для клиента.
- Написали конфиг для Nswag CLI – Nswag.json (описан в предыдущем разделе).
- Написали PostBuild Target внутри csproj проекта сервиса поставщика.
<Target Name="GenerateWebApiProxyClient“ AfterTargets="PostBuildEvent">
<Exec Command="$(NSwagExe_Core22) run nswag.json”/>
- $(NSwagExe_Core22) run nswag.json – запускаем утилиту NSwag под .bet runtine netCore 2.2 с конфигурацией nswag.json
Target делает следующее:
- NSwag генерирует спецификацию из сборки сервиса поставщика.
- NSwag генерирует клиентский код по спецификации.
После каждой сборки проекта сервиса-поставщика обновляется проект клиента.
Проект клиента и сервиса-поставщика находятся в рамках одного решения.
Сборка проходит в рамках решения. В решении настроено, что проект клиента должен собираться после проекта сервиса поставщика.
Также NSwag позволяет настроить генерацию спецификации/кода императивно через программный API.
Как добавили поддержку для JWT
Нам необходимо защитить наш сервис от неавторизованных запросов. Для этого будем использовать JWT-токены. Они должны передаваться в заголовках каждого HTTP запроса, чтобы сервис поставщик мог их проверить и решить, выполнять запрос или нет.
Больше информации про JWT здесь jwt.io.
Задача сводится к тому, что нужно модифицировать заголовки исходящего HTTP запроса.
Для этого генератор кода NSwag может сгенерировать точку расширения – метод CreateHttpRequestMessageAsync. Внутри этого метода есть доступ к HTTP запросу до того, как он будет отправлен.
Пример кода
protected Task<HttpRequestMessage> CreateHttpRequestMessageAsync(CancellationToken cancellationToken) {
var message = new HttpRequestMessage();
if (!string.IsNullOrWhiteSpace(this.AuthorizationToken)) {
message.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(BearerScheme, this.AuthorizationToken);
}
return Task.FromResult(message);
}
Вывод
Мы выбрали вариант с OpenAPI, т.к. внедрить его несложно, а инструменты для работы с этой спецификацией весьма развиты.
Выводы по OpenAPI и GRPC:
OpenAPI
- спецификация многословна;
- генерация прикручивается быстро за счёт хорошей документации к спецификации и инструментам, её реализующим;
- пришлось докрутить процессор для генерации спецификации;
- много атрибутов на контроллерах отвлекают.
GRPC
- выше уровень абстракции, не нужно явно работать с URL, HTTP и т.п.;
- спецификация более человекочитаемая по сравнению с OpenAPI;
- нет поддержки браузером из коробки;
- использовать пока не имеет смысла без обновления фреймворка и всех зависимых проектов;
- подходит для общения микросервисов по HTTP/2.
Таким образом, мы получили спецификацию на основе уже написанного кода контроллеров. Для этого понадобилось навесить на контроллеры специальные атрибуты.
Затем на основе полученной спецификации реализовали генерацию клиентского кода. Теперь нам не нужно вручную актуализировать клиентский код.
Были проведены исследования в области версионирования и тестирования контрактов. Однако не удалось опробовать все это дело на практике из-за нехватки ресурсов.
Версионирование публичных контрактов
Зачем нужно версионирование публичных контрактов
После изменений в сервисах-поставщиках вся система должна оставаться в согласованном, рабочем состоянии.
Нужно избежать breaking changes в публичном API, чтобы не поломать клиентов.
Варианты решения
Без версионирования публичных контрактов
Команда сервиса-поставщика сама исправляет сервисы-потребители.
Этот подход не сработает, если у команды сервиса-поставщика нет доступа к репозиториям сервисов-потребителей или не хватает компетенций. Если таких проблем нет, то можно обойтись без версионирования.
Использовать версионирование публичных контрактов
Команда сервиса-поставщика оставляет предыдущую версию контрактов.
Данный подход лишен недостатков предыдущего, но добавляет другие сложности.
Нужно определиться со следующим:
- какой инструмент использовать;
- когда вводить новую версию;
- как долго поддерживать старые версии.
Какой инструмент использовать
В таблице представлены возможности OpeanAPI и GRPC, связанные с версионированием.
gRPC | OpenAPI | |
Атрибут версии | На уровне protobuf есть атрибут package [packageName].[Version] | На уровне спецификации есть атрибуты basePath (для URL) и Version |
Атрибут Deprecated для методов | Есть, но не учитывается генератором кода под C# | Есть, помечается как Obsolete В NSwag не поддерживается при code first, нужно писать свой процессор |
Атрибут Deprecated для параметров | Есть, помечается как Obsolete | Есть, помечается как Obsolete В NSwag не поддерживается при code first, нужно писать свой процессор |
Оба инструмента поддерживают атрибуты версии и Deprecated.
Если использовать OpenAPI и подход code first, снова нужно писать процессоры для создания правильной спецификации.
Когда вводить новую версию
Новую версию нужно вводить, когда изменения в контрактах не сохраняют обратную совместимость.
Как проверить, что изменения нарушают совместимость между новой и старой версией контрактов?
- для OpenAPI есть инструмент Azure opeanapi-diff проверки совместимости между 2 спецификациями github.com/Azure/openapi-diff;
- для gRPC автоматического инструмента не обнаружено, есть только политики версионировния;
- общие github.com/grpc/grpc/blob/master/doc/versioning.md;
- под .Net docs.microsoft.com/ru-ru/aspnet/core/grpc/versioning?view=aspnetcore-3.1.
Как долго поддерживать версии
На этот вопрос нет правильного ответа.
Чтобы убрать поддержку старой версии, нужно знать, кто пользуется вашим сервисом.
Будет плохо, если версию уберут, а ею ещё кто-то пользуется. Особенно сложно, если вы не контролируете своих клиентов.
Так что можно сделать в этой ситуации?
- уведомлять клиентов, что старая версия больше поддерживаться не будет. В этом случае можем потерять доход клиентов;
- поддерживать весь набор версий. Растет стоимость поддержки ПО;
- чтобы ответить на этот вопрос, нужно спросить бизнес – превышает ли доход от старых клиентов затраты на поддержку старых версий ПО? Может выгоднее попросить клиентов обновиться?
Единственный совет в такой ситуации – уделять больше внимания публичным контрактам, чтобы снизить частоту их изменений.
Если публичные контракты используются в замкнутой системе, можно воспользоваться подходом CDC. Так можем узнавать о том, когда клиенты перестали использовать старые версии ПО. После этого можно убирать поддержку старой версии.
Вывод
Используйте версионирование, только если без него не обойтись. Если решили использовать версионирование, то при проектировании контрактов учитывайте совместимость версий. Нужно найти баланс между стоимостью поддержки старых версий и выгодой, которую она дает. Также стоит определиться с тем, когда можно перестать поддерживать старую версию.
Тестирование контрактов и CDC
Данный раздел освещается поверхностно, т.к. нет серьезных предпосылок для внедрения этого подхода.
Consumer driven contracts (CDC)
CDC это ответ на вопрос, как гарантировать, что поставщик и потребитель используют одинаковые контракты. Это своего рода интеграционные тесты, направленные на проверку контрактов.
Идея в следующем:
- Потребитель описывает контракт.
- Поставщик реализует этот контракт у себя.
- Этот контракт используется в CI-процессе у потребителя и поставщика. Если процесс нарушился, значит кто-то перестал соблюдать контракт.
Pact
PACT – это инструмент, который реализует эту идею.
- Потребитель пишет тесты с помощью PACT библиотеки.
- Эти тесты преобразуются в артефакт – pact-файл. Он содержит информацию о контрактах.
- Поставщик и потребитель используют pact-файл для запуска тестов.
Во время тестов клиента создается заглушка поставщика, а во время тестов поставщика создается заглушка клиента. Обе этих заглушки используют pact-файл.
Похожее поведение по созданию заглушек можно достичь через Swagger Mock Validator bitbucket.org/atlassian/swagger-mock-validator/src/master.
Полезные ссылки про Pact
- Pact broker – решение для управления pact-файлами docs.pact.io/pact_broker
- Pact пока не поддерживает GRPC. Подробнее про roadmap Pact pact.canny.io.
- Подробнее про PACT на docs.pact.io.
- Почему стоит использовать PACT docs.pact.io/faq/convinceme
Как CDC можно встроить в CI
- самостоятельно развернув Pact + Pact broker;
- приобрести готовое решение Pact Flow SaaS.
Вывод
Pact нужен для обеспечения соответствия контрактов. Он покажет, когда изменения контрактов нарушают ожидания сервисов-потребителей.
Этот инструмент годится, когда поставщик подстраивается под заказчика – клиента. Такое возможно только внутри изолированной системы.
Если вы делаете сервис для внешнего мира и не знаете кто ваши клиенты, то Pact не для вас.