Pact:把跨服务兼容性变成可计算的发布证据
支付页刚上线,商品服务也通过了自己的单元测试和接口冒烟,但结算请求开始成批失败。消费者把 stock 当作数字比较,提供者为了统一序列化把它改成了字符串;OpenAPI 文件仍然能生成,Mock Server 也照旧返回旧 fixture。两个团队都说“我的测试是绿的”,真正发生过组合验证的却只有生产流量。
Pact 解决的不是“再造一个 Mock”,而是让消费者用正在运行的客户端代码声明自己真正依赖的请求和响应,再让提供者对这些依赖逐条给出验证结果。生成一份 JSON 文件只是链路的起点。只有契约进入 Broker、提供者验证结果回写、应用版本与分支环境被准确记录、部署前查询兼容矩阵,契约测试才会成为发布控制,而不是仓库里一批逐渐过期的快照。
先分清消费者、提供者和契约证据
消费者是发起调用并解释响应的一方,提供者是接收请求并返回结果的一方。消费者测试启动 Pact Mock Server,把预期交互注册进去,然后让真实 API client 向这个 Mock Server 发请求。Pact 同时检查客户端实际发出的请求和测试声明是否一致;测试结束后,它把成功发生的交互写入 pact file。
提供者验证走相反方向。Verifier 读取 pact file,为每个 provider state 准备数据,把契约中的请求重放到真实启动的提供者进程,再按 matching rules 比较真实响应。它不是启动一个“模拟消费者”去跑业务流程,而是把已经被消费者代码证明需要的交互逐条重放。因此两边验证的证据不同:
消费者测试证明真实客户端会按契约构造请求,并能处理契约示例响应。提供者验证证明某个提供者版本能满足该契约中的最小响应约束。Broker 保存消费者版本、提供者版本、契约内容和验证结果之间的关系。
can-i-deploy 查询这些关系,判断待发布版本能否与目标环境中的版本共同运行。
Pact 不会证明业务结果正确,也不会替代提供者自己的功能测试、安全测试、性能测试和端到端关键链路。消费者若从未覆盖“库存为零”,契约不会凭空发现该分支;provider state 若伪造了不可能存在的数据,验证通过也不代表生产数据模型成立。它擅长回答的是更窄、也更适合自动化的问题:接口双方对一条具体交互的结构、值类型和必要语义是否仍然兼容。
在本机生成第一份 pact file
下面使用 Pact JS 展示完整链路,因为 Node.js 内置测试运行器和 fetch 可以把样例压在少量文件内。Pact 在 JVM、.NET、Go、Python、Ruby、Rust 等语言中也有实现,但 DSL、运行时依赖、V4/插件支持和 provider state API 并不完全相同。项目应选择与消费者语言一致的实现,并在 实现矩阵 中确认 Pact Specification 兼容级别;不要把 Pact JS 的参数名机械翻译到 Pact JVM。
开发机需要一个受支持的 Node.js 发行线、npm、可绑定本地回环端口的权限,以及能够下载依赖的代理配置。创建可销毁目录并把依赖写进 lockfile:
mkdir pact-catalog-lab
cd pact-catalog-lab
npm init -y
npm install --save-dev @pact-foundation/pact
mkdir pacts不要在团队脚本里永久使用“安装最新版本”这一命令。首次验证后提交 package-lock.json,由依赖升级任务审查 Pact JS、其共享 Rust core/FFI、Node.js 和目标平台的兼容关系。企业代理若做 TLS 解密,应把受信 CA 注入 Node.js 和容器信任库;关闭证书校验会把依赖下载和 Broker 通信暴露给中间人。
创建 catalog-client.js。客户端只负责 HTTP 交互,让 Pact 测试覆盖真实的 URL、header、状态码和反序列化路径:
async function getProduct(baseUrl, id) {
const response = await fetch(`${baseUrl}/products/${id}`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`catalog request failed: ${response.status}`);
}
const product = await response.json();
if (typeof product.stock !== "number") {
throw new TypeError("catalog stock must be a number");
}
return product;
}
module.exports = { getProduct };创建 catalog-consumer.pact.test.js:
const assert = require("node:assert/strict");
const path = require("node:path");
const { test } = require("node:test");
const { PactV3, MatchersV3 } = require("@pact-foundation/pact");
const { getProduct } = require("./catalog-client");
const { integer, like, regex } = MatchersV3;
test("结算服务读取仍有库存的商品", async () => {
const pact = new PactV3({
consumer: "Checkout Web",
provider: "Catalog API",
dir: path.resolve(process.cwd(), "pacts"),
});
pact
.given("product 42 exists with stock")
.uponReceiving("a request for product 42")
.withRequest({
method: "GET",
path: "/products/42",
headers: { Accept: "application/json" },
})
.willRespondWith({
status: 200,
headers: { "Content-Type": regex("application/json.*", "application/json") },
body: {
id: integer(42),
name: like("Mechanical Keyboard"),
stock: integer(8),
},
});
await pact.executeTest(async (mockServer) => {
const product = await getProduct(mockServer.url, 42);
assert.equal(product.id, 42);
assert.equal(product.stock, 8);
});
});执行后检查测试和产物。只有实际保留命令退出码、pact file 和 verifier 输出,才能把这组本地实验记为通过;运行后应观察到下面的结果:
node --test catalog-consumer.pact.test.js
node -e "const fs=require('node:fs'); const files=fs.readdirSync('./pacts').filter(f=>f.endsWith('.json')); if(files.length!==1) throw new Error('expected exactly one pact file'); const p=require('./pacts/'+files[0]); console.log(p.consumer, p.provider, p.interactions.length)"测试应通过,pacts/ 中应出现一份 JSON 契约,交互数量为 1。若客户端漏发 Accept、路径写成 /product/42,Mock Server 会报告 request mismatch;若客户端没有完成已声明的请求,测试结束时会报告未满足交互。这些失败先证明“消费者代码与消费者声明不一致”,尚未触及真实提供者。
matcher 决定兼容边界。integer(8) 表示真实值可以是任意整数,示例值 8 只是供 Mock Server 返回;直接写 stock: 8 则要求精确等于 8。对象响应通常允许提供者返回额外字段,因此消费者只声明自己读取的最小字段即可。把整份生产响应复制成精确 JSON,会让无害的新字段和动态值制造脆弱失败;把所有字段都写成 like(),又可能放过消费者真正依赖的格式。每个 matcher 都应能回答“哪个消费者分支会因这种变化而坏掉”。
pact file 是生成物,不是人工维护的接口文档
pact file 通常包含 consumer/provider 标识、interactions、provider states、请求与预期响应、matching rules、generators 和实现元数据。V3/V4 还支持更丰富的消息与插件交互。示例值让测试可以运行,matching rules 才描述允许的值空间;generator 可在验证阶段根据 provider state 注入动态值。
不要手工改 pact file 来“修复”失败。人工修改会切断契约与消费者测试的因果关系:文件说消费者需要 A,真实客户端可能实际发送 B。正确动作是修改消费者测试或客户端,重新生成,再由提供者验证。仓库可以保留本地调试 pact,但 Broker 中的正式契约必须由对应 commit 的 CI 生成和发布。
还要避免把秘密写入示例。Authorization token、Cookie、真实用户、邮箱、订单号和业务 payload 都会进入 pact file、Broker 数据库、构建日志与失败报告。认证头只声明结构性占位值;提供者验证需要短期 token 时,在 verifier 的 request filter 中运行时注入。request filter 会改变重放请求,只能用于时间敏感凭据等无法固化的字段,不能借它偷偷修正消费者生成的错误请求。
用真实提供者制造一次稳定失败
创建 catalog-provider.js,用一个进程内状态模拟 provider state。示例故意支持通过环境变量把 stock 改成字符串,以便稳定复现契约破坏:
const http = require("node:http");
let productExists = false;
function setProductExists(value) {
productExists = value;
}
function createServer() {
return http.createServer((request, response) => {
if (request.method === "GET" && request.url === "/products/42" && productExists) {
const stock = process.env.BREAK_STOCK_TYPE === "1" ? "8" : 8;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({
id: 42,
name: "Mechanical Keyboard",
stock,
warehouse: "lab-a",
}));
return;
}
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "not_found" }));
});
}
module.exports = { createServer, setProductExists };创建 catalog-provider.pact.test.js:
const path = require("node:path");
const fs = require("node:fs");
const { test } = require("node:test");
const { Verifier } = require("@pact-foundation/pact");
const { createServer, setProductExists } = require("./catalog-provider");
test("Catalog API 满足 Checkout Web 契约", async () => {
const server = createServer();
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
const pactDir = path.resolve("pacts");
const pactFiles = fs.readdirSync(pactDir).filter((name) => name.endsWith(".json"));
if (pactFiles.length !== 1) {
throw new Error(`expected one pact file, found ${pactFiles.length}`);
}
try {
await new Verifier({
provider: "Catalog API",
providerBaseUrl: `http://127.0.0.1:${port}`,
pactUrls: [path.join(pactDir, pactFiles[0])],
stateHandlers: {
"product 42 exists with stock": {
setup: async () => {
setProductExists(true);
return "product 42 prepared";
},
teardown: async () => {
setProductExists(false);
},
},
},
}).verifyProvider();
} finally {
await new Promise((resolve) => server.close(resolve));
}
});先运行正向路径,再打开故障开关:
node --test catalog-provider.pact.test.js
BREAK_STOCK_TYPE=1 node --test catalog-provider.pact.test.js第一条命令应通过;提供者额外返回 warehouse 不会破坏只读取三个字段的消费者。第二条命令应以非零退出,mismatch 应指出 $.stock 期望整数而实际是字符串。Windows PowerShell 使用:
$env:BREAK_STOCK_TYPE = "1"
node --test catalog-provider.pact.test.js
Remove-Item Env:BREAK_STOCK_TYPE若反例仍然通过,先检查消费者是否用了 like("8") 或把 stock 从契约删掉;若正例报 provider state 不存在,检查状态名是否逐字匹配。provider state 是验证前置数据的声明,不是测试顺序。Pact JS 的 state handler 可以提供 setup 与 teardown:前者在请求重放前建立状态,后者在该请求发送后回收状态;即使 verifier 随后报告响应 mismatch,也必须让清理可重入。每个 interaction 都应独立准备状态,不能依赖上一条 interaction 创建的数据;Verifier 可以并行或改变执行顺序,Broker 触发的验证也可能只重放一条契约。
真实项目中的 state handler 应调用测试数据工厂、数据库事务或受控管理端点,建立最小确定状态,并在 interaction 后恢复。禁止用生产账号连接共享数据库,也不要让多个 CI job 共用固定主键 42。可用 ${BUILD_ID}:${INTERACTION_KEY} 形成隔离命名空间,给临时记录设置 owner 和 TTL,作业退出时按 owner 清理。清理失败必须可观测,不能让过期 fixture 慢慢改变后续验证结果。
Broker 把文件交换升级成版本关系
用邮件、对象存储或 Git 传 pact file,无法可靠回答“哪个消费者 commit 生成了它”“哪个提供者 commit 验证过”“生产正在运行哪些版本”。Pact Broker 把这些关系保存为可查询矩阵。Pact JS 与 Pact Broker 都是开源项目,具体发行物和传递依赖仍要进入组织的许可证清单;PactFlow 是在 Broker 兼容能力上提供权限、审计、secret、商业支持和双向契约测试等能力的商业托管产品。自建 Broker 不要求购买 PactFlow,但需要团队自己承担数据库、备份、TLS、认证、升级、授权粒度和容量治理,也不能把 PactFlow 文档中的 token、团队角色或商业能力当作开源 Broker 自带能力。
本地联调可用 Broker 和 PostgreSQL Compose。镜像标签由团队批准基线注入,避免 latest 悄悄改变行为:
services:
postgres:
image: "postgres:${POSTGRES_IMAGE_TAG:?set POSTGRES_IMAGE_TAG}"
environment:
POSTGRES_DB: pact_broker
POSTGRES_USER: pact_broker
POSTGRES_PASSWORD: ${PACT_DB_PASSWORD:?set PACT_DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pact_broker -d pact_broker"]
interval: 2s
timeout: 3s
retries: 30
volumes:
- pact-db:/var/lib/postgresql/data
broker:
image: "pactfoundation/pact-broker:${PACT_BROKER_IMAGE_TAG:?set PACT_BROKER_IMAGE_TAG}"
depends_on:
postgres:
condition: service_healthy
environment:
PACT_BROKER_DATABASE_URL: postgresql://pact_broker:${PACT_DB_PASSWORD}@postgres/pact_broker
PACT_BROKER_BASE_URL: http://localhost:9292
PACT_BROKER_BASIC_AUTH_USERNAME: ${PACT_BROKER_USERNAME:?set PACT_BROKER_USERNAME}
PACT_BROKER_BASIC_AUTH_PASSWORD: ${PACT_BROKER_PASSWORD:?set PACT_BROKER_PASSWORD}
PACT_BROKER_BASIC_AUTH_READ_ONLY_USERNAME: ${PACT_BROKER_READ_USERNAME:?set PACT_BROKER_READ_USERNAME}
PACT_BROKER_BASIC_AUTH_READ_ONLY_PASSWORD: ${PACT_BROKER_READ_PASSWORD:?set PACT_BROKER_READ_PASSWORD}
PACT_BROKER_LOG_LEVEL: INFO
ports:
- "127.0.0.1:9292:9292"
volumes:
pact-db:为实验生成随机密码,启动后从宿主机检查 heartbeat:
export POSTGRES_IMAGE_TAG="<APPROVED_POSTGRES_TAG>"
export PACT_BROKER_IMAGE_TAG="<APPROVED_BROKER_TAG>"
export PACT_DB_PASSWORD="$(openssl rand -hex 18)"
export PACT_BROKER_USERNAME="lab-writer"
export PACT_BROKER_PASSWORD="$(openssl rand -hex 18)"
export PACT_BROKER_READ_USERNAME="lab-reader"
export PACT_BROKER_READ_PASSWORD="$(openssl rand -hex 18)"
docker compose up -d
curl --fail --user "$PACT_BROKER_READ_USERNAME:$PACT_BROKER_READ_PASSWORD" \
http://127.0.0.1:9292/diagnostic/status/heartbeat端口只绑定回环地址,避免开发机 Broker 暴露到局域网。生产自建必须使用 PostgreSQL;官方镜像允许 SQLite 做调查或 spike,但 SQLite 不支持生产并发模型。数据库密码不应直接拼入可被进程和诊断工具读取的 Compose 环境;生产应由 secret manager 或平台 secret 注入,并评估 URL 编码、证书验证、数据库最小权限与轮换方式。Compose 示例只是本地验证入口,不是生产高可用方案。
发布时让一个应用版本只对应一个 commit
消费者 CI 通过后发布 pact。当前官方为新项目优先提供统一 Rust CLI 的 pact broker ... 入口,同时保留独立 Rust pact-broker-cli 作为现有流水线的替代制品,Ruby pact_broker-client 则提供传统的 pact-broker 命令。三种入口的安装包、命令层级、参数覆盖范围和发布节奏不同,不是机械替换可执行文件名就能完成迁移。团队应锁定一种制品及版本,在升级时用 --help 和一条实验 Broker 流水线核对所需子命令;下面使用独立 Rust CLI,可通过固定版本的 Cargo 包、官方容器或固定 tag 的 GitHub Action 安装:
export PACT_BROKER_BASE_URL="https://pact.example.internal"
export PACT_BROKER_USERNAME="ci-consumer-writer"
export PACT_BROKER_PASSWORD="<INJECTED_BY_SECRET_STORE>"
export GIT_SHA="$(git rev-parse HEAD)"
export GIT_BRANCH="$(git branch --show-current)"
pact-broker-cli publish ./pacts \
--consumer-app-version "$GIT_SHA" \
--branch "$GIT_BRANCH" \
--validate --strictconsumer-app-version 使用完整 Git commit 或能一一映射到 commit 的不可变版本。把每次构建都发布成 1.0.0-SNAPSHOT 会覆盖身份关系,使验证结果可能被错误继承,can-i-deploy 也无法区分两个不同实现。Broker 会按契约内容去重;多个应用版本生成相同内容时,可以复用已有验证关系,但前提是应用版本本身仍然唯一。
branch 表示产生 pact 的源码分支,应在发布 pact 或验证结果时写入。environment 表示真实部署或发布位置,不是 publish 的参数;它由成功部署后的 record-deployment 或客户端发布后的 record-release 建模。tag 仍受支持,也可承载自定义元数据,但“发布时用 tag 模拟分支、部署时再用 tag 模拟环境”的旧工作流已经被一等公民的 branch/environment 取代。即使团队只有 main,也要发布 branch,并为 pacticipant 配置 main branch;缺少这些元数据时,Broker 无法正确计算 pending、WIP 和分支兼容关系。
同一个 consumer/provider/version 若从多个并行 job 发布不同契约,最后写入可能丢失交互。优先让单一聚合 job 生成完整 pact;确需分片时使用 CLI 明确支持的 merge 能力,并在发布后校验 interaction 总数和内容摘要。merge 不能解决两个 job 对同一 interaction 给出矛盾定义,这类冲突应在消费者测试层失败。
提供者验证要覆盖主线和正在运行的消费者
本地文件验证适合开发时快速反馈,正式 provider CI 应从 Broker 拉取与当前发布决策相关的契约,并把结果回写。Pact JS 的典型配置如下:
const { Verifier } = require("@pact-foundation/pact");
async function verifyProvider(providerBaseUrl) {
const isCI = process.env.CI === "true";
if (isCI && (!process.env.GIT_SHA || !process.env.GIT_BRANCH)) {
throw new Error("CI verification requires immutable version and branch");
}
return new Verifier({
provider: "Catalog API",
providerBaseUrl,
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerUsername: process.env.PACT_BROKER_USERNAME,
pactBrokerPassword: process.env.PACT_BROKER_PASSWORD,
consumerVersionSelectors: [
{ mainBranch: true },
{ deployedOrReleased: true },
{ matchingBranch: true },
],
enablePending: true,
includeWipPactsSince: process.env.PACT_WIP_CUTOFF,
stateHandlers: buildStateHandlers(),
publishVerificationResult: isCI,
providerVersion: process.env.GIT_SHA,
providerVersionBranch: process.env.GIT_BRANCH,
}).verifyProvider();
}selector 决定“哪些契约参与这次判断”,遗漏比失败更危险:
mainBranch 保护消费者主线的最新契约。deployedOrReleased 保护仍部署或仍受支持的消费者版本,尤其适合无法强制升级的移动端和外部客户端。matchingBranch 让同名功能分支协同验证,但不能替代主线与已部署版本。
只拉一个手写 latest,会漏掉其他消费者和仍在生产的旧版本。
提供者验证应启动当前 commit 的真实应用边界,把外部下游替换为稳定 stub,但不能把被验证的 controller/serializer 本身 Mock 掉。数据库 schema、JSON serializer、认证 middleware、错误映射等会影响线协议的组件必须在进程内真实运行。验证结果只在 CI 发布;开发机带着任意未提交代码回写 Broker,会污染可部署矩阵。
Broker 凭据由 CI secret 注入。pact-broker-cli 同时暴露 Basic Auth 和 bearer token 参数,但认证能力取决于服务端:开源 Broker 只支持 Basic Auth,PACT_BROKER_TOKEN 用于 PactFlow。不要把 PactFlow token 参数照搬到开源 Broker,也不要把写凭据发给每位开发者。开发者通常只需只读权限,消费者 CI 负责发布契约,提供者 CI 负责发布验证,部署流水线负责记录环境。自建开源 Broker 的授权粒度和企业身份能力有限,强隔离团队需要在反向代理、独立实例或托管平台之间做取舍。
pending 与 WIP 保护的是发布节奏,不是掩盖失败
如果消费者在功能分支发布了一个新增交互,而提供者主线尚未实现,直接让这份失败契约阻断提供者所有发布,会产生反向依赖:提供者连与该功能无关的补丁也发不出去。pending pacts 根据“当前 provider branch 是否曾成功验证过这份契约”动态计算状态。启用后,尚未被该分支支持的 pending 失败会被报告和发布,但不会使 provider build 失败;一旦该分支成功支持过,后续破坏就不再 pending,失败会阻断构建。
WIP pacts 解决另一个问题:提供者配置不可能预先知道所有消费者功能分支名称。启用 includeWipPactsSince 后,Broker 会把 selector 没有显式选中、但最新且仍缺少当前 provider branch 成功验证的契约加入验证,并把它们标为 pending。日期是检索边界,不是安全豁免;值过早会让历史噪声和构建负担暴涨,值过晚会漏掉仍在开发的契约。团队应以启用 WIP 的迁移起点设置 <WIP_CUTOFF_DATE>,在配置中保留其决策记录,而不是每次构建自动改成“今天”。
pending/WIP 应启用在“provider 代码发生变化”时运行的常规发布流水线;contract_requiring_verification_published webhook 触发的是一条脱离 provider 发布链的定向验证任务,它本来就要对指定 provider 版本和新契约给出真实成功或失败,不能用 pending 把失败改成绿色。两类 job 应分开配置:常规 provider job 使用 selectors、pending 与 WIP 保护发布节奏,webhook job 接收受控的 pact URL、provider commit 和 branch,只验证指定组合并回写结果。若复用同一配置导致 webhook 失败不返回非零退出,消费者会长期等不到可解释的兼容证据。
pending 绝不意味着消费者可以部署。新消费者版本在目标环境没有兼容验证时,can-i-deploy 仍应拒绝它;pending 只是避免尚未承诺支持的新需求锁死提供者发布。监控必须单独统计 pending 失败年龄和 owner。长期 pending 通常意味着需求已废弃、provider state 无人维护或跨团队交付停滞,不能靠永久放宽 cutoff 让它消失。
can-i-deploy 查询的是矩阵,而不是重新运行测试
Broker 的矩阵由 pact publication 与 provider verification result 连接而成。部署前用不可变版本询问目标环境:
pact-broker-cli can-i-deploy \
--pacticipant "Checkout Web" \
--version "$GIT_SHA" \
--to-environment test命令成功只说明 Broker 已知的版本组合有兼容证据。它不会连接目标集群探测实际镜像,也不会知道一次失败部署仍有旧副本运行。因此部署成功、旧版本已经按部署模型退出后,必须记录真实状态:
pact-broker-cli record-deployment \
--pacticipant "Checkout Web" \
--version "$GIT_SHA" \
--environment testrecord-deployment 用于新部署替换旧部署;移动端、桌面客户端或可同时受支持的库版本更适合 record-release,停止支持时再 record-support-ended。同一环境永久并存多个实例时使用 application instance 建模。滚动发布期间不要在第一个 Pod 就绪时过早记录新版本已经完全替换旧版本;记录点应放在部署已经不可失败、旧版本不再属于该应用实例之后。
一次可审计流水线应保持顺序:消费者发布契约,provider 验证并发布结果,部署前 can-i-deploy,实际部署与业务 smoke 成功,最后 record-deployment。若部署失败,不得记录环境;若记录动作失败,发布任务应告警并补偿,否则下一次 can-i-deploy 会基于过期环境状态计算。不要用“部署阶段 tag”同时代替 branch 和 environment,回滚到较早版本时 tag 的 latest 语义尤其容易给出错误判断。
新接入时可先使用 can-i-deploy 的 dry-run 观察矩阵缺口,但正式启用后必须让非零退出码阻断部署。脚本若在命令后写 || true、只打印 JSON 不检查结果,等于把门禁降级成日志。反向实验可选择一个没有 provider verification 的消费者 commit 发布到实验 Broker,然后执行查询;预期结果是 no 和非零退出。随后运行 provider verification、发布结果并再次查询,预期才变为 yes。
Webhook 让消费者变化主动触发提供者证据
只靠 provider 仓库变更触发验证,会让消费者的新契约一直等待。Broker webhook 可以在契约需要验证时调用 provider CI。现代自建 Broker 优先使用 contract_requiring_verification_published:它针对缺少结果的 provider 主线版本和已部署版本触发验证,比每次 contract_published 都启动构建更少噪声。
contract_requiring_verification_published 的 payload 应传官方模板提供的 ${pactbroker.pactUrl}、${pactbroker.providerVersionNumber} 和 ${pactbroker.providerVersionBranch},让 provider CI 检出并验证指定 provider 版本,而不是总拿当前主线验证。回调端必须验证来源或使用最小权限的随机 secret,限制可触发的 job 和参数,禁止让外部 payload 覆盖脚本路径或任意命令;branch 与 commit 也只能进入预定义的 checkout 参数,并校验版本确实属于允许的仓库。先用 curl 把目标 CI API 调通,再通过 CLI 创建 webhook,并执行 Broker 的 pb:execute 测试动作检查状态码与响应。
开源 Broker 的 webhook Basic Auth 凭据会存入 Broker 数据库,官方调试文档明确提醒它不是加密 secret store。为 webhook 创建只能触发单一流水线的凭据,限制网络出口和目标域名,缩短轮换周期;需要平台内加密 secret、细粒度角色和审计时,评估 PactFlow 或在 Broker 外加受控事件网关。自建 Broker 还应把允许的 HTTP 方法保持为 POST,避免 webhook 被滥用为读取内网信息的 SSRF 通道。
Webhook 在响应状态不属于成功码时会按 Broker 的 retry schedule 重试,CI 接口也可能超时后已经接收请求。官方模板没有保证提供独立事件 ID,接收端应以 pact URL + provider version 形成幂等键,并把 branch 作为校验上下文;不能依赖一个并不存在的通用事件字段。容量规划要观察 webhook 重试状态、provider build 启动量和验证等待时间;一个公共 provider 被几十个 consumer 同时更新时,未经聚合的 webhook 可以制造构建风暴。可按 provider/version 合并短窗口内事件,但不能把不同 pact URL 悄悄丢掉。
CI 中的 Docker、网络和代理问题会伪装成契约失败
Pact 测试可能启动本地 mock server、原生共享库或独立 verifier;Broker 和测试数据库又常运行在容器里。CI 失败先按层定位:
| 现象 | 第一证据 | 判断路径 |
|---|---|---|
| Mock Server 无法启动 | 端口绑定错误、原生库加载日志 | 检查 runner 架构、执行权限、临时目录与 FFI 制品 |
容器内访问 localhost 失败 | 连接拒绝、容器网络命名空间 | localhost 指向当前容器,改用服务名、共享网络或显式 host gateway |
| Broker 返回 401/403 | HTTP 状态与认证挑战 | 区分 OSS Basic Auth 和 PactFlow bearer token,检查读写角色 |
| Broker TLS 握手失败 | CA/hostname 错误 | 注入企业 CA,禁止长期使用 skip verification |
| 契约下载超时 | DNS、代理、NO_PROXY、出口策略 | 为内部 Broker 配置 NO_PROXY,核对代理是否修改证书和 body |
| provider verification 没有契约 | selector 与 Broker 元数据 | 检查 consumer branch、main branch、deployment 记录和 provider 名称 |
| CI 没有 Docker socket | /var/run/docker.sock 不存在或权限拒绝 | 选择带 Docker 的 runner、远程 daemon 或无 Docker verifier,不要盲目挂宿主 socket |
把 Docker socket 挂进测试容器,等价于把宿主 Docker daemon 的高权限交给该进程。Pact 本身不一定需要 socket;Testcontainers 或 Compose 才需要。共享 runner 应使用隔离 VM、rootless/远程受控 daemon 或平台原生 service container,不要为了让一个测试通过就把生产 runner 的 socket 设为全局可写。
镜像和 npm 制品应固定版本与 digest,并通过组织镜像仓库缓存。拉取失败与契约 mismatch 是两个故障域,流水线日志和重试策略要分开;网络错误可以有限重试,确定性 mismatch 重试没有价值。代理环境还要统一 HTTP_PROXY、HTTPS_PROXY 与 NO_PROXY 的大小写和作用范围,确保 Broker、provider service name、回环地址不被错误送到外部代理。
语言实现、开源 Broker和商业平台的选型边界
Pact 是跨语言规范,不是所有库共享同一套实现。许多现代客户端通过 Rust core/FFI 共享匹配和 mock server 能力,Pact JVM 也有自己的纯 JVM 生态;每个语言包装层仍决定测试框架集成、安装方式、provider state、异步消息、插件、日志和平台支持。选型时至少做四个验证:消费者 DSL 能表达所需交互,提供者能在目标测试框架准备状态,目标平台能加载运行时依赖,双方生成和读取相同 Pact Specification 级别。
| 决策 | 适合情形 | 主要代价 |
|---|---|---|
| 本地 pact file + verifier | 单仓原型、单一双方团队 | 没有版本矩阵、环境记录和跨团队发布门禁 |
| 自建开源 Pact Broker | 有平台运维能力、Basic Auth 与社区支持可接受 | PostgreSQL、升级、备份、TLS、授权、审计和 webhook secret 自行治理 |
| PactFlow | 希望托管、token、团队权限、审计、secret、商业支持或双向契约 | 订阅成本、供应商边界、数据驻留和退出计划 |
| 独立 provider verifier CLI | 提供者语言没有成熟库或只需黑盒重放 | state setup、测试框架诊断和请求注入集成更弱 |
费用不能只比较许可证。自建成本包括数据库、对象备份、网络、升级演练、值班、身份接入和故障恢复;托管成本包括席位/用量、合规审批、网络出口与供应商退出。契约数量本身通常不是主要成本,低质量交互、重复 provider build、长期 WIP 和错误环境建模才会持续消耗计算与团队注意力。
退出设计应保留 pact files、应用版本映射、验证结果导出、Broker 配置和 CI 适配层。业务代码不应直接依赖某个平台专属 API;把发布、验证和部署记录封装在仓库脚本中,迁移时才能替换认证或 Broker 地址,而不重写所有服务流水线。
从失败现象回到正确责任人
消费者测试 request mismatch:查看 Mock Server 报告的 method、path、query、header 和 body 差异。责任通常在消费者客户端或测试声明,不要让 provider 团队先改接口。
provider verification body mismatch:先定位 JSON path 和 matcher,再检查 serializer、字段类型、缺失值、Content-Type 与 provider state。若只有共享测试环境失败,核对数据竞争和状态清理,不要立即放宽 matcher。
验证显示 no pacts found:把它视为配置故障而不是成功。检查 provider 名称大小写、consumer selector、branch、main branch 和部署记录;生产 CI 不应长期启用“没有契约也成功”。
pending 失败一直不阻断:检查 provider branch 是否准确发布、该分支是否从未有成功结果,以及 WIP cutoff 是否不断漂移。pending 年龄超过团队交付窗口时,要求 consumer owner 删除废弃分支契约或完成协同,不允许静默积压。
can-i-deploy 意外返回 yes:确认查询使用具体 commit,而不是会变化的 latest;确认所有部署都执行了 record-deployment/record-release;检查是否遗漏 consumer/provider 或错误使用环境 tag。矩阵正确性首先取决于输入事件完整性。
can-i-deploy 长期返回 no:读取缺失组合,区分“验证真实失败”和“结果从未发布”。前者修实现或契约,后者修 provider CI、webhook 或凭据。手工在 Broker 修改结果会破坏审计链。
Webhook 反复触发相同构建:检查 Broker 重试状态码、CI 接口超时和幂等键。先让回调快速确认接收,再异步执行验证;不要通过扩大 webhook retry 无限掩盖下游故障。
清理实验不能留下第二套事实
本地文件实验结束后删除生成物和依赖目录;先确认当前目录确实是实验目录:
pwd
rm -rf pacts node_modules
rm -f catalog-client.js catalog-consumer.pact.test.js
rm -f catalog-provider.js catalog-provider.pact.test.js
rm -f package.json package-lock.json
cd ..
rmdir pact-catalog-labBroker 实验结束前可先导出需要保留的失败证据,再销毁容器和专用 volume。down -v 会删除数据库数据,只能在确认 Compose project 名和 volume 都属于本次实验后执行:
docker compose ps
docker compose down -v --remove-orphans
unset PACT_DB_PASSWORD PACT_BROKER_PASSWORD PACT_BROKER_READ_PASSWORD
unset PACT_BROKER_USERNAME PACT_BROKER_READ_USERNAME
unset POSTGRES_IMAGE_TAG PACT_BROKER_IMAGE_TAG共享 Broker 不能按“测试完了”随意删除契约。删除应用版本会改变矩阵和审计证据,应先按发布模型记录 undeployment 或 support ended,再依据组织保留策略归档。废弃 pacticipant 需要 consumer/provider owner 双方确认、保留部署历史和事故证据;数据库清理任务要先在副本验证,不按名称前缀批量硬删。
把契约测试经营成团队能力
每个 consumer/provider 关系应有双方 owner、主分支、目标环境、provider verification job 和失败响应时限。消费者 owner 负责交互最小且源自真实客户端,提供者 owner 负责状态工厂和验证稳定性,平台 owner 负责 Broker 可用性、身份、备份、升级和矩阵数据完整性。没人负责的契约会先变成 pending 噪声,最后变成所有团队都不敢删除的遗留资产。
治理指标应绑定不变量,而不是追求“契约数量”:
每个生产部署版本都能在 Broker 查到唯一 commit、branch 与 environment 记录。每个已部署或受支持消费者契约都有目标 provider 版本的验证结果。pending/WIP 失败年龄不超过团队协作窗口,且每条都有 owner。
provider verification 的无契约运行数为零;selector 变化经过审查。webhook 重试和队列年龄不会随发布轮次单调增长。测试数据按 build/interaction 隔离,清理后存活对象回到稳定基线。
Broker 备份做过隔离恢复,恢复后矩阵、pact 内容和验证结果可查询。
Broker 升级先在副本数据库回放真实规模的契约和 webhook,验证 API、CLI、客户端库与数据库 migration,再按可回退窗口发布。客户端升级按语言分别验证,不能因为规范都叫 V4 就假设 FFI、插件和 matcher 行为完全相同。生产阈值来自服务数量、发布频率、CI SLO 和数据库容量测量,不照搬示例数字。
Pact 真正产生价值的时刻,不是生成第一份 JSON,而是一次不兼容改动在合并或部署前被精确拒绝,同时无关服务仍能继续发布。消费者声明最小需求,提供者用真实实现给出证据,Broker 保存版本关系,环境记录让矩阵对应现实,can-i-deploy 才能成为架构层的兼容性判断。
发布链路逐项确认
消费者测试经过真实 API client,matcher 只约束会破坏消费者的字段和格式。pact file 由 CI 从唯一 commit 生成,未手改,未包含 token、Cookie、个人数据和生产 payload。consumer/provider 名称稳定,应用版本一一映射 commit,branch 与 main branch 已准确记录。
provider verification 启动当前实现,provider state 独立、可重入、可清理,没有连接生产或共享脏数据。selectors 同时保护主线、已部署或仍受支持版本,no pacts found 不会伪装成成功。pending 和 WIP 已启用并被监控;pending 失败不会阻断无关 provider 发布,也不能绕过 consumer 部署门禁。
Broker 使用受支持数据库、TLS、最小权限凭据、备份恢复、升级与容量监控。Webhook 使用最小权限、幂等接收和受限网络出口,secret 不进入 URL、日志或仓库。部署前检查具体版本,成功后在正确时点记录 deployment/release,失败部署不写入环境状态。
Docker socket、代理、CA、镜像源、端口与 runner 架构有明确基线,基础设施故障和契约 mismatch 分开处理。自建与托管选择包含运维、权限、审计、数据驻留、成本和退出计划,而不只比较功能列表。清理策略保留必要审计证据,测试数据、临时容器和本地凭据可证明已经回收。
