如果你和我一样,维护过几十个微服务、理过错综复杂的依赖关系,大概率经历过这种场景:改了一行代码,要去更新架构图时,打开白板软件,对着一堆框和箭头发呆,心里想的却是“先画哪个才能少拖一次线”。我最初做 diagram-design 这个项目,就是实在受不了手动画图的维护成本。画图本身不难,难的是后续每一次改动都要重新拖拽、对齐、调样式,图一多起来根本管不住。所以我把整套画图流程迁移到了“描述式图表”上,让图的结构和内容用代码来定义,再自动产出 SVG、PNG 这类可交付物。这篇文章就把我在搭建这套 diagram-design 工作流时的选型思路、语法设计、自动化接入和踩坑过程完整记录下来,适合那些需要长期维护架构图、技术方案图、拓扑图,又不想被画图工具绑住的团队和个人参考。
1. 从“手绘架构图”到“Diagram-as-Code”:我为什么决定做一件麻烦事
1.1 手绘架构图的三大痛点:一次性、不可追踪、不可复用
很多人觉得画图么,用白板或者在线画图工具拖一拖就好了,为什么要费劲去学一套文本语法?这个想法我太理解了,因为我自己一开始也是这么干的。直到某次做系统重构,需要给三个不同环境画四张架构图,我整整拖了一个下午,最后发现核心链路画错了,又要从头调整。那一瞬间我意识到,在线画图工具根本不是为“维护”设计的,它是为“画一张图”设计的。
手绘架构图的第一个痛点是不可追踪。图被导出成图片之后,和代码仓库里的提交记录完全脱节,你根本不知道这张图是什么时候画的、对应的是哪个版本、谁改过哪里。第二个痛点是不可复用。很多模块在不同图里反复出现,手绘时只能一帧一帧地重新画,换个布局又要全部重排。第三个痛点是不可评审。别人给你提修改意见时,只能靠“大概往左挪一下”“颜色换一换”这种模糊描述,因为你没法对着一张图片做精确的 diff 和注解。
这三个痛点叠加在一起,对任何稍微有点规模的技术项目来说都是灾难。架构图一旦失准,比没有图危害更大,因为新同学会照着错误的图去理解系统,老同学也觉得图是“历史遗留产物”,干脆不去更新。所以我才下定决心,把所有核心图表全部迁移到代码化方案上,让图的每一个节点、每条连线都有明确的文本定义。这个决定短期看是“给自己找麻烦”,长期看却是唯一能让人愿意持续维护的方案。
1.2 把图变成“资产”:版本管理、差异评审、与代码同生命周期
迁移到 Diagram-as-Code 之后,最大的感受不是“画图变快了”,而是“图终于可以被管理了”。以前导出图片是终点,现在生成 SVG 只是起点。因为源代码是纯文本,我可以把它放进 Git 仓库,和业务代码一样走提交、审阅、发布流程。一次改动对应一个 commit,reviewer 可以直接看到图中哪个节点被增删了、连接关系怎么变化,这种精确性手绘图永远做不到。
另一个好处是图纸与代码的生命周期终于统一了。服务上线时,架构图同步更新;服务下线时,架构图同步删除。因为图和代码在同一个仓库里,这种同步不再是“需要记得做”的事情,而是“代码审查时顺带就做了”的事情。我们甚至在 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 的横向对比
下面这张表是我在做选型时整理的一个比较粗略的对照,不追求面面俱到,只挑我在意的维度:
| 维度 | Mermaid | Graphviz | PlantUML | D2 |
|---|---|---|---|---|
| 语法上手速度 | 快,几行就能画流程图 | 中,属性较多 | 中,关键词丰富 | 快,语法非常简洁 |
| 布局引擎可控性 | 弱,复杂图容易乱 | 强,但配置复杂 | 中,默认布局一般 | 强,多布局引擎可切换 |
| 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 连向 api,api 连向 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 --layout=elk arch.d2 arch.svg d2 --layout=dagre 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 :lang=zh如果输出为空,说明服务器缺少中文字体,需要手动安装。CentOS 上执行yum install fontconfig和wqy-zenhei,Debian 系则用apt install fonts-noto-cjk。本地 macOS 一般没这个问题,但 CI 服务器上如果没装中文字体,生成出来的 PNG 同样会是空方块。这个坑的排查链路虽然不复杂,但很容易被忽略,因为 CI 里页面能正常渲染 SVG,等到把 SVG 转成图片时才暴露问题。
5.2 嵌套容器和布局引擎“打架”
第二个坑出现在我第一次画带多级容器嵌套的架构图。D2 的语法没有任何问题,容器在语法上就是父子关系,但布局结果却很反直觉:子容器被排到了父容器的外部。排查这个问题的过程中,我先去掉了所有样式,只保留节点和连接关系,发现布局依然错乱,初步判断是布局引擎的问题。后来我在命令行手动试了--layout=google的替代方案,没解决。
真正定位到的问题比我想象的更底层:当一个容器内的节点之间没有内部连线时,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 工作流,最深的体会是:画图的本质是沟通,而沟通的前提是信息准确、可追溯、可讨论。当你的图的每一处细节都能被代码定位到,画图这件事才真正回到了它本来的目的。