发布管线:一次 publish 的完整旅程
本章讲什么: 作者带着 JWT 把
server.jsonPOST 上来后,服务端到底做了哪几件事、每件事在防什么坑。
1. 两层:HTTP handler 与 service 事务
发布逻辑分两层,职责清晰:
POST /v0/publish
│
▼
┌─ HTTP 层 (publish.go) ─────────────────────────────┐
│ (1) 扒 Bearer token │
│ (2) jwtManager.ValidateToken 验签名+过期 │
│ (3) jwtManager.HasPermission 令牌能发这个名字吗? │ ← 失败 403
│ (4) ValidateServerJSON 结构/schema 合法吗? │ ← 失败 422
│ (5) registry.CreateServer(...) │
└────────────────────────┬───────────────────────────┘
▼
┌─ service 层 (registry_service.go) 一个大事务 ───────┐
│ a validate (包归属反查 npm/OCI…) │
│ b acquire_lock (per-server advisory lock) │
│ c remotes (远程 URL 不与他人冲突) │
│ d version_checks (重复? 超 1 万上限? 当前 latest?)│
│ e unmark_latest │
│ f db_create │
└─────────────────────────────────────────────────────┘
HTTP 层入口 RegisterPublishEndpoint(internal/api/handlers/v0/publish.go:23),service 层入口 CreateServer → createServerInTransaction(internal/service/registry_service.go:94 / :108)。
2. HTTP 层:四道关卡
按顺序读 publish.go:37 起的 handler 闭包:
关卡 1 — Bearer 格式 + 验令牌(publish.go:39-50): 取出 Bearer 后面的 token,jwtManager.ValidateToken 验 Ed25519 签名并强制要求过期声明(jwt.go:129,WithExpirationRequired())。失败 401。
关卡 2 — 权限匹配(publish.go:52-55):
// internal/api/handlers/v0/publish.go:53
if !jwtManager.HasPermission(input.Body.Name, auth.PermissionActionPublish, claims.Permissions) {
return nil, huma.Error403Forbidden(buildPermissionErrorMessage(input.Body.Name, claims.Permissions))
}
这一行就是第 1 章那套所有权的兑现点:令牌里的 io.github.alice/* 能否覆盖你 body 里写的 name。失败时 buildPermissionErrorMessage(publish.go:78)会列出「你有哪些权限、你想发什么」,还对 io.github. 开头的失败贴心提示「也许该把你的 org 成员身份设为 public」。
关卡 3 — schema 校验(publish.go:57-61): ValidateServerJSON(..., ValidationSchemaVersionAndSemantic) 做纯结构性校验(JSON Schema + 语义规则,如版本不能是范围、transport 类型合法等),不联网。失败返回 422,并提示去 /validate 看详情。
关卡 4 — 交给 service(publish.go:64): registry.CreateServer(ctx, &input.Body),进入事务。
一个要点:这里没做「包归属」反查。 那一步在 service 的 validate phase 里(因为要联网,慢且可能失败)。HTTP 层只做快校验,联网校验和落库一起放进事务。
3. service 层:分相位的事务
createServerInTransaction 是整个项目的心脏。它最值得学的不是逻辑本身,而是把事务切成命名相位、逐相位计时的工程手法。
3.1 为什么要分相位计时
函数顶部注释(registry_service.go:101-107)讲了来龙去脉:2026-04-27 出过一次事故,publish 偶发 50 秒+,但当时只测了 validate 的耗时,结果发现 validate 才几百毫秒——真正的卡顿藏在 acquire_lock/version_checks/db_create(连接池被打满)。教训:每个相位都要单独计时,下次慢的时候日志直接告诉你该怪哪一步。
实现用了一个小巧的 runPhase 闭包(registry_service.go:139):
// internal/service/registry_service.go:139 runPhase(节选)
// 给 fn 计时写入 *ms;出错则记下相位名和 err,返回 false 让调用方早退
runPhase := func(name string, ms *int64, fn func() error) bool {
t := time.Now()
e := fn()
*ms = time.Since(t).Milliseconds()
if e != nil {
failedPhase = name
err = e
return false
}
return true
}
配合一个 defer(registry_service.go:116):无论成功失败,最后都打一条结构化日志,把每个相位的毫秒数 + 总耗时 + 失败相位都带上("publish complete" 或 "publish failed")。
3.2 六个相位逐个看
相位名是常量(registry_service.go:24-31)。调用顺序:
| 相位 | 做什么 | 在防什么 | 代码 |
|---|---|---|---|
| validate | 包归属反查 npm/PyPI/OCI(联网) | 防你绑定一个不属于你的包 | :154 → ValidatePublishRequest |
| acquire_lock | 取 per-server advisory 锁 | 防同名 server 并发 publish 打架 | :163 → AcquirePublishLock |
| validate_remote_urls | 远程 URL 没被别的 server 占用 | 防两个 server 声明同一个远程端点 | :170 → validateNoDuplicateRemoteURLs |
| version_checks | 版本不重复、没超 1 万上限、取当前 latest | 防重复版本/无限版本爆库 | :180 |
| unmark_latest | 把旧的 latest 标记清掉 | 为新 latest 让位 | :221 → UnmarkAsLatest |
| db_create | 真正插入新版本行 | — | :238 → CreateServer |
version_checks 相位(registry_service.go:180-202)把三个小 DB 查询打包成一个逻辑步:
// internal/service/registry_service.go:180 version_checks 相位(节选)
versionCount, e := s.db.CountServerVersions(ctx, tx, serverJSON.Name)
if versionCount >= maxServerVersionsPerServer { return database.ErrMaxServersReached } // 上限 1 万
versionExists, e := s.db.CheckVersionExists(ctx, tx, serverJSON.Name, serverJSON.Version)
if versionExists { return database.ErrInvalidVersion } // 不许重复版本
currentLatest, e = s.db.GetCurrentLatestVersion(ctx, tx, serverJSON.Name) // 拿来比大小
maxServerVersionsPerServer = 10000(registry_service.go:19)是防滥用的硬上限。
3.3 新版本是不是 latest?
拿到 currentLatest 后,用版本比较决定(registry_service.go:205-217):若没有现存版本,新版直接是 latest;否则调 CompareVersions 比新旧。比较规则在第 4 章详解。是新 latest → 先 unmark_latest 清旧标记,再插入时把 IsLatest: true 写进官方元数据(officialMeta,registry_service.go:229)。
4. server.json 数据模型一瞥
落进库的是 ServerJSON(pkg/api/v0/types.go:36)。读一遍它的字段就懂 registry「存什么」:
| 字段 | 含义 |
|---|---|
Name | 反向 DNS 名字(唯一标识) |
Description / Title | 人读的说明 |
Repository | 源码仓库 URL/source/subfolder |
Version | 本次发布的版本 |
Packages[] | 去哪下载、怎么跑(npm/pypi/oci…) |
Remotes[] | 远程 transport(streamable-http/sse) |
Meta.PublisherProvided | 发布者自定义元数据(≤4KB) |
Package(pkg/model/types.go:28)按 RegistryType 决定哪些字段相关——这个「一个结构体,字段含义随 type 变」的注释(types.go:21-27)是理解包校验的前提:npm 看 Identifier+Version,OCI 把版本塞进 Identifier,MCPB 必须有 FileSHA256。
响应 ServerResponse(types.go:22)= 你交的 ServerJSON + registry 自己加的 _meta.io.modelcontextprotocol.registry/official(RegistryExtensions,types.go:9),里面有 Status、PublishedAt、IsLatest 等由 registry 管理、作者不能自己写的字段。
5. 编辑与状态变更(发布的近亲)
除了 publish,还有 PUT 编辑和 PATCH 改状态:
- 编辑
RegisterEditEndpoints(internal/api/handlers/v0/edit.go:30):需要edit权限(不是publish),禁止改名(edit.go:88,改了名直接 400),body 里的版本必须和 URL 一致。 - 状态变更(active/deprecated/deleted):走
UpdateServerStatus系列,改状态后会触发recalculateLatest重算 latest(见第 4 章)。