Diagram as Code:用代码化驱动架构图设计与团队协作

Diagram as Code:用代码化驱动架构图设计与团队协作 先交代一下背景。我维护了一个叫diagram-design的项目最初只是想把团队散落在文档里的几十张架构图、流程图、时序图统一管起来。试过在线画图工具也试过用白板软件“齐心协作”最后发现所有手工图都有一个绕不开的痛点图一旦脱离绘制者就成了只读资产谁都不敢改改完也不知道对不对。后来我把整套流程改成了“代码化图表”也就是业内常说的 Diagram as Code才真正解决了维护、评审、版本回溯和自动校验的问题。这篇文章就把我在 diagram-design 上的选型思路、完整工作流、视觉规范以及踩过的坑一次性写清楚给同样被图表维护折磨过的人一个可以直接抄作业的参考。1. 图表设计为什么要走“代码化”这条路先别急着争论哪款绘图软件好用想清楚“图表在团队里是怎么死的”你就知道代码化为什么值得投入。图表不是画出来就结束它要在评审会上被讨论、在文档里被引用、在架构变更时被更新。这个生命周期里任何一张图都需要反复修改、比较、合并、回滚——这些恰恰是文本和代码最擅长的操作。1.1 一张架构图的变更能暴露多少问题我手上有个典型的微服务模块图里面有十几个服务、四五个中间件、还有若干外部依赖。用可视化工具画的时候连线拖一拖配色调一调半小时能出图。但过了两个月订单服务拆分成了订单中心和支付回调两个模块需要在这张图上动刀问题马上来了源文件躺在同事的本地电脑里你拿到的是一张导出的 PNG想改一个节点得整个重画。就算拿到了可编辑文件多人同时改的时候没有 diff谁动了哪条连线、有没有删掉一个关键依赖完全看不出来。图片文件没法做评审评论你只能在文档里写“第 3 张图里的 XX 服务有变化”别人还得找到图再找到位置。这三个问题只是冰山一角。更隐蔽的是很多图根本没人维护代码都重构了两次架构图还是三年前的旧版。代码化之后图表变成了仓库里的.d2或.puml文本文件谁改了什么一目了然提交记录天然就是图表的变更历史。1.2 代码化图表的本质文本、语法树、渲染引擎Diagram as Code 的核心逻辑不复杂用一门描述性语言DSL把图的结构写出来然后交给渲染引擎生成 SVG、PNG 或 PDF。比如一个最简单的模块依赖关系用 D2 语言写就是order-service - payment-service: 调用 order-service - inventory-service: 扣库存 payment-service - payment-gateway: 发起支付这段文本描述了两个关键信息节点是谁节点之间有什么关系。渲染引擎拿到这段文本后会先解析成结构化的图数据再通过布局算法决定每个节点的坐标最后绘制成图形。这个“文本 → 图数据 → 渲染”的链路才是代码化图表的本质。也正因如此代码化图表能获得三个隐藏能力第一是可复用一个节点定义可以出现在多张图里第二是可校验语法不正确时引擎直接报错不会出现手绘图里那种“线穿过了另一个框”的低级错误第三是可编程后面我会讲到你可以用脚本从接口数据直接生成图表这是手工绘图完全做不到的。1.3 哪些场景收益最大并不是所有图都适合代码化。手工涂鸦类的、强调创意表达的图硬用代码画反而低效。我在 diagram-design 项目里总结了一张适用性对照表图表类型手工绘图代码化绘图说明系统架构图中高节点和关系结构清晰最适合代码化时序图低高参与者与消息顺序天然适合声明式表达ER 图中高表和字段结构稳定便于版本对比网络拓扑图中高设备、链路关系明确节点多时优势明显思维导图高中适合随手记但代码化能记录演进过程手绘风格示意图高低创意表达为主不建议强行代码化用户旅程地图中中适合用表格数据驱动生成如果你所在团队的痛点集中在“架构文档没人维护”“流程变更频繁导致图经常过期”那代码化几乎是唯一能长期解决问题的方式。2. 六类表达方案选型Mermaid、PlantUML、Graphviz、D2 与 Excalidraw很多人在网上问“哪个画图工具最厉害”其实这个问题本身就是错的。diagram-design 项目的设计原则是不要被某一个 DSL 绑死按图的类型选渲染方案。下面按我的实际使用体验逐个说清楚它们的边界。2.1 Mermaid上手最快但布局可控性偏弱Mermaid 是前端文档圈最流行的方案GitHub 原生支持很多笔记软件也内置了渲染。它的语法几乎是最低门槛的画个流程图只需要flowchart LR A[用户请求] -- B[网关] B -- C{路由判定} C --|订单| D[订单服务] C --|支付| E[支付服务]Mermaid 最大的价值是“零成本嵌入”。Markdown 文档、Notion、GitHub 都能直接渲染团队成员不需要装任何软件就能看图。但它的短板也明显布局算法比较复杂稍微大一点的图节点位置就不可控连线交叉经常发生。我在 diagram-design 里只把它用于轻量级流程示意和文档内嵌图正式交付的架构图不会用它。2.2 PlantUML时序图和 UML 场景的老牌选手PlantUML 的历史比 Mermaid 长得多生态也很成熟。它的强项是时序图语法表达消息交互非常直观startuml skinparam sequenceMessageAlign center actor 用户 participant Web端 as Web participant 订单服务 as Order participant 支付服务 as Pay 用户 - Web: 提交订单 Web - Order: 创建订单 Order - Pay: 发起支付 Pay -- Web: 支付结果 Web -- 用户: 下单成功 enduml相比 MermaidPlantUML 对复杂 UML 图的语义表达更严谨官方支持的图类型也更多活动图、状态图、部署图、组件图等。如果团队有严格 UML 建模要求它是默认选择。但 PlantUML 的渲染依赖 Java 环境语法也更啰嗦对新手不算友好。2.3 Graphviz布局引擎强适合复杂有向图Graphviz 并不是专门的图表工具而是一套图形可视化库中心思想是“你只需要描述图结构布局交给算法”。它使用 DOT 语言表达力很强digraph G { rankdirLR; node [shapebox, stylerounded]; 客户端 - 网关; 网关 - 订单服务 [labelHTTP/JSON]; 网关 - 支付服务 [labelHTTP/JSON]; 订单服务 - 数据库 [penwidth2]; }Graphviz 的历史包袱重语法风格偏老但它的布局引擎dot、neato、fdp非常能打复杂有向图的节点排布、层级关系处理得比 Mermaid 好一大截。它处理 50 个节点以上的大图时优势更明显。缺点是样式的表达靠属性拼凑颜色、字体、边框写起来不够直观。2.4 D2现代 DSL 的黑马变量和导入是杀手锏D2 是我在 diagram-design 项目里的主力方案。它的设计哲学很现代只关注“关系描述”语法干净、可读性极强同时内置了变量、样式类、导入等工程化能力。同一个服务依赖图用 D2 写出来可读性比 Graphviz 好很多vars: { color: #2D5BA5 } services: { order: { label: 订单中心 shape: rectangle } payment: { label: 支付中心 shape: rectangle } } services.order - services.payment: 同步订单状态D2 还有一个我特别看重的特性支持多文件导入。你可以把公共组件抽到一个common.d2文件里各个图表按需引用这样改公共模块时所有关联图同步更新彻底解决“同一份组件在图里画了好几遍改的时候漏改”的问题。2.5 Excalidraw手工感和协作感的最佳补充Excalidraw 严格来说不是代码化方案它生成的是 JSON 数据但 JSON 也是文本这意味着它同样具备版本管理能力。它的价值在于手绘风格、白板式的自由排版非常适合评审阶段快速画草图。diagram-design 项目中Excalidraw 用来做“想法期”的草稿D2 用来做“定稿期”的正式图。2.6 选型结论我最终为团队定下的规则是文档内嵌的轻量流程图用 Mermaid。正式架构图、数据流图用 D2统一放到diagrams目录。UML 时序图、类图用 PlantUML配合 IDEA 插件本地预览。超大规模节点图节点 50用 Graphviz 的 dot 布局。头脑风暴阶段的自由草图用 Excalidraw但要求导出的 JSON 也提交到仓库。这套规则的核心思路是用不同工具去解决不同复杂度层级的表达问题而不是一个工具打天下。3. 搭建 diagram-design 工作流目录、渲染、CI选型只是开始真正让代码化图表运转起来的是工作流。这一节我把 diagram-design 项目的仓库结构、渲染命令、样式规范和自动校验完整复述一遍你可以按这个骨架直接搭。3.1 仓库目录结构设计目录结构直接影响协作效率。我的项目结构是这样的diagram-design/ ├── src/ │ ├── system/ │ │ ├── order-system.d2 │ │ ├── payment-system.d2 │ │ └── inventory-system.d2 │ ├── sequence/ │ │ ├── create-order.puml │ │ └── refund-flow.puml │ └── common/ │ ├── services.d2 │ └── colors.d2 ├── out/ │ ├── svg/ │ └── png/ ├── scripts/ │ ├── render.sh │ └── verify.sh ├── styles/ │ └── d2-style.css ├── .github/ │ └── workflows/ │ └── diagram-ci.yml └── README.md几个关键设计src存放源文件按主题分目录common放公共片段。out是渲染产物目录可以交给 CI 自动生成也可以本地生成后提交看团队习惯。我建议提交 SVG不提交 PNG因为 PNG 是二进制diff 看不到变化。scripts放渲染和校验脚本统一入口避免每个成员用不同的参数。styles放全局样式。3.2 渲染一条命令搞定D2 官方提供了 CLI渲染命令很简单d2 --theme 200 -l dagre src/system/order-system.d2 out/svg/order-system.svg d2 --theme 200 -l dagre src/system/order-system.d2 out/png/order-system.png --scale 2我建议把常用参数写进scripts/render.sh脚本里做三件事遍历src下所有.d2文件。按相对路径创建对应的输出目录。统一指定主题、布局引擎和字体配置。渲染脚本大概是这样的逻辑#!/usr/bin/env bash set -euo pipefail SRC_DIRsrc OUT_DIRout find $SRC_DIR -name *.d2 | while read -r file; do rel_path${file#$SRC_DIR/} base_name${rel_path%.d2} mkdir -p $OUT_DIR/svg/$(dirname $base_name) mkdir -p $OUT_DIR/png/$(dirname $base_name) d2 --theme $D2_THEME -l $D2_LAYOUT $file $OUT_DIR/svg/$base_name.svg d2 --theme $D2_THEME -l $D2_LAYOUT --scale 2 $file $OUT_DIR/png/$base_name.png done这里有一个重要的参数-l dagre。D2 默认内置的好几个布局引擎都可以切换dagre在 D2 里的表现比较稳适合大多数架构图的层级展示。如果你的图是环形依赖为主可以试试-l elk效果会不一样。3.3 统一样式与主题代码化图表最容易被吐槽的一点是“看起来都长得一样没有设计感”。其实设计感完全可以通过样式体系做出来。D2 支持在源码顶部定义变量和样式我在这方面的做法是vars: { // 颜色语义 colorPrimary: #1F6FEB colorSuccess: #2DA44E colorWarning: #D29922 colorDanger: #CF222E colorNeutral: #57606A // 字体与尺寸 fontSize: 16 fontFamily: Inter, PingFang SC, Microsoft YaHei }相同类型的组件统一使用同样的颜色和形状vars.Backend: rectangle vars.Backend.style.fill: #E8F0FE order-service.BACKEND: 订单服务 payment-service.BACKEND: 支付服务样式统一降低的是认知成本。看图的人不需要每次重新理解颜色含义他知道蓝色代表后端服务、绿色代表外部依赖、橙色代表异步消息扫一眼就能定位问题。3.4 自动化校验与 Git 集成代码化图表最大的工程化优势是可以在 CI 里做校验。我在 GitHub Actions 里配置了这样的流程检查所有.d2文件语法是否正确。渲染全部文件生成out/svg。对比本次提交前后的 SVG 差异如果有变化但没更新图CI 直接红色提醒。实际的 CI 配置核心逻辑如下name: diagram-ci on: push: paths: - src/** - styles/** jobs: render-and-check: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Install D2 run: curl -fsSL https://d2lang.com/install.sh | sh -s -- - name: Render all diagrams run: bash scripts/render.sh - name: Check Git diff run: | git add out/ git diff --cached --exit-code || (echo 检测到渲染产物未更新请先运行渲染脚本并提交 SVG exit 1)这一步非常关键。它强制团队成员改完.d2文件之后必须把渲染出的 SVG 一并提交。这个“强制提交产物”的设计一是方便文档直接引用渲染结果二是让评审者能在 diff 里直观地看到图形变化。3.5 嵌入文档站点渲染出的 SVG 文件可以直接嵌入 VitePress、Docusaurus 或 MkDocs。我用的 VitePress 里直接写相对路径即可img src/diagram-design/out/svg/system/order-system.svg alt订单系统架构图 /有些团队会进一步做“文档即代码”让图表和文档的版本号保持同步。如果你还没有文档站也可以只在 Markdown 里引用 SVG 文件GitHub 或 GitLab 本身就支持图片预览。4. 代码化图表的“设计感”布局、颜色与排版的调优实战很多从手绘转代码化的人第一个反应是“生成的图好丑”。丑的根源通常不是工具而是没有做视觉设计。这一节我专门讲图表的视觉规范这部分是 diagram-design 项目里投入最多、回报也最大的工作。4.1 布局方向决定阅读动线代码化图表默认的布局方向是自上而下Top-Down。但对很多架构图来说横向Left-Right更符合“请求从左到右流转”的阅读习惯。D2 里设置方向非常方便direction: right布局方向不是玄学它决定读者第一次看图时视线的移动轨迹。我的经验是系统调用链、数据流图用direction: right从左到右模拟水流。组织结构图、类图用direction: down自上而下表达层级。复杂网状关系图尽量用dagre引擎自动分层不手动指定方向。4.2 用分组和容器表达边界很多烂图的问题不是节点关系没画对而是“哪些元素属于哪个系统”完全看不出来。手绘阶段大家习惯用一个大框把子系统框起来代码化图表里的对应能力是容器container。D2 中给节点加容器非常简单zone: 交易域 { style: { fill: #F6F8FA stroke: #D0D7DE } order-service payment-service }容器不仅提供视觉边界还能简化连线。外部节点指向容器内任意节点时可以声明为指向容器视觉上依然会自动连到具体节点。这个特性在大系统架构图里非常有用能减少大量交叉连线。4.3 颜色语义要像红绿灯一样严格颜色是图表设计里最容易失控的地方。有些图红黄蓝绿全往上堆看起来热闹读起来头痛。我建议建立一套固定的颜色语义并且把语义作为注释写在源文件开头。例如红色#CF222E异常、拒绝、高风险路径。绿色#2DA44E正常、成功、健康状态。蓝色#1F6FEB内部服务、主动调用。灰色#57606A外部依赖、第三方系统。紫色#8250DF异步消息、事件总线。一旦语义确定所有图都必须遵守。团队新增成员时第一件事就是看styles/colors.d2里的语义定义而不是自己发明新配色。我甚至见过因为图例没写清楚两个服务颜色代表相反含义导致线上事故定位错误的情况——颜色语义一定要写在图里。4.4 文本标签与字体排版节点太挤、标签重叠是代码化图表最常见的观感问题。解决办法有几个节点内不要塞长文本。标签控制在 6 个中文词以内长描述放到备注或者文档正文。给标签留白。D2 中节点默认的 padding 已经不错但如果文字被截断可以手动设置节点尺寸order-service: 订单中心 { width: 180 height: 60 }统一字体栈。我强制在样式里声明字体顺序中英文混合场景优先保证中文显示正常fontFamily: Inter, PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif4.5 大图切割与多图引用图一旦超出 40 个节点不管布局算法多强信息密度都会超出人类短时记忆范围。我的经验是一张图表达一个核心问题。如果一张图概括整个系统读者反而什么都记不住。diagram-design 项目的处理方式是把大图拆成“总览图 模块详图”总览图用容器表达模块边界模块详图通过import引入公共定义两张图共享同一份组件定义确保一致性。D2 的 import 语法很简单import ./common/services.d2公共组件的修改会同步影响所有引用了它的图这才是代码化图表的真正威力。5. 团队协作的硬骨头版本冲突、编码与渲染差异代码化图表把图表生命周期的管理问题解决了但同时也带来了新的协作问题。这一节分享几个我在 diagram-design 实践中踩过的坑每一个都曾让团队成员挠头。5.1 图表文件的合并冲突是常态多人同时改同一张架构图Git 合并冲突几乎必然发生。和代码冲突不同图表文件的冲突往往不止一个点你在文件头部加了样式同事在文件尾部加了个新服务Git 可能直接判定冲突。应对策略不是避免冲突而是让冲突更好解决。我们定了三条约定按模块拆分文件一个模块一张图从源头减少多人改同一文件。公共组件与实例拆分common目录的文件尽量只由一人维护。冲突发生时优先保留语义而不是保留文本重新看图比硬解冲突更高效。5.2 中文与特殊字符的编码坑D2 默认用 UTF-8但如果你在 Windows 上编辑文件编辑器默认可能是 GBK渲染出来的图就会乱码。我们统一要求所有源文件使用 UTF-8 无 BOM 编码并在仓库根目录放了.editorconfigroot true [*] charset utf-8 end_of_line lf insert_final_newline true另外标签里如果用了特殊字符比如引号、冒号DSL 解析可能出错。我的建议是标签文本尽量用中文或英文普通字符必须用特殊字符时用双引号包住整个标签比如label: 支付服务: 核心。5.3 本地字体和 CI 渲染不一致这是最容易忽视的坑。本地电脑上装了“微软雅黑”渲染出的 SVG 字体正常CI 容器里没有这套字体回退到 sans-serif导致整个图的文字宽度变化节点大小、连线位置全跟着变。同一份源文件本地和 CI 渲染结果对不上评审时非常痛苦。解决办法CI 阶段安装固定的字体包并且确定渲染机器的字体顺序和本地一致。GitHub Actions 的 Ubuntu 镜像可以这样装字体sudo apt-get update sudo apt-get install -y fonts-noto-cjk fonts-noto-core装完字体后CI 渲染的 SVG 和本地基本就能保持一致。如果团队用的是自建服务器一定要把字体安装步骤写进镜像 Dockerfile不能靠“到时候再说”。5.4 图例和说明应该放进图里还是放文档里手绘图时代图例经常画在图外的文档里图一被单独引用图例就丢了。代码化图表时代我建议把图例直接以隐藏节点或旁注节点的形式画进图里确保每张图独立可读。D2 的旁注能力可以这么做legend: { shape: text label: | 图例 蓝色: 内部服务 绿色: 外部依赖 红色: 异常路径 }独立图例块确实占空间但换来的是“图不外传就失义”的安全感。团队外部的人拿到任何一张图不需要翻你的文档就能看懂基本语义。5.5 用 SVG diff 做视觉回归代码 diff 只能看出源文件变化看不出图形效果变化。为了让评审更直观我在 CI 里加了 SVG 对比步骤。GitHub 的imagediff工具有些场景不太好用但你可以用 Git 自带的 diff 能力或者直接让 CI 应用d2渲染后输出两张图对比。简单做法是在 PR 描述里贴out/svg路径的旧图和新图让评审人自己打开对比。更有条件的话可以引入 Playwright 对渲染后的 HTML 页面截图做像素 diff但这套配置成本较高小型团队不必强上。5.6 “图形即代码”的代码评审原则代码评审的时候评审人通常只看.d2文件的变化。为了降低评审负担我们总结了评审检查清单新加节点是否属于正确的分组容器。新连线是否有明确语义同步/异步/依赖箭头方向是否正确。颜色是否符合styles/colors.d2里的语义。标签文本是否容易理解有没有歧义。是否更新了对应文档站里的 SVG 引用。这套清单其实就是在把“画图经验”显性化。以前手工图靠画图的人自我约束现在可以靠评审流程强制保障。6. diagram-design 的进阶玩法从简单流程图到复杂信息图如果你已经把基础的代码化图表跑通可以往更复杂的方向扩展。这一节讲我在 diagram-design 项目里验证过的几个进阶应用。6.1 云架构图云厂商架构图通常节点特别多、层级特别深手工画非常痛苦。用 D2 画云架构图时我习惯用容器表达 VPC、可用区、子网用不同形状表达计算、存储、网络vpc: VPC 10.0.0.0/16 { style.fill: #F6F8FA public-subnet: 公网子网 { lb: 负载均衡 web: Web服务 } private-subnet: 内网子网 { app: 应用服务 db: 数据库 } } web - app: HTTPS app - db: SQL这种图直接嵌在架构文档里结合 CI 渲染每次基础设施变更都能留下可持续追溯的版本记录。6.2 时序图和异步消息图时序图是 PlantUML 的地盘但如果你不想在团队里维护两套 DSLD2 也支持时间线类的表达。D2 官网有 sequence diagram 的扩展我实际用下来体验不如 PlantUML但胜在不动样式体系。我的建议是团队如果已经引入 PlantUML时序图就继续用它不必为了统一而统一。6.3 从 JSON 数据生成图表代码化图表非常有意思的玩法是通过脚本生成。我曾经从监控系统拉了一组服务依赖关系输出成 JSON再写一个小脚本把 JSON 转成 D2 源码const services [{ name: order, deps: [payment, inventory] }]; const lines services.map(s { return s.deps.map(d ${s.name} - ${d}).join(\n); }).join(\n); console.log(lines);生成的文本直接喂给d2一张动态的架构依赖图就出来了。这种方式特别适合“图需要和数据保持同步”的场景比如根据 API 网关配置生成路由图、根据数据库表结构生成 ER 图。6.4 路线图、看板与用户旅程图图表不只有技术类。diagram-design 项目里也放了一些产品路线图和用户旅程图用 D2 的容器和列表形状就能表达。这类图的核心价值是“版本化”——产品每个季度的路线图变更都有提交历史回溯时非常清楚。6.5 SVG 动画与交互D2 渲染出的 SVG 支持基础的 CSS 动画这意味着你可以把代码化图表嵌入到内部系统页面做成实时数据大屏。比如一个服务依赖图动态改变节点边框颜色表达健康状态。这个方向属于进阶玩法建议技术基础比较扎实的团队再尝试。6.6 与 AI 工具结合让图表设计更快最近我在尝试让 AI 根据自然语言直接生成 D2 源码。比如输入一句“订单服务调用支付服务支付服务调用第三方支付网关”AI 能直接输出符合项目样式规范的 D2 文件。这一步把“画图的动手成本”降到了几乎为零人只需要负责内容的逻辑正确性和美观调整。AI 生成的图大概率不是完美的但作为初稿再人工调整比从空白画布开始快很多。我在 diagram-design 项目里最大的体会是图表设计的本质是信息表达不是画图技巧。代码化之后图不再是文档里一张孤立、易腐化的图片而是可以通过版本管理、自动化渲染和团队评审持续更新的“活文档”。真正让图表长期有效的不是某一次画得多漂亮而是这套围绕图表建立的流程和规范。如果你正准备把自己的图表体系代码化我给的路径建议是先从 Mermaid 入门它最轻、最快能让你体验“写代码出图”的感觉当你开始头疼布局问题时切到 D2 并建立统一的源文件目录和渲染脚本如果你的团队已经有 UML 建模要求再引入 PlantUML最后一定别忘了把 CI 校验和字体统一这两件事做掉它们是整个工作流稳定运行的地基。一个小技巧送给正在犹豫的人不要试图一夜之间把团队的图全部代码化。挑一张最近的、维护最痛苦的架构图用 D2 重画一遍提交到仓库配上渲染脚本。这一张图跑通的全流程比看一百篇教程都管用。