自研图表生成工具:从文本DSL到SVG渲染的架构设计与实践

发布时间:2026/10/19 11:00:12
自研图表生成工具:从文本DSL到SVG渲染的架构设计与实践 这段时间我们组里在内部工具链上做了一个叫 diagram-design 的图表生成项目核心目标很朴素用纯代码的方式快速画出架构图、流程图、时序图这些常见图形并且能直接嵌入到文档和前端应用里。坚持到现阶段这个项目的收益已经远超预期来梳理一下整体设计、技术取舍和踩过的坑给同样想自己做一套图形化表达工具的同学一个可参考的思路。之所以要自己动手做而不是直接用现成的绘图软件或在线白板是因为团队日常有大量“代码即文档”的需求。举个例子微服务治理文档里需要画依赖关系图业务评审材料里需要画状态机流转图这些图的生命周期跟代码一样长一旦散落在画图工具里版本管理和多人协作就会失控。diagram-design 要解决的正是这个问题让图表定义以文本形式存在基于一套简单可靠的描述语言自动算出布局和连接线再渲染成清晰的矢量图。在这篇文章里我先把项目的整体设计拆开讲清楚再详细说明 DSL 定义、布局算法、渲染层选型和接入方式最后按实际记录总结常见问题和排查技巧。不管你是准备搭一个类似的轻量绘图工具还是想在现有系统里加入自动化出图能力这些内容应该都能直接派上用场。1. 内容整体设计与思路拆解1.1 核心需求解析为什么图表需要“代码化”在做 diagram-design 之前我统计过团队日常产出图表的场景发现可以归为三大类结构类图表系统架构图、模块依赖图、组织关系图。这类图以节点和连线为主强调的是层次和归属关系。流程类图表业务流程图、状态机图、审批流图。核心是流转路径需要表达分支、条件和循环。时序类图表接口调用链、消息队列消费关系、跨系统协作顺序。特征是横向时间轴和纵向参与者的组合。这三类图在传统绘图软件里画起来都挺费劲尤其是改动频繁的阶段。产品经理改了需求研发要同步更新设计图架构调整了依赖图得全部重画。几次下来你会发现消耗时间的不是画图本身而是“同步”和“维护”。如果把图表定义变成代码这些问题就有了突破口可以像管理代码一样管理图表用版本管理工具追踪每次改动用代码评审流程约束变更甚至接入持续集成流水线自动生成最新版图纸。1.2 方案选型从嵌入文本到自研 DSL 的决策过程图表代码化这个方向其实有不少现成方案。我最早重点考察了三个PlantUML、Mermaid 和 Graphviz。先说说各自的优劣。Graphviz 的布局算法非常成熟用 DOT 语言描述图结构渲染出来的层次图在这个领域是经典水准。但它的问题是语法偏底层描述复杂节点属性的时候不够直观而且输出样式偏学术范儿放在产品文档里显得不够好看。PlantUML 的语法阅读性更好能通过简单的关键字画时序图和用例图内置多种配色主题。但它的渲染依赖 Java 环境在纯前端场景里接入比较痛苦。为了一个画图功能让用户装 JRE这在 2020 年之后的产品里已经很难接受了。Mermaid 是纯 JavaScript 实现跟浏览器的亲和度最高而且市面上已经有比较成熟的编辑器插件。但试用了两个星期之后我发现两个问题一是复杂布局的可控性不够当节点数量超过 20 个时连线交叉和节点重叠的情况明显增多二是样式的扩展性有限想实现团队自己的视觉规范需要深入改造它的内部渲染逻辑这相当于被绑在它的架构上。综合评估后我决定自研一套轻量 DSL。核心思路是用最少的语法元素覆盖最常见的图类型布局算法自己控制渲染层通过 SVG 实现完全跑在浏览器里。提示不是所有场景都需要自研。如果你的需求用 Mermaid 完全能覆盖那直接用是最稳妥的选择。自研的前提是定制化程度要求高、使用频次高、长期演进价值大。1.3 管线的整体架构从文本到图形的四层转化链路diagram-design 的整体处理链路分四层每一层职责单一方便单独替换和升级。第一层是解析层读取 DSL 文本做词法分析和语法分析输出一个结构化的中间表示 AST。第二层是语义层把 AST 转换成内部图模型这个模型里有节点、边、分组容器、端口、标签等概念。第三层是布局层计算每个节点的坐标、每条边的路径处理层级排列和交叉减少。第四层是渲染层把布局完成的数据转换成 SVG 元素交由浏览器绘制。分开设计的好处是每一层都可以独立演进。比如我最近在改造渲染层想换成 Canvas 渲染以支撑超大图布局层完全不用动想支持导出 PNG也只需要加一个渲染后端。分层设计还有利于测试。词法分析和语法分析是纯函数逻辑可以直接做单元测试布局算法接收图模型输出坐标也能脱离渲染层测试。这让整个项目的质量保障有了抓手。2. 核心细节解析与实操要点2.1 DSL 语法设计用最小的规则覆盖最多的场景自研 DSL 最容易犯的错误是语法设计过于复杂最后变得跟通用编程语言一样难用。我在设计 diagram-design 的 DSL 时遵循三个原则关键词最少、优先级直观、排错友好。目前整个语法体系只有三个核心概念图类型声明、节点定义、连线定义。以下是一个例子// 架构图示例 arch component[网关服务] as gateway component[用户服务] as user component[订单服务] as order component[数据库] as db gateway - user : HTTP gateway - order : HTTP user - db : SQL order - db : SQLarch 是图类型声明component 是节点类型- 表示有向连线。语法规则简洁到可以在一页纸上写完但配合不同节点类型和连线样式已经能覆盖架构图、流程图、时序图的大多数需求。时序图在这个基础上增加了一个边界符号sequence participant[前端] as web participant[后端] as api participant[数据库] as db web - api : 创建订单 api - db : 事务开始 api - db : 插入记录 api - web : 返回订单号解析逻辑上我采用了逐行处理的方案。先判断当前是否处于注释状态再识别行首关键词。每行解析结果存成一个结构化对象最终聚合到图模型里。这种简化策略让解析器实现起来只有几百行代码出错了也容易定位。2.2 图模型设计节点、边和容器组的概念DSL 文本解析完之后需要转成内部图模型这是后续布局和渲染的数据基础。图模型里的核心数据结构有三个节点包含唯一 ID、类型、标签、样式引用、所属分组标识。节点类型会影响布局行为比如 component 和 actor 的尺寸计算方式不同。边包含起点 ID、终点 ID、标签文本、样式引用。在模型层我只存逻辑关系不存坐标数据路径计算完全交给布局层。分组对应 DSL 里的 block 语法让节点可以嵌套在容器里。这个能力对画系统边界、业务域划分特别有用。模型对象附带一个全局样式标记用于控制背景色、字体族、圆角半径等视觉参数。数据结构和渲染完全解耦保证了同一份模型可以输出不同风格的视觉产物。2.3 布局算法选型层次化布局的实现思路布局是整个 diagram-design 里最有技术含量的部分。在方案选型阶段我先后考虑了力导向布局、圆形布局和层次化布局。力导向布局的效果图很漂亮节点会自动分散连线交叉也少。但它的计算复杂度高图稍微大一点就卡顿而且结果具有随机性同一个模型每次出的图位置都不固定。这对文档场景来说是不能接受的因为读者需要稳定的图形记忆。圆形布局适合表达环形依赖关系但大部分业务场景是明确的分层结构强行圆形布局会破坏可读性。最终选了层次化布局核心思路沿用图论中经典的 Sugiyama 框架分四个步骤建层次对有向无环图做拓扑排序给每个节点分配一个层级序号。定排序在同一层内部调整节点顺序尽可能减少交叉连线。算坐标在同一层的节点按次序横向均匀分布不同层的节点按层级纵向分布。画连线根据节点在层级中的具体位置生成正交折线路径。为了简化实现我没做完整版 Sugiyama 算法里的所有优化而是做了一套基于规则的前置处理先识别出所有子图再对每个子图单独布局最后合并坐标。这个方法在节点数少于 200 的情况下效果已经很不错。2.4 SVG 渲染架构为什么坚决不选 Canvas渲染层的选型对比很直接SVG 和 Canvas。SVG 的优点是 DOM 树里的每个图形元素都是独立节点天然支持事件绑定方便做悬停高亮、点击跳转等交互。文本渲染清晰在多数浏览器里缩放不糊。缺点是节点数量过大时渲染性能下降。Canvas 恰好相反绘制海量节点时性能好但图形本身是画布上的像素无法对单个图形做事件细粒度绑定要实现类似 SVG 的交互效果需要自己做厚层命中检测。考虑到 diagram-design 的主要使用场景是文档内嵌单图的节点数通常不超过 300 个SVG 是完全够用的。而且文档场景对交互的要求高读者需要能悬停查看节点详情点击跳到关联文档SVG 的天然事件模型能省下很多工作量。注意如果未来要面对几千个节点的超大架构图SVG 方案需要做分块渲染或者虚拟滚动这是目前已知的性能瓶颈。3. 实操过程与核心环节实现3.1 项目工程搭建和基础数据结构工程搭建上我没有引入大型框架代码组织相对轻量核心逻辑全部用 TypeScript 编写保证类型安全。解析器和布局器是纯函数模块不依赖 DOM。渲染层单独一个模块操作 SVG 元素。对外暴露一个统一的入口方法render(dslText, mountNode)。核心数据结构定义大致如下interface DiagramNode { id: string; label: string; type: component | actor | db | decision; groupId?: string; width: number; height: number; x: number; y: number; } interface DiagramEdge { id: string; source: string; target: string; label?: string; points: Array{ x: number; y: number }; }3.2 解析器实现从文本到 AST解析器是整个链路的第一步也是最容易出 bug 的地方。我采用的方法是逐行扫描每行做一次模式匹配。核心逻辑如下function parseLine(line: string): { type: string; payload: any } | null { const trimmed line.trim(); if (!trimmed || trimmed.startsWith(//)) return null; const componentMatch trimmed.match(/^component\[(.)\] as (\w)$/); if (componentMatch) { return { type: NODE, payload: { kind: component, label: componentMatch[1], id: componentMatch[2] } }; } const edgeMatch trimmed.match(/^(\w) - (\w)(?:\s*:\s*(.))?$/); if (edgeMatch) { return { type: EDGE, payload: { source: edgeMatch[1], target: edgeMatch[2], label: edgeMatch[3] || } }; } throw new Error(无法解析的行: trimed); }这版实现看起来笨拙但胜在直观可靠。每增加一种语法只需要增加一个正则模式和对应的 payload 结构。为了排错方便我特意在错误信息里带上原始行号和原始文本这样用户在 DSL 里看到报错时能立刻定位问题。3.3 布局器实现坐标计算和连线路径生成布局器的核心代码分三个步骤。第一步是对节点进行分层function assignLayers(nodes, edges): Mapstring, number { const layerMap new Map(); const inDegree new Map(); // 初始化入度 nodes.forEach(node inDegree.set(node.id, 0)); edges.forEach(edge { inDegree.set(edge.target, (inDegree.get(edge.target) || 0) 1); }); const queue []; inDegree.forEach((degree, nodeId) { if (degree 0) queue.push(nodeId); }); let currentLayer 0; while (queue.length 0) { const size queue.length; for (let i 0; i size; i) { const nodeId queue.shift(); layerMap.set(nodeId, currentLayer); edges.forEach(edge { if (edge.source nodeId) { const targetNodeId edge.target; inDegree.set(targetNodeId, (inDegree.get(targetNodeId) || 0) - 1); if (inDegree.get(targetNodeId) 0) queue.push(targetNodeId); } }); } currentLayer; } return layerMap; }这是典型的拓扑排序实现。需要注意的是如果图里存在环这个算法会有节点分不到层所以我在外层加了强连通分量检测遇到环就自动把环内的第一条边反向标记为虚线提示用户检查依赖关系。第二步是同一层内节点的排序。这里目标是最小化连线交叉。我用了简化版的 Barycenter 启发式算法每次迭代时遍历每个节点计算它的所有邻居节点在当前层的位置平均值按照这个平均值重新排序重复若干次直到交叉数不再下降。第三步是根据分层和排序结果计算坐标。每个节点会被分配宽、高、x、y 四个值。连线路径的生成采用正交折线从起点右侧出发向下到达目标节点左侧中间经过的两个拐点坐标直接用数值计算出来。3.4 渲染器实现把坐标模型变成 SVG渲染层接收布局器输出的坐标模型将其转换成 SVG 元素。功能拆成两个部分绘制节点生成 rect 元素和 text 元素rect 的位置和大小来自布局器坐标text 使用自动截断逻辑处理超出宽度的标签。绘制连线生成 path 元素路径的 d 属性由布局器生成的折线点拼装而来支持在连线上方添加 label 文本。渲染层的样式策略是代码里不硬编码颜色和字体而是通过 CSS 变量统一控制。这样用户可以直接改造一套主题后台管理系统、对外文档、技术评审材料各用不同的视觉风格只需覆盖一组 CSS 变量。交互方面也放在渲染层做节点监听 click 事件触发外部传入的回调函数边的 label 监听 mouseenter 事件在附近显示一个工具提示浮层。4. 常见问题与排查技巧实录4.1 解析器常见错误解析器最容易出现的错误是行匹配失败。反复出现的典型原因有三个中文括号和冒号混入了英文语法。在 DSL 里必须全程使用英文半角符号。节点 ID 与关键字冲突。比如把某个节点 ID 命名为 type会导致语义歧义。边定义里引用了不存在的节点 ID。排查技巧解析器编译期做引用检查。在正式进入布局前我会先遍历所有边确认 source 和 target 都在节点列表里如果缺失直接抛出编译错误而不是等到渲染时显示一个断掉的连线。这项检查让错误发现时间大幅提前体验改进明显。遇到解析失败时建议在调试模式下输出 AST 结构能直观看到是哪一行产生了畸形结果。4.2 布局异常的典型困境布局层遇到最多的问题是节点重叠。常见原因是同类节点的默认尺寸不满足标签长度。比如说一个架构图里有几个节点的名称特别长比如 “transaction-service-prod-02”默认宽度 160 就装不下文本相邻节点的文本就会互相覆盖。解决方式是在布局前加一个预处理步骤根据每个节点的标签文本长度动态计算宽度公式大致为宽 中文字符数 x 20 西文字符数 x 9 左右内边距 32这个公式是经验值实测能覆盖绝大多数场景。另一个高发问题是环状依赖处理。拓扑排序遇到环会把节点漏掉导致画面上出现飞线。我的处理策略是预先用强连通分量算法识别环选中环内一条边标记为反向虚边并在该边上加一个警示图标这样既能渲染完整图又能让用户注意到依赖关系的不健康。4.3 SVG 渲染的样式缺陷SVG 渲染的坑多数与样式细节有关文本截断不生效常见原因是 text 元素没有显式设置 font-family 和 font-size。因为不同浏览器对默认字体处理不一致必须显式声明否则文本宽度计算不准。连线 label 出现重叠。当两条边方向相反时label 会挤在一起。解决方法是渲染时先对边集合做一个预检如果发现反向边对就把 label 的偏移量一个往上一个往下错开位置。低分辨率截图模糊。这是 SVG 嵌入到文档里的常见吐槽。方案是在导出 PNG 时使用 2 倍到 3 倍的 scale 参数调用一个将 SVG 序列化为图片的函数放大 canvas 分辨率输出。4.4 性能优化经验分享性能问题通常出现在节点超过 200 个或连线超过 300 条的情况下。实测下来主要的性能瓶颈在 SVG 的 DOM 节点数量和布局算法的迭代次数。我的优化手段是分层渲染主图只渲染节点和连线的骨架标签和描述性文本在鼠标悬停时才动态插入 DOM。这个策略把初始渲染时间从 800 毫秒降到 200 毫秒左右。布局算法优化上将 Barycenter 迭代次数从固定 20 次改为动态收敛每次迭代后统计交叉数如果连续三轮没有改善就提前退出。实测在多数图上把布局耗时压缩了四成左右。5. 实际接入案例从一个典型场景走通全链路5.1 已应用的场景示例以我们目前使用最多的微服务依赖图为例走一遍完整流程编写 DSL 源文件统一放在仓库的 architecture/docs 目录下。提交代码后由持续集成流水线自动执行 diagram-design 的生成命令。产物是 SVG 文件和 HTML 预览页面自动发布到内部文档站点。前端应用通过一个轻量 SDK 按需加载 SVG 文件嵌入到管理后台的模块详情页。整个过程中没有人手动画过一张图。所有变更都是代码变更有提交记录有评审记录出了问题还可以溯源到具体某次提交。这个体验是传统绘图软件无法替代的。5.2 SDK 接入流程对外部使用者来说接入 diagram-design 只需要三步第一步执行安装命令引入核心包。第二步引入默认样式文件。第三步调用 render 方法并把容器 DOM 节点传进去。直接复用默认主题的场景渲染代码不过十行import { render } from diagram-design; import diagram-design/dist/style.css; const dslText arch component[聚合层] as gateway component[服务层] as svc component[数据层] as mysql gateway - svc : RPC svc - mysql : SQL ; render(dslText, document.getElementById(container));5.3 样式主题定制实践团队接入之后不同项目组对视觉风格有各自的要求。我建议做了一套基于 CSS 变量的主题机制把背景色、边框色、文字色、连线色、圆角、间距全部暴露为 CSS 变量。这样每个组只需要在入口文件里覆盖几个变量就能得到一套贴合品牌规范的图表风格。实际定制过程中发现最影响观感的是连线颜色和节点边框宽度。默认连线是灰色节点边框是 1 像素。在深色主题下调整为一组高对比度的亮色连线外加 2 像素边框观感提升非常明显。6. 经验总结与扩展建议最后分享几点这个项目沉淀下来的实际操作体会。第一个感受是轻量 DSL 的价值远不止减少重复劳动更大的价值在于把图表做成可以审查、可以追溯、可以自动生成的对象。这种能力在现代研发协作里特别重要。第二个经验是分层架构设计值得坚持。哪怕项目初期规模很小把解析、布局、渲染三个层彻底分开后续迭代会轻松非常多。我曾在初期想省事把布局逻辑和渲染逻辑混在一起结果每次加新功能都要同时改两处代码测试也难以覆盖后来硬着头皮拆开才走上正轨。如果是从零开始做类似工具这个教训可以直接避掉。第三个建议是把“报错信息友好性”放到优先级前列。解析器报出的每条错误都带行号和期望语法提示这个投入会极大降低使用门槛。一个工具的上手成本很大程度取决于报错信息质量而不是功能多少。后续可以扩展的方向也很多。短期内计划加入时序图的分组能力让参与者可以在垂直维度上聚合为一组角色中期想把布局算法从 Sugiyama 思路扩展到自定义边路由支撑跨层虚线和回环路径再长远一点可能会出一个纯 Python 的渲染后端方便直接在服务端生成图表文件。这些方向的共同点依然是保持 DSL 稳定只在引擎层面增强能力。