加入 Hanzo 测试,任意付费方案立减 50%

路由器就是注册表

Hanzo Cloud 没有签入的 OpenAPI 文件。活的路由表才是源头,以三种方式投影,任一投影漂移,双射测试就让构建失败。963 条路径上的 1,362 个操作,在请求时按进程生成。

本页内容

Hanzo Cloud 仓库里没有 openapi.yaml。没有第二份列出端点的注册表,没有注解文件,也没有 CI 里把规范写到磁盘的生成步骤。想知道 API 是什么,就问一个正在运行的进程,它读自己的路由器来回答。

这篇文章讲的是,为什么最后只有这种安排站得住,以及其中不工作的部分。不工作的那一半,反而更有用。

一张表,三种投影

serve.go 组成一张路由表。然后三样东西读它,都在 MountAll 之后,于是各自看到完整的表:

  • /zap 在 ZAP 传输上 重放 /v1 的处理器。
  • 控制台 渲染 它们。
  • GET /v1/openapi.json 描述 它们。

三者都不持有任何副本。这就是全部设计约束,其余都由此而来。自己持有端点清单的投影会成为第二真相源,而第二真相源会漂移。不是有时,是永远,按有人忘记的频率排期。

描述投影是人们会问的那个,所以:openapi.Live(app) 调用 app.Fiber().GetRoutes(true),openapi/ 里的其他函数都是返回的 []Route 的纯函数。文档不是由路由器建成的。文档 就是 路由器,只是另一种形状。

为什么读活的路由器才是唯一的完整来源

显而易见的替代是扫描源码。它行不通,理由值得说准,因为这正是很多 API 工具悄悄少报的原因。

POST /v1/kms/auth/login 是这样注册的:

Group("/v1/kms/auth").Post("/login")

没有 grep 能找到那条路径。字符串 /v1/kms/auth/login 不在代码库里。它只存在于组装后的路由器中,在运行时,在框架把组前缀和叶子拼起来之后。

然后事情变得更糟,而这种方式其实是对的。路由集合是部署配置的函数:cfg.Enabled,再加上 KMS 的 if kc != nil 这类内部闸门。不挂载管理功能的部署就不提供管理路由。所以那个部署的诚实规范不会宣传它们。

所以文档按进程、在请求时生成,而不是在 CI 里构建一次。CI 里构建的规范描述的是一个假想的最大部署,未必是你正在交谈的那个。从活路由器读出的规范描述的是回答你的那个进程。

让这件事为真的测试

规范不能漂移的架构是一项主张,主张需要一道门。我们的门是双射,在 cmd/cloud/openapi_test.go:

func TestSpecDoesNotDriftFromLiveRouter(t *testing.T) {

它挂载 apps.Wire() 里的每个子系统,生成文档,并双向断言映射:每条活路由都作为操作出现,每个操作都有活路由支撑。任一方向都不是子集。

今天跑一遍:

bijection holds over 1362 operations / 963 paths / 138 products

这是仓库里唯一一个失败就意味着已发布文档在说谎的测试。别的东西坏了,是产品错了;这个坏了,是契约错了,更糟,因为集成方已经照着它建过了。

计数移动不是问题。每加一个子系统它就动。要紧的数是两边的差,那个数是 0,否则构建失败。

产品轴是机械的

/v1/ 之后的第一段路径就是产品。openapi.Product 读它,并用它给每个操作打标签,于是 CLI 可以构造 hanzo <product> <resource> <verb>,而不需要谁去判断一条命令该住在哪里。

这故意不是子系统的名字。clients/billing 也提供 /v1/finance/*,调用方看到的是 URL,所以 URL 赢。若按内部包给这条轴命名,CLI 就会变成一件有意见、得有人维护的策展物,而不是投影。

路由器不能告诉你的事

这就是限度,我们不打算绕过它。

方法、路径、路径参数和产品可以从路由器导出。请求与响应的模式、查询和头参数、状态码以及认证则 不能。路由器持有一个 func(*zip.Ctx) error。请求类型是处理器体内的局部变量:

var req secretPutRequest
json.Unmarshal(ctx.Body(), &req)

Go 无法从函数值反射进它的函数体。这里没有巧妙的修法,在生成器里尝试就意味着猜测,规范就是这样得到自信而错误的条目。

通往模式的唯一路径是带类型的操作:

zip.Get[In, Out](...)

In 和 Out 在注册上,所以不必读任何人的心思就能得到模式。也因为它们在注册上,同一条目还产出一个 MCP 工具。zip/openapi.go 和 zip/mcp.go 是同一份注册表的两个读者。那是第四种投影,它到来时并没有第四套机制。

迁移按构造是增量的:GetRoutes() 是 app.ops 的超集,所以把处理器改成带类型的操作会加上模式,而不改动它上游的任何东西。

不能加类型的路由,以及原因

clients/integrations 已经转换到线路允许的地步:41 条里有 20 条是带类型的操作。另外 21 条仍是原始处理器,每一条都列在 ops_projection_test.go 的 rawRoutes 映射里,并写明它不能移动的原因:

  • 13 个浏览器重定向。 OAuth 回调和每一段链接都以 302 应答。带类型的操作返回一个值,而不是 Location 头和 300 段的状态。
  • 6 个 webhook。 Slack 与 GitHub 的 HMAC、Discord 的 Ed25519、Teams Bot Framework 的 JWT、Telegram 的秘密令牌头。每一个都在 原始请求字节 上认证签名。带类型的操作收到解码后的 In,永远看不到计算签名所用的字节,所以给它们加类型会破坏认证。
  • 2 个 202 Accepted 的创建者。 cloud.Created 给我们一条 201 的接缝。没有 202 的接缝。两条路由需要一条,所以两条路由等待。

我们认为那份清单是整个系统里最有价值的制品。它是一份枚举的、被测试钉住的清单,精确标出抽象在哪里停下,每一条都有评审可以争辩的理由。它防止的失败是:一条路由悄悄滑回原始处理器,同时从文档、MCP 工具集和 CLI 里消失,看不见,因为没有任何东西在断言它曾经在那里。

架构不是那些能够推广的部分。它是能够推广的部分,再加上对不能推广的部分一份诚实、被强制执行的交代。

这买到什么

对接 Hanzo Cloud 的调用方读到的文档,是回答他们请求的那个进程、在他们真正交谈的部署里、几毫秒前产出的。经 MCP 调用的智能体到达同一个处理器,带同样的租户检查,和 REST 调用方一样。一次匿名的 /mcp 调用得到 REST 给出的同一个 403,因为那是同一条代码路径,并且有测试把它钉住。

这一切都不需要谁记得去重新生成任何东西。

一般形式是:若两件制品必须一致,就不要从一件生成另一件然后指望。让其中一件成为另一件的视图,并写下它们不一致时会失败的测试。

评论

使用 Hanzo ID 登录后即可评论。

正在加载评论…

继续阅读