diagram-design:用文本DSL与自动布局生成统一风格的架构图

发布时间:2026/10/17 11:17:29
diagram-design:用文本DSL与自动布局生成统一风格的架构图 不知道你有没有过这种经历打开一个画图软件准备画架构图结果光是调对齐、调颜色、统一箭头样式就花掉一下午。项目里的图一多每一张的视觉风格还都不一样甲图用红色表示核心服务乙图又用蓝色文档看多了脑壳疼。我搞 diagram-design 这个项目就是想把这件事彻底解决掉让图表从一个文本描述里直接生成所有人都用同一套主题、同一套布局规则画完即用不用再手动拖拽微调。这个工具适合自己要写技术文档、画流程图、维护架构图的人也适合团队里想统一图表输出规范、又不想被商业软件绑定的情况。diagram-design 本质上是一个独立的图表生成方案我自己定义了一套轻量描述语法配合布局计算和 SVG 渲染能把节点关系图、流程图、分层架构图直接转成高质量图片。做这个项目前我对比了挺多成熟方案最后决定自己写核心原因只有一个我需要让图表的每个细节都受控样式能统一维护还能嵌进自动化文档流程里。这篇就把整个项目从设计到落地的过程完整拆开包括语法设计、布局算法、渲染思路和踩坑记录都是实际跑过之后沉淀下来的经验。1. 为什么我会做一个叫 diagram-design 的小工具1.1 背景问题文档里的图为什么越来越难维护我最早接触图表维护是在一个跨多个组的协作项目里。项目文档里需要放系统架构图、调用时序图、部署拓扑图每个组都有自己的画图习惯有人用在线白板有人用桌面工具还有人直接拿表格拼颜色来做图。这些图放在文档里还好一旦需求变了、服务拆分了就得重新画一遍或者手工改动非常痛苦。最典型的场景某次架构调整后我拿到一份十几个节点的老图光是梳理哪些连线已经不存在、哪些节点需要改名就用了几个小时。这不是某个人画图技术差的问题而是思路的问题。图表本质上是信息的结构化表达信息一变图必须跟着变。但手工画的图没有数据来源它不是一个可计算的中间结构而只是一堆形状和线条的视觉残留。只要图片不是由一段描述性文本生成的维护成本就永远降不下来。这也是为什么越来越多的项目开始采用文本描述图表的路线把图当作代码一样管理。1.2 设计目标与功能取舍在动手写 diagram-design 之前我给自己定了几个必须要达到的目标第一必须支持纯文本定义图和连线文本可以进代码仓库可以做变更评审。第二视觉风格必须统一颜色、字号、圆角、间距都通过主题配置控制而不是散落在每个节点里。第三输出格式必须是矢量 SVG同时能转 PNG方便文档嵌入和分享。第四部署成本要低最好一个命令行工具就能搞定不需要维护数据库或服务端。围绕这四个目标我主动砍掉了不少非核心功能。比如交互式编辑、手绘风格、拖拽调整这些第一版通通不做。原因很简单一个工具如果什么都想沾最后就是什么都做不精。diagram-design 专注做的就是结构化的文本进标准化的图出别的交给更专业的工具去解决。功能范围确定之后整个项目的边界就非常清晰了文本解析、图结构建模、自动布局、主题渲染、文件导出。按这个边界去拆任务后续每一步都有明确的产出这也是这个项目能推进得比较顺利的关键原因。2. 核心架构与关键技术拆解2.1 文本即源DSL 语法设计思路diagram-design 的核心输入是文本所以语法设计决定了整个工具的上限。我参考了不少文本化图表方案的设计但我没有照搬任何一套而是按实际使用场景重新定义了一套 DSL。基本原则有三条第一语法要少能表达节点和连接就够了不需要花哨的控制结构第二结构要和常见语义对齐比如跨层架构里的横向分组要能直接表达第三风格配置和结构分离颜色主题不要写在节点定义里避免每张图都重复一堆样式。我的 DSL 实际长这样title: 订单服务依赖关系 direction: LR node: 网关服务: group: 接入层 color: blue 订单中心: group: 核心服务 支付服务: group: 外部依赖 消息队列: group: 中间件 edge: - 网关服务 - 订单中心 - 订单中心 - 支付服务 - 订单中心 - 消息队列这里用 YAML 作为外层承载声明 title、方向、节点和边。节点可以用 group 字段做视觉分区color 字段可以按需覆盖主题色。边用箭头表示方向左边是起点右边是终点。写完保存为.dd.yaml文件工具读入后直接生成图。你看这个设计的妙处它不要求使用者掌握编程知识只要会写简单的键值对和箭头就能画一张结构清晰的图。同时它又保留了结构化的可扩展性后面如果需要加分组嵌套、端口描述、超链接信息直接在 YAML 里增加字段即可不影响已有内容的解析。2.2 解析器与中间表示DSL 设计好之后下一步就是解析。我选择用 Python 来写主程序因为它处理 YAML 和文本文件非常方便而且跨平台打包也简单。解析阶段做的事情很朴素读入 YAML把节点定义和边定义拆成两个独立的数据结构再做一些必要的数据清洗。import yaml from dataclasses import dataclass, field dataclass class DiagramNode: id: str label: str group: str color: str width: float 0 height: float 0 dataclass class DiagramEdge: source: str target: str dataclass class Diagram: title: str direction: str LR nodes: dict field(default_factorydict) edges: list field(default_factorylist) groups: list field(default_factorylist)def parse_diagram(file_path: str) - Diagram: with open(file_path, r, encodingutf-8) as f: raw yaml.safe_load(f) diagram Diagram() if raw.get(title): diagram.title raw[title] if raw.get(direction): diagram.direction raw[direction] raw_nodes raw.get(node, {}) # 收集分组信息 for node_id, attrs in raw_nodes.items(): grp attrs.get(group, ) if grp and grp not in diagram.groups: diagram.groups.append(grp) for node_id, attrs in raw_nodes.items(): diagram.nodes[node_id] DiagramNode( idnode_id, labelnode_id, groupattrs.get(group, ), colorattrs.get(color, ), ) raw_edges raw.get(edge, []) for edge in raw_edges: if - in edge: src, dst edge.split(-, 1) diagram.edges.append(DiagramEdge(src.strip(), dst.strip())) return diagram这段代码不是最精简的但胜在可读性强。我在真正动手完备解析器之前想的很清楚只要节点和边能够准确提取出来后面布局和渲染就有据可依。中间表示尽量精简避免把后面会用不到的数据塞进来这对后续调试帮了大忙。我实际排查过几次问题发现大部分异常都出在 YAML 本身的格式错误上比如缩进不一致、中文冒号混用所以解析阶段我会额外做一层校验如果节点或边为空就提示具体文件位置而不是直接让程序崩掉。2.3 布局算法让节点排布符合直觉拿到节点和边之后最棘手的问题来了怎么把一堆互相关联的节点放在合适的位置上。布局算法直接决定图好不好看这也是 diagram-design 里技术含量最高的部分。我第一版用的是最简单的层级布局思路。以方向LR从左到右为例步骤是这样的把图看作有向图先找出所有没有入边的节点作为第一层。对第一层节点把它们的下游节点放到第二层如果某个节点已经分配过层了就不重复放。不断往下游推进直到所有节点都分配完毕。对每个层内的节点做排序尽量避免交叉连线。最后根据层号计算 X 坐标根据层内序号计算 Y 坐标。这里有一个关键参数节点的宽度和间距需要事先估算。估算公式我写得比较直接节点宽度 max(标签字符数 * 单字符估算宽度 左右内边距, 最小宽度) 每层高度 max(节点高度) 纵向间距实际代码里我把这些参数抽成了常量比如单字符宽度 14 像素、节点水平间隔 60 像素、垂直间隔 40 像素、内边距 20 像素。实测下来这个配置在大多数中文标签 4 到 12 个字的场景下表现良好。CHAR_WIDTH 14 PADDING_X 20 MIN_NODE_WIDTH 120 NODE_HEIGHT 44 LEVEL_GAP_X 80 LEVEL_GAP_Y 40 def compute_layout(diagram: Diagram) - dict: in_degree {nid: 0 for nid in diagram.nodes} for edge in diagram.edges: if edge.target in in_degree: in_degree[edge.target] 1 level {} current [nid for nid, deg in in_degree.items() if deg 0] depth 0 while current: for nid in current: level[nid] depth next_level [] for nid in current: for edge in diagram.edges: if edge.source nid and edge.target not in level: next_level.append(edge.target) depth 1 current next_level # 按层归组并计算坐标 layer_groups {} for nid, lv in level.items(): layer_groups.setdefault(lv, []).append(nid) positions {} max_width {} for lv, node_ids in layer_groups.items(): gap_count len(node_ids) - 1 total_height sum(estimate_node_height(diagram.nodes[nid]) for nid in node_ids) gap_count * LEVEL_GAP_Y start_y -total_height / 2 cursor_y start_y x lv * (LEVEL_GAP_X estimate_max_width(node_ids, diagram)) for nid in node_ids: w estimate_node_width(diagram.nodes[nid]) h estimate_node_height(diagram.nodes[nid]) positions[nid] (x, cursor_y h / 2, w, h) cursor_y h LEVEL_GAP_Y return positions上面这个简化版计算会保证所有节点围绕水平中线居中分布。实际工程里还有几处细节需要考虑比如环形依赖的检测两个节点相互依赖时层数会算不完整、孤立节点的兜底没有入边也没有出边时单独成层。我在代码里加了最大递归层数保护如果某个节点的层数超过总节点数就主动断掉一条依赖边防止死循环。2.4 渲染层与主题体系布局坐标算好之后剩下就是渲染到 SVG。为什么选 SVG 而不是直接画到画布像素因为 SVG 是个矢量格式天然适合技术图表放多大都清晰而且可以直接嵌进 HTML 文档查看。图里的每个元素我都直接生成对应的 SVG 节点包括矩形、路径、线条和文本。渲染的链路是这样的def render_svg(diagram: Diagram, positions: dict) - str: svg_parts [] width, height calc_canvas_size(positions) svg_parts.append(fsvg xmlnshttp://www.w3.org/2000/svg width{width} height{height} viewBox{-width/2} {-height/2} {width} {height} font-family{theme.font_family}) svg_parts.append(defs) # 箭头定义 svg_parts.append(marker idarrowhead markerWidth10 markerHeight7 refX10 refY3.5 orientautopolygon points0 0, 10 3.5, 0 7 fill#666//marker) # 分组背景模板省略 svg_parts.append(/defs) # 绘制边 for edge in diagram.edges: src_x, src_y, _, _ positions[edge.source] dst_x, dst_y, _, _ positions[edge.target] d fM {src_x}, {src_y} C {src_x 40}, {src_y} {dst_x - 40}, {dst_y} {dst_x - 8}, {dst_y} svg_parts.append(fpath d{d} stroke{theme.edge_color} stroke-width1.5 fillnone marker-endurl(#arrowhead)/) # 绘制节点 for nid, node in diagram.nodes.items(): x, y, w, h positions[nid] fill theme.get_node_fill(node) stroke theme.get_node_stroke(node) svg_parts.append(frect x{x - w/2} y{y - h/2} width{w} height{h} rx{theme.corner_radius} fill{fill} stroke{stroke} stroke-width1.5/) svg_parts.append(ftext x{x} y{y} text-anchormiddle dominant-baselinecentral font-size{theme.font_size} fill{theme.label_color}{node.label}/text) svg_parts.append(/svg) return \n.join(svg_parts)这段代码里有一个很容易被忽视但非常重要的细节SVG 文本绘制时必须显式设置text-anchormiddle和dominant-baselinecentral否则文本会以左对齐或基线对齐的方式摆放看起来会偏一格。初次开发时我没注意后来发现中文字符在不同系统里的渲染差异很大这两个属性就是为了让文本中心点和矩形中心点严格对齐。主题体系是整个工具的颜值保障。我把颜色、字体、圆角、边距这些全都抽成一个配置对象PALETTE { blue: #4A90D9, green: #27AE60, red: #E74C3C, gray: #95A5A6, orange: #E67E22, } class Theme: def __init__(self, config: dict): self.font_family config.get(fontFamily, PingFang SC, Microsoft YaHei, sans-serif) self.font_size config.get(fontSize, 14) self.corner_radius config.get(cornerRadius, 8) self.edge_color config.get(edgeColor, #666666) self.label_color config.get(labelColor, #333333) self.group_colors config.get(groupColors, {})主题的存在让我们团队内部可以一键完成全量图表的风格升级。以前换一套主色调可能要重画几十张图现在只需要改一份主题配置重新跑一遍生成命令就行真实节省了大量时间。3. 从零开始跑通 diagram-design 的核心实操3.1 安装与基本用法diagram-design 的安装没有复杂依赖只要本机有 Python 3.9 以上环境再装一个 PyYAML 库即可。我不打算做一个需要漫长初始化流程的重型工具命令行使用才是它的主要入口。pip install pyyaml git clone your-repo-url diagram-design cd diagram-design python main.py --input ./examples/order.yaml --output ./dist/order.svgmain.py 是入口文件--input 指定输入的 DSL 文件--output 指定输出 SVG 路径。如果需要在文档里直接贴图再加一个--format png参数就可以转换成 PNG。这个转换依赖系统的图像处理能力我直接在 SVG 输出之后调用渲染器完成栅格化保证两种格式始终出自同一套布局结果。3.2 用一份配置画第一张架构图我们来看一个实际可用的例子。假设要画一个简化版会员系统的服务依赖图文件内容如下title: 会员系统依赖关系 direction: LR node: 客户端: group: 接入层 会员网关: group: 接入层 会员服务: group: 核心服务 积分服务: group: 核心服务 用户数据库: group: 存储 消息通知: group: 外部依赖 edge: - 客户端 - 会员网关 - 会员网关 - 会员服务 - 会员服务 - 积分服务 - 会员服务 - 用户数据库 - 积分服务 - 用户数据库 - 会员服务 - 消息通知用 diagram-design 生成后你会看到一张从左到右的架构图最左侧是客户端经过会员网关走到核心服务再分流到积分服务、用户数据库和消息通知。接入了分层分组之后不同 group 的节点背景会被分成不同的色块区域视觉上一眼就能读出接入层到底连着什么。这里值得留意的是节点在 DSL 里的书写顺序不会影响最终布局位置布局完全由依赖关系决定。这意味着你可以按任何顺序写节点图的结构不会乱这在迭代修改时极其友好。3.3 主题定制与样式统一统一的视觉风格是 diagram-design 比手画更具优势的地方。默认主题足够应付大部分场景但如果想让它匹配自己公司的企业色只需要覆盖主题配置。我支持全局主题文件和单图覆盖两层配置全局的放在项目根目录theme.yaml单图可以在 DSL 文件头部声明theme字段。theme: fontFamily: Source Han Sans CN, Microsoft YaHei, sans-serif fontSize: 15 cornerRadius: 10 edgeColor: #7F8C8D groupColors: 接入层: #F0F8FF 核心服务: #FFF8E1 存储: #F0FFF0 外部依赖: #FFEBEE我团队里的用法是全局主题放一份标准色彩所有新增图表直接继承某一次专项汇报需要突出特定模块时才在单图覆盖某些节点的 color 值。这样既保证了绝大多数图片风格一致又保留了按场景微调的灵活性。3.4 批量导出与文档集成命令行工具如果只能一份一份地执行还是会麻烦。所以我给 diagram-design 加了一个非常省心的批量模式输入一个目录它会自动查找目录下所有.dd.yaml文件逐个生成对应的 SVG 和 PNG。python main.py --input ./docs/diagrams/ --output ./docs/images/ --format both这个模式特别适合放在文档构建流程里。我现在的习惯是把文档项目里的所有图集中在diagrams目录保存为文本文件然后写一个简单的构建脚本在文档构建前批量执行上面的命令。这样团队成员修改了任何一张图的 DSL构建时图片都会自动更新完全不用担心文档图和实际结构不同步的问题。4. 真实操作中踩过的坑与排查方案4.1 中文渲染与字体问题第一个让我印象深刻的坑是中文乱码。在本地开发环境里一切正常但部署到一台纯净的 Linux 机器上后生成的 SVG 里汉字全部变成了方框。排查下来才发现是目标机器上完全没安装中文字体SVG 会回退到系统默认无衬线字体而那条默认字体链上根本没有能显示汉字的字体。解决思路分两层。第一层是系统层面在项目文档里明确写了环境要求执行生成前先确认系统有可用中文字体如果没有用包管理工具安装一个文泉驿或思源黑体。第二层是 SVG 本身在 font-family 配置里显式声明多个候选字体比如Source Han Sans CN, PingFang SC, Microsoft YaHei, sans-serif。这是我后来在所有项目环境里都会检查的一个基础项尤其是跑自动化构建的最小化容器最容易漏掉字体包。4.2 文本溢出与节点重叠第二个高频问题是节点标签太长。比如一个节点叫用户行为数据清洗服务按默认 14 像素单字符宽度估算这个节点宽度就要超过 210 像素。如果相邻层节点很多很容易出现文本挤在一起或者节点边界重合的情况观感很差。我处理这个问题的方案有两步。第一步是在估算阶段引入文本长度上限如果字数超过阈值就在标签中间插入换行把展示文本拆成两行甚至三行而不是一味地把节点拉宽。第二步是布局完成后做一次碰撞检测遍历所有相邻层节点对如果两个节点的水平投影有重叠就通过增加层内间距的方式把重叠推开。这一步让最终输出图在绝大多数场景下都不会看起来乱糟糟。4.3 复杂图走向混乱第三个问题是当图里存在循环引用或者多入边节点特别多时层级布局容易出乱子。举个例子A 依赖 BB 依赖 CC 又依赖 A这种循环在真实系统里经常出现比如配置服务互相拉取对方配置。此时我的分层算法会因为 A 已被分层而跳过 C 到 A 的边最终布局看起来像断了一条线但没法直接反映出来。我的兜底处理方式解析阶段先检测环如果发现环就输出一条警告日志提示哪些节点之间存在循环依赖并且自动断开其中一条边来保证布局可计算。同时在生成图里用虚线样式单独画出被断开的边视觉上仍能看出原来有依赖关系只是布局上避免死循环。4.4 性能抖动与大数据量图表最后一个问题是性能。日常维护的图大多数是几十个节点规模处理时间几乎是零。但有一次我拿一个超过 400 个节点、600 条边的配置去跑批量模式发现单个文件生成耗时超过了 10 秒布局碰撞检测部分是最大的性能瓶颈。原因是它的复杂度接近 O(n²)遍历次数随节点数指数增长。我做了两处优化后效果非常明显第一把碰撞检测的全局两两遍历改成只检测相邻层之间的节点因为非相邻层节点本身不可能重叠第二为每个节点预先缓存估算宽度和高度避免在循环里重复测量文本宽度。优化后同一个文件从 10 秒降到了 1 秒以内在批量渲染整个文档目录时体感已经和瞬时完成没什么区别。5. 最后分享几个使用经验和后续设想diagram-design 做到现在最让我得意的不是某个算法有多精深而是它真正融入了我自己和团队的日常工作流。现在团队里任何人要画图不再需要打开画图工具只需要新建一个.dd.yaml文件按格式写好节点和连线跑一句命令图就出来了。评审代码的时候连改图的需求也能像改需求文档一样在 MR 里清晰看到改动的是哪个节点、加了哪条边。过程中的经验概括起来有三点第一文本 DSL 的设计一定要克制语法越少越容易推广不要一上来就追求表达所有复杂的图形逻辑。第二主题配置和数据结构分离是让团队统一风格的关键否则每个人都会按自己审美给节点加颜色。第三布局输出后要加一道碰撞检测的兜底环节在算法不完美的情况下这道防线能挡住绝大多数视觉灾难。这个项目后续我还有几个方向想尝试。一是增加垂直方向布局TB的精细调整让树形结构展示更整齐二是增加端口定义能力这样画接口依赖图时能精确到方法和调用链三是把渲染器做成异步并行进一步压缩超大型图表的生成时间。如果你也在维护一堆让人头疼的技术图表我建议可以先从最小可用版本开始不要一上来就规划宏大功能先把文本进、图出这条链路跑通剩下的优化都是锦上添花。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询