Всем привет! Меня зовут Егор Гурин и я разработчик в компании MTC Web Services. Работаю в стриме, который занимается разработкой контактного центра МТС. Практически любые обращения клиентов в компанию, будь то неработающий интернет или вопрос по заказу в интернет-магазине, проходят через нас. 

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

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

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

С чего все началось и почему мы выбрали DDD     

Так вышло, что в команду я и мои текущие коллеги попали в момент кадровых изменений, поэтому пришлось вставать на те рельсы, которые заложили прошлые поколения. Реальность встретила меня диаметрально противоположным подходом к разработке, но, думаю, знакомым большинству — code-first во всей своей красе. При таком подходе на первом месте реализация, а документирование архитектуры или контрактов происходит уже после, или, как это часто бывает, вообще не происходит. Не знаю как вам, но мне такой подход напоминает мои первые увлечения программированием в детстве, когда просто хотелось сделать что-то работающее и не требующее тестирования, и не задумываться над тем, как это будет выглядеть в результате. 

Одной из болей такого подхода является в «худшем» случае задержка других участников процесса разработки (фронтендеров и тестировщиков), а в «лучшем» — разрастание псевдо-документации контрактов на Confluence, за которой в скором времени никто не станет следить и поддерживать в актуальном состоянии. В общих чертах, с такой ситуацией мы и столкнулись. На помощь пришла методология DDD. Но не та, о которой вы, возможно, подумали.

В чем суть методологии DDD

         Document Driven Development можно перевести как «разработка через документирование». Но что это значит на практике?

 

           Этапы разработки в DDD
           Этапы разработки в DDD

 

Перед началом разработки мы устанавливаем «правила игры» — описываем контракты, которым будет удовлетворять разрабатываемый сервис. Дальше я буду говорить о 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, удобно определить конфигурацию в отдельном каталоге. В нем находится два файла:

  1. models.cfg.yaml — отвечает за настройку генерации входных и выходных моделей, а также базовую валидацию полей.

  2. 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.

А в комментариях буду рад почитать про ваш опыт внедрения этой методологии и инструменты, которые упрощают вам работу!

 Используемые источники:

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