前阵子带团队做过一批技术文档,里面光是架构图、流程图、时序图加起来就有一百多张。刚开始大家还很乐意用绘图软件一张张拖拽,后来发现只要依赖关系一变,就要连改四五张图;方案评审时对着投影仪改图,改完还要重新导出、重新上传。折腾几次后,我索性抽空做了个小项目,代号叫 diagram-design,核心思路就一句话:把画图变成写代码。用一套接近自然语言的描述语法定义图,由程序统一解析、布局、渲染、导出。这个项目帮我解决了几个很具体的问题:图可以被版本管理、可以看 diff、可以写模板批量生成、可以直接嵌进文档构建流程。
如果你也在维护大量技术图表,或者想给自己的博客、项目文档建立一套可持续维护的可视化方案,这篇文章应该能帮你少走不少弯路。我会从设计思路、核心细节、实操过程到踩坑记录,把整个 diagram-design 的来龙去脉讲清楚。
1. 为什么我会做一套 diagram-design
1.1 手工画图的最大痛点
手工绘图的体验,一开始很愉快,越到后期越痛苦。拿我自己的经历来说,一个系统从评估到落地,架构图至少会经历三轮大改:第一轮是初步设计,第二轮是细化依赖,第三轮是在联调时发现少了几条链路。每一轮改图,不仅要重新移动节点、修改连线,还要保证文字不重叠、配色不乱,更麻烦的是团队成员手里各有一份最新版本,最终合并时全靠口头对齐。
手工图形的另一个致命问题是难以检索。图片里的文字没法被搜索引擎、文档工具直接查找,更没法做代码评审。有一次我需要确认某个模块到底连了哪些下游,只能打开图片对着屏幕数。这种体验让我意识到,长期维护的图表资产,必须从“画出来”变成“写出来”。
1.2 代码定义图表的优势
用代码定义图,最直接的好处是文本化。一段图描述放进代码仓库里,任何人都能通过 diff 看到这次改动新增了哪个节点、修改了哪条连线。代码可以写函数、写模板,一个通用结构可以复用;代码可以跑测试,可以校验连线是否有悬空节点;代码还可以接入持续集成,文档发布时自动重新渲染。
这套模式非常适合技术团队内部使用。过去画一张架构图,需要一个人专门去维护,其他人要改还得排队。现在只要会编辑文本,就能改图。说白了,diagram-design 解决的不是“怎么画得好看”,而是“怎么让图可持续维护、可规模化生产”。
1.3 这个项目适合谁来用
我个人觉得,diagram-design 最适配三类场景:
- 需要长期维护的动态架构文档,尤其是系统演进频繁、版本迭代快的项目;
- 博客作者和技术写作者,需要批量产出风格统一的技术示意图;
- 需要把文档构建流程自动化的团队,希望图表随代码一起发布、一起审查。
如果你追求的是像素级视觉设计、海报级美术效果,那这类代码方案不一定适合。它的定位是表达逻辑关系,而不是艺术创作。认清这个边界,后面设计 DSL 和渲染流程时就不会跑偏。
2. 整体设计:分层、DSL 与渲染解耦
2.1 核心架构分成哪几层
我在设计 diagram-design 时,把整个流程拆成了四个层级:
- DSL 描述层:用户用接近自然语言的语法写图;
- 图模型层:语法解析后生成一张中间图模型,包含节点、边、分组、样式等结构化数据;
- 布局层:负责计算坐标,支持自动布局与手动坐标混合;
- 渲染导出层:把图模型渲染成 SVG、PNG、PDF、HTML 交互页等产物。
这个分层是我最坚持的部分。理由很简单:如果解析、布局、渲染三件事耦合在一起,那换渲染引擎时会导致描述语法跟着改;如果布局和渲染不分家,自动布局的算法就没法单独测试。分层之后,每一步都可以独立演化。后来我想在渲染层增加一种新的导出格式,只改了渲染适配器,没有动 DSL 一个字母。
2.2 DSL 设计原则:像写句子,不要像写 XML
设计图描述语言时,我最开始尝试过 XML 和 JSON 风格,写起来非常啰嗦,一个简单架构图要嵌套好几层括号,可读性很差。后来我改成关键字加块的结构,让每一个声明像一句话。
比如下面这段描述:
graph app-deploy { group web { node nginx[ingress] style=server node app-server[application] style=service } node db[(database)] style=storage nginx -> app-server label="HTTP" app-server -> db label="JDBC" }这里的 graph 声明一张图;group 定义一个分组;node 定义节点,括号里是展示名称;-> 表示有向边;label 是连线文本;style 用于关联主题中的样式规则。整段描述读起来接近自然语言,新人看了基本能猜出含义。
我坚持用关键字而不是符号堆砌,有三个考虑:
- 第一,可读性高,代码评审时容易辨认;
- 第二,解析器容易实现,词法分析时按关键字切分即可;
- 第三,方便模板生成,字符串拼接时语义清晰。
DSL 不需要覆盖所有场景,能表达 80% 的常用图就够了。真正复杂的视觉调整,我把入口设计成允许用户直接指定节点坐标和样式覆盖,作为逃生舱口,而不是在 DSL 里堆砌无数语法糖。
2.3 布局引擎:自动为主,手动为辅
布局是 diagram-design 里最让我头疼的模块。市面上常见的布局思路大概分为层次布局、力导向布局、环形布局和网格布局,各自有适用场景。层次布局适合分层架构图,力导向适合关系网络,环形适合围绕核心模块的依赖图。
自动布局不是万能的,节点一多、连线一复杂,交叉线几乎不可避免。我的妥协方案是:布局器只负责给出初始位置,DSL 里允许通过坐标或相对位置的描述覆盖结果。比如某个关键节点我希望固定在右上角,就直接写 position="top-right",布局器会尊重这个锚定。
我花了很长时间确定布局模块的接口设计,最终只暴露三个输入:图模型、布局策略、布局参数(间距、对齐方式、边缘距离)。输出是每个节点的最终坐标。中间过程在黑盒里完成,这样我可以单独对布局器做单元测试,不会影响 DSL 层和渲染层。
2.4 主题与样式:不让颜色失控
图表一旦多起来,最容易被吐槽的就是颜色不统一。有人用蓝色表示服务,有人用绿色,有人干脆用随机色。我在 diagram-design 里引入了一套主题机制,把颜色、字号、边框、圆角这些视觉属性全部抽到主题配置中。
主题配置里我区分了两类样式语义:
| 类型 | 作用 | 示例 |
|---|---|---|
| 类型语义 | 表达节点是什么角色 | 服务、存储、外部依赖、消息队列 |
| 状态语义 | 表达节点当前是什么状态 | 正常、异常、发布中、下线 |
这样每个节点只需要声明自己的类型和状态,具体什么颜色由主题统一决定。所有授权使用的色值都写在同一份主题配置里,DSL 中不允许出现硬编码颜色。这个约定带来的好处是,想换一套夜间模式主题或者一套公司色板时,只需要改一个文件,全量图表情色一起变。
样式层面我还设计了继承覆盖规则:全局主题 < 容器分组 < 节点自身。节点自身声明的样式优先级最高,用来处理特殊情况。
3. 核心环节实现与实操过程
3.1 从最小可用 DSL 开始
如果你也想做类似的图描述系统,我的建议是先实现一个极简版本。不要一开始就设计完整的语言规范,先支持三件事:节点、边、分组。哪怕是节点只有一种形状,边的样式只有一种,也先把链路跑通。
我实现 DSL 解析器的步骤是:
- 词法分析:把文本拆成 token,识别关键字 graph、node、edge 等;
- 语法分析:根据关键字结构,构建一棵抽象语法树;
- 模型组装:遍历语法树,生成图模型对象集合;
- 校验:检查是否有重复 ID、存在悬空连线、分组层级是否合法。
当时没有使用现成解析器生成器,而是手写了一个递归下降解析器,因为这套语法足够简单,写起来也就几百行。如果直接用第三方解析系统,反而引入不必要的学习成本。这个决定帮我后续扩展语法时保持了很高的灵活性,加一个新的节点命令只需要在语法分析里加一个分支。
3.2 渲染引擎怎么选
渲染层是整个 system 中最麻烦的部分。我需要同时支持 SVG、PNG、HTML 交互页,所以最开始考虑了三种路线:
- 方案一:纯服务端渲染,直接输出位图。优点是部署简单,缺点是字体、抗锯齿、交互都是麻烦事;
- 方案二:基于浏览器的渲染适配层,把图模型转成 SVG DOM,再通过脚本导出位图;
- 方案三:自研绘制引擎,完全抛弃 DOM 直接画 Canvas,性能好但工程量太大。
我最终选了方案二,理由很实在:SVG 天然支持样式、事件、文本排版,浏览器负责处理字体加载和渲染细节,我再专注于图模型的正确性。在使用某个开源渲染适配层时,我先在浏览器中渲染 SVG,导出时通过配置指定尺寸、边距、缩放比例,再交给后端脚本生成 PNG 或 PDF。整个过程不需要手动调整渲染细节,只要把图模型的样式属性传过去就行。
3.3 一张架构图的完整实操
用一个稍完整的例子走一遍流程。假设我现在要画模拟项目 X 的部署架构,模块有负载均衡、网关、服务 A、服务 B、缓存、数据库,分成接入层、应用层、存储层三层。
我用 DSL 这样描述:
graph project-x { group access-layer { node lb[负载均衡] style=server } group service-layer { node gateway[API网关] style=service node service-a[服务A] style=service node service-b[服务B] style=service } group storage-layer { node cache[(缓存)] style=storage node db[(数据库)] style=storage } lb -> gateway label="HTTPS" gateway -> service-a label="RPC" gateway -> service-b label="RPC" service-a -> cache label="读写" service-a -> db label="SQL" service-b -> db label="SQL" }保存文件后,运行渲染命令,程序会经历:解析 DSL 生成图模型,布局引擎按照分层策略计算坐标,渲染层读取主题配置生成 SVG,最后导出 PNG。整个过程不到三秒。这就是文本化流程带来的效率,改了任何一条线,重新跑一次命令,产物立刻更新。
3.4 嵌入文档构建与持续集成
图上手之后,我很快就把它接进了文档项目的构建脚本。在文档源文件里直接引入图描述文件,构建时自动渲染成图片并嵌入页面。这样文档仓库更新后,图表自动重绘,不需要人工盯版本。
关键点是脚本监听文件变化。我写了一个监听任务,DSL 文件一保存,立即重新渲染到临时目录,方便本地预览。后续接入持续集成时,只需要在构建阶段安装渲染依赖、调用命令行工具,并把生成的图片当作构建产物对待。
这里有一个需要注意的坑:在持续集成容器里渲染字体和本地环境不同,如果容器没装中文字体,导出图片很可能出现中文方框。我在构建脚本里显式指定了字体目录,而不是依赖系统字体,这个问题才被彻底解决。
4. 常见问题与排查技巧实录
4.1 中文渲染成方框或乱码
这是图描述工具最常遇到的问题。原因通常是渲染环境没有安装相应字体,或者主题配置中没有指定字体回退方案。
排查思路分三步:
- 确认本地开发环境渲染正常,只有在容器中失败,那么基本是容器缺字体;
- 在容器里通过字体列表命令检查中文字体是否存在;
- 在渲染配置里显式声明字体名称,并把字体文件放入项目目录,避免依赖系统环境。
更稳妥的做法是,在渲染启动时做一次环境自检:加载不到指定字体就直接报错,而不是等到输出图片才发现乱码。这个前置检查帮我省了好几次返工。
4.2 布局混乱、连线交叉严重
自动布局很难对所有图都产生理想结果。最有效的补救方式不是反复调整布局参数,而是调整图的结构化表达。
连线交叉严重时,我一般优先考虑:
- 尽量用分组约束布局区域,让同一层的节点聚在一起;
- 关键节点使用锚定坐标固定位置;
- 把连线分为主干连线和辅助连线,拖动节点时优先保持主干连线清晰;
- 考虑把边路由改成正交样式,而不是斜线,视觉上会干净很多。
这里分享一个经验心得:不要追求布局算法一次到位。先跑出默认结果,再根据实际效果决定哪些节点需要手动干预,这种做法省时且可控。
4.3 图规模一大就性能暴跌
当单张图节点数超过一百,浏览器渲染开始变得明显卡顿,缩小放大都掉帧。主要原因是 SVG 中的节点数量大导致 DOM 事件挂载过多,以及重绘时样式频繁触发。
我的处理策略:
- 在渲染层设置节点数量阈值,超过阈值时自动关闭交互事件,只保留静态展示;
- 导出图片时关闭动画,直接使用最终状态;
- 对大图默认使用懒渲染,只在滚动到目标区域时才绘制;
- 将纯静态结构渲染拆成多个层,按需更新,而不是整体重绘。
如果你的场景中经常有超大型依赖图,建议预先考虑分层渲染架构,否则后期优化会非常被动。
4.4 多人协作时的样式冲突
文本化之后,协作冲突从图片文件飞到了文本 diff 上。最常见的是你改你的布局、我改我的配色,最后合并到一个文件。避免这个问题的办法是严格分离结构描述与样式配置。DSL 文件只描述节点和关系,主题文件描述所有视觉规则。合拼时结构冲突只和结构有关,样式冲突只和样式有关,砍掉一半混乱。
我还会在脚本里做一次静态检查,扫描 DSL 中是否有硬编码颜色。如果有人在节点里直接写了颜色值,构建就会报 waining,要求移到主题里。这条规则执行之后,组内图表风格终于稳定下来。
5. 一些额外心得与后续可以玩的方向
5.1 文本化图表带来的协作方式变化
自从团队开始用 diagram-design,最明显的变化是:代码评审时大家会认真看图的 diff。以前图片更新就是一张新图,没人知道到底改了什么;现在一段文本 diff 清清楚楚,哪条边删了、哪个节点改名了,一目了然。新同学加入时也不用学绘图软件,会打文本就能改图。
这种协作方式还降低了数据采集成本。过去整理模块依赖清单,需要人工翻图,现在我直接解析 DSL 文本,几行脚本就能导出完整关系列表。可以说,图变成了结构化的数据资产,而不只是给人看的静态图片。
5.2 未来可以继续扩展的方向
使用过程中我也积累了一些后续想法,留在这里作为扩展参考:
- 支持生成幻灯片格式,让评审材料直接从 DSL 构建;
- 自动推断节点分层和分组关系,减少人工声明;
- 根据图模型生成接口清单或数据字典,打通设计与开发文档;
- 接入动态监控数据,让架构图中的服务节点实时显示指标状态;
- 支持对比模式,同屏展示不同版本的结构差异。
这些方向本质上都是因为图已经结构化,才能继续做二次加工。如果还停留在手工绘图的阶段,任何自动化扩展都无从谈起。
做 diagram-design 这轮实践,我最大的体会是:先定义好中间图模型,比先选渲染引擎重要得多。图描述语言、布局算法、主题机制都是围绕模型转的,模型定义合理,后面加需求时会从容很多。最后再分享一个小技巧:任何图先跑通最小可用版本,再迭代样式,不要一上来就雕花。写作过程是,先有骨架,再谈皮肤。