Всем привет! Меня зовут Егор Гурин и я разработчик в компании MTC Web Services. Работаю в стриме, который занимается разработкой контактного центра МТС. Практически любые обращения клиентов в компанию, будь то неработающий интернет или вопрос по заказу в интернет-магазине, проходят через нас.
Продукт большой, у нас несколько команд и множество интерфейсов интеграции как между самими командами, так и с внешними вендорами, поэтому не согласованные вовремя контракты могут привести не просто к потере времени, но и задержать выход фичи в прод.
В этом материале я поделюсь инструментами, которые помогли наладить процессы в нашей команде в рамках методологии Document Driven Development, — возможно, вам она знакома под такими терминами как design-first или API-first. Покажу, как в удобной форме описывать контракты с помощью TypeSpec, использовать мокирующие сервера не дожидаясь реализации серверной, а еще — расскажу про инструмент кодогенерации на Go и автотесты с помощью Schemathesis.

Перед тем как приступить к основной части, считаю важным рассказать про флоу разработки, который был в команде до внедрения методологии. Если вам интересны именно детали реализации — переходите сразу к следующему заголовку.
С чего все началось и почему мы выбрали DDD
Так вышло, что в команду я и мои текущие коллеги попали в момент кадровых изменений, поэтому пришлось вставать на те рельсы, которые заложили прошлые поколения. Реальность встретила меня диаметрально противоположным подходом к разработке, но, думаю, знакомым большинству — code-first во всей своей красе. При таком подходе на первом месте реализация, а документирование архитектуры или контрактов происходит уже после, или, как это часто бывает, вообще не происходит. Не знаю как вам, но мне такой подход напоминает мои первые увлечения программированием в детстве, когда просто хотелось сделать что-то работающее и не требующее тестирования, и не задумываться над тем, как это будет выглядеть в результате.
Одной из болей такого подхода является в «худшем» случае задержка других участников процесса разработки (фронтендеров и тестировщиков), а в «лучшем» — разрастание псевдо-документации контрактов на Confluence, за которой в скором времени никто не станет следить и поддерживать в актуальном состоянии. В общих чертах, с такой ситуацией мы и столкнулись. На помощь пришла методология DDD. Но не та, о которой вы, возможно, подумали.
В чем суть методологии DDD
Document Driven Development можно перевести как «разработка через документирование». Но что это значит на практике?

Перед началом разработки мы устанавливаем «правила игры» — описываем контракты, которым будет удовлетворять разрабатываемый сервис. Дальше я буду говорить о REST API, как о самом популярном способе взаимодействия по веб-API, апод контрактами буду подразумевать документирование через OpenAPI (ранее Swagger) и объяснять технический инструментарий именно для этого вида транспорта.
Как только контракты разработаны, все заинтересованные стороны собираются, чтобы обсудить их целостность и валидность в контексте рабочей деятельности. В этот узкий круг обычно входят бекенд- и фронтенд-разработчики, а также автотестировщики.
После окончательного утверждения контрактов, все участники процесса разработки приступают к работе независимо друг от друга. В случае, если контракты были соблюдены верно, по итогу их ждет бесшовная интеграция разрабатываемых сервисов.
Как и с помощью чего нам удалось приблизиться к такому процессу?
Как мы разрабатываем контракты
Расскажу про инструменты для проектирования REST API. Бесспорно, синтаксис OpenAPI specification с годами стал проще для ручного написания, но удобным его по-прежнему назвать нельзя. Один мой коллега предпочитает писать спецификацию самостоятельно, но лично я выбираю инструмент, который превращает написание документации в что-то похожее на декларативное программирование —TypeSpec. Это бесплатный инструмент от Microsoft, который предоставляет простые синтаксические конструкции, и затем компилирует их в готовую документацию, а еще позволяет сразу же сгенерировать из этой документации как клиент, так и сервер.
Как это работает? Рассмотрим на абстрактном примере — сервисе, который отвечаетза АЗС в царстве N. У него есть методы, позволяющие узнать количество оставшегося бензина всех или конкретной марки, и добавить или убавить N литров топлива выбранной марки.
Первым шагом устанавливаем Node.js.
Затем, согласно документации [3], устанавливаем TypeSpec CLI:
npm install -g @typespec/compiler
Теперь создаем любой каталог, в котором планируем вести проект. В реальной работе мы создали общий каталог specs, где в каждой отдельно вложенной папке хранится документация конкретного сервиса. Выглядит это вот так:
. ├── README.md ├── service_1 │ ├── api │ ├── main.tsp │ ├── node_modules │ ├── package-lock.json │ ├── package.json │ └── tspconfig.yaml └── service_2 ├── api ├── main.tsp ├── node_modules ├── package-lock.json ├── package.json └── tspconfig.yaml
Репозиторий находится в Gitlab в пространстве команды, для каждого изменения заводится ветка, так что при необходимости через Merge Request удобно смотреть, что и как меняется в контракте.
Но вернемся к примеру. Вводим команду$ tsp init для инициализации проекта и выбираем то, что нас интересует — REST API и название проекта:

После того, как зависимости будут установлены, в каталоге появится шаблонный проект и файлы конфигурации. Я не хотел бы превращать этот пост в справочную документацию, так что про состав проекта и назначение каждого поля конфигурации вы можете самостоятельно почитать в документации
Первым делом приведем конфигурационный файл tspconfig.yaml в следующий вид:
emit: - "@typespec/openapi3" options: "@typespec/openapi3": emitter-output-dir: "{cwd}/api" experimental-parameter-examples: "serialized" openapi-versions: - 3.0.0
Нас интересуют следующие поля:
openapi-versions — это версия OpenAPI, которую мы получим после комплиляции проекта.
emitter-output-dir — папка, в которую будет помещаться скомпилированная документация. Здесь cwd равносильна pwd и отвечает за текущую рабочую директорию.
Файл main.tsp является рабочим, в нем описывается сама документация в синтаксисе TypeSpec. В нашем примере содержимое может быть следующим:
main.tsp
// Импорт библиотек для HTTP-примитивов и OpenAPI-декораторов import "@typespec/http"; import "@typespec/openapi"; // Подключаем пространства имён библиотек, чтобы не писать Http.OkResponse, а просто OkResponse using Http; using OpenAPI; // @service — метаданные сервиса (название в OpenAPI-спеке) // @server — объявляем окружения; можно указать несколько // @info — дополнительные метаданные OpenAPI (версия API) // @useAuth — глобальная схема авторизации для всех операций (BearerAuth — JWT через Authorization: Bearer) @service(#{ title: "Система по управлению Мини-АЗС" }) @server("https://dev.api.example.com/api/v1", "Development") @server("https://api.example.com/api/v1", "Production") @info(#{ version: "1.0" }) @useAuth(BearerAuth) namespace PetrolStationWebAPI; // Вложенный namespace для переиспользуемых типов ошибок namespace Common { // Базовая форма тела ошибки — используется как @body во всех error-ответах model ErrorResponse { error: string; status_code: int32; } // @error — помечает модель как ошибочный ответ; // это влияет на генерацию OpenAPI (responses) и клиентских SDK // Generic-параметр Code позволяет переиспользовать модель для любого HTTP-кода ошибки @error model ErrorResponseFor<Code extends int32> { @statusCode statusCode: Code; // @statusCode привязывает поле к HTTP-статусу ответа @body body: ErrorResponse; // @body указывает, что это тело ответа } } // Алиасы для конкретных кодов ошибок — удобнее, чем писать ErrorResponseFor<400> везде alias BadRequestResponse = Common.ErrorResponseFor<400>; alias NotAuthorized = Common.ErrorResponseFor<401>; alias NotFoundResponse = Common.ErrorResponseFor<404>; alias UnprocessableEntityResponse = Common.ErrorResponseFor<422>; alias InternalServerErrorResponse = Common.ErrorResponseFor<500>; // Модель сущности model Petrol { id: int32; kind: Kind; // @minValue / @maxValue — валидационные декораторы; попадают в OpenAPI как minimum / maximum @minValue(0) @maxValue(1000) total: int32; } // Отдельная модель для создания — без id (его генерирует сервер) model PetrolCreate { kind: Kind; @minValue(0) @maxValue(1000) amount?: int32; } // enum — перечисление допустимых значений; генерируется как enum в OpenAPI enum Kind { euro: "euro", regular: "regular", premium: "premium", } // Spread-модели для ответов: разворачивают поля OkResponse/CreatedResponse (statusCode) и Body<T> (body) model PetrolResponse { ...OkResponse; // statusCode: 200 ...Body<Petrol>; // body: Petrol } model PetrolListResponse { ...OkResponse; // statusCode: 200 ...Body<Petrol[]>; // body: массив Petrol } model PetrolCreatedResponse { ...CreatedResponse; // statusCode: 201 ...Body<Petrol>; // body: Petrol } // @route — базовый путь для всех операций внутри namespace // @tag — группировка операций в OpenAPI UI @route("/petrol") @tag("Petrol") namespace Petrols { @get @summary("Получение информации о бензине на АЗС") op getPetrols(): | PetrolListResponse | NotAuthorized | InternalServerErrorResponse; // @path — параметр из URL-пути (/petrol/{petrolId}) @get @summary("Получение информации о конкретном бензине") op getPetrol(@path petrolId: integer): | PetrolResponse | BadRequestResponse | NotAuthorized | NotFoundResponse | InternalServerErrorResponse; @post @summary("Добавить новую марку бензина") op createPetrol(@body petrol: PetrolCreate): | PetrolCreatedResponse | NotAuthorized | BadRequestResponse | InternalServerErrorResponse; @delete @summary("Удалить марку бензина") op deletePetrol(@path petrolId: integer): | NoContentResponse | BadRequestResponse | NotAuthorized | NotFoundResponse | InternalServerErrorResponse; // Вложенные маршруты для действий над конкретным ресурсом // Итоговый путь: POST /petrol/{petrolId}/supply @route("{petrolId}/supply") @post @summary("Добавить количество бензина") op supplyPetrol(@path petrolId: integer, @query amount: integer): | PetrolResponse | BadRequestResponse | NotAuthorized | NotFoundResponse | BadRequestResponse | UnprocessableEntityResponse | InternalServerErrorResponse; // Итоговый путь: POST /petrol/{petrolId}/dispense @route("{petrolId}/dispense") @post @summary("Убавить количество бензина") op dispensePetrol(@path petrolId: integer, @query amount: integer): | PetrolResponse | BadRequestResponse | NotAuthorized | NotFoundResponse | BadRequestResponse | UnprocessableEntityResponse | InternalServerErrorResponse; }
Я постарался задействовать богатство синтаксиса TypeSpec по максимуму, чтобы вы могли представить все возможности и гибкость инструмента. Принцип написания документации сводится к описанию моделей запроса-ответа и CRUD-операций, на которые навешиваются декораторы.
Для компиляции документации в формат yaml, введем команду:
$ tsp format . && tsp compile .
Так в папке ./api появится сгенерированная спецификация, которую можно отдавать другим участникам разработки.
Кодогенерация из спецификации
Теперь, когда готова спецификация, можно сгенерировать из нее клиентскую и серверную часть, и сфокусироваться на написании бизнес-логики. Эта идея не нова и существует множество инструментов в зависимости от вашего стека. Я пишу на Go, поэтому активно применяю в работе oapi-codegen. Кстати, подробнее о том, как в нашей компании используется эта библиотека, мы рассказывали в другом материале.
С его помощью удобно создать сервер на базе одного из возможных роутеров, который поддерживает gin, echo, gorilla/mux, chi и другие. Демо сервера из нашего примера можно посмотреть в репозитории на GitHub.
Для того, чтобы пользоваться oapi-codegen, удобно определить конфигурацию в отдельном каталоге. В нем находится два файла:
models.cfg.yaml — отвечает за настройку генерации входных и выходных моделей, а также базовую валидацию полей.
server.cfg.yaml — позволяет сконфигурировать выходящий сервер.
Затем в отдельной папке задать значимый комментарий с командой, которая запустит процесс кодогенерации. В Go есть встроенная утилита go generate, которая ищет в проекте комментарии в виде:
//go:generate
И выполняет стоящую справа от них команду.
Результат — готовый набор моделей запроса-ответа с возможностью как упрощенной валидации, так и более строгой при подключении соответствующих middleware, а также настроенный роутинг эндпоинтов сервиса.
Мокирующие сервера
Перенесемся в мир клиентской части. Так как мы уже знаем об инструментах кодогенерации, не составит труда создать клиентскую часть. Но иногда неоптимально ждать готовности бекенда, поэтому существуют так называемые mock-сервера. С их помощью можно запустить сервер, который будет принимать запросы и отдавать ответы согласно спецификации, а значит, сразу перейти к отладке.
В качестве такого инструмента хорошо подходит Prism. Его можно запустить в контейнере Docker командой:
docker run --init --rm \ -v "$(pwd)/api":/api \ -p 4010:4010 \ stoplight/prism:5 \ mock -h 0.0.0.0 -m false "/api/openapi.yaml"
Результат работы:

Тестирование сервера на соответствие спецификации
Использование инструментов кодогенерации повышает согласованность, но на код ответа также влияют и бизнес-сценарии. Например, можно возвращать ошибку 422, если поле from_date больше, чем to_date. И хорошо было бы дополнительно протестировать реализацию до того, как передавать наработки на ревью или в тестирование.
Когда я заканчиваю работу над API, для самопроверки использую не только Postman, но и менее известный инструмент Schemathesis. Принцип его работы похож на Prism— для работы нужен файл спецификации OpenAPI, запущенный сервер и файл конфигурации с выбранными проверками. Утилита очень тщательно тестирует сервер, поэтому я отключаю некоторые некритичные для моих сценариев проверки.
В нашем примере запустить утилиту можно командой:
TOKEN="secret" schemathesis run api/openapi.yaml --url http://localhost:5001/api/v1
В итоге должен получится отчет:

Я слышал о кейсах, когда запуск таких тестов добавляют в пайплайн CI/CD, но в нашем случае я предпочел оставить запуск на ответственности разработчика.
Преимущества и итоги
Какие преимущества такого подхода? Я выделил следующее и основное. Раньше мы буквально страдали от меняющихся в процессе разработки контрактов и отсутствия валидной документации, а теперь любое изменение влечет за собой правки в общем репозитории контрактов приложений, что мотивирует тщательнее собирать бизнес-требования и внимательнее проектировать спецификацию. А еще — любое изменение транспортной части на бекенде требует перегенерации этого слоя, благодаря чему документация API остается актуальной на все время разработки.
Я поделился инструментами и подходом, который практикует наша команда разработки. Надеюсь, наш опыт поможет вам быстрее перейти на методологию Document Driven Development.
А в комментариях буду рад почитать про ваш опыт внедрения этой методологии и инструменты, которые упрощают вам работу!