一个长期被低估的技术活diagram-design 背后的设计逻辑与工程实践diagram-design 这个名字乍一看平平无奇好像就是把图表画好看一点。但我做了几年架构设计和技术方案之后越来越觉得 diagram-design 是一门被严重低估的技术活——它不只是“画图”而是把系统结构、业务流程、模块边界用规范化的方式表达出来让团队在一个共识的视觉语言下沟通。这不夸张一套糟糕的图能让一次跨团队评审多开一倍的会而一套设计得当的图往往能让方案一句话就讲明白。这个主题适合做架构设计的技术负责人、写技术文档的一线开发以及所有被 Visio 和 ProcessOn 折腾过的人。我最近把手上的图表工作流整体重构了一遍沉淀出一套叫 diagram-design 的方法和工具链。这篇文章就把这个过程的完整思考、踩过的坑、以及可以直接照搬的配置和步骤原原本本写出来。你会发现这里面很多问题不是“画图技巧”能解决的而是设计规范和工具链的问题。1. diagram-design 要解决的核心问题1.1 图表与文档脱节是大多数团队的隐形内耗我在过去几个项目的技术方案评审里反复碰到同一个场景PRD 里贴了一张流程图开发设计文档里又画了一张差不多的到了测试阶段发现两张图在某个分支上不一致最后只能拉会对齐。这种问题的根源不在于谁的图画错了而是图表本身没有被当作一份可维护的工程资产来管理。传统拖拽式画图工具的问题非常明显。我早期也用 ProcessOn 和 draw.io 画架构图这类工具上手快但一旦图多起来痛点就集中爆发图文件存在各自的网盘和本地目录里没有版本概念多人协作时谁改了哪个节点只能靠肉眼 diff想复用一套通用的图结构只能复制粘贴。更要命的是图里的逻辑和代码里的逻辑完全没有关联改一处流程代码改了但要记得去改图——这个“记得”恰恰是最不靠谱的。diagram-design 的核心思路是把“图”当作代码仓库里的普通文件来处理。每张图都是一个文本文件有版本历史可以走 MR 评审可以被脚本校验。这套思路在日本团队和硅谷技术社区里已经非常成熟但在国内很多团队里仍然被当作“画图”而不是“工程设计”来对待。这个认知差距就是内耗的来源。1.2 为什么我最终选择了代码驱动而非拖拽工具代码驱动图表说白了就是用一种结构化语法描述图的节点和关系再通过渲染器生成最终图片或 SVG。最典型的代表是 Mermaid、PlantUML、Graphviz。drawn 这种纯前端交互式画图工具也能导出代码但那只是导出不是驱动的核心。我选代码驱动有几个非常现实的理由。第一diff 友好。图改了什么在 git 提交记录里一目了然评审时能精确看到“这里加了一个判断分支”而不是对着两张 PNG 反复对比。第二可以嵌套进文档和代码块。Mermaid 可以直接写在 Markdown 文件里文档和图天然一体不存在同步问题。第三渲染统一。同一套语法在任何环境渲染出来布局算法是一致的不会出现“我在我电脑上排得整整齐齐你打开就乱成一团”的尴尬。当然代码驱动有学习门槛但它的学习曲线远比大多数人想象中平缓。Mermaid 的流程图语法一个下午就能掌握核心PlantUML 的时序图更简单基本就是描述“谁发了什么消息给谁”。一旦上手画图效率比拖拽快得多——你想加一个节点复制一行改个名字就行而不是拖一个框再反复对齐。这中间的“为什么”归根结底是图的生命周期和文档工程化形态决定了它应该具备版本化、文本化、可校验的能力。2. 工具选型Mermaid、PlantUML、Graphviz 到底怎么分2.1 Mermaid文档嵌入场景的第一选择如果你是写技术方案、README、知识库那 Mermaid 是首选没有之一。它对 Markdown 的嵌入支持最好GitHub、GitLab、语雀、飞书文档现在都原生支持 Mermaid 渲染你不需要任何额外的渲染服务器写进去就能出图。Mermaid 的定位是“让用文本画图这件事变得足够简单”。它的流程图语法用graph TD开头节点和箭头一行一个分支判断用花括号表示注释用%%。我发现大多数开发者在 15 分钟内就能画出第一张像样的图。它的时序图语法也足够表达常规的接口调用、消息传递、缓存更新的场景。不过 Mermaid 有明显的上限。布局算法相对固定当节点数量超过 20 个、关系复杂度较高时自动布局很容易出现交叉连线图的可读性会快速下降。这是 Mermaid 作为“轻量级方案”的代价它不会像专业图谱工具那样给你精细的布局控制。但换个角度看这种约束反而帮团队建立了规范——图太复杂了说明模块拆分不合适逼着你去拆图。2.2 PlantUML 与 Graphviz各有所长的重型选手PlantUML 我最常用的是它的时序图。它的语法比 Mermaid 更贴近自然语言比如A - B: 请求数据一行就说清楚了谁发给谁、消息是什么。对于有大量接口交互、消息队列通信、事件流传递的场景PlantUML 的阅读和编写体验都非常顺畅。我甚至见过把 PlantUML 当成伪代码来评审接口设计因为它的时序图本身就带有清晰的参与者、激活态和消息类型描述。Graphviz 是另一种思路它更像一个“布局引擎”。它的强项是 DOT 语言的精确描述能力尤其适合画层级关系明确的架构图、依赖关系图、集群分组。它是老牌算法驱动引擎布局质量稳定但语法相对底层上手难度最高。我在实践中会把 Graphviz 用在两个场景一是节点多但结构规整的依赖图二是需要精确控制集群和分组的架构图。这三个工具的关系不是谁替代谁而是各管一段。Mermaid 管日常、PlantUML 管时序、Graphviz 管复杂布局。这种按场景拆分的组合策略是 diagram-design 的一个重要设计决策。2.3 diagram-design 的组合策略主 Mermaid辅 Graphviz我最终的选型组合是默认使用 Mermaid遇到三类情况切换到 Graphviz。第一类是层级超过三层、节点数超过 15 个的架构图。Mermaid 的布局在这种情况下容易出现连线打架Graphviz 的rankdirTB配合子图分组可以更稳定地表达层次关系。第二类是需要把子系统明确划分区域的图Graphviz 的 cluster 能力非常干净。第三类是依赖关系复杂、需要拓扑排序展示的图Graphviz 的 dot 布局是天然的。PlantUML 我不会作为主选项原因是它在非时序图场景下并没有比 Mermaid 有压倒性优势反而多引入了一种语法体系增加团队认知成本。但我会在涉及大量消息交互的设计文档里用 PlantUML 专门画时序图然后作为图片插入文档。这种“多重工具组合”听起来不如统一工具链酷但在实践中最灵活也最实用。下面这张表是我当时对比时做的直接放出来供参考维度MermaidPlantUMLGraphviz上手难度低低中高Markdown 原生支持最好一般一般时序图表达够用最顺手很弱复杂架构布局一般较弱最强集群分组不支持有限原生适合场景文档内嵌、快速流程接口交互、事件时序架构分层、依赖关系3. diagram-design 的目录结构与工作流设计3.1 这样组织目录图和文档永远不会对不上diagram-design 在设计时我把所有图文件统一放在仓库的diagrams/目录下按内容类型分成若干子目录。这不是随便分分类而是为了让所有团队成员形成“找图先去 diagrams 看一眼”的直觉。目录结构大致是这样的diagrams/ ├── README.md # 说明每类图适合什么场景、如何渲染 ├── flow/ # 业务流程图 │ ├── checkout.md │ └── refund.md ├── sequence/ # 时序图 │ ├── payment.md │ └── stock.md ├── architecture/ # 架构图 │ ├── system-overview.dot │ └──>npm install -g mermaid-js/mermaid-cli然后准备一个puppeteer-config.json配置文件主要是为了在无头浏览器模式下手动指定加载路径避免默认环境访问不到本地资源{ args: [--no-sandbox, --disable-setuid-sandbox] }再写一个最简单的批量渲染脚本遍历目录下的.md文件提取其中的 mermaid 代码块并调用mmdc渲染。我用的不是太复杂的程序就是一个 Node 脚本核心逻辑是读取 Markdown 内容用正则匹配 fenced code block然后对每个匹配到的 Mermaid 源码执行mmdc命令。const fs require(fs); const path require(path); const { execSync } require(child_process); const dir diagrams/flow; const files fs.readdirSync(dir).filter(f f.endsWith(.md)); for (const file of files) { const content fs.readFileSync(path.join(dir, file), utf8); const blocks content.match(/mermaid\n([\s\S]*?)/g) || []; blocks.forEach((block, index) { const code block.replace(/mermaid\n|/g, ); const tmpFile path.join(/tmp, ${file}-${index}.mmd); const output path.join(dir, ${file.replace(.md, )}-${index}.svg); fs.writeFileSync(tmpFile, code); execSync(mmdc -i ${tmpFile} -o ${output} -w 1200, { stdio: inherit }); }); }需要注意mmdc首次运行会下载 Chromium网络环境差的情况下有可能失败。我后来改用系统中已有的 Chrome 路径通过-p puppeteer-config.json参数指定绕开了这个坑。这一步的本质是把渲染环境固定到本机而不是依赖临时的浏览器下载。从写图到渲染再到提交整个链路不超过两分钟而且因为每一步都是命令和文件天然具备可重复性。这比每次打开在线画图工具、导出、上传要高效得多。4. 三类高频图表的实操细节4.1 流程图你的分支逻辑比美观重要得多画业务流程图我最常被问的问题是“怎么画出好看的图”。我自己的回答是流程图的美观度90% 取决于结构是否清晰而不是配色和圆角。一个结构混乱的流程图再好看也是灾难。所谓结构清晰第一原则是“从上到下、从左到右”箭头方向尽量一致不要出现回旋箭头扎堆。第二个原则是“状态优先于动作”。我见过太多流程图用动词命名节点比如“填写订单”“提交订单”这些其实是动作不是状态。正确的做法是围绕状态迁移来画比如“待支付”“已支付”“已取消”节点表达的是系统的状态而不是操作步骤。这两者的区别在评审流程图时高下立判按状态画的图逻辑漏洞很容易暴露比如漏了超时关单的分支按动作画的图看起来顺实际漏洞藏在细枝末节里。操作要点上Mermaid 的节点命名我建议不用空格和中文标点用驼峰或短横线加一个可读的文案标签graph TD A[创建订单] -- B{支付成功?} B --|是| C[进入待发货] B --|否| D{超时?} D --|是| E[自动取消] D --|否| A这样写有几个好处整体结构一眼能看到条件分支收敛在哪里后续想加节点不影响其他行的表达也方便脚本解析。另外我强烈建议给每个分支加上明确的标签比如|是|、|否|不要只画两条光秃秃的线。原因很直接——分支标签是图里最容易被询问的关键信息没有标签的流程图评审会上十有八九要来回问。4.2 时序图用两条规则解决八成的混乱时序图我踩过最大的坑是“参与者太多”。早期画接口交互流程图时我把所有微服务、缓存、数据库、消息队列全部画进去一张图上有七八个生命线消息交叉得像一团毛线。后来我给自己定了一条规则一张时序图的活跃参与者不超过四个超过就拆图。这条规则背后的逻辑是时序图本质是讲一个“调用故事”的参与角色越多读者心智负担越大。最佳实践是分层次比如“客户端—网关—业务服务”一张图“业务服务—缓存—数据库—MQ”另一张图两张图之间通过同一个业务动作衔接。这样每张图都能讲清楚一个完整的小故事读者不需要同时记住八九个角色的交互。Mermaid 的时序图语法里特别值得利用的是激活态。默认情况下一条消息线两端没有激活条接收方看起来只是路过没有处理过程。给参与者加上激活态可以更准确表达同步阻塞的时间范围这对评审接口性能和处理耗时特别有用。实操中我会这样加激活标记sequenceDiagram participant C as 客户端 participant S as 服务端 participant D as 数据库 C-S: 提交订单 activate S S-D: 事务写入 activate D D--S: 返回订单号 deactivate D S--C: 返回成功 deactivate S另一个容易忽略的点是消息类型。实线带箭头-表示同步调用虚线带箭头--表示异步返回还有一种-)表示异步消息。很多人默认都用-结果异步事件流在图上表现不出来和同步调用混在一起评审时非被问不可。我现在的写法是同步调用用-返回用--异步事件用-加上明确标注“异步”保证语义准确。4.3 架构图图层结构和边界比单个框框重要架构图是 diagram-design 里最难画、也最有价值的图。很多团队画架构图是把所有系统画在同一个平面上通过不同的颜色区分层次说实话这种图在评审时很容易被挑战——“这两个系统到底谁依赖谁”“这台机器在哪个安全边界内”我推荐的画法是用图层来强制表达分层关系。Graphviz 的 cluster 天然适合做这件事。下面是一个用 DOT 语言表达的简化示例展示如何把网关层、业务层、数据层放到不同的集群里digraph K { rankdirTB; subgraph cluster_gateway { label接入层; styledashed; gw1 [labelAPI 网关]; gw2 [label消息接入]; } subgraph cluster_biz { label业务层; styledashed; svc1 [label订单服务]; svc2 [label库存服务]; svc3 [label支付服务]; } subgraph cluster_data { label数据层; styledashed; db1 [labelMySQL]; db2 [labelRedis]; mq1 [labelKafka]; } gw1 - svc1; gw1 - svc2; gw1 - svc3; svc1 - db1; svc1 - db2; svc2 - db1; svc3 - mq1; }这里的关键不是语法而是设计意图图层是架构图的第一信息然后才是节点之间的关系。用集群把这些层框出来读者第一眼就能get到整个系统的层级结构而不是在一个平面里费力找谁依赖谁。另外一个经验是“节点命名用服务名不要用 IP 或主机名”。要暴露物理部署详情那是拓扑图的事和架构图不是一个图。如果你用 Mermaid 画架构图我建议也用子图方式subgraph来表达分组Mermaid 对子图的支持虽然不是原创最强但表达分层也够用关键是保持每个子图内不超过 3 个节点否则布局还是容易乱。5. 渲染与集成让图表真正被用起来5.1 本地批量渲染一次配置反复使用前面提到用mermaid-js/mermaid-cli渲染单张图但在实际使用中我更倾向于做一次性的全局配置然后反复跑脚本。这里有几个配置文件可以固定下来。第一是刚才提到的puppeteer-config.json它负责让 mmdc 使用系统安装的 Chromium而不是每次临时下载浏览器。这个配置在 CI 环境里尤其重要。第二是mermaid-config.json用来统一主题和字体避免不同机器渲染出来的图颜色不一致。我常用的配置是这样的{ theme: base, themeVariables: { fontSize: 16px }, flowchart: { curve: basis } }这个文件在渲染时通过-c参数传入。有了统一主题团队内所有图看起来就是一套视觉风格不需要每个人自己去调色这种“默认即规范”的做法效果特别好。批量渲染脚本我上面已经给了一个雏形实际使用中还可以加上模板功能比如自动在渲染后的 SVG 文件中插入页脚、生成时间戳这样后续追溯图对应的版本会更方便。不过我不建议把这事儿做太重核心目的是让渲染从手工点按钮变成一条命令做到这一步效率就已经提升巨大了。5.2 文档与 CI 集成让图成为提交的一部分本地渲染只解决个人效率问题团队协作还需要把图表接入文档平台和 CI。我试过的最顺滑的方式是把 Markdown 文档直接放在代码仓库里通过文档工具链比如 VuePress、Docusaurus渲染Mermaid 代码块天然被支持不需要额外处理。如果你用的是 GitLab 或 GitHub 这类平台Markdown 原生支持 Mermaid 渲染只需要在合并请求描述里直接贴代码块预览时就能出图。这一点看似简单却极大降低了图表的审查门槛——评审人不需要本地安装任何工具就能看到最新的图。再进一步可以把渲染接入 CI。在 GitLab CI 里加一个 job监听diagrams/目录的变化有变更时批量渲染并让渲染产物随 repo 一起发布。核心配置大概是diagram-render: script: - npm install -g mermaid-js/mermaid-cli - node scripts/render-diagrams.js artifacts: paths: - diagrams/**/*.svg only: changes: - diagrams/**/*这样每次有图被修改提交后 CI 自动出图产出物作为构建产物保存或发布到文档站点。整个流程自动化之后图表的更新不再依赖某个人记得去手动导出工程化的闭环就算建成了。当然CI 环境里跑 Puppeteer 需要额外注意系统依赖比如 CentOS 上要装一些字体和动态库这个我放到后面的排查部分细讲。6. 常见问题与排查技巧实录6.1 语法报错但找不到问题位置先学会逐行注释Mermaid 的语法错误提示一直不算友好经常是一段通用的“Parse error on line X”但实际问题往往在上一行或某个字符上。我遇到最多的情况是节点文案里包含了特殊字符比如括号、引号或#这些字符在没有转义时会导致解析中断。我的排查技巧是“二分注释法”。把疑似出错的那一半代码注释掉看错误还在不在如果错误消失就说明问题在被注释的那一段里继续缩小范围。Mermaid 的注释语法是%%注释掉一段后再渲染几分钟内就能定位到具体节点。这个方法比盯着一大段代码猜高效太多。还有一个很容易被忽略的点Mermaid 对中文标点支持不好。如果你在节点文案里用了中文冒号“”或者中文括号“”某些版本渲染时会莫名报错。我现在一律使用英文标点中文字符只用在文案内容里这个习惯帮我省了很多排查时间。6.2 图太大导致渲染变形或重叠试着拆图和调参一段 Mermaid 代码里的节点和边太多渲染结果往往是一团乱麻。我遇到过一次画数据血缘关系图状态节点加上数据流向总共有 40 多个节点渲染出来连线交叉密集几乎无法阅读。后来我把图拆成了“源系统到数仓”和“数仓到应用”两张图中间通过一个共享节点衔接问题立刻解决。如果不想拆图还有两个参数可以减轻交叉一个是flowchart的nodeSpacing和rankSpacing可以适当调大让节点之间更松另一个是换布局方向比如从TB改成LR。但说实话这两个参数只是治标结构复杂带来的阅读障碍不是调参能解决的拆图才是根本。这里再强调一下我的经验阈值一张 Mermaid 图的节点尽量控制在 12 个以内15 个是上限。超过这个阈值不管怎么调参都不容易达到清晰的阅读效果。这个阈值不是拍脑袋定的是经过实际评审场景验证的——节点多了人的注意力会分散图的表达效率反而下降。6.3 中文乱码问题几乎都出在字体和系统依赖上Mermaid 在网页端嵌入时中文渲染一般没有问题。但用mmdc在本地或 CI 里渲染时中文经常变成方框也就是乱码。原因就一条渲染环境缺少对应字体的安装。Puppeteer 调用的 Chromium 没有正确的字体文件中文符号就渲染不出来。解决办法分两步。第一步确保环境中安装了中文字体CentOS 或 Ubuntu 上可以执行安装命令把noto-sans-cjk或wqy-microhei装上。第二步在mermaid-config.json里显式指定字体名称确保渲染引擎使用到正确的中文字体。比如{ theme: base, themeVariables: { fontFamily: Noto Sans CJK SC, WenQuanYi Micro Hei, sans-serif } }这里有一个容易踩的坑是配置中指定的字体名称必须和环境里实际安装的字体完全一致否则还是会回落到默认字体。我在本地 Mac 上能渲染一上 Linux CI 就乱码排查了很久最后发现是 CI 环境没装任何 CJK 字体。装好字体、改好配置之后乱码问题彻底消失。另外补充一个和字体相关的细节如果渲染出来的图片放到 Windows Office 文档里中文字体可能因为目标机器没有对应字体而发生替换导致排版异常。建议导出前把 SVG 转成 PNG 或 PDF这两种格式在多数场景下不会出现字体替换问题。最后再分享两个我在 diagram-design 实践中调整过的习惯第一个习惯是“图为先、文档为后”。以前我是先写完设计文档再顺手补图。现在完全反过来先画图再根据图的结构去展开文字描述。这样做的好处是图本身会强迫你把逻辑理顺而那些理不顺的地方往往就是设计里还没想清楚的地方。图能反映思路但更厉害的是它能逼出思路里的漏洞。第二个习惯是“图纸也做版本评审”。我见过不少团队代码评审做得很严格但图纸评审基本靠口头确认图改了也没人细看。我的做法很土就是每次图有变更改动说明写清楚哪个节点改了、哪条边删了、为什么改。这样三个月后翻 git 历史还能知道当初这个架构决策的原因不至于对着过去的设计问“这当初谁画的”。别看这个习惯土它给我省掉的返工时间远超花费的那点功夫。
