Инженерная практика
Верификация провайдера и брокер: переигрывание ожиданий консьюмера против реального сервиса
Команда Прии checkout-web опубликовала pact: «когда я делаю GET /prices/42, я читаю amount_cents (число) и currency (строку)». Контракт теперь существует. Но контракт, который никто не проверяет, — лишь пожелание. Сэм владеет сервисом pricing, и в прошлом спринте он переименовал amount_cents в unit_amount — чистый рефакторинг, все собственные тесты pricing зелёные, выкатил в пятницу. В понедельник checkout-web рисовал NaN на каждой странице товара, потому что поле, которое он парсит, тихо исчезло. Pact описывал ровно ту поломку, что выкатилась, и ничто не переиграло его против реального сервиса Сэма до деплоя. Запись так и не переиграли. Это переигрывание — отправка каждого записанного запроса на реально работающий провайдер и проверка того, что ответ всё ещё соблюдает то, что читает консьюмер, — и есть верификация провайдера (provider verification), а место, которое хранит pact’ы и результаты верификации, чтобы двум командам не пришлось координироваться вручную, — это брокер (broker).
Верификация — это переигрывание, а не перечитывание
Pact-файл — это список конкретных взаимодействий: запрос, ожидаемый ответ и provider state, который каждое из них предполагает. Верификация провайдера механична: верификатор Pact берёт этот список и для каждого взаимодействия отправляет записанный запрос на реальный, работающий провайдер и сравнивает фактический ответ с ожидаемым. Со стороны провайдера никакого мока нет — запрос попадает в подлинную маршрутизацию, контроллеры, сериализаторы и (при настроенном состоянии) подлинный слой данных. Если pact checkout-web говорит, что GET /prices/42 должен вернуть тело, содержащее amount_cents, верификатор шлёт ровно этот запрос на поднятый сервис pricing и осматривает, что вернётся.
Сравнение намеренно асимметрично, и именно это свойство делает гейт стабильным. Верификатор проверяет, что фактический ответ содержит как минимум те данные, что описал консьюмер, — минимальный ожидаемый ответ. Сэм может добавить десять новых полей в /prices/42, и верификация останется зелёной, потому что консьюмер никогда не утверждал их отсутствие. Но если он переименует amount_cents, поле, которое читает консьюмер, исчезнет, фактический ответ перестанет содержать минимальную ожидаемую форму, и верификация упадёт до деплоя. Контракт срабатывает ровно на том изменении, что сломало бы реального консьюмера, и молчит обо всём остальном — та же точность, что давала consumer-driven запись, теперь обеспеченная со стороны провайдера.
Provider states — тонкая трудная часть
Взаимодействие вроде «GET /prices/42 возвращает 200 с телом цены» выполнимо, только если цена 42 действительно существует в провайдере в момент переигрывания запроса. Консьюмер записал это предусловие как provider state (состояние провайдера) — строку в клаузе given, например "a price with id 42 exists", Pact-эквивалент шага Given из Cucumber: привести систему в известное состояние перед запуском взаимодействия. Верификация — момент, когда эту строку приходится превратить в реальные данные.
Поэтому провайдер должен зарегистрировать обработчики состояний (state handlers): код, который для каждого именованного состояния настраивает данные, нужные взаимодействию. Перед переигрыванием каждого взаимодействия верификатор вызывает соответствующий обработчик (часто через POST { "consumer": "...", "state": "..." } на тестовый эндпойнт смены состояния), обработчик вставляет цену 42 в хранилище данных провайдера, затем отправляется запрос. Два правила делают это рабочим и легко нарушаются. Во-первых, каждое взаимодействие верифицируется в изоляции — никакое состояние не переносится из предыдущего, так что каждый обработчик должен устанавливать своё полное предусловие с нуля и в идеале убирать за собой после. Во-вторых, обработчик настраивает данные, а не ответ: он делает взаимодействие выполнимым, но ответ по-прежнему порождает реальный код провайдера, в чём весь смысл. Здесь верификация тихо дорожает — каждая отдельная строка given, что написали консьюмеры, становится обработчиком состояния, который команда провайдера обязана реализовать и поддерживать, а отсутствующий или неверный обработчик валит верификацию не потому, что контракт сломан, а потому что предусловие так и не было выполнено.
Почему это работает
Почему бы просто не направить верификацию на общую staging-базу, где цена 42 уже есть? Потому что тогда тест перестаёт быть детерминированным и изолированным: вернёт ли GET /prices/42 200, зависит от того, какие строки случайно существуют в этот день, чужой джоб очистки может удалить цену 42 между прогонами, а взаимодействие, ожидающее 404, не может сосуществовать с ожидающим 200 против одних и тех же фиксированных данных. Provider states переносят предусловие в контракт и в принадлежащий провайдеру код настройки, так что каждое взаимодействие несёт собственный мир и переигрываемо в изоляции на свежезасеянном провайдере. Именно это позволяет верификации прогоняться в собственном CI провайдера, против его собственных эфемерных данных, без координации с окружением консьюмера.
Брокер — система записи между двумя пайплайнами
Верификации нужны два артефакта, чтобы встретиться: pact консьюмера и результат «прошло/упало» провайдера. Наивный способ их свести — подключить CI консьюмера так, чтобы он напрямую запускал сборку провайдера, — но это снова связывает те два пайплайна, ради развязки которых ты и пришёл к контрактному тестированию. Pact Broker (open-source) / PactFlow (хостинг) — обменник, разрывающий эту связь. Консьюмер публикует свой pact в брокер, помеченный версией и веткой, и уходит. Независимо CI провайдера тянет нужные pact’ы из брокера, прогоняет верификацию и публикует результаты обратно. Ни один пайплайн не вызывает другой; брокер — общая, асинхронная система записи, держащая каждый pact и каждый результат верификации.
Брокер также может толкать (push). Webhook срабатывает на событиях брокера — важнее всего contract_requiring_verification_published (который пришёл на смену contract_content_changed в брокере 2.82.0) — чтобы запустить сборку верификации провайдера в момент, когда приходит новый или изменённый pact, передавая шаблонные параметры вроде ${pactbroker.pactUrl} и ${pactbroker.providerVersionNumber}, так что сборка провайдера точно знает, что верифицировать. Итог всего этого — матрица результатов верификации (verification results matrix): сетка того, какие версии консьюмеров были верифицированы против каких версий провайдеров. Эту матрицу запрашивает инструмент can-i-deploy перед релизом — «верифицирована ли версия pricing, которую я собираюсь выкатить, против версии checkout-web, уже работающей в проде?» — так что каждый pacticipant (слово Pact для приложения в контракте) может деплоиться по собственному расписанию, под гейтом из записанных результатов, а не из человека, сверяющегося с другой командой.
| Аспект | Без брокера: пайплайны связаны напрямую | Брокер как система записи |
|---|---|---|
| Передача pact | Закоммичен в репо провайдера / передан вручную | Опубликован в брокер, помечен версией + веткой |
| Кто запускает верификацию | CI консьюмера вызывает сборку провайдера напрямую | Webhook брокера на contract-requiring-verification |
| Связанность пайплайнов | Тесная — сборка консьюмера блокируется на провайдере | Асинхронная — ни один пайплайн не вызывает другой |
| Решение о деплое | Вручную «зелёная ли сборка другой команды?» | can-i-deploy запрашивает матрицу результатов |
| Независимый деплой | Нет — оба должны релизиться вместе | Да — каждый pacticipant по своему расписанию |
Упорядочьте сквозной поток от нового pact консьюмера до гейтированного деплоя провайдера:
- 1 CI консьюмера публикует pact в брокер, помеченный его версией и веткой
- 2 Брокер шлёт webhook contract_requiring_verification_published в CI провайдера
- 3 Провайдер поднимается, и для каждого взаимодействия запускает обработчик состояния, чтобы настроить данные, предполагаемые его `given`
- 4 Верификатор переигрывает каждый записанный запрос и проверяет, что ответ содержит минимальную ожидаемую форму
- 5 Результаты верификации публикуются обратно в брокер, заполняя матрицу
- 6 Перед релизом can-i-deploy запрашивает матрицу, чтобы подтвердить: эта версия провайдера верифицирована против консьюмера из прода
Во время верификации провайдер добавляет в GET /prices/42 три новых поля, которые pact консьюмера никогда не упоминает. Что произойдёт и почему?
У взаимодействия `given` — 'a price with id 42 exists', но команда провайдера так и не написала обработчик для этого состояния. Каков результат и его правильное прочтение?
Как провайдер должен подготовить данные, чтобы каждое записанное взаимодействие было выполнимо во время верификации?
- 01Объясните полный механизм верификации провайдера, включая сравнение по минимальному ответу и роль provider states.
- 02Для чего нужен брокер и как webhooks плюс can-i-deploy позволяют двум командам деплоиться независимо?
Консьюмер опубликовал pact, но контракт, который никто не переигрывает, — лишь пожелание: переименование, вернувшее NaN, выкатилось, потому что ничто не переиграло запись против реального провайдера. Верификация провайдера — это и есть переигрывание: верификатор шлёт каждый записанный запрос на подлинный работающий провайдер и проверяет, что фактический ответ содержит как минимум минимальную ожидаемую форму, так что гейт остаётся зелёным, когда провайдер добавляет поля, которые никто не читает, и срабатывает лишь когда поле, которое консьюмер действительно парсит, переименовано, удалено или сменило тип. Тонкая трудная часть — provider states: каждое взаимодействие объявляет своё предусловие как строку given, и провайдер должен зарегистрировать обработчики на каждое состояние, засевающие ровно те данные — запускаемые перед каждым взаимодействием, в изоляции, настраивающие данные, а не ответ, — потому что направление верификации на общие staging-данные разрушает детерминизм, который контракт призван гарантировать. Связывает всё брокер (Pact Broker или PactFlow), асинхронная система записи, держащая каждый pact и каждый результат верификации, чтобы ни одному CI-пайплайну не пришлось вызывать другой; webhooks вроде contract_requiring_verification_published запускают сборку верификации провайдера, когда pact меняется, результаты накапливаются в матрицу верификации, а can-i-deploy запрашивает эту матрицу, чтобы каждый pacticipant мог деплоиться независимо — под гейтом из записанного доказательства, а не из двух команд, координирующихся вручную. Дальше: как контракты безопасно эволюционируют, пока обе стороны меняются со временем.