画架构图这件事,我以前一直是“打开画图工具,拖拽、连线、对齐、导出”这一套流程。图小的时候还行,一旦节点超过二三十个,改一处布局就得连带动好几条线,评审意见回来的时候往往不是改内容,而是在跟线条位置作斗争。后来我把图表改成用代码来设计和维护,项目代号就叫 diagram-design,核心思路很简单:图也是代码,应该被版本管理、被 review、被自动化渲染。这套方式我用了一年多,从架构图到时序图、ER 图,基本都迁过来了,这篇文章就把我给团队内部整理的方法、踩过的坑、沉淀下来的习惯完整写出来。
如果你也在为“画图五分钟,改图两小时”头疼,或者需要把图表放进文档、PPT 和 Wiki 里又要保持一致性,这篇内容应该对你有用。我会从设计思路、语法选型、实操步骤、样式经验到问题排查完整讲一遍,尽量做到能直接照着落地。
1. 为什么我最终选择用代码来设计图表
1.1 手动画图的三座大山
先说说我可视化画图的真实体验。早期在文档里放图,用的是白板类工具和桌面绘图软件。遇到的第一座大山是版本管理,图导出的 PNG 放进仓库里,改一次就生成一个新文件,过两周根本分不清哪张是最新的,Review 时也只能看到最终图片,改动过程完全丢了。第二座大山是协作,多人同时编辑一张图,经常出现有人覆盖了别人的布局调整,或者节点的连线被挪得乱七八糟,最后只能靠人工“劝架”。第三座大山是复用,这个项目里的系统架构,在下个项目里只有部分类似,想要复制过去改一改,得对着图重新拖半天。
这些问题不是不能忍,但频率高到一定程度就让人烦躁。尤其是给外部团队同步接口调用关系时,我总是要先打开编辑器,确认每个节点位置没丢,再导出、压缩、贴到文档,过程很机械。
1.2 用代码定义图表的本质
diagram-design 解决这些问题的思路,是把图的结构和样式都用文本描述出来,配合渲染引擎生成图片。核心价值有三个:第一是图与文字一样能进 Git,每次改动都是可追踪、可回滚的 diff;第二是渲染完全自动化,文档构建时执行一段命令就能重新生成图片,不会出现“文档更新了但图还是旧的”这种低级错误;第三是结构可以复用,复制一份.dot文件,改几个节点名,新图就出来了。
这个理念和“基础设施即代码”很像,都是把原本靠手工维护的东西变成可声明、可执行的文本。一开始可能会觉得写代码比拖拽慢,但当你只需要改一个节点名称,然后重新渲染一次,其余布局全自动调整时,效率优势就体现出来了。尤其是图表经常更新的项目,用文本描述几乎是唯一稳妥的方式。
1.3 什么场景不适合代码画图
当然,代码画图也不是万能的,我试下来有三种情况不适合硬上。第一种是快速草图,比如开会时随手画个思路,或者白板上草拟流程,这时候工具的即时性和自由度远比可维护性重要,打开编辑器敲代码反而耽误事。第二种是高度视觉化的设计图,比如运营海报、产品 UI 稿、带大量图标和手绘风格的示意图,Graphviz 这类声明式工具在精细视觉控制上很吃力,用矢量设计软件更合适。第三种是节点位置有严格像素级要求的图,比如必须精确对齐到某个坐标,这种需求在绘图工具里直接拖很容易,但在代码里要用 pos 属性手动指定坐标,维护成本比较高。
我的建议是:凡是会被反复修改、需要多人维护、要放进正式文档的图,优先考虑代码化;一次性草图、纯创意视觉内容,继续用手工工具。两种方式并存,不冲突。
2. 从零开始:diagram-design 的核心语法与绘图模型
2.1 一条边和一个节点背后的绘图模型
diagram-design 底层我主要用 Graphviz 的 dot 语言来描述图表,它的核心模型非常简洁,只有三个元素:图、节点、边。声明一张有向图用digraph,无向图用graph,节点就是出现名字的实体,边就是两个节点之间的连接。
一个最小的示例:
digraph demo { A -> B; A -> C; }这段代码定义了一张有向图,节点 A 指向 B 和 C。渲染引擎会自动计算节点位置,不需要你指定坐标。这正是代码画图和画布类工具最大的区别:你描述的是“对象之间的关系”,而不是“对象在画布上的位置”。
在此基础上,可以给节点和边附加属性,比如形状、颜色、标签,从而控制最终呈现效果:
digraph demo { node [shape=box, style="rounded,filled", fillcolor="#EAF2F8"]; edge [color="#718096"]; A [label="Git 仓库"]; B [label="构建任务"]; A -> B [label="触发"]; }这里node [ ... ]声明的是所有后续节点的默认样式,edge [ ... ]同理。如果你只想修改单个节点,就在节点名后面的方括号里写属性;只想修改某条边,就在箭头语句后面的方括号里写属性。这种默认值加局部覆盖的设计,让我能先把整张图的风格统一起来,再对重点节点做差异化强调。
2.2 布局引擎如何决定位置
刚接触代码画图的人,最容易困惑的问题就是“我没写坐标,它是怎么知道节点该放哪里的”。Graphviz 的布局引擎会先分析图结构,构建一个有向图模型,然后通过一系列布局算法计算节点坐标。常见的引擎包括dot、neato、fdp、circo、twopi。其中 dot 引擎适合有层次、有方向的图,比如流程图、架构图,它会尽量让边朝一个方向流动,减少交叉;neato 基于力导向布局,适合无向图和关系网络;circo 适合环形结构。
实际使用时,通过-K参数指定引擎,或者直接在文件开头声明:
digraph demo { rankdir=LR; A -> B -> C; }rankdir是一个高频参数,控制图的整体方向。TB(从上到下)适合流程顺序清晰、步骤较多的图;LR(从左到右)适合系统架构成分较多、希望按模块横向展开的场景。我的习惯是:流程类图用 TB,模块架构类图用 LR。布局引擎不是让你完全失控,rank=same、constraint=false、nodesep、ranksep这些属性可以用来干预局部布局,后面我会专门讲。
2.3 与 Mermaid 的定位差异
很多人可能听说过 Mermaid,它也是文本生成图表,但和 diagram-design 侧重点不太一样。Mermaid 胜在简单、学习成本低,适合直接嵌入 Markdown 文档,比如画简单的流程图、时序图、甘特图,语法到渲染只要十几秒,很多技术博客和项目文档都在用。Graphviz 的优势在于布局引擎更成熟,对复杂图的表达力和控制力更强,尤其是节点数量较多、层次关系复杂、需要精细控制样式和分组时,Graphviz 的稳定性和输出质量会更胜一筹。
我的实际选择标准是:如果图比较简单,结构不超过十来个节点,用 Mermaid 写进 Markdown 最方便;如果图要支撑正式技术文档、架构评审,或者节点数量较多、层级关系明显,我用 diagram-design 这套 Graphviz 工作流。两者的关系和“脚本语言”与“传统编程语言”的区别类似,各有定位,按需取用。
3. 实操:用 diagram-design 画一张 CI/CD 部署架构图
3.1 确定图的结构与分层
我用一个真实的例子来走一遍完整流程,这张图是持续集成与部署流程的架构图,会包含开发环境、CI/CD 平台、生产环境三块,以及它们之间的数据流关系。画图之前先梳理结构,不急着写代码。我的做法是先在纸上列出所有节点,然后确定它们之间的关系,再按层次分组。
这张图的组成:
- 开发环境:Git 仓库、本地 IDE
- CI/CD 平台:流水线触发、构建镜像、推送镜像仓库
- 生产环境:应用服务器、健康检查、告警通知
流程方向是:开发者从本地 IDE 推送代码到 Git 仓库,Git 仓库通过 webhook 触发流水线,流水线依次执行构建、推送镜像,随后部署到生产服务器,部署后执行健康检查,异常时触发告警通知。
3.2 从零写 dot 文件
确定结构后,我写下完整 dot 文件。为了便于展示,先给出全文,再逐段说明关键点:
digraph cicd { rankdir=LR; pad=0.5; node [shape=box, style="rounded,filled", fillcolor="#EAF2F8", color="#2B6CB0", fontname="Microsoft YaHei", fontsize=11]; edge [color="#718096", fontname="Microsoft YaHei", fontsize=10, arrowsize=0.7]; subgraph cluster_dev { label="开发环境"; style="rounded,dashed"; color="#805AD5"; dev1 [label="Git 仓库", shape=folder]; dev2 [label="本地 IDE", shape=note]; } subgraph cluster_ci { label="CI/CD 平台"; style="rounded,filled"; color="#2B6CB0"; fillcolor="#EBF8FF"; ci1 [label="流水线触发"]; ci2 [label="构建镜像"]; ci3 [label="推送镜像仓库"]; } subgraph cluster_prod { label="生产环境"; style="rounded,filled"; color="#38A169"; fillcolor="#F0FFF4"; p1 [label="应用服务器"]; p2 [label="健康检查"]; p3 [label="告警通知"]; } dev2 -> dev1 [label="push 代码"]; dev1 -> ci1 [label="webhook"]; ci1 -> ci2; ci2 -> ci3; ci3 -> p1 [label="deploy"]; p1 -> p2; p2 -> p3 [label="异常时", style=dashed]; }3.3 逐段拆解关键写法
先看全局设置。rankdir=LR让整张图从左向右展开,这样“开发环境 -> CI/CD -> 生产环境”的主链路符合阅读习惯。pad=0.5是给画布四周留白,避免节点贴边。
node [ ... ]和edge [ ... ]是全局默认样式。我统一设置了圆角填充的方框、蓝灰色边框、中文字体、字体大小,这样整张图基础风格一致,后面单个节点只需要覆盖需要变化的属性即可。这个习惯很重要,如果你在每个节点上重复写样式,文件会变得又长又难维护。
三个subgraph是结构分组。注意命名规则:subgraph 的 id 如果以cluster开头,才会渲染成带边框、带背景色的分组框,否则它只是逻辑分组,不会影响布局和显示。这是我刚开始踩过最明显的坑。cluster_dev、cluster_ci、cluster_prod都以 cluster 开头,所以三个环境框能正确显示。
每个 cluster 内部我设置了独立的边框颜色和背景色,形成三块环境的视觉区分。节点形状上,Git 仓库用folder文件夹形状,本地 IDE 用note便签形状,其他流程节点保持默认方框。节点形状是传达语义的一种低成本手段,不需要额外画图标,但读者能快速形成印象。
边的部分比较直接:dev2 -> dev1 [label="push 代码"]表示一条带标签的有向边。健康检查到告警通知的边用了style=dashed和label="异常时",用来表达“仅异常时才触发”的条件关系,虚线在语义上能明显区分主流程与旁路。
3.4 渲染与导出图片
写完后,执行渲染命令。我一般用命令行直接导出,最常用的命令是:
dot -Tpng cicd.dot -o cicd.png如果要更高清的图,加 dpi 参数:
dot -Tpng -Gdpi=150 cicd.dot -o cicd.png如果希望放到网页或文档中可缩放的矢量图,导出 SVG:
dot -Tsvg cicd.dot -o cicd.svgSVG 的好处是无限缩放不模糊,而且可以被文本工具搜索和编辑。我的习惯是:文档内嵌图用 SVG,PPT 和对外交付的文件用高 dpi 的 PNG。注意,如果 PNG 导出后文字发虚,先检查是不是 dpi 太低,而不是急着换字体。
3.5 接入自动化构建流程
单次手工渲染只是第一步,更有价值的是把渲染过程自动化。我自己会在项目仓库里放一个脚本,循环渲染目录下的所有 dot 文件:
#!/bin/bash for f in diagrams/*.dot; do name=$(basename "$f" .dot) dot -Tsvg "$f" -o "output/${name}.svg" dot -Tpng -Gdpi=150 "$f" -o "output/${name}.png" done配合持续集成流水线,文档目录一旦有 dot 文件变更,就自动重绘图片并提交到产物目录。这样文档站点、Wiki、API 说明里的图永远和源代码保持一致,不会出现“图是上个迭代的”这种尴尬。自动化之后,review 图表就变成了 review 文本 diff,改动了几行、改了哪个标签,一目了然。
4. 让图更好看:样式设计与可读性经验
4.1 全局样式与局部覆盖的配合
图表的第一要义是信息清晰,其次才是美观。我的通用做法是:先在文件顶部定义全局默认样式,把字体、边框、底色、线条颜色统一;再针对重点模块做局部覆盖。全局默认值相当于主题,局部覆盖相当于强调。
一个相对稳定的主题配置:
node [fontname="Microsoft YaHei", fontsize=11, shape=box, style="rounded,filled", fillcolor="#F7FAFC", color="#4A5568", fontcolor="#1A202C", margin="0.15,0.08"]; edge [fontname="Microsoft YaHei", fontsize=10, color="#A0AEC0", arrowsize=0.7, penwidth=1.2];这套主题的特点是:颜色偏中性,不喧宾夺主;字体统一,中文不乱;边框颜色和文字颜色有足够的对比度,打印出来也能看清。如果你对颜色不擅长,少用高饱和度颜色,多用带灰度的柔和色,整体立刻会高级不少。
4.2 分组框的层次设计
分组框不只是把节点圈起来,它还能承担“阅读暗号”的功能。我在 cluster 的样式里,一般会设置style="rounded,filled",让分组框有浅色背景,内部节点保持白色或更浅的底色,这样两层视觉层次就出来了。如果分组框内部节点也用深色背景,整张图会变成“一堆色块”,层次反而消失。
嵌套是另一个实用特性,cluster 里还能再放 subgraph,适合表达复杂系统的多级分组。但嵌套层级不建议超过三层,否则渲染出的边框、间距会占用大量空间,图会变得很散。如果超过三层,我的做法是拆成多张图,而不是硬塞进一张。
4.3 调整布局的三个高频属性
实际场景里,布局引擎给出的结果不一定完美,这时候用几个属性微调就够了:
rank=same:把一组节点强制放在同一水平线上。比如集群内的多个服务节点,希望它们并排显示,就在这些节点后用{ rank=same; svc1; svc2; svc3; }指定。constraint=false:让某条边不参与排序计算。比如两个模块之间有数据回调,但我不想让这条边影响整体方向,就给边加上constraint=false,它只画线,不挤占布局。nodesep和ranksep:分别控制同层节点间距和层与层之间的间距。图太挤就调大,太大就调小。
这三个属性覆盖了 90% 的布局微调需求。剩下的一些极端情况,比如两节点之间距离不满意,可以用weight属性调整边的权重,权重高的边会尽量保持短且直。
4.4 突出主链路的经验
架构图往往同时存在主流程和辅助流程,如果不做区分,读者第一眼抓不到重点。我的做法是:主链路线条用更深的颜色、更粗的宽度,辅助流程用灰色或虚线。比如 CI/CD 主流程用color="#2B6CB0", penwidth=1.8,异常告警旁路用color="#A0AEC0", style=dashed。
另一个经验是标签不要全写在边上。边太多时,标签会重叠,尤其是交叉区域,几乎无法阅读。我的策略是:主干流程的边尽量不写标签,靠节点命名本身表达语义;只有关键的触发条件、传输协议才在边上标注。图上文字密度越低,信息传递效率越高,这个理念在代码画图里同样适用。
5. 常见问题与排查实录
5.1 中文字体变方块或乱码
这是最常遇的问题,几乎是每个中文用户入门的必经坑。现象是节点里的中文显示成一排小方块,或者干脆空白。原因基本就一个:渲染环境里没有可用的中文字体,或者没有正确指定字体名。解决方案分两步:先在node和edge的属性里明确指定中文字体,比如fontname="Microsoft YaHei",如果是 macOS 可以用"PingFang SC",Linux 可以用"Noto Sans CJK SC"。其次确认系统确实安装了对应字体,用fc-list能查到字体列表。
补充一条经验:如果本机预览正常,但 CI 环境里导出乱码,多半是 CI 机器没装中文字体。需要把字体文件一起放进项目或在 CI 初始化时安装,否则本地能过、线上就挂。
5.2 图片导出模糊
导出 PNG 后发现文字或者线条有锯齿,第一反应不应该是换绘图工具,而是检查 dpi。Graphviz 默认的渲染 dpi 偏低,导出社交图片和文档插图时明显不够,加上-Gdpi=150或-Gdpi=200会改善很多。
dot -Tpng -Gdpi=200 architecture.dot -o architecture.png如果希望完全不靠 dpi 解决,则导出 SVG,在文档里按矢量图引用。
5.3 布局方向不受控制
有时候明明指定了rankdir=LR,某些节点还是不在预期位置,这通常是边的方向或权重影响导致的。排查思路是:先看有没有边把两个大分组“横向拉”在一起,导致后续节点被挤到奇怪的位置;再看有没有constraint=true的边参与了跨层排序。我的经验是,对不参与主方向的边统一加上constraint=false,主方向立刻清晰。
5.4 标签太长撑坏节点
节点标签过长时,Graphviz 会按照标签长度扩展节点宽度,整张图会沿着某一行被拉得很长。解决办法是给标签换行,不要在 label 里写一长串句子。如果用的 HTML 标签型节点,可以用<BR/>换行;普通标签可以用\n转义换行。同时,在全局节点属性里设置margin="0.15,0.08"控制内边距,让节点紧凑一些。
5.5 边交叉严重
边交叉过多时,可读性会断崖式下降。我的排查顺序是:先检查是否有不必要的跨模块连线,比如两个不同 cluster 的节点之间直接相连,这种边通常是交叉的主要来源;其次考虑把 cluster 内部的细节隐藏或用注释说明,不在主图上展开;最后才用rank=same和weight属性手动调整。还有一种经验是,不要试图在一张图里表达所有信息,一张图对应一个主题,是最有效的防交叉手段。
6. diagram-design 进阶:与文档自动化深度联动
6.1 图表进入 Git 后的 review 体验
把 dot 文件纳入版本管理带来的最大变化,是代码评审的维度变了。以前评审图表,只能看一张最终图片,现在可以直接看 diff:一个节点改了标签、一条边的方向反了、某个 cluster 的颜色变了,全部以文本形式呈现。这种可读性让非绘图者也能参与评审,甚至能在 Review 评论里直接写“把 p2 到 p3 的边改成虚线”,因为大家讨论的是代码,而不是对着图片比划。
为了进一步提升 diff 可读性,我会在提交前对 dot 文件做一次格式化,统一缩进和换行。Graphviz 自带了规范化输出参数:
dot -Tdot cicd.dot -o cicd_normalized.dot这样输出的 dot 文件会自动统一结构,减少无意义的 diff 噪声。
6.2 把 SVG 嵌入文档系统
SVG 是文档集成里最友好的格式。无论是 Markdown、reStructuredText,还是各种 Wiki,都能直接引用 SVG 文件。我常用的做法是让文档构建时先执行渲染脚本,然后通过相对路径引用生成的 SVG:
这里需要注意,SVG 文件里的字体依赖阅读环境的字体配置,如果读者的系统没有对应的中文字体,SVG 会回退到其他字体显示,可能和设计好的排版有差异。所以如果是 PDF 或打印场景,我更倾向导出高 dpi 的 PNG,保证字体完全可控。
6.3 批量渲染与增量更新
项目里的图会越来越多,我的建议是建立统一目录规范,比如把 dot 文件放在diagrams/目录,输出到output/。然后写脚本批量渲染,同时利用文件的变更时间做增量更新,避免每次全量重绘。一个轻量的实现可以基于 make:
DIAGRAMS := $(wildcard diagrams/*.dot) OUTPUTS := $(patsubst diagrams/%.dot, output/%.svg, $(DIAGRAMS)) all: $(OUTPUTS) output/%.svg: diagrams/%.dot mkdir -p output dot -Tsvg $< -o $@这样每次只重新渲染发生过变化的 dot 文件,效率高,也不会产生多余的提交记录。
6.4 用 SVG 做图表内文本检索
SVG 还有一个隐藏优势,它内部是 XML 文本,节点标签和边标签都会保留下来。这意味着你可以用普通的文本搜索工具在图里找到某个关键词,而不需要逐个打开图片看。我有时候会把所有 SVG 拼成一个索引文件,或者用 grep 直接搜索:
grep -l "告警通知" output/*.svg这个功能在维护大型架构文档时非常有用,尤其是当你不确定某张图里有没有提到某个组件时,一条命令就能定位。
另外,Graphviz 还能通过-Tcmapx生成图像映射文件,配合 SVG 或者 PNG 可以做出带超链接的交互式图表。节点可以链接到具体的代码仓库、接口文档、监控面板,团队内部用来做系统概览页非常高效。
最后分享一个我坚持很久的习惯:每张图在 dot 文件头部都写清楚这张图的目的、更新人和日期,即使图很小也不省略。原因很简单,图表一旦变成代码,它就有了生命周期,后续维护的人需要知道这个图为什么要存在、什么时候改过。代码化的图表最怕的不是画不出来,而是退化成“没人愿意维护的死图”。diagram-design 的价值在于让图的维护成本降到最低,但前提是有人愿意维护。把图表当代码来规范,它就能像代码一样长期健康地生存下去。