C4 动态图(Dynamic):指南与示例

容器图告诉你:API 与订单服务通信,订单服务与 Kafka 通信,通知服务从 Kafka 读取消息。但它没有告诉你,当顾客点击 Place order 时,会按什么顺序发生什么。扣款发生在订单记录写入之前还是之后?确认邮件要等仓库处理吗?这些正是故障复盘时大家会问的问题,而静态图回答不了。

这就是 C4 动态图的用途。它取用你已经画好的元素,为某一个具体场景中它们之间的交互标上编号。本指南涵盖:动态图是什么,它与 UML 时序图有何不同,什么时候值得画(比你想象的要少),一个完整的实战示例,常见错误,以及如何在静态模型变化时避免它过时。

如果你刚接触 C4,请先阅读 C4 模型是什么。下面的实战示例建立在 容器图指南所讲的那类图之上。

什么是动态图

动态图是 C4 模型的补充图之一,与系统全景图和部署图并列。它不属于四个核心层级,而是位于它们旁边,借用它们的元素。

c4model.com 上的定义很简短:

  • 范围:"某个特定的功能、故事、用例等。"
  • 元素:"由你决定——你可以展示在运行时交互的软件系统、容器或组件。"
  • 受众:"软件开发团队内外的技术人员和非技术人员。"
  • 是否推荐?"不推荐。动态图应当谨慎使用,只用来展示有意思的/反复出现的模式,或需要一组复杂交互的功能。"

从这个定义可以得出两点。

第一,动态图展示的是你已有关系的实例。如果容器图上有一条从订单服务指向 Kafka 的箭头,动态图就会说"在结账的第 4 步,这条箭头被用来发布 OrderPlaced"。Structurizr 的 DSL 把这一点写得很明确:它的文档说,使用动态视图时,"你展示的是静态模型中所定义关系的_实例_",而且关系必须先在静态模型中存在(Structurizr DSL 参考)。这个约束很有用:它阻止动态图凭空捏造一个静态模型并不知道的调用。

第二,一张图一个场景。不是"订单服务如何工作",而是"顾客下单,刷卡支付,商品有货"。失败路径如果值得画,就单独画一张。

顺序用箭头上的编号来表示。这就是全部记法:同样的方框,同样的箭头,再加上序号和对这一步所发生事情的描述。

动态图与时序图

"C4 时序图"是一个常见的搜索词,混淆也情有可原:这两种图回答的是同一个问题。C4 网站说,动态图可以用两种风格来画,承载的信息相同:

  • 协作风格。 方框自由布局(通常就是它们在容器图上的位置),之间用带编号的箭头连接。C4 指出,这种风格基于 UML 通信图,也就是以前所说的协作图。
  • 时序风格。 元素作为列排在顶部,时间沿页面向下流动,箭头画在生命线之间。它看起来像 UML 时序图,但参与者是 C4 元素。

所以,时序风格的动态图就是一种时序图。真正的区别在于它与从代码画出的经典 UML 时序图之间:

C4 动态图 UML 时序图(典型用法)
参与者 你的 C4 模型中的系统、容器或组件 对象、类,常常细到方法级
箭头的含义 对静态模型中某个关系的一次使用,带协议 一条消息或一次方法调用
细节程度 架构级:"发布 OrderPlaced(Kafka)" 常常是实现级:validate()、save()、返回值
记法 方框和带编号的箭头,特殊之处由图例说明 生命线、激活条、组合片段(alt、loop、par)
与其他图的关联 复用容器图或组件图中的元素 通常独立存在

使用协作风格,当空间布局本身有意义时,比如读者已经熟悉容器图,你希望流程直接叠加在它上面。使用时序风格,当顺序就是重点、步骤超过大约八步,或者两个元素之间有大量往返(请求、响应、回调)时。两者没有对错之分,C4 把选择权交给你。

如果你需要 alt 和 loop 片段才能讲清一个场景,这往往说明你在描述的是算法而不是架构。把架构版本画成动态图;如果有人需要,把详细版本作为 UML 时序图留在代码旁边。我们的 C4 与 UML 对比介绍了两种记法各自适用的场合。

