← 返回文章选择

架构图画到哪一层,Spec 写到哪一步

C4 每层画给谁看,图怎样离开讲解也能读懂,Spec 写到能验收为止。

这几页笔记把两个问题写在了一起:要做的东西说清楚了没有,这个方案行不行得通。PRD、架构图和代码设计各回答其中一部分。全塞进一份文档,常见的结果是需求还没说清,类名已经写了一页。

先决定这张图给谁看

C4 的四个层次是系统上下文、容器、组件和代码,像一路放大的视图。读者要的细节不同,图就跟着换。下面把四层画在一起,箭头表示继续展开的方向,不表示运行时调用。

C4 从系统上下文逐层展开到代码的四层视图

查看 Mermaid 源码
flowchart TD
context["系统上下文:使用者、外部系统与边界"] -->|展开内部| containers["容器:应用、服务与数据存储"]
containers -->|展开一个容器| components["组件:职责、接口与依赖"]
components -->|按需解释实现| code["代码:类型、方法与关系"]

每一层对应不同的讨论:

层次 什么时候展开 要交代什么
系统上下文 需求讨论 系统服务谁、依赖什么
容器 讨论部署单元和通信 通信、端点、队列和存储;两个方块之间传什么、走什么协议
组件 进一步划分职责 哪个接口承担职责,内部实现要隐藏哪些细节
代码 写实现说明 类型和签名,不必在第一张总览图上提前铺开

C4 官方也提醒,四层不必每次画全,留下有沟通价值的层次就行。

官方的建议是能少画就少画。容器图的读者写得很具体:团队内外的技术人员,架构师、开发、运维和支持,图上要标出主要技术选型。到了后两层,组件图页对“推荐画吗”的回答是不,觉得有价值才画;代码图也是不,长期文档尤其不推荐,因为 IDE 能按需生成,真要有也最好由工具自动产出。所以上面那张表,我会读成“默认画到容器,组件和代码等需要时再展开”。

按读者切视图也不是 C4 独有的。Kruchten 的 4+1 视图模型用几张并行的视图,分别面向最终用户、开发、系统工程师和项目经理;Rozanski 和 Woods 干脆把 viewpoint 定义成为某类读者构造视图的一套约定。C4 只是其中一种切法。

组件层把接口标出来,也等于说清了哪些部分不对外承诺。只要接口契约和外部行为不变、调用方也不依赖内部实现,换掉接口之外的实现,调用方就不用跟着改。

图要离开讲解也能读懂

给图一个写明类型和范围的标题,解释缩写,在线上写清关系,补上图例。读者能不能独立看懂,就看这些细节。C4 的标记建议要求关系有方向,标签和方向表达的意思一致;跨进程通信还要写明技术或协议。

笔记里“中间件用虚线”这句写得太绝对。虚线在某张图里可以表示异步、可选关系或别的约定,含义由图例说了算,C4 本身不规定中间件只能用某种形状。颜色也一样:同步和异步只靠红绿区分,打印出来或换个主题就可能看不明白。

这还关系到色觉障碍的读者。色彩通用设计指引给的数字是:男性里红绿色觉障碍约占欧裔 8%、亚裔 5%、非裔 4%。它的建议是颜色和形状一起区分,元素直接标在图上,而不是另配一个颜色图例。

把这些收成一张交图前的核对表:

  • 标题写明图的类型和范围;
  • 有图例,虚线、颜色各代表什么写在图例里;
  • 每条线单向、有标签,标签和箭头方向说的是一回事;
  • 跨进程的关系写明技术或协议;
  • 区分关系时,颜色之外还有线型或形状。

Spec 要能落到一个看得见的结果

PRD 阶段可以先写 JSON 或 HTML 样例,让结果有个形状。比如要加一个页面,先说清用户看到什么、能做哪些操作、失败时显示什么。样例是讨论对象,不代表背后的接口和数据已经实现。

功能规格写用户行为和验收结果,技术规格再写模块边界、调用顺序、数据结构和必要的签名。两部分互相引用,比在需求段落里来回切换抽象层次好审得多。

PRD 里的样例和验收里的例子长得像,用途不同。Matt Wynne 的 Example Mapping 给了个好判据:说不清 Then 的时候,你手上的不是例子,是一个问题,先记下来接着讨论。讨论阶段的样例就是用来暴露这种问题的。等到写进 Gherkin,场景就既是规格,也是测试,整体构成“可执行的规格”。例子跑通了,才算实现。

小改动写清当前问题、修改范围和验收方式就够了。任务越大,才越需要展开关系和阶段。

写多少,看任务体量

几种现成的写法,正好对应大、中、小三档:

体量 写法 要点
大 Google 的设计文档 大项目约 10–20 页;背景只写客观事实,目标与非目标、取舍、备选方案;评审不太可能再大改时才开始实现
中 Shape Up 的 pitch 问题、时间预算、方案、兔子洞、不做什么;方案故意用低保真草图,免得把设计框死
小 Nygard 的 ADR 一篇短文只记一个决定,放在仓库的 doc/arch/adr-NNN.md

arc42 有一句我很认同:相关性优先于完整性。只写重要的、意外的、有风险或容易变的部分,普通和标准的部分略过。Shape Up 里的“不做什么”和“兔子洞”,也值得加进我自己的 Spec 清单。

依赖方向要回到代码里查

架构图定下依赖方向后,可以把允许的关系写成规则,交给检查器和当前代码比对。图本身不会验证代码:能查到哪些依赖,取决于哪些规则已经写明、接进了检查器。检查器只报告违反了哪些规则,具体怎么改还是由人判断。

Java 里现成的工具是 ArchUnit:允许的依赖方向写成规则,不通过就抛 AssertionError,报出违规的类和行号。它不改代码。老项目历史违规多,可以用 FreezingArchRule 冻住,之后只报新增的违规,这比一上来要求全部清零现实得多。

ThoughtWorks 把这类检查叫架构适应度函数:架构目标写成自动执行的检查,放进流水线当门禁,用来防止架构漂移。分工就清楚了:图定方向,检查器守方向,人决定怎么改。

依赖规则和当前代码汇入检查器,再由人工审查修正

查看 Mermaid 源码
flowchart LR
view["架构视图"] -->|明确允许方向| rules["依赖规则"]
rules -->|转成可执行检查| checker["依赖检查器"]
source["当前代码"] -->|提取实际依赖| checker
checker -->|报告违反的规则| review["人工审查和修正"]

模块好不好用,看调用方需要知道多少内部信息。接口很短,使用者却得理解一串内部状态,照样难用;文件拆得再多,边界也不会自己变清楚。Ousterhout 在 Stanford CS190 的讲义里把这叫深模块和浅模块:深模块接口小、功能多;浅模块要你花大量时间学接口,省下的时间却不多。实现细节进了接口,就是信息泄漏。

审查时可以沿着一次真实调用走一遍:调用者为了完成自己的工作,被迫了解了哪些内部细节?这种具体问题,比单独统计模块数量更能帮你改设计。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。