ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用代码画架构图:diagram-design 从 DSL 到渲染的实践

用代码画架构图:diagram-design 从 DSL 到渲染的实践

前阵子带团队做过一批技术文档,里面光是架构图、流程图、时序图加起来就有一百多张。刚开始大家还很乐意用绘图软件一张张拖拽,后来发现只要依赖关系一变,就要连改四五张图;方案评审时对着投影仪改图,改完还要重新导出、重新上传。折腾几次后,我索性抽空做了个小项目,代号叫 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 时,把整个流程拆成了四个层级:

  1. DSL 描述层:用户用接近自然语言的语法写图;
  2. 图模型层:语法解析后生成一张中间图模型,包含节点、边、分组、样式等结构化数据;
  3. 布局层:负责计算坐标,支持自动布局与手动坐标混合;
  4. 渲染导出层:把图模型渲染成 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 解析器的步骤是:

  1. 词法分析:把文本拆成 token,识别关键字 graph、node、edge 等;
  2. 语法分析:根据关键字结构,构建一棵抽象语法树;
  3. 模型组装:遍历语法树,生成图模型对象集合;
  4. 校验:检查是否有重复 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 中文渲染成方框或乱码

这是图描述工具最常遇到的问题。原因通常是渲染环境没有安装相应字体,或者主题配置中没有指定字体回退方案。

排查思路分三步:

  1. 确认本地开发环境渲染正常,只有在容器中失败,那么基本是容器缺字体;
  2. 在容器里通过字体列表命令检查中文字体是否存在;
  3. 在渲染配置里显式声明字体名称,并把字体文件放入项目目录,避免依赖系统环境。

更稳妥的做法是,在渲染启动时做一次环境自检:加载不到指定字体就直接报错,而不是等到输出图片才发现乱码。这个前置检查帮我省了好几次返工。

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 这轮实践,我最大的体会是:先定义好中间图模型,比先选渲染引擎重要得多。图描述语言、布局算法、主题机制都是围绕模型转的,模型定义合理,后面加需求时会从容很多。最后再分享一个小技巧:任何图先跑通最小可用版本,再迭代样式,不要一上来就雕花。写作过程是,先有骨架,再谈皮肤。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进