diagram-design 这个项目名,听起来挺普通的,但它背后的思路我琢磨了很久。简单说,它就是一套“用代码画图”的完整工作流:架构图、流程图、时序图、部署拓扑,全部用文本型的 DSL 写源文件,再用渲染引擎导出成 SVG、PNG,最后嵌进文档、Wiki 或者直接发布到内部站点。所有图都交给 Git 管理,改图就是改代码,走评审流程也顺理成章。
这套方案解决的是我长期以来被拖拽画图折磨的痛点:图文件格式混乱、没法 diff、复制粘贴容易错位、版本之间说不清改了什么。如果你也经常要给项目画架构图、给 API 写时序图、给部署流程画拓扑图,又不想在多个画图软件之间反复横跳,那这篇内容应该能帮你少踩不少坑。不管你是后端、前端还是运维,只要手上有一堆“不得不维护的图”,都值得往下看。
1. 为什么做 diagram-design:写代码的画图方案到底解决什么问题
1.1 传统拖拽画图的四个致命伤
以前我画图主要用两款工具:一个是 draw.io,本地文件为主;另一个是 ProcessOn 这类在线工具。不是不好用,而是只要图一多、版本迭代一快,问题就全暴露出来了。
第一是版本管理黑洞。draw.io 导出的文件,本质是压缩过的 XML,你根本看不出这个版本和上个版本做了什么改动。在线工具更麻烦,免费版的历史记录有限,团队里随便谁改一下,可能就把上一版覆盖了。想搞明白“这张架构图为什么删掉了这个服务”,翻遍历史记录都未必找得到。
第二是模板复用困难。拖拽画图最理想化的流程是“模板-复制-微调”,但实际用起来,你复制的组件往往带着一堆没删干净的样式,或者坐标位置全乱。到第三个人接手维护时,图上已经有一堆“画完之后我再也没碰过”的孤立方框。
第三是协作评审没有记录。图是跟着需求、代码一起变更的,但在拖拽型工具里,图和 PR、评审记录、讨论串是完全割裂的。评审人看到的只是一张静态图,他没法知道谁在什么时候为什么改了这里。
第四是部署链路断裂。图的最终去处应该是文档、百科、官网,而不是某个人的本地文件。拖拽工具导出图片之后,每次更新都要手动再导一遍,再手动替换到文档里。只要忘了,文档和实际架构就慢慢分叉了。
1.2 用文本 DSL 带来哪些实际变化
换成“代码画图”之后,变化是结构性的。最直观的是“可 diff”。每个图的源文件都是一段文本,一行新增、一行删除,在 Git 提交记录里看得清清楚楚。代码评审拿它和普通代码一样过,哪怕是一个矩形的位置挪动了,也会体现在提交里。
第二个是“可复用”。DSL 文件本身是纯文本,完全可以抽模板、写循环、套变量批量生成。比如系统里有十几个微服务,每个服务的依赖关系图结构都差不多,我只要用脚本读一份服务清单,就能一次性生成所有服务的专属拓扑图。这个需求在拖拽工具里几乎不可能高效完成。
第三个是“一套源文件,多处发布”。同一份 Mermaid 文件,本地渲染成 SVG 发到文档平台,CI 里渲染成 PNG 挂到内部百科,再集成进 Markdown 文档里展示。源文件只维护一份,发布渠道可以任意扩展。对于需要频繁更新图表的团队来说,这一步省下的时间是持续的。
1.3 这套方案不适合的场景
但我不是无脑推荐所有人都切到文本画图。如果你满足下面这些条件,建议还是老老实实用拖拽工具:图是给外部客户看的,对视觉精美度要求极高;需要和 PPT 深度配合,经常要调渐变阴影和动效;或者是产品原型级别的 UI 草图,线框图中细节太多太碎。
文本 DSL 的优势是结构化、可追溯、易维护,牺牲的是像素级自由。换句话说,它更适合“工程师写给工程师看”的图,而不是“设计师做给客户看”的图。搞清楚这个边界,后面用起来才不会被局限在某个单一工具里。
2. 核心引擎选型:Mermaid、PlantUML、Graphviz、D2 怎么选
2.1 四类引擎的定位差异
选引擎之前,我先做了一番对比。市面上的文本画图方案大致可以分成四类:Mermaid、PlantUML、Graphviz 和 D2。这四类不是替代关系,而是各自主打的场景差异很大。
| 引擎 | 上手难度 | 主打场景 | 依赖环境 | 布局方式 |
|---|---|---|---|---|
| Mermaid | 低 | 流程图、时序图、甘特图、饼图 | Node.js 或浏览器 | 自动布局,改造成本低 |
| PlantUML | 中 | 时序图、用例图、状态图、活动图 | Java + Graphviz | 布局规整,时序图细节丰富 |
| Graphviz | 较高 | 无向图、有向图、复杂拓扑 | 原生工具链 | 自动布局能力最强,但学习曲线陡 |
| D2 | 中低 | 架构图、网络拓扑、动态演示 | 单一二进制 | 声明式布局,强调可读性 |
从表格能看出来,没有任何一个引擎能覆盖所有需求。Mermaid 语法流畅、渲染效果好,尤其是在浏览器和 Markdown 生态里,几乎零成本接入;PlantUML 对时序图和用例图的支持,细节程度是 Mermaid 目前赶不上的;Graphviz 虽然语法老,但它的自动布局算法,尤其是大规模复杂图,多年积累非常扎实;D2 是最新的方案,设计理念现代,代码可读性极高,就是生态还在成长期,很多周边工具还没有跟进。
2.2 我最终的选型组合与理由
我最终没有只选一个,而是按场景做了组合:主力引擎用 Mermaid,处理 80% 的日常图表;时序图、用例图这类强调交互顺序和角色关系的图,切到 PlantUML;遇到复杂依赖、集群网络拓扑这种对自动布局要求极高的图,再用 Graphviz。D2 我一直在关注,但目前只用在个人项目和概念验证里,没有大规模引入团队。
这么选的核心原因是“团队接受度”。画图工具再强,如果成员不愿意用,推广不起来等于零。Mermaid 语法接近自然语言,新人看一遍示例就能上手,这是它能成为主力的关键。而 PlantUML 和 Graphviz 学习成本相对高,只有遇到特定场景才需要,所以放在备用位置,不影响团队主流工作流。
2.3 工程初始化:目录、命名与依赖锁定
搭建这套工程,目录结构是第一步。我习惯把图和文档分开,但保证它们之间能互相引用,结构大致如下:
diagram-design/ ├── sources/ # 所有图源文件 │ ├── architecture/ # 架构图 │ ├── sequence/ # 时序图 │ ├── network/ # 网络拓扑 │ └── state/ # 状态机 ├── output/ # 渲染产物,不入库或按需入库 ├── scripts/ # 构建、校验脚本 ├── templates/ # 模板文件 ├── Makefile # 一键构建入口 └── README.md命名规范我花了些时间定出来,因为源文件多了之后命名乱是最可怕的。我采用的是“图名-类型-版本”的结构,比如order-service-arch-v2.mmd,一眼就能看出描述对象、图表类型和当前版本。源文件本身最好只放最近一个版本,历史版本交给 Git 去管,不要人肉在文件名里堆-final-2-final。
依赖锁定是第二个关键决定。Mermaid 的 CLI 工具、PlantUML 的 jar 包、Graphviz 的版本,都要固定到具体版本号。我最开始图省事全局装,结果团队里三个人三套版本,渲染出来细节各有差异。后来统一用固定版本命令,问题立刻消失。这个环节千万别忽略,它决定了整套工程能不能稳定复现。
3. 从零实现一个 diagram-design 工程
3.1 先画一张架构图:Mermaid 语法实操
我以最常见的“订单服务架构图”为例,带你过一遍从零开始写 Mermaid 的完整流程。源文件命名为order-service-arch-v1.mmd,核心内容如下:
flowchart TB subgraph Client["客户端"] A["Web 前端"] B["移动端"] end subgraph Gateway["网关层"] C["API Gateway"] end subgraph Services["微服务层"] D["订单服务"] E["用户服务"] F["库存服务"] end subgraph Storage["存储层"] G[(MySQL)] H[(Redis)] end A --> C B --> C C --> D C --> E C --> F D --> G D --> H E --> G F --> H这里我选flowchart TB而不是graph TB,是因为 flowchart 语法更丰富,对子图、样式定制的支持更好。subgraph是关键,它把图分成了客户端、网关层、微服务层、存储层四个区块。没有子图的架构图,节点全部平铺,视觉上非常混乱,不利于快速理解系统的层级关系。
写完之后,用 Mermaid CLI 渲染:
npx -y @mermaid-js/mermaid-cli@10.9.1 -i sources/architecture/order-service-arch-v1.mmd -o output/order-service-arch-v1.svg这里指定了 CLI 版本号,避免因为全局默认版本不同导致渲染差异。输出用 SVG 还是 PNG,取决于使用场景:如果最终要嵌到网页或文档里,SVG 更合适,缩放不失真;如果要在群里发图片或者放进离线文档,PNG 更通用。个人默认输出 SVG,按需再转一份 PNG。
3.2 时序图:PlantUML 讲清交互流程
时序图是 PlantUML 的主场,我用一个“用户下单”的场景来示范。把这部分保存为order-sequence.puml:
@startuml actor User participant "Web Frontend" as Web participant "API Gateway" as Gateway participant "Order Service" as Order participant "Payment Service" as Pay database "Order Database" as DB User -> Web: 点击下单 Web -> Gateway: POST /api/v1/orders Gateway -> Order: 调用创建订单接口 Order -> DB: 写入订单记录 Order --> Web: 返回订单ID Web -> Pay: 发起支付 Pay --> Web: 返回支付二维码 User -> Web: 完成支付 Web -> Gateway: 支付结果通知 Gateway -> Order: 更新订单状态 Order --> User: 展示订单完成 @enduml这段代码的核心在于“参与者”的定义和“消息箭头”的语义。actor代表用户这种外部角色,participant代表系统内部的组件,database代表存储层。箭头方向也很讲究:实线->表示同步调用,虚线-->表示返回或异步通知。这样画出来的时序图,阅读者能直接从箭头形状判断出消息是请求还是响应,信息的密度比纯单线画法高出不少。
渲染 PlantUML 需要先准备环境。我通常在容器里跑,避免本地 Java 环境的各种坑:
docker run --rm -v $(pwd):/work ghcr.io/plantuml/plantuml:1.2024.6 /work/sources/sequence/order-sequence.puml -o /work/output/用容器而不是本地安装,是因为 PlantUML 内部还会调用 Graphviz,本地环境缺东缺西的情况太多了。容器镜像里已经把所有依赖打好了,团队协作的时候,只要大家同一份镜像,渲染结果就完全一致。
3.3 复杂依赖关系图:Graphviz 的用武之地
遇到动不动几十个节点、还需要自动排布防止交叉的复杂依赖图,Graphviz 是更好的选择。以服务依赖关系为例,用 DOT 语言写:
digraph dependencies { rankdir=LR; node [shape=box, style="rounded"]; subgraph cluster_api { label="API 层"; gateway -> order_api; gateway -> user_api; } subgraph cluster_service { label="服务层"; order_api -> order_service; user_api -> user_service; order_service -> inventory_service [label="check stock"]; order_service -> payment_service [label="deduct balance"]; } inventory_service -> db_inventory [label="R/W"]; payment_service -> db_payment [label="R/W"]; order_service -> db_order [label="R/W"]; db_order [shape=cylinder]; db_payment [shape=cylinder]; db_inventory [shape=cylinder]; }DOT 语法上手确实要适应一下,但它的自动布局能力是四者里最强的。digraph表示有向图,rankdir=LR控制整体方向是从左到右,subgraph cluster_前缀能让相关节点聚集成区块。对于节点很多、关系复杂、手工拖拽根本无法排布的场景,Graphviz 的布局引擎是救命的。
渲染命令:
dot -Tsvg sources/network/service-dependencies.dot -o output/service-dependencies.svgGraphviz 除了dot布局引擎,还有neato、fdp等,分别适用于无向图、力导向布局等不同场景。日常使用过程中,dot引擎能解决大部分有向依赖图的问题,遇到特殊情况再按需切换。
3.4 一键构建脚本:把链路串起来
单个命令渲染单个文件没问题,但图一多,一个个执行肯定不现实。我用 Makefile 做了一键构建入口,命令行直接make build就能把sources/下所有图全部渲染到output/:
SOURCES := $(shell find sources -name "*.mmd" -o -name "*.puml" -o -name "*.dot") OUTPUTS := $(patsubst sources/%.mmd,output/%.svg,$(SOURCES)) all: build build: $(OUTPUTS) output/%.svg: sources/%.mmd mkdir -p $(dir $@) npx -y @mermaid-js/mermaid-cli@10.9.1 -i $< -o $@ output/%.svg: sources/%.puml mkdir -p $(dir $@) docker run --rm -v $(PWD):/work ghcr.io/plantuml/plantuml:1.2024.6 $< -o /work/output/ output/%.svg: sources/%.dot mkdir -p $(dir $@) dot -Tsvg $< -o $@Makefile 的好处是天然做增量构建:只有文件内容变了才会重新渲染,节省了不少时间。如果团队统一使用 Linux 服务器或 CI 环境,这套逻辑几乎零成本维护。Windows 用户如果本地跑,建议还是用 WSL,否则需要额外处理路径问题。
3.5 接入 CI:提交代码自动出图
只有把渲染过程自动化,这套工程的价值才真正发挥出来。我在 GitHub Actions 里配置了这样一个流程:每次 push 到main分支,自动检查sources/下的变更,渲染出所有图并上传为构建产物,同时更新 Wiki 页面。
name: render-diagrams on: push: branches: [main] paths: - 'sources/**' - 'scripts/**' jobs: render: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: 渲染 Mermaid 图 run: | npm install -g @mermaid-js/mermaid-cli@10.9.1 for file in sources/**/*.mmd; do mmdc -i "$file" -o "output/${file#sources/}".svg done - name: 渲染 PlantUML 图 run: | docker run --rm -v $(pwd):/work ghcr.io/plantuml/plantuml:1.2024.6 /work/sources -o /work/output - name: 上传产物 uses: actions/upload-artifact@v4 with: name: diagram-output path: output/这里有个细节:paths字段只触发和sources/、scripts/相关的变更,避免改个 README 也触发一次全量渲染。CI 里所有依赖版本都是显式指定的,没有用任何latest标签,这是保证构建结果可复现的基础。团队多人开发时,这套流水线就是那个“最后把关的人”,图渲染失败会直接提交失败,带着错误图上线的情况被彻底杜绝了。
4. 实战中的坑与排查技巧
4.1 中文乱码问题
这是所有中文团队用文本画图最先遇到的坑。Mermaid 默认字体路径找不到中文字体时,渲染出来就是方框或者乱码。处理方式是两步:先安装字体,再在配置里指定。
Mermaid 的配置我单独放一个mermaid-config.json:
{ "theme": "default", "fontFamily": "Noto Sans CJK SC", "securityLevel": "loose" }然后在渲染命令里指定:
mmdc -i input.mmd -o output.svg -c mermaid-config.jsonPlantUML 里则通过skinparam设置字体:
skinparam defaultFontName "Noto Sans CJK SC"关键点是字体名字要写对。Linux 系统里不一定装了 Noto 字体,我用的是 CentOS 环境,需要先执行yum install -y fontconfig和fc-list :lang=zh查看当前系统中文字体。每次构建机器一变,字体问题就会重新冒出来,所以 CI 里最好在安装阶段就把字体一起装好,不要依赖基础镜像自带。
4.2 输出图被截断、边距过大
Mermaid 在渲染大图时,偶尔会出现内容被截断或者上下左右留白异常的情况。这个问题通常不是语法错误,而是渲染器的自动缩放和页面边界计算出了问题。
遇到这种问题,我一般按三步走:先调整mmdc的截图参数,设置-s 2提高缩放因子,让 SVG 里的文本和图形更清晰;再在配置里加themeVariables调整节点间距和留白;如果还是不行,就检查一下源文件里是不是有隐藏字符或者异常空格。
Graphviz 的截断问题则集中在ranksep和nodesep参数上,调整这两个值可以控制行列间距:
digraph G { ranksep=0.6; nodesep=0.4; // ... }数值调的思路是“从默认值慢慢往上加”,每次加 0.1,直到不重叠为止。一次调太大,整个图会变得稀稀拉拉,阅读起来也很难受。
4.3 本地能渲染但 CI 上失败
这是我最开始遇到的高频问题。本地能出图,推到 CI 却报错,原因十有八九是环境差异:本地装了生产环境的依赖,但 CI 是干净环境,什么都需要重新装。
解决的思路是“让本地环境尽量接近 CI”。我不再把渲染依赖全局安装,而是把要用到的命令都封装到容器或 npx 调用里,并且固定版本号。前文 Makefile 里已经展示了这个思路:Mermaid 用npx -y @mermaid-js/mermaid-cli@10.9.1,PlantUML 用固定 tag 的容器,Graphviz 用系统包管理安装。这样本地和 CI 跑的是同一套命令,问题自然消失。
4.4 渲染超时或容器内存耗尽
大图渲染时,Puppeteer(Mermaid CLI 底层依赖)偶尔会超时或者内存溢出。我踩过的最狠的一次,是一张包含 400 多个节点的大拓扑图,直接让 CI 任务 OOM。
解决办法是显式增加超时时间和内存限制:
mmdc -i huge.mmd -o huge.svg --puppeteerConfigFile puppeteer-config.json对应的puppeteer-config.json:
{ "timeout": 120000, "args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] }--disable-dev-shm-usage这个参数很关键,容器环境下 /dev/shm 只有 64MB,不关闭的话 Puppeteer 很容易因共享内存不足退出。靠这个配置,我的大图渲染问题基本解决了。
| 问题现象 | 常见原因 | 解决办法 |
|---|---|---|
| 中文显示为方框 | 缺少中文字体或字体配置错误 | 安装字体并在配置文件指定 fontFamily |
| 图被截断 | 渲染缩放比例或边距计算异常 | 调 scale、调整 PHP 间距参数 |
| 本地能出图 CI 失败 | 环境依赖版本不一致 | 固定版本、使用容器、统一命令 |
| 渲染超时 | 图过大或 Puppeteer 资源限制 | 增加 timeout、关闭 shared memory |
| 多处图风格不统一 | 缺少全局样式配置 | 统一定制主题,使用配置文件渲染 |
4.5 实用排查小工具:一个脚本搞定批量检查
当工程里有几十张图时,逐个检查渲染是否成功太累了。我写了个小脚本,扫描所有源文件,逐个渲染到临时目录,只要有一个失败,就退出非零状态码,方便 CI 中断:
#!/bin/bash set -euo pipefail for file in $(find sources -type f \( -name "*.mmd" -o -name "*.puml" -o -name "*.dot" \)); do echo "检查 $file" case "$file" in *.mmd) npx -y @mermaid-js/mermaid-cli@10.9.1 -i "$file" -o /tmp/render-check.svg ;; *.puml) docker run --rm -v $(pwd):/work ghcr.io/plantuml/plantuml:1.2024.6 "$file" -o /tmp/ ;; *.dot) dot -Tsvg "$file" -o /tmp/render-check.svg ;; esac done echo "全部渲染通过"这个脚本虽然简单,但把它挂在 CI 的检查阶段之后,团队里谁提交了语法有误的图,push 的瞬间就会被拦下来,不用等图发布到文档平台才被发现。
5. 使用场景拓展与个人心得
5.1 不止画架构图:我实际用 diagram-design 做的几件事
这套方案跑通之后,我慢慢把它用到了更多场景。除了最常见的架构图和时序图,状态机图也很有价值。状态机的分支特别多,手动画绝对会漏,用 Mermaid 的stateDiagram-v2配合文本,可以把每个状态和迁移条件写清楚,代码评审时直接看语法和状态定义就能发现问题。
部署拓扑图同样受益。用 Graphviz 画容器和网络关系,和配置中心里的节点列表一一对应。以前部署框架升级,我要手工调整拓扑图,现在直接从配置文件生成,图形和实际部署始终保持同步。
CI 流水线可视化也很有意思。把Makefile和 CI 步骤整理成流程图,新成员看一次就能理解整个发布过程,比十几个文档管用得多。
5.2 基于模板的批量生成
项目里有一类图的结构高度相似,比如多个微服务的架构图。单个手写没问题,但十几个服务如果每张都手写,内容里能抽出来的变量就太多了。我用 Python 写了模板脚本,用 Jinja2 渲染批量生成。
模板文件service-arch.template.mmd大概长这样:
flowchart TB subgraph Layer["{{ service_name }} 架构"] A["API 入口"] B["业务逻辑"] C[("数据库")] end A --> B B --> C然后读一份服务清单,循环生成:
from jinja2 import Template services = [ {"name": "order-service", "db": "order_db"}, {"name": "user-service", "db": "user_db"}, ] with open("service-arch.template.mmd") as f: tpl = Template(f.read()) for s in services: output = tpl.render(service_name=s["name"], db_name=s["db"]) with open(f"sources/architecture/{s['name']}-arch.mmd", "w") as f: f.write(output)这套批量化思路的威力在于:服务列表一更新,所有图一次性刷新。业务侧再也不用提“帮忙更新一下图”这种低频又耗时的需求了。
5.3 让团队真正用起来的几点经验
工具链搭好只是第一步,团队愿意长期用才是真正的成功。我推广这套方案时候有几个体会。
第一,不要改变团队已有的写作习惯。有的同事习惯写 Markdown,有的喜欢用 Confluence,那图就同时提供 SVG 和 PNG 两种产物,让不同人用不同方式引用,而不是强迫所有人都用一个格式。
第二,目录结构一开始就要定好,并且文档写清楚。我见过太多工程因为没人维护 README,后来者根本不知道源文件该放哪里,只好自己新起一个目录,最后到处都是半成品。
第三,把图渲染失败当成构建失败来处理。只要图挂了,代码就不允许合并。这个策略看着严格,实际上帮助团队养成了“写完就验证”的习惯,省掉了很多返工。
5.4 最后一个小技巧:用 Git Commit 消息触发定向渲染
默认情况下,CI 每次变更sources/都会全量渲染所有图。图少时没问题,图一多就是浪费资源。我在 CI 里加了一层判断:通过读取提交信息里的特殊标记,决定是渲染单个文件还是全量渲染。
commit message 写成render: order-service-arch-v2时,CI 脚本从消息里捞出文件名,只渲染对应图;什么都不写,就走全量渲染。这个技巧看着小,但在几十张图的项目里,能把渲染时间从几分钟压到几秒钟,对开发体验的提升非常明显。
我在实际使用 diagram-design 这套方案的几个月里,最大的感受是心态变了。以前画图是个“一次性交付”的活,交完就不想再碰;现在改图就像改代码一样自然,甚至有点期待有人提出修改建议,因为我知道改起来毫无负担。如果你正被一堆越维护越乱的图表困扰,把图变成代码,绝对是一条值得走的路。