Document Driven Development: превращаем хаос разработки в порядок с помощью TypeSpec и не только
Всем привет! Меня зовут Егор Гурин и я разработчик в компании 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.
А в комментариях буду рад почитать про ваш опыт внедрения этой методологии и инструменты, которые упрощают вам работу!
Автор: jdrzz