什么时候值得画(什么时候不值得)

对于"是否推荐?",C4 自己的回答是不推荐,这值得认真对待。每一张动态图都是架构变化时又一个需要跟着改的产物。当场景至少满足以下一条时再画:

  • 从静态图看不出顺序。 结账、支付确认、失败时执行补偿的 saga。如果团队里的资深工程师都可能把顺序搞错,那就画出来。
  • 场景横跨多个容器或系统。 任何涉及四个及以上容器,或者离开你的系统再回来的流程(Webhook、回调、像 3-D Secure 这样的第三方跳转)。
  • 它是异步的。 一旦涉及队列,静态图能说明 A 和 B 都会接触 Kafka,却说明不了 B 在 A 之后运行,也说明不了 A 并不等待它。
  • 它反复出现。 一个在许多地方使用的模式(每个服务如何认证请求,每次写入如何发出事件),值得画成一张图,供其他文档引用。
  • 有人在评审或故障中要求画。 这是最好的触发条件。如果一次故障复盘花了二十分钟在白板上还原一个时序,那个时序就值得一张图。

以下情况可以跳过:

  • 流程是一条直线。 浏览器、API、数据库,再返回。容器图已经说明了这一点。
  • 它是 CRUD。 为创建、读取、更新、删除和列表画五张动态图,什么也没增加。
  • 没人会看。 为每个用户故事都画一张动态图,那是文档积压,不是文档。

对一个典型的产品来说,合理的目标是寥寥几张:两三条能带来收入或会把人半夜叫醒的旅程,再加一两个反复出现的模式。

实战示例:"顾客下单"

以我们 完整指南 中的电商系统为例。它的容器图包含一个 React 单页应用、一个 Kong API 网关、分别处理订单、商品和用户的 Go 服务(各自拥有独立的 PostgreSQL 数据库)、Kafka,以及一个通知服务。在第 1 层,该系统还与作为支付网关的 Stripe 以及用于发送邮件的 SendGrid 通信。

下面是这个场景用到的静态模型中的关系。后面的每一步都必须对应其中之一。

[Customer] --> [Single-Page Application (React)] : Uses (HTTPS)
[Single-Page Application] --> [API Gateway (Kong)] : Makes API calls (HTTPS/JSON)
[API Gateway] --> [Order Service (Go)] : Routes requests
[Order Service] --> [Product Service (Go)] : Checks stock (gRPC)
[Order Service] --> [Payment Gateway (Stripe)] : Authorizes payments (HTTPS/REST)
[Order Service] --> [Order Database (PostgreSQL)] : Reads/writes orders (SQL)
[Order Service] --> [Message Queue (Kafka)] : Publishes order events
[Notification Service (Go)] --> [Message Queue] : Consumes order events
[Notification Service] --> [Email Service (SendGrid)] : Sends email (HTTPS)

动态图:协作风格

在同样的方框上画出的编号交互:

1.  [Customer] -> [Single-Page Application] : Clicks "Place order"
2.  [Single-Page Application] -> [API Gateway] : POST /orders (HTTPS/JSON)
3.  [API Gateway] -> [Order Service] : Routes the authenticated request
4.  [Order Service] -> [Product Service] : Reserves stock for each line item (gRPC)
5.  [Order Service] -> [Payment Gateway (Stripe)] : Authorizes the card for the order total (HTTPS)
6.  [Order Service] -> [Order Database] : Writes the order with status "placed" (SQL)
7.  [Order Service] -> [Message Queue] : Publishes OrderPlaced (Kafka)
8.  [Order Service] -> [Single-Page Application] : Returns 201 with the order number (via the gateway)
9.  [Notification Service] -> [Message Queue] : Consumes OrderPlaced (Kafka)
10. [Notification Service] -> [Email Service (SendGrid)] : Sends the confirmation email (HTTPS)

把它们放在容器图上,编号本身就讲清了故事:第 1 到 8 步是同步的,发生在顾客等待期间;第 9 和 10 步发生在之后,顾客从不需要等待它们。

同一场景,时序风格

# From To 发生了什么 同步?
1 Customer Single-Page Application 点击"Place order" 是
2 Single-Page Application API Gateway POST /orders 是
3 API Gateway Order Service 路由请求 是
4 Order Service Product Service 预留库存 是
5 Order Service Payment Gateway (Stripe) 对卡进行授权 是
6 Order Service Order Database 写入订单 是
7 Order Service Message Queue 发布 OrderPlaced 否(发后即忘)
8 Order Service Single-Page Application 返回 201 和订单号 是
9 Notification Service Message Queue 消费 OrderPlaced 异步
10 Notification Service Email Service (SendGrid) 发送确认邮件 异步

用这样一张表格记录动态图完全可行。把它画成生命线,就是时序风格。

这张图告诉你什么

读完这十个步骤,你就能回答容器图回答不了的问题:

  • Stripe 宕机了会怎样? 第 5 步授权失败时,库存已经在第 4 步被预留了,必须有人把它释放掉。这张图一眼就能看出:订单服务需要一条补偿路径,或者第 4 步和第 5 步应该对调。
  • 顾客会不会收到一个并不存在的订单的确认邮件? 不会。事件在第 7 步发布,位于第 6 步写入之后。如果这两步反过来,写入失败时仍可能发出邮件。(如果你需要写入和发布是原子的,那就是 outbox 表登场的地方,值得写一条 ADR。)
  • 顾客的关键路径上有什么? 第 2 到 8 步。邮件不在其中,这也是它走 Kafka 的原因。

下面是用 Structurizr DSL 表示的同一场景,适合把模型当作代码维护的团队。只有当每个关系都存在于静态模型中时它才能编译通过,这正是前面描述的约束:

dynamic webshop "PlaceOrder" "Customer places an order" {
    customer -> spa "Clicks Place order"
    spa -> gateway "POST /orders"
    gateway -> orderService "Routes the request"
    orderService -> productService "Reserves stock"
    orderService -> stripe "Authorizes the card"
    orderService -> orderDb "Writes the order"
    orderService -> kafka "Publishes OrderPlaced"
    notificationService -> kafka "Consumes OrderPlaced"
    notificationService -> sendgrid "Sends confirmation"
    autoLayout lr
}

第 8 步(响应)在静态模型中并不是一个独立的关系,所以 DSL 版本里省略了它。响应通常隐含在请求之中;只有当响应本身很重要时才画出来。

常见错误

步骤太多

一张有三十个编号箭头的动态图,是一个没人能记在脑子里的时序。如果一个场景超过大约十五步,就把它拆开:"结账,支付之前"和"结账,支付之后",或者按流程经过的每个系统各画一张。我们自己的流程文档建议每个流程 5 到 15 步,也是出于同样的原因。

混用层级

C4 允许你选择层级(系统、容器或组件),但每张图只选一个。如果一张图的第 3 步指向"Order Service"容器,第 4 步又指向它内部的 PaymentClient 组件,读者就被迫在故事讲到一半时切换缩放级别。如果某一步需要组件级细节,就再画一张限定在该容器范围内的动态图。

静态模型中不存在的箭头

如果动态图显示通知服务直接调用订单服务,而容器图中没有这个关系,那么两者之中必有一个是错的。通常错的是凭记忆画出来的动态图。把静态模型当作唯一可信来源,让每一步都引用其中的某个关系。

画出每一次调用

健康检查、令牌刷新、日志传输和指标采集都是真实存在的,但它们不是场景本身。凡是会出现在你画的每一张动态图里的东西,都应该省略。如果它确实重要,就把它作为反复出现的模式单独画一次。

用看起来像同步的箭头掩盖异步

上面的第 9 和 10 步发生在顾客已经拿到响应之后。如果它们和第 1 到 8 步用同样的箭头画,读者就会以为邮件在页面加载之前就发出了。给异步步骤做标记(虚线、"async"标签,或像 9a 这样的单独编号),并在图例中说明约定。

漏掉关键的失败路径

正常路径图是正确的默认选择。但如果你画这个流程的原因是"支付失败时会发生什么",那就画那条路径,而不是正常路径。

静态模型变化时保持准确

动态图对静态模型有双重依赖:依赖它的元素,也依赖它的关系。这让它成为最先过时的东西之一。有人把订单服务改名为"结账服务",把 Kafka 换成 SQS,或者把库存预留挪到一个新的库存服务里——所有涉及这些方框的动态图就都错了,而且没有任何东西会提醒你。

三个习惯会有帮助:

  1. 从模型出发画,而不是在模型旁边画。 在绘图工具里画的动态图只是容器图的一份拷贝,而拷贝会漂移。通过标识符引用模型元素的动态视图(Structurizr DSL 就是这样做的)至少能跟上改名,并且在某个关系消失时明确报错。
  2. 保持清单简短。 每季度检查一次的五张动态图,胜过从来没人打开的三十张。
  3. 当它涉及的容器变化时进行评审。 当一个拉取请求修改了某个容器或关系时,使用它的动态图也属于评审范围。

archyl 中的流程如何工作

在 archyl 中,动态图就是流程(Flow):一个有序的步骤列表,每一步都有源元素、目标元素、关系和描述,并在图上逐步回放(流程文档)。你可以从模型中挑选关系手工构建,也可以描述场景,让 AI Flow Generator 根据你的 C4 模型起草步骤。生成器在保存之前会用模型校验每一步:每一步的源和目标都必须存在,它引用的关系也必须连接这两个元素。不匹配的步骤会被丢弃,而不是被画出来。

有两个限制,我们明确说出来,因为它们正是本节讨论的问题:

  • 流程会在添加步骤时,保存它所用元素和关系的快照。 这样即使某个元素之后被删除,流程依然可读;但这也意味着在模型中重命名一个容器,不会同步重命名现有流程中的它。模型变化时,打开涉及它的流程检查一遍。
  • 漂移评分不检查行为。 archyl 的漂移评分告诉你文档中的元素是否仍然存在于代码中。如果两个服务之间的同步调用变成了队列消息,而没有任何东西被重命名或移动,评分不会变化,流程也不会。

关于实践层面,包括我们如何把流程写成带有前置条件和错误处理的文档,请参阅记录用户流程。

常见问题

动态图是 C4 模型的一部分吗?

是的,作为补充图。四个核心层级是 System Context、Container、Component 和 Code。C4 模型另外增加了三种补充图:系统全景图、动态图和部署图。动态图复用核心层级的元素,展示它们在一个场景中如何交互。

C4 动态图和时序图有什么区别?

C4 动态图可以用协作风格(自由布局,带编号的箭头)或时序风格(生命线,时间向下流动)来画。时序风格看起来像 UML 时序图,但它的参与者是 C4 的系统、容器或组件,每一条箭头都是对静态模型中某个关系的一次使用,而不是一次方法调用。

动态图应该使用哪个层级?

能回答问题的那个层级,并且每张图只用一个。最常见的是容器层级,因为大多数值得画的场景都横跨多个可部署单元。系统之间的流程用系统层级,解释一个容器的内部用组件层级。

动态图应该有多少步?

没有官方上限。超过大约十五步后,大多数读者就跟不上了,所以请把场景拆分成几部分,或者按它经过的每个系统各画一张图。

C4 动态图能表示异步消息吗?

能。把发布和消费画成两个独立的编号步骤,并让人看得出调用方等待哪些步骤、不等待哪些步骤:使用虚线、"async"标签或单独的编号方案,并在图例中加以说明。

archyl 支持 C4 动态图吗?

支持,形式是流程。每一步都引用你模型中的一个源元素、一个目标元素和一个关系,流程会在图上逐步回放。你可以手工编写流程,也可以根据文字描述生成草稿。流程会保存所用元素的快照,所以当它涉及的容器发生变化时,请进行复查。


想在一个已有的模型上画出你的第一个流程?免费试用 archyl,先从你的代码生成 C4 模型。继续阅读:什么是 C4 模型?完整指南 | C4 容器图指南 | 记录用户流程 | 流程文档