Diagram-as-Code:用D2构建可版本化的架构图工作流
如果你和我一样维护过几十个微服务、理过错综复杂的依赖关系大概率经历过这种场景改了一行代码要去更新架构图时打开白板软件对着一堆框和箭头发呆心里想的却是“先画哪个才能少拖一次线”。我最初做 diagram-design 这个项目就是实在受不了手动画图的维护成本。画图本身不难难的是后续每一次改动都要重新拖拽、对齐、调样式图一多起来根本管不住。所以我把整套画图流程迁移到了“描述式图表”上让图的结构和内容用代码来定义再自动产出 SVG、PNG 这类可交付物。这篇文章就把我在搭建这套 diagram-design 工作流时的选型思路、语法设计、自动化接入和踩坑过程完整记录下来适合那些需要长期维护架构图、技术方案图、拓扑图又不想被画图工具绑住的团队和个人参考。1. 从“手绘架构图”到“Diagram-as-Code”我为什么决定做一件麻烦事1.1 手绘架构图的三大痛点一次性、不可追踪、不可复用很多人觉得画图么用白板或者在线画图工具拖一拖就好了为什么要费劲去学一套文本语法这个想法我太理解了因为我自己一开始也是这么干的。直到某次做系统重构需要给三个不同环境画四张架构图我整整拖了一个下午最后发现核心链路画错了又要从头调整。那一瞬间我意识到在线画图工具根本不是为“维护”设计的它是为“画一张图”设计的。手绘架构图的第一个痛点是不可追踪。图被导出成图片之后和代码仓库里的提交记录完全脱节你根本不知道这张图是什么时候画的、对应的是哪个版本、谁改过哪里。第二个痛点是不可复用。很多模块在不同图里反复出现手绘时只能一帧一帧地重新画换个布局又要全部重排。第三个痛点是不可评审。别人给你提修改意见时只能靠“大概往左挪一下”“颜色换一换”这种模糊描述因为你没法对着一张图片做精确的 diff 和注解。这三个痛点叠加在一起对任何稍微有点规模的技术项目来说都是灾难。架构图一旦失准比没有图危害更大因为新同学会照着错误的图去理解系统老同学也觉得图是“历史遗留产物”干脆不去更新。所以我才下定决心把所有核心图表全部迁移到代码化方案上让图的每一个节点、每条连线都有明确的文本定义。这个决定短期看是“给自己找麻烦”长期看却是唯一能让人愿意持续维护的方案。1.2 把图变成“资产”版本管理、差异评审、与代码同生命周期迁移到 Diagram-as-Code 之后最大的感受不是“画图变快了”而是“图终于可以被管理了”。以前导出图片是终点现在生成 SVG 只是起点。因为源代码是纯文本我可以把它放进 Git 仓库和业务代码一样走提交、审阅、发布流程。一次改动对应一个 commitreviewer 可以直接看到图中哪个节点被增删了、连接关系怎么变化这种精确性手绘图永远做不到。另一个好处是图纸与代码的生命周期终于统一了。服务上线时架构图同步更新服务下线时架构图同步删除。因为图和代码在同一个仓库里这种同步不再是“需要记得做”的事情而是“代码审查时顺带就做了”的事情。我们甚至在 commit message 里规定如果本次变更涉及服务间调用关系必须附图变更说明否则不通过 CI 检查。这样虽然一开始大家都嫌麻烦等到真出了问题要排查历史版本时每个人都在感谢这个决定。如果你问我 Diagram-as-Code 到底解决了什么我会说它解决的不是“画得快”的问题而是“图还有人信”的问题。一张能被版本控制、能被 diff、能被自动校验的图才真正称得上技术资产。而画得快慢反倒是次要的因为大部分架构图的变更频率本来就不高维护成本才是关键。1.3 什么人适合引入 Diagram-as-Code并不是所有画图需求都适合代码化。我的判断标准很简单第一图的使用场景是否是长期维护的第二图的阅读者是否是多人的第三图的变更是否频繁到需要记录历史。三个条件至少满足两个就值得引入如果只是临时画个示意图给老板看一次性的那直接打开白板拖一拖反而更快。规模上三五个人、两三个服务的小项目也可以先只把最核心的系统架构图代码化不必一下铺开。等团队尝到版本管理的甜头再逐步把部署拓扑图、数据流图、时序图这些都纳入进来。Diagrams-as-Code 并不是银弹它更适合的是那些“图比代码还要多、图比代码还容易过期”的场景。说实话工具的选择不是最难的最难的是让团队养成“图随代码走”的习惯。2. 选型之战D2、Mermaid、Graphviz、PlantUML到底谁更适合当主力2.1 画图语法选型本质上是选“改图的成本”当我决定把图表代码化之后面临的下一个问题就是选语言。GitHub 上这类方案很多老牌的有 Graphviz 的 DOT 语言轻便的有 PlantUML教程多、普及率高的是 Mermaid还有一个新兴的 D2。只看宣传页大家都说自己是“最清晰”“最快速”真到自己手写维护时就原形毕露了。我的选型标准只围绕一个核心改图时的成本。因为图和代码不一样代码的修改往往是在已有逻辑上打补丁而图的修改尤其是架构图经常是整个布局都要因为新增一个模块而推倒重来。好的图表描述语言必须让你把精力集中在“我要表达什么关系”上而不是“这条线怎么摆才不交叉”。另外一个容易被忽略的点是可读性。图是给团队看的语法文件也是给团队改的。如果一段图代码像天书改它的人只会越来越少。Graphviz 的 DOT 语法表达力很强布局和渲染的成熟度也高但它的语法对新人不太友好尤其在处理集群、端口、样式这些细节时记住一堆属性名并不比记住快捷键轻松。PlantUML 功能非常全面时序图、用例图、活动图都能画但它的语法比较老派稍微复杂一点的图代码行数会比实际的节点数多出一倍。2.2 D2、Mermaid、Graphviz、PlantUML 的横向对比下面这张表是我在做选型时整理的一个比较粗略的对照不追求面面俱到只挑我在意的维度维度MermaidGraphvizPlantUMLD2语法上手速度快几行就能画流程图中属性较多中关键词丰富快语法非常简洁布局引擎可控性弱复杂图容易乱强但配置复杂中默认布局一般强多布局引擎可切换Markdown 内嵌方便度很高原生支持一般一般中等需额外插件中文渲染依赖环境有字体问题需要字体配置比较好需注意字体总体可控大图渲染性能一般快偏慢快设计理念轻量内嵌老而强大全能型认为代码应是可维护的Mermaid 无疑是最容易上手的你只要在 Markdown 里写一个 flowchart 代码块渲染出来就能用。但它的硬伤在于布局算法比较“随缘”节点一多、连线一多图就容易乱成一团。Graphviz 是瑞士军刀什么都能画但你要付出的学习成本也最高。PlantUML 适合需要支持多种 UML 图的团队但如果你的核心场景是云原生架构图、系统拓扑图它反而显得笨重。我在对比后选了 D2原因是它在“代码可读性”和“布局可控性”之间找到了一个平衡点。D2 的语法刻意保持了极简一个节点就是一行名一条连线就是两个名字加一个箭头没有任何修饰符号。加上它的布局引擎做得不错默认的 TALA 引擎对树状、分层结构很友好几个引擎之间可以自由切换。当然Mermaid 也不是没有优势如果你的核心场景就是写 Markdown 文档时顺手画一张简单流程图Mermaid 依然是效率最高的。我对工具的理解是没有最好的只有当下最合适的。我需要在 CI 工作中大规模产出标准化架构图所以 D2 胜出。2.3 什么情况下我不建议用 D2说完了 D2 的优点也得说说它的边界。团队里如果有人特别抵触“写代码画图”就别硬推简单草图用在线画图工具五分钟就能搞定非要引入一套命令链是明显的过度设计。其次如果你的核心需求是 UML 类图里的各种继承、实现、依赖语义PlantUML 的领域词汇更贴切如果你需要在网页里做动态交互图可能还是前端绘图库更合适。D2 最适合的场景是静态架构图、流程拓扑、部署关系图这类“结构相对稳定、变更频繁但变更模式固定”的图。3. 把 D2 吃透核心语法、布局引擎与设计理念3.1 D2 文件的最小可运行示例D2 的学习曲线非常平缓。一个最小的 d2 文件只需要几十个字节client - api - db上面这一行的意思是client 连向 apiapi 连向 db。保存为 arch.d2然后执行d2 arch.d2 arch.svg当前目录下就多了一张 arch.svg打开就是三个节点、两条箭头的流程示意。D2 会帮你完成节点命名、连线布局、样式配色你不需要指定节点的 x、y 坐标也不需要管箭头从哪个位置伸出。这种“只描述关系不描述坐标”的设计就是它和传统绘图工具最本质的区别。坐标由引擎去算人只负责表达关系关系一变布局自动跟着变。多容器的表达也很自然。D2 里只要使用缩进就能表达容器关系cloud { gateway service_a service_b } user - cloud.gateway注意看cloud.gateway这种带点的路径写法它是 D2 处理嵌套结构的核心。外部节点指向容器内部节点时不需要手动去“连一条线到边框再穿进去”直接引用完整路径即可。这让图的语义和代码的层级一一对应代码怎么分层图就怎么表达。3.2 三种布局引擎TALA、Dagre、ELK怎么选D2 支持三套布局引擎自带默认的 TALA以及老牌的 Dagre 和 ELK。三者的设计侧重点不同同一份代码在三套引擎下产出的布局可能完全不同。我刚开始时只在默认引擎下工作直到遇到一张节点较多的全景图才意识到布局引擎也是可以调参的。TALA 是 D2 官方主推的布局引擎对分层结构、树状结构做了深度优化默认产出就是比较现代的“从上到下”“从左到右”风格而且对连通图处理得很干净多数情况下不需要额外操心。Dagre 更偏向 DAG有向无环图布局适合结构偏线性的流程图。ELK 则是 Eclipse 基金会贡献的布局库强在大型图形和复杂层级上节点多、嵌套深的时候它算出的布局通常更紧凑但速度也会慢一些。在命令行里切换引擎很简单d2 --layoutelk arch.d2 arch.svg d2 --layoutdagre arch.d2 arch.svg在实际项目中我会以默认 TALA 为第一选择只有发现默认布局有交叉线很严重的问题时再切换到另外两个引擎对比看哪个效果更符合直觉。引擎没有绝对的好坏只有适不适合当前这张图。3.3 变量、导入与主题让大图保持可维护D2 语法看似简单真正让它撑得起大型图的是变量和导入机制。比如你可以在一个公共文件里定义一套云厂商图标映射然后其他图统一导入保证所有图的视觉风格一致# common/cloud.d2 aws: { shape: cloud style.fill: #FF9900 } gcp: { shape: cloud style.fill: #4285F4 }在另一张图里你只需要import ./common/cloud.d2 aws.ec2 - gcp.bigtable被导入的aws、gcp节点会自动出现在新图里。这个能力的大规模价值是团队可以沉淀一组标准的图标库、颜色规范、命名规范所有人工整图时直接引用而不是各自画各自的。不再出现同一张图里同一个服务在左边叫user-service、在右边叫用户服务的混乱情况。变量const在处理重复文本时很好用。例如环境名可能出现在多个标签里我们就可以统一管理const env: prod service_a - service_b: ${env}后面如果要改成灾备演练环境只需把 env 换掉全图联动。这种“一处修改处处生效”的能力是纯手绘、甚至普通图片编辑工具都做不到的。3.4 输出格式不止是 SVG还能衔接 PPT 和文档D2 默认输出 SVG但实际工作中我们需要交付的往往不只是图片文件。公司内部的方案评审最后可能要把架构图贴到 PPT 里对外分享文档又需要 PNG 方便直接嵌到网页。D2 支持的输出格式非常务实d2 arch.d2 arch.svg d2 arch.d2 arch.png d2 arch.d2 arch.pdf d2 arch.d2 arch.pptxPDF 适合打印和阅读PPTX 可以直接编辑。我比较常用的是 SVG 加 PNG 组合SVG 作为源交付物保留在仓库里PNG 则嵌入到 Confluence 或飞书文档中。还有一个实用的 watch 模式d2 --watch arch.d2 arch.svg开启 watch 后编辑 d2 文件保存SVG 会自动重新生成。配合一个支持自动重载的 SVG 预览窗口整个体验就和本地开发热更新一样。我写图时基本都是这个模式左边编辑器右边预览图改一行看一行效率比“全改完再批量生成”高得多。4. 沉淀一套可落地的 diagram-design 工作流4.1 仓库目录怎么组织才不乱有了语言和工具下一步是把它工程化。我和团队定的目录结构大概长这样diagram-design/ ├── src/ │ ├── common/ │ │ ├── cloud.d2 │ │ └── themes.d2 │ ├── system/ │ │ ├── checkout.d2 │ │ ├── payment.d2 │ │ └── order.d2 │ ├── infra/ │ │ ├── kubernetes.d2 │ │ └── network.d2 │ └── overview.d2 ├── dist/ │ ├── svg/ │ └── png/ ├── scripts/ │ ├── build.sh │ ├── render-all.sh │ └── check.sh └── Makefilesrc目录按主题划分common 放共用组件定义system 放业务系统图infra 放基础设施图顶层放一张全局 overview。每一张图都是独立的 d2 文件文件名与系统名保持一致。dist是渲染产物不进 Git由构建脚本产生。scripts里放置统一入口脚本。这样组织的好处是职责清晰新人进来打开仓库一看目录就明白每张图在哪维护。更重要的是它可以支撑后续的自动化构建脚本只需要遍历src下所有 d2 文件就能批量生成所有图表校验脚本也只需要检查这些 d2 文件有没有语法错误。4.2 让 CI 帮你检查图有没有“画坏”很多人以为代码化图表入库后就万事大吉了其实少了自动化校验图的质量依然不可控。我会在 CI 里挂三个检查项。第一语法检查。D2 提供d2 fmt命令本质上它不只做格式化还会顺带做语法解析如果有语法错误命令直接报错。d2 fmt --check src/**/*.d2如果把--check换成不带参数的d2 fmt它会自动把不符合格式的文件改好。我在 CI 里强制要求所有 d2 文件必须先通过d2 fmt --check否则不能合入。这个体验和 Go 语言的 gofmt 几乎是同一个思路。第二渲染检查。光有语法检查不够因为语法合法不代表布局不会乱。我们的 build 脚本会把所有 d2 文件渲染成 SVG并检查渲染过程是否有 warning 输出比如某些节点互相遮挡、连接线穿过了不相关的容器这些会在输出日志里看到。虽然这一步没法完全靠自动化判断审美但至少能把“明显渲染异常”挡在外部。第三git diff 检查。每次合并请求里如果涉及 d2 源码变更CI 会要求同时提交渲染后的 SVG 快照方便 reviewer 直接在页面上看到图的变化而不需要本地跑一遍命令。这个流程听起来简单在实际协作中却非常有效它强制“图和代码同步改”。4.3 与代码仓库、文档平台、分享页面的联动图表工作流的最终价值是要被使用。我做了三个方向的联动。第一与代码仓库联动。每个微服务仓库的 README 里会嵌入dist/svg下的系统架构图这样任何一个开发者点进仓库先看到图再决定读不读代码。第二与文档平台联动。Confluence 或飞书文档里不支持动态拉取 SVG我们就用 CI 把渲染出的 PNG 推送到文档平台保证文档平台上的图永远来自最新一次构建。第三与分享页面联动。D2 支持d2 --animate-interval参数生成带逐帧动画的 SVG用在对外分享时可以让图上的连线按顺序出现演示体验比静态图好不少。我个人最推荐先做第一件给 README 挂图。原因很简单它是成本最低、收益最直接的联动。一张正确、最新的架构图对一个仓库来说就像地图对一个陌生城市一样重要。别人愿不愿意读你的代码很大程度上取决于他能不能快速建立对系统结构的直觉。5. 实际项目中踩过的五个大坑与排查过程5.1 中文渲染成方框乱码第一个坑是中文。我最早写好的 d2 文件里带了中文标签渲染出来 SVG 在浏览器打开中文全变成了方框。排查时第一步先用 d2 的 watch 模式重新渲染发现控制台没有任何报错说明问题出在字体而不是代码。后来我查了 D2 的文档发现它定位中文字体依赖操作系统的字体库如果环境里没有合适的中文字体渲染时就会退回默认字体。解决方式是在命令行指定字体目录。Linux 服务器上需要先确认有没有安装中文字体fc-list :langzh如果输出为空说明服务器缺少中文字体需要手动安装。CentOS 上执行yum install fontconfig和wqy-zenheiDebian 系则用apt install fonts-noto-cjk。本地 macOS 一般没这个问题但 CI 服务器上如果没装中文字体生成出来的 PNG 同样会是空方块。这个坑的排查链路虽然不复杂但很容易被忽略因为 CI 里页面能正常渲染 SVG等到把 SVG 转成图片时才暴露问题。5.2 嵌套容器和布局引擎“打架”第二个坑出现在我第一次画带多级容器嵌套的架构图。D2 的语法没有任何问题容器在语法上就是父子关系但布局结果却很反直觉子容器被排到了父容器的外部。排查这个问题的过程中我先去掉了所有样式只保留节点和连接关系发现布局依然错乱初步判断是布局引擎的问题。后来我在命令行手动试了--layoutgoogle的替代方案没解决。真正定位到的问题比我想象的更底层当一个容器内的节点之间没有内部连线时TALA 引擎会倾向于把这些节点当成独立元素处理无法形成明确的“聚合”关系。解决办法也很简单给容器内节点之间补上明确的连接关系或者用direction关键字显式声明容器内部的排列方向cloud { direction: right service_a service_b }这个坑的经验是D2 虽然语法宽松但布局引擎毕竟不是读心术。你想表达“这些节点属于同一个组”除了代码缩进还要在语义上让节点之间有联系引擎才能领会你的意图。5.3 文本溢出节点被内容“撑坏”第三个坑更具迷惑性节点文字过长时D2 会自动扩展节点尺寸但如果文字超过一定长度就可能导致节点把箭头压到旁边、整条链路被强行换行。我第一次遇到时以为是自己样式写错了查了很久发现是标签里写了一段很长的服务描述D2 不会自动换行默认就横向堆下去。解决这个问题的思路有两条一是在文字里手动加\n换行二是利用style.overflow和text相关的属性控制。更实用的做法是给节点加一个label用简洁名称而把详细描述放在tooltip里order_service: 订单服务 { tooltip: 负责订单创建、支付回调、状态流转 }渲染后画面上只显示“订单服务”鼠标悬停才能看到详细说明。这个方案既保住了画面的干净也让细节信息不丢失强烈推荐。5.4 大图渲染性能下降、SVG 体积膨胀第四个坑出现在我把所有系统画进一张全景图时。节点超过 200 个、连线超过 300 条后D2 的渲染速度明显变慢生成的 SVG 体积大得离谱在线打开时浏览器要加载很久。这个问题的本质是大而全的图本身就不合理而不是工具性能差。我的应对方式是把一张全景图折分成多张分域图。比如入场域、订单域、支付域各画一张然后用 D2 的import机制把它们在另一张总揽图里合成import ./system/order.d2 import ./system/payment.d2 import ./common/cloud.d2这样单张图的复杂度可控渲染速度恢复正常拆分后的图也更聚焦业务边界。如果你想看全局总揽图依然可以看到各个子系统之间的关系。分而治之永远是解决复杂性的第一原则乱画大图的教训我帮大家踩过了。5.5 多人同时改同一张图合并冲突怎么解最后一个坑和代码工作流相关多人并行修改同一个 d2 文件时文本格式的图必然产生合并冲突。普通代码冲突要靠人理解代码逻辑去解决d2 文件的冲突本质上也是但会比图片格式的冲突好处理一万倍至少 diff 能看到具体改了哪一行、删了哪个节点。为了减少冲突频率我们约定按域拆分文件每个域控制在 50 到 80 个节点之间。一个大域由一个人负责其他人若要改动优先提出 CR 而不是直接改原文件。如果 CO 时发现文件被 fmt 格式化了产生大量非实质性 diff先在本地跑d2 fmt再提交避免混入格式噪声。这些都是把图当作代码之后才有的纪律约束也是让协作不乱的前提。6. 一张全景图的重构案例从边画边想到边想边画6.1 从“依赖迷宫”到分层清晰的图最后用一个实际案例收尾。我之前负责一个结算系统图上有支付渠道、对账任务、账务中心、通知中心、风控引擎逻辑错综复杂。刚开始用 D2 重画时我试图一次性把所有关系都画出来结果代码写了 400 行布局乱到连我自己都不想看。后来我静下心来先从“边界”出发结算系统对外暴露什么、依赖什么、内部有哪些核心模块分成三层来表达。第一层外部系统第二层网关层第三层核心领域层每一层的节点用统一的容器包住。重构后的 d2 文件结构类似这样external { merchant bank_gateway } core { ledger settlement reconcile } external.merchant - external.bank_gateway external.bank_gateway - core.ledger core.ledger - core.settlement core.settlement - core.reconcile当我把图从“边画边想”变成“边想边画”之后系统结构的理解反而清晰了很多。因为你必须先明确边界才能动手画而这种“协议先行”的思路正是好的架构设计的核心。D2 在这里给我的帮助不是画图本身而是让我强迫自己用结构化的方式组织信息。6.2 针对场景做图的“阅读动线”重构完结构之后我开始针对不同阅读场景做“动线”设计。给老板看的图突出北极星指标和链路成本给研发看的图突出模块依赖和可部署单元给运维看的图突出网络边界和高可用。同一套源数据通过 D2 的多主题和条件展示可以派生不同视觉风格的图。像 D2 里的--theme参数快速换配色一套代码出多种肤色的图配合vars变量控制节点是否显示已经能满足我 90% 的定制化需求。6.3 给想入坑的人一份起步清单如果你也想在自己的项目里引入 Diagram-as-Code 思路我的建议是别一上来就追求全套工程化。先把一张最让你头疼的架构图用文本语法写出来跑通渲染链路感受“改一行代码、图自动更新”的体验。然后逐步把公共组件抽成 import 文件引入 fmt 格式化检查。最后再考虑 CI 接入和团队协作规范。这条路我从手绘白板一路走到 D2 工作流最深的体会是画图的本质是沟通而沟通的前提是信息准确、可追溯、可讨论。当你的图的每一处细节都能被代码定位到画图这件事才真正回到了它本来的目的。
