flowchart LR
subgraph BUILD["构建期"]
direction TB
DECL[".aio 声明"] -->|派生| S1[Swift] & S2[Go] & S3[TS] & S4[Kotlin]
end
subgraph RUN["运行期"]
direction TB
PR["Protocol<br/>定接口"] --> AS["Assembly<br/>注册"] --> RS["运行时<br/>依赖注入解析"]
end
subgraph SHIP["交付期"]
direction TB
MANY["打包 · 上传 · 元数据<br/>符号表 · 提审"] --> ONE{{"唯一入口<br/>只接受一种触发"}}
end
BUILD --> RUN --> SHIP
style DECL fill:#eef2fb
style ONE fill:#f8d7da
7 架构:让行为可以被断言
章节 6 讲的是代码放在哪里,这一章讲它跑起来之后是什么样子。
整个系统按构建期、运行期、交付期分成三段,而每一段都有一个明确的方向: 构建期是”一份声明派生出多端”,运行期是”依赖只向一个方向流动”, 交付期是”触发权收敛到唯一入口”。方向一旦确定,Agent 改动任何一处, 都能沿着这个方向推出”会影响到谁” —— 这正是它自己判断影响面所需要的东西。
这一章真正想说的是最后一节那句话:架构在这里不是审美选择, 每一条边界都对应一种”这次改动对不对”能被机器验证的能力。 说不出这种能力的边界,是装饰。
7.1 构建期:一份声明 → 多端派生
这一段是整套基建里最根本的一环,因为它不是在事后挑错, 而是在源头上把出错的机会消掉 —— 把”改 4 个地方”压成”改 1 个地方”。
7.1.1 它解决的是什么问题
原始状态是这样的:iOS 上有五个手写的 HTTP 客户端,约 109 个接口 在服务端处理器、客户端调用点、前端请求三处重复。每加一个接口是四个地方的 改动,而一处漂移会悄悄弄坏其它几处。
“悄悄”是关键词。类型不一致不会让任何一端编译失败,它只会在运行时表现成 一个字段是空的、一个枚举值没有匹配上、一个日期格式被解析成了 1970 年。 这类问题的排查成本极高,因为每一端单独看都是对的 —— 这是形状 B 的一个变种:同一份契约有四个写者。
改完之后是:编辑一个声明片段,跑构建,四个平台从同一个逻辑模块重新派生类型。 实测下来,仓库里有 694 个这样的声明文件,而同样的”声明 → 生成”结构 在别处又复制了 11 遍,覆盖接口契约、产品元数据、设计变量等等。
7.1.2 管线的形状
支撑它的是一个约三千行的 Rust 程序,管线分成五段:
lexer → parser → assembly → ir → emit
前四段是通用的,只有最后一段 emit 分语言,而且 emit 走模板 —— 每种目标语言一个 .tera 模板。所以要调整生成结果通常是改模板, 而不是改编译器本身。这是机制与策略分离在代码生成上的形态: 编译器是机制,模板是策略。
它的命令行接口把这个结构暴露得很清楚:
enum Cmd {
Swift { inputs, output },
Go { inputs, output, package },
Ts { inputs, output },
Kotlin { inputs, output, package },
/// Validates the declared fragments as one logical module.
Check { inputs },
}注意最后那个 Check —— 它可以只校验、不生成。 这意味着 “这份声明合不合法”是一个独立于”生成什么”的问题,可以在任何时候单独问: 在编辑器里、在提交钩子里、在 CI 最便宜的那一层里。
7.1.3 错误枚举就是不变量清单
这个编译器最值得学的地方不在管线,在它的错误类型。 一个程序拒绝什么,比它接受什么更能说明它守着哪些不变量。
pub enum AssemblyError {
Empty, // 逻辑模块至少要有一个片段
Lex { path, source }, // 词法错误,带上是哪个文件
Parse { path, source }, // 语法错误,带上是哪个文件
NamespaceMismatch { path, expected, actual }, // 片段之间必须同意命名空间
DuplicateName { path, name }, // 跨片段不许重名
}逐条读一遍,这五个变体就是五条不变量。Empty 说一个逻辑模块必须有内容, 空模块不是合法状态。Lex 和 Parse 带上了哪个文件 —— 多片段组装时这是唯一有用的定位信息。而后两条尤其值得说: NamespaceMismatch 要求多个片段必须同意自己属于谁,DuplicateName 要求同一个名字不能有两个定义 —— 单一 writer 用在了类型定义上。
后两条的存在意味着一个刻意的设计取舍:声明是可以拆成多个文件的, 但拆开之后它们仍然被当作一个整体来校验。允许拆分是为了让不同的人 (和不同的 Agent)能并行编辑不同的接口而不互相冲突;而在组装阶段 做整体校验,是为了不让这种并行制造出不一致。拆分是为了并发, 组装校验是为了一致 —— 两者必须同时存在,只有前者就是形状 B。
类型检查阶段只有一个错误变体,但它写得很讲究:
pub enum CheckError {
#[error("unknown type reference {name:?} at {span} (defined types: {defined:?})")]
UnknownRef { name, span, defined: Vec<String> },
}注意 defined: Vec<String> —— 错误消息里带着”你本来可以用哪些类型”。
这不是锦上添花。小节 13.4.4 讲过,一个只说”错了”的判定 只提供了误差的符号,而一个带着可选项的错误提供了方向 —— 读到这条错误的人 (或 Agent)不需要再去翻文档找有哪些类型可用。这条纪律从检查器 一直贯穿到编译器:失败的时候,把下一步一起给出去。
7.1.4 生成物的漂移检测
这套东西还给自己加了一道自检。构建规则里有一条测试,它的文档字符串只有一句:
Fails when a checked-in API client differs from its Bazel output.
签入仓库的生成代码,和构建系统当场重新生成的产物,必须一致。 它防的是这样一种失效:有人手改了生成物,但源声明没动。
这是最难查的一类问题,因为改完之后代码能编译、测试能通过、行为可能也对。 唯一坏掉的是”生成物由声明派生”这条不变量 —— 而它坏了之后, 下一次重新生成会把手改的部分静默地抹掉。
小节 18.2.3 会从控制的角度重新讲这个机制。 在这里只需要记住它的结构:用第二个独立通道去验第一个。
7.2 ⚙️ 依赖图:影响面变成可以计算的东西
这一节需要统一构建系统。没有它,本节的方法不成立 —— 但 章节 8、章节 12、章节 13、章节 15 这四章完全不依赖它,你可以跳过这一节继续读。
单仓加上统一构建系统,换来的最有价值的东西其实不是构建速度, 而是一张覆盖全仓的依赖图。构建加速只是这张图的副产品, 真正的产出是”影响面变成了可以计算的东西”。
这张图能回答四类问题:查反向依赖,让 CI 只跑受影响的目标,测试按目标分片并行; 查正向依赖,让架构规则变成可执行的查询,禁止的方向当场拦下; 密封加内容寻址,让远程缓存跨机复用、没变的不重跑、结果可复现; 把生成器接进图,让改一处之后下游产物自动重新派生、漂移即失败。
这一点对 Agent 的意义比对人更大。
人改代码时会凭经验估计影响范围。估得准不准取决于他在这个仓库里待了多久, 而估错了就在评审或线上被发现 —— 这个过程是有反馈的, 所以一个人的估计会随时间变准。
Agent 没有这个积累,它每次都是新来的。让它去”估计”影响范围, 等于让它每次都从零开始猜,而且它的猜测不会因为上次猜错了而变好。 所以对 Agent 来说,正确的做法不是让它估得更准,而是让它根本不需要估 —— 影响面是查出来的,不是想出来的。
这也是 小节 1.2 那堵墙的真正解法。章节 17 会从控制的角度 重新讲一遍:这不是给测量扩容,是把测量收缩到本次真正变化的那个子空间。
7.3 运行时:三条链路,各只有一个方向
7.3.1 客户端:装配链是固定的
整条链是 Protocol-first 定接口,Assembly 负责注册,运行时通过依赖注入解析。
值得看一眼这个微内核的实现,因为它的核心是一个很小的状态机:
private enum Entry {
case pending(ProcessAssembly)
case assembling(ProcessAssembly)
case resolved(Any)
}三个状态,而整个类的注释只有一句:线程安全的服务容器;装配在锁外执行, 并由状态机保证每个服务只有一个装配者。
“装配在锁外执行”是这里的关键。装配一个服务会调用业务代码, 而在锁内调用业务代码是死锁的标准配方 —— 那段业务代码可能会去解析另一个服务。
stateDiagram-v2
[*] --> pending: register(lazy)
pending --> assembling: 锁内原子认领
assembling --> resolved: 锁外装配完成后写回
assembling --> pending: 装配失败,退回重试
assembling --> assembling: 并发的第二个调用者<br/>在此等待
resolved --> [*]
note right of assembling
装配在锁外跑
写回前检查条目
是否仍属于本次
end note
“装配在锁外”这句话落到代码上,是把一次解析拆成了两半。 第一半在锁内,它只做一件事 —— 原子地认领:
private func beginResolution(for key: ServiceKey) -> GetServiceAction {
state.withLock { entries in
guard let entry = entries[key] else { return .notRegistered }
switch entry {
case .resolved(let service): return .alreadyResolved(service)
case .pending(let assembly):
entries[key] = .assembling(assembly) // ← 认领
return .needsAssembly(assembly)
case .assembling: return .waitForOther
}
}
}注意这个函数没有调用任何业务代码,它只是读一个枚举、写一个枚举、 返回一个决定。真正的装配发生在返回之后、锁已经放开的地方。 那个 GetServiceAction 枚举存在的唯一理由就是这个 —— 它把”在锁内决定”和”在锁外执行”这两件事显式地分开, 而不是靠写代码的人记得别在锁里调用户代码。
这是 小节 5.7 第三层的一个小例子: 锁内的那段代码在类型上就够不着业务代码, 因为它能返回的只有四个枚举值中的一个。
写回的时候还有一道检查:guard case .assembling = entries[key] else { return } —— 只有在这个条目仍然属于本次装配时才发布结果。这防的是装配进行到一半时 有人调用了 reset()(测试的清理路径),或者别的什么把这个条目清掉了 —— 这时候把结果写回去,会往一个已经被重置的容器里塞一个幽灵服务。
这三十行代码是这一章那句话的最好例子:它换来的可测性是”装配只发生一次”。 这条不变量在测试里是被直接断言的 —— 并发地解析同一个服务, 装配计数必须恰好是 1。这是一个能失败的断言:如果状态机写错了,它会红。
7.3.2 这一层禁止三件事
跨模块直接实例化具体类型、绕过协议访问器直接解析、给服务加兜底 fallback。
前两条好理解。第三条值得多说一句:只要有兜底,就存在一条不经过正常装配的 隐藏路径。行为就不再唯一,而测试断言的也就不再是真实运行时会走的那条路。
它在实践中的形态是这样的(这是一条真实沉淀下来的规则的原话): 缺失的服务注册被转成正常业务值或第二实现,会掩盖组合根错误并制造分裂状态。
“掩盖组合根错误”是关键。一个服务没被注册,这是组装的 bug, 应该在启动时就炸掉;而一个兜底会把这个 bug 转换成一个看起来合理的默认值, 于是应用照常启动、照常运行,只是某个功能悄悄不工作了。 这是形状 A 在运行时的形态:系统不报错,因为按它自己的标准它没出错。
小节 13.5 讲过,检查这条规则的分析器有五层过滤 —— 而那五层的复杂度,就是这条边界价值的度量。
7.3.3 后端:三条禁令是同一个决定的三个面
不新增数据库外键、不依赖数据库的级联行为、账号不硬删除而必须软删并清空个人信息。
这三条其实是同一个决定的三个面:删除、解绑、匿名化的引用完整性, 全部在应用层的同一个事务里显式保证,而不是交给数据库的约束去隐式触发。
好处是删除行为变得可控、可测,而”可测”这里有一个很具体的含义: 数据库级的级联一旦触发,你既看不到它删了什么,也没办法给它写一条断言。 你能断言的只有”删完之后这张表是空的”,但那不是行为断言,那是状态断言, 而且它没法告诉你级联的顺序对不对、有没有多删。把这套逻辑放在应用层的 一个事务里,你就能对每一步写断言。
7.4 交付期:最不可逆的动作,收进唯一一个 owner
前两个阶段出了问题都还能重来,交付这一段不行。打包、上传、上架、 改线上配置 —— 这些动作一旦做错,重试是挽回不了的,所以它们的 owner 必须唯一。
配置文件里那行注释把这件事说得很直白:
# release/*/v* excluded: Mainline owns release builds via API连看起来最像发布分支的分支模式都被排除在外,因为发布的触发权 不在分支上,在唯一的那个发布入口手里。
这个约束在实现上是这样的:五个发版动作 —— 打包、上传、改元数据、 报符号表、提审 —— 只接受一种触发模式。没有 webhook 入口, 没有定时触发,没有裸 API。
不可逆的动作,连触发方式都要被收窄。
值得注意的是 Agent 在这条链上能做的事其实不少:建一个新产品 (包括那些不可逆的标识符与签名配置)、出上架截图、调关键词和元数据、 接订阅、提交并盯流水线。它能把发布准备到完全就绪的状态 —— 但最后按下去那一下,始终是人的决定。
这不是对 Agent 能力的不信任。章节 20 会说明: 这是一类按定义就在这套系统能力之外的判断。
7.5 贯穿三个阶段的同一条约束
把上面这些边界连起来看,会发现它们指向同一件事:让行为可以被断言。
| 架构选择 | 换来的可测性 |
|---|---|
| Protocol-first 定接口 | 可注入替身,不必起真实依赖 |
四件套第四格 Testing/ |
替身有归属,一个 testonly 目标,不各存一份 |
| 领域层只依赖抽象 | 脱离数据库测业务规则 |
| 禁止服务兜底 | 没有隐藏路径,行为唯一可断言 |
| 契约由声明派生 | 四端类型同源可对拍,漂移即失败 |
| 构建目标粒度 | 测试可分片、只跑受影响的 |
| 装配在锁外 + 三态机 | “装配恰好一次”成为可断言的不变量 |
| 发布触发权收窄 | 不可逆动作的发生是显式的、可审计的 |
这张表是这一章唯一需要记住的东西,而它给出一个可执行的自检: 拿你自己架构里的每一条规则,在右边这一列填一个词,填不出来的那条, 大概率是装饰。
装饰性的架构规则不是无害的。它们和真规则长得一样,消耗同样的注意力 去遵守、去审查、去在重构时维护 —— 而它们不产生任何可验证的东西。 在 Agent 规模化的场景下,这个成本会被乘上并行度。
7.5.1 可测性作为架构讨论的终结器
这张表有一个更强的用法:把它当成架构讨论的终结器。
架构讨论最容易陷入的僵局是两个人各有一套说法,而双方的论据都是 “这样更清晰”“这样更灵活”“这样更符合某某原则” —— 这些都不是可以被检验的命题。
“这条边界换来什么可测性”是可以被检验的。你说这样分层更好, 那么分层之后,有什么原来测不了的东西现在能测了?
三种可能的回答,各自意味着不同的事。具体说出了一样东西 —— 这条边界有价值,讨论结束。说不出,但坚持它更”清晰” —— 这是品味,应该被明确标记为品味。说不出,而且承认 —— 这条边界大概率是装饰,删掉。
第二种最需要被诚实对待。品味不是坏东西 —— 可读性、一致性、 符合团队习惯,这些都有真实价值。但它们不该被伪装成架构规则, 因为一旦被写成规则,Agent 会把它当成硬约束去满足, 而满足一条品味规则最快的方式通常是抄一段现有代码。 把品味明确标为品味,Agent 反而能处理得更好 —— 它知道这里有自由度。
7.6 三段之间的接缝在哪
三段各自的规则讲清楚了,但最容易出问题的地方是接缝。
第一个接缝是生成物进入运行期。 一份声明生成了四端的类型, 然后这些类型被运行期的代码使用。接缝上的问题是:如果有人手改了生成物, 运行期用的是手改后的版本,而下一次重新生成会把它抹掉。 这个接缝的守卫是对拍测试(小节 7.1.4)—— 它是唯一能发现”签入的和生成的不一致”的机制。
第二个接缝是运行期状态进入交付期。 打包时会把当前的配置、当前的资源、 当前的版本号一起打进去。接缝上的问题是:这些”当前”的东西从哪来? 如果它们可以被环境变量覆盖、被本地配置影响,那么同一份代码在两台机器上 会打出不同的包。这个接缝的守卫是密封构建 —— 构建的输入被完整声明, 不受环境影响。这才是那个”密封 + 内容寻址”真正的价值: 它不只是让缓存能复用,它让”这个包里到底是什么”变成一个确定的问题。
第三个接缝是交付期反馈回构建期。 线上出了问题,需要定位到是哪次改动。 接缝上的问题是:如果构建产物和源码版本之间没有确定的对应关系, 这个定位就是猜的。这个接缝的守卫是证据绑定版本 —— 每个判定都绑在它实际观察到的那个版本上,而在这一层的形态是 每个发布产物能追溯到唯一一个源码版本。
三个接缝的共同点是:它们都是”同一份东西在两个阶段之间传递”, 而守卫全都是”确保两边说的是同一件事”。
7.7 三个方向为什么是三个
构建期”一份声明派生多端”、运行期”依赖单向流动”、交付期”触发权收敛到唯一入口”—— 这三条看起来是三件事,但它们是同一件事在三个时间尺度上的形态: 让”谁能改变这个东西”这个问题,在每个阶段都只有一个答案。
构建期那个东西是四端的接口类型,唯一的改变者是那份声明;运行期那个东西 是一个服务的实例,唯一的改变者是装配链;交付期那个东西是线上的状态, 唯一的改变者是发布入口。
三条禁令也因此同构:禁止手改生成物,是因为它绕过了声明这个唯一改变者; 禁止跨模块直接实例化,是因为它绕过了装配链;禁止旁路触发, 是因为它绕过了发布入口。三次都是同一个形状:一条不经过唯一 owner 的旁路。
这三条旁路的共同特点是:走它们的时候,一切看起来都正常。 手改的生成物能编译,直接实例化的服务能工作,旁路触发的发布能发出去 —— 它们的代价都在以后。
7.8 三个方向的例外情况
三条方向性约束都有它的例外,而把例外写清楚比假装没有例外好 —— 因为未被承认的例外会以”绕过”的形式出现。
构建期的例外是需要手改生成物的情况。 有时候生成器还不支持某个特性, 而你现在就需要它。错误的做法是手改生成物、等生成器支持了再说 (对拍测试会当场红,这是它存在的意义);正确的做法是改生成器, 或者在生成物之外扩展 —— 后者通常可行,因为生成的类型可以被扩展, 而扩展代码不在生成物文件里。这个例外的处理方式本身就是一条可以抄的原则: 不要修改自动生成的东西,要在它旁边扩展。
运行期的例外是真正可选的能力。 “禁止兜底”不等于”所有服务都必须存在”—— 有些能力真的是可选的,比如一个只在某些设备上可用的传感器。区分的方式 是显式的:可选的能力必须提供一个命名明确的”可选访问器”, 必需的能力用会 fail-fast 的访问器。关键不在于”能不能是可选的”, 在于”可选性是不是显式声明的” —— 一个隐式的可选(取不到就用默认值) 会掩盖组装错误,一个显式的可选(明确返回”不可用”)不会。
交付期的例外是紧急发布。 而处理方式不是开一个后门, 是让那条唯一的通道足够快。因为一旦有第二条路,它会在非紧急的情况下 也被使用 —— 理由永远是充分的(这次很小、这次很急、这次是例外)—— 而”唯一入口”这条约束的全部价值,就在于它是唯一的。
三个例外的处理方式不同,但它们遵循同一条元规则: 例外必须走和主路径同一个机制,而不是绕过它。理由是一个绕过主路径的例外, 它不受主路径上的任何守卫保护 —— 而例外的情况往往正是最需要守卫的时候, 紧急发布尤其如此。
7.9 边界的成本
这一章讲了很多边界的好处,但每一条边界都有成本,不算这笔账就是耍赖。
协议优先的成本是每个服务多一个目标、多一层间接;禁止跨模块实例化的成本是 有些”就用一下”的场景要走完整装配;声明派生的成本是多一个编译步骤、 改接口要重新生成;而发布触发权收窄的成本最实在 —— 出线上事故时, 那条通道就是关键路径上的额外一环。
我接受了最后这个成本,理由在 小节 7.4:打包、上传、上架这些动作 做错了重试挽回不了,而一次错误发布的代价高于所有紧急发布加起来 多花的那几分钟。
但这个判断依赖于具体场景。 一个改一行配置就能回滚的服务端系统, 这笔账可能算出相反的结果 —— 那时候正确的做法是收窄”不可逆”的那部分, 而不是收窄所有发布。边界的价值来自不可逆性,不是来自严格本身。
7.10 为什么架构约束在 Agent 场景下更重要
同一套架构约束,在人的团队和 Agent 的团队里价值不一样,而原因有三个, 每一个都指向同一个方向。
第一,Agent 的”影响面直觉”是零。 一个在仓库里待了半年的人, 改一处代码时会有一个模糊的感觉:“这个改动可能会影响到那边”。 这个感觉不精确,但它非零,而且会随经验变准。Agent 每次都是零。 所以架构约束对它的价值不是”防止它做错”,是给它一个可以查询的影响面模型 —— 依赖只向一个方向流动,意味着影响面是可以沿着方向推出来的。
第二,Agent 会同时改很多地方。 人改代码倾向于局部,因为读代码有成本; Agent 没有这个阻力,它可以毫不犹豫地同时改二十个文件。这让”一次改动 穿过了哪些层”从一个偶尔需要问的问题,变成了每次都需要回答的问题。
7.10.1 几十个 Agent 并行时,约束是唯一的协调机制
第三个原因,也是三个里最结构性的一个: 人的团队有一个 Agent 没有的东西:互相知道对方在干什么。站会、聊天、“我在改这块你别动”—— 这些非正式的协调在人的团队里承担了大量的边界维护工作, 而几十个并行的 Agent 之间没有任何这类沟通。所以那些原本由沟通维护的边界, 必须被显式化 —— 要么写进结构,要么写进检查,没有第三条路。
这解释了一个现象:很多团队在引入 Agent 之后,才发现自己的架构约束 其实一直很松 —— 不是它变松了,是原本兜住它的那层沟通消失了。
7.11 架构决定了 Agent 能自主到什么程度
最后一个观察,它把这一章和全书连起来。
一个 Agent 能自主完成一个任务的前提是:它能自己判断”我做完了没有”。 这个判断需要三样东西 —— 知道该改哪里(目录结构)、知道改动会影响谁 (架构的方向性)、知道改对了没有(判定层)。
中间那一样是这一章的全部价值。在一个依赖方向混乱的系统里, Agent 无法回答”我还需要改什么”,于是它要么改少了(漏掉了下游), 要么改多了(动了不该动的)。
两种错误的表现是不同的:改少了会被测试抓到,改多了不会。 一次多余的改动能编译、能通过测试,它只是扩大了这次改动的影响面 —— 而影响面的扩大是静默的。所以架构的方向性守住的,是一个测试守不住的东西。
7.12 什么时候不该抽象
这一章讲了很多”收敛到唯一 owner”,但收敛本身是一种抽象,而抽象有它的适用条件。 判据和目录层级那条是同一个(小节 6.2):抽象必须被第二个 真实的用例挣得。
这条在架构上比在目录上更重要,因为一个过早的抽象比一层空目录贵得多。 空目录的直接成本是一层路径,过早抽象的直接成本是一层间接、一个接口、 一批适配代码;空目录的认知成本很低,抽象的认知成本是每个读者都要理解它; 空目录删掉就行,抽象改起来要改所有实现。
最要紧的是错误的代价。一个基于单个用例设计的抽象,它的形状是那个用例的形状; 第二个用例来的时候,它要么被硬塞进那个形状(扭曲),要么迫使抽象重做。 “硬塞进去”是默认发生的,因为它当下更省事。
那”机制第二次出现就上移”和这条矛盾吗?不矛盾,它们是同一条:第一次直接写在 它需要的地方、不抽象;第二次现在有两个真实用例了,这时候上移、形状是对的; 第三次及以后它已经在共享层了。
“第二次”这个时机不是随便定的 —— 它是”最早的、你手里有足够信息来设计 正确抽象”的那个时刻。早一次你在猜;晚一次你已经付了重复的代价, 而且两份实现已经开始分叉(小节 6.5.1 讲的那种分叉)。
7.13 架构约束怎么从想法变成机制
前面列了很多”应该这样”的边界,但没说它们是怎么落地的。 这一节补上这段路径,因为它是这一章和判定层之间的桥。
拿”禁止给 Service 加兜底 fallback”这条走一遍,它有五个阶段。
阶段一是一次具体的事故。 某个服务在未注册时返回了一个默认实现, 于是一整条链路”正常工作”了三周,直到有人发现数据是空的。 这一步的产出不是规则,是一句话:这里不该有一条不经过装配的路径。
阶段二是把它写进常驻文件。 一句话加一个失败形态,成本十分钟。 这一步的价值是从此每个新会话都读得到它, 而它的局限是 小节 5.7 的第一层 —— 读到不等于遵守。
阶段三是写成一条报数模式(小节 13.7)的检查。天真的实现是 grep 一个 ??, 而跑一遍就会发现有六十多处命中,其中大部分是合法的。 这一步的产出不是拦截,是那个数字—— 它告诉你这条规则的边界画在哪里。
阶段四是把边界调到零误报。 小节 13.5 讲的那五层过滤 就是这一步的产物,而每一层都对应一次具体的误报。 这一步通常比阶段三贵好几倍,而它是整条路径上最容易被低估的一段 (小节 13.14 那张表的第三行)。
阶段五是切成拦截,成本接近零 —— 前提是阶段四做完了。
五个阶段里,第一阶段是免费的(事故已经付过钱了), 第二阶段十分钟,第五阶段零成本,真正的工作全在第三和第四阶段。 这两个阶段有一个共同特征:它们的产出是数据,不是代码。 第三阶段产出”有多少处”,第四阶段产出”哪些是合法的”—— 而这两份数据没有任何办法可以跳过采集直接得到。
这条路径解释了这一章为什么不给”推荐的架构约束清单”。 一条约束的价值不在它的措辞,在它走完这五个阶段之后剩下的东西 —— 而那个东西是你自己仓库的形状,不是别人的。
7.14 架构文档为什么可以很少
我这套系统里几乎没有独立的架构文档,这不是疏忽。 因为这一章讲的每一条边界,都有一个比文档更好的载体。
依赖方向住在构建文件的依赖声明里,加一条检查;协议优先住在目录结构里 (Protocol/ 和 Service/ 是两个目录);禁止兜底住在一条语义规则里; 触发权收窄住在配置的触发模式声明里;生成物由声明派生住在一条对拍测试里。 五条边界,零份文档。
它们的共同点是每一条都住在它约束的那个东西旁边,而不是住在一份需要被 单独查阅的文档里。这正是 章节 8 那一章的核心,只不过用在了架构上: 规则的载体应该由”什么时候需要到达”决定,而架构约束需要在”写这段代码时”到达, 所以它该住在代码结构里,不该住在文档里。
一份架构文档能做而结构做不到的事只有一件:解释为什么。 所以剩下的那点架构文档,应该只写”为什么”,不写”是什么”—— “是什么”由结构本身回答。
7.15 架构决定该记在哪一层
小节 7.14 说这一章的每条边界都有比文档更好的载体, 但那句话留了一个尾巴:“为什么”该记在哪。
一条依赖规则住在检查里,它回答”是什么”; 而”为什么依赖只能往这个方向流”这个问题,检查回答不了。 把它写进一份架构文档是最自然的做法, 而它的问题在 小节 8.14.0.2:那份文档不会被读, 直到某天有人想改那条规则 —— 而那正是最需要它的时刻, 也是它最可能已经过时的时刻。
更稳的形态是把”为什么”放进规则本身。 那二十三条规则的 incident 字段就是这个 —— 一行字,说明这条规则来自哪次失败。它的好处是三重的: 它和规则同生共死,所以不会留下一条失效的解释; 它在规则失败时会被打印出来,所以读到它的时机恰好是需要它的时机; 而它的长度被格式逼着压到一行, 所以它必须说最要紧的那件事。
对那些确实需要几段话才能说清的决定, 正确的做法不是写一份架构文档,是写一份设计记录, 然后从规则的 read 字段指向它(小节 14.8)。 这样那份长文档的到达时机就不再依赖”有人想起来去查”, 而是绑在了具体的路径上。
一句话:为什么该和它解释的那条约束住在一起, 或者被那条约束指着。一份不被任何东西指向的架构文档, 它的实际读者是零 —— 而这一点在有 Agent 的仓库里更极端, 因为 Agent 连”我记得好像有一份文档讲过这个”这种模糊的索引都没有。
7.16 架构债的一个特殊性质
最后一个观察,说的是架构问题为什么值得被单独拦: 架构债和代码债的还债成本曲线不一样。
一段写得不好的代码,重写它的成本大致是恒定的 —— 今天重写和一年后重写, 工作量差不多。一条错误的依赖方向,还债成本随时间超线性增长, 因为在这条边上会不断长出新的依赖,而每一个新依赖都让解开它更贵。
结构检查的失败率最高却最便宜之所以是一件好事 (小节 13.1.1)—— 它拦的正是那类”现在很便宜、以后极贵”的问题。
这也给出了一个判断标准:一个架构约束值不值得强制执行, 看它的违规成本随时间怎么变。成本恒定的可以先记账(小节 13.3), 成本超线性增长的应该立刻拦。
7.17 架构约束和 Agent 的搜索空间
最后补一个视角:架构约束在 Agent 场景下的收益 和在人的团队里不是同一个量级。
一个人写代码时,他的搜索空间已经被大量隐式的东西剪掉了: 他记得上周在哪儿见过类似的实现,他知道这个模块最近谁在动, 他对”这样写会不会被评审骂”有一个直觉。这些东西没有一条写在代码里, 但它们每一条都在缩小他实际考虑的方案数量。
Agent 没有这些。 它的搜索空间由它读到的上下文定义, 而在一个约束稀薄的仓库里,那个空间大得离谱 —— 它会认真地考虑一个把服务直接实例化的方案, 因为没有任何东西告诉它不行;它会把一个机制复制到第二个产品里, 因为两边的代码看起来都很合理。
一条架构约束做的事情,是在搜索开始之前就把一整片区域剪掉。 “服务必须经注册取得”这一条,剪掉的不是一个方案, 是所有绕过装配链的方案 —— 而那一片区域在一个没有这条约束的仓库里 是 Agent 每次都要重新走一遍的。
这也解释了一个实践中的观察:架构约束越明确, Agent 的产出反而越多样。这听起来矛盾,但它和 小节 3.4 是同一条 —— 被剪掉的那片区域里 本来也没有好方案,剪掉它之后, Agent 的注意力落在剩下那片真正有选择的空间里。 约束不是在限制它,是在告诉它别在哪里浪费时间。
7.18 三个阶段之外还有一个:删除
构建期、运行时、交付期这三段讲的都是”东西怎么产生和流动”, 而有一个方向没被讲:东西怎么消失。
删除是所有架构决定里最少被设计的一个, 而它的代价和别的方向不对称。加一个模块,如果加错了, 它只是多了一点东西;删一个模块,如果删错了, 你可能已经丢掉了唯一一份关于它为什么存在的记录。
具体到这三个阶段,删除各有各的难处。构建期的删除 通常是安全的,因为依赖图会告诉你还有谁在用它 —— 这是有统一构建系统最被低估的收益之一: 它让”这个东西还有人用吗”变成一个可以被回答的问题。 在一个没有依赖图的仓库里,这个问题只能靠搜索, 而搜索答不出”完整性”。
运行时的删除最危险,因为一个服务可能有一个 不在依赖图上的消费者 —— 通过配置、通过反射、 通过一个字符串名字解析。所以 “服务必须经注册取得”那条规则的价值不只在装配期: 它同时让服务的消费者集合变成可枚举的。
交付期的删除是不可逆的,所以它属于 小节 14.2 那个最高风险等级 —— 下架一个产品、废弃一个接口、删掉一份线上配置, 这些做错了重试挽回不了。
三个阶段的删除难度递增,而这个递增和 “东西离外部世界有多近”完全一致 —— 这不是巧合,它是同一条原则的又一次出现: 离外部世界越近,可逆性越低,而机制该越紧。
7.19 留给读者的问题
这一章讲的所有边界都来自一个特定的技术栈组合,而有一个问题我答不了: 在一个没有编译期类型系统的技术栈里(比如纯动态语言的项目), 小节 5.7 那三个层次里的第三层(做错是不可能的)该怎么达到?
类型是那一层最主要的工具,而没有它,大部分约束只能停在第一层(信息在场) 或者靠外部检查(还是反馈)。这不是一个修辞性的问题 —— 它意味着 在那类技术栈里,“环境先于检查”可能需要一个不同的实现路径。 我给不出那个路径,因为我的样本里没有这类项目。