在 PR(Pull Request)评审中,读代码 diff 只是第一步,真正费时间的是把变更映射回系统架构。一个 PR 可能只改了三个文件,但影响的服务边界、依赖关系、消息链路,往往牵动一整条业务分支。评审者如果只能在脑海里拼出架构变化,很容易漏掉边界情况。最近开源社区出现了一类把 PR 转成动画架构图的工具:它基于代码变更生成一组架构快照,再用动画展示服务之间如何新增依赖、断开连接,以及哪些节点被标记为 changed。这类工具把 PR 评审从“读 diff”推进到“看架构演化”,非常适合跨团队评审、新人理解系统和发布前影响分析。
这里说的 PR 指 Pull Request,不是视频剪辑软件 Premiere Pro 的缩写。下面从工程角度拆解这类开源工具的实现思路,给出一个最小可运行示例,说明如何接入 GitHub Actions,并补充参数调优、常见问题和生产环境建议。
1. 先想清楚:PR 评审最需要的是什么
1.1 代码 diff 回答“改了什么”,架构图回答“影响了什么”
代码 diff 是文件级别的变更视图。一个 PR 修改了services/order/client.go,diff 会精确到第几行新增、第几行删除,但它很难回答一个问题:这个改动会影响哪些服务?
如果 order-service 被 api-gateway 依赖,而本次改动修改了接口签名,受影响的就不只是 order-service 自身,还有所有调用方。代码 diff 看不到这层关系,静态架构图也只能看到改动前后的两张图,需要人工比较。真正有价值的是把“影响关系”显式画出来,并且让评审者一眼看到变化路径。
架构图的优势在于,它把代码变更从“文件维度”提升到“系统维度”。评审者不再需要从几十个文件 diff 中推断架构影响,而是直接看服务节点和依赖边发生了什么变化。
1.2 什么是动画架构图:架构快照 + 变更路径 + 时间轴
动画架构图不是简单地把两张静态架构图拼在一起,而是由三部分构成:
- 架构快照:某个时间点系统的节点集合、边集合和节点属性。节点可以是微服务、模块、数据库、消息队列。
- 变更路径:PR 的 diff 影响到了哪些节点和边。它描述的是“从 base 到 head 的变化轨迹”。
- 时间轴:从 base 快照到 head 快照之间的中间状态。中间状态可以表达“新增服务尚未完全接入”“旧依赖正在移除”等复杂变化。
动画的价值在于,它比静态图多了一个维度。静态图只能并排展示 before 和 after,动画可以展示变化顺序:先出现哪个节点、先连接哪条依赖边、哪些旧边被标记为失效。对复杂系统来说,这个时间轴比两张静态图的信息密度高得多。
1.3 它要解决的问题
这类工具的核心目标不是替代 Code Review,而是补充一个架构视角。在以下场景中价值最明显:
- 跨团队评审:后端改动影响前端联调时,架构图能快速标出受影响方。
- 新人理解系统:新成员通过动画形态的架构变更,比读文档更直观。
- 发布前影响分析:合并前确认本次变更波及哪些服务,避免发布后才发现下游调用失败。
- 基础设施变更:修改公共库、网关路由、消息协议时,架构图能暴露隐藏依赖。
一句话概括:代码 diff 描述“改了什么”,架构图描述“影响了什么”,动画则描述“影响是怎么发生的”。
2. 整体设计:一个开源工具最少需要哪几部分
2.1 模块划分
把这类工具拆成五个模块,每个模块只负责一个环节,这样便于测试和扩展。
| 模块 | 输入 | 输出 | 核心问题 |
|---|---|---|---|
| 变更解析 | git 分支、commit 范围 | 变更文件列表 | 拿到准确的文件级 diff |
| 架构建模 | 源码目录、注册表 | 图结构快照 | 用节点和边表达系统 |
| 差分引擎 | base 快照、head 快照 | 变更列表 | 找出新增、删除、修改 |
| 渲染器 | 快照和变更列表 | 单帧图片 | 把图结构画成可视化图像 |
| 动画合成 | 多帧图片 | GIF 或视频 | 按时间轴生成动画 |
模块之间通过中间格式解耦。渲染器不需要关心 diff 是怎么解析的,动画合成器也不需要知道架构图用的是 Graphviz 还是 matplotlib。只要快照格式稳定,后续替换任一模块都可行。
2.2 核心数据结构:用图模型表达系统架构
系统架构天然适合用有向图表达。节点是服务或模块,边是依赖关系或调用关系。一个中间格式可以设计成 JSON,包括 base 快照、head 快照和变更列表。
{ "base": { "nodes": ["api-gateway", "order-service", "user-service"], "edges": [ ["api-gateway", "order-service"], ["api-gateway", "user-service"] ] }, "head": { "nodes": ["api-gateway", "order-service", "user-service", "payment-service"], "edges": [ ["api-gateway", "order-service"], ["api-gateway", "user-service"], ["order-service", "payment-service"] ] }, "changes": [ {"kind": "add_service", "node": "payment-service"}, {"kind": "add_edge", "from": "order-service", "to": "payment-service"} ] }这个中间格式的价值在于:渲染器、动画器、PR 评论脚本都可以消费同一份数据。后续要做“受影响服务清单”或“测试建议”,也可以基于 changes 继续扩展。
2.3 工作流程:PR 事件如何驱动架构图生成
在 CI 场景下,完整流程是:
- PR 触发 GitHub Actions 工作流。
- 检出仓库,并把 base 分支和 head commit 都拉取完整。
- 通过
git diff --name-only拿到变更文件列表。 - 根据文件路径到服务节点的映射表,解析出受影响节点。
- 分别构建 base 快照和 head 快照。
- 差分引擎比较两个快照,生成 changes 列表。
- 渲染器先渲染 base 帧,再渲染中间帧,最后渲染 head 帧。
- 动画合成器把多帧图片合成为 GIF。
- 把 GIF 和状态 JSON 上传为 CI artifact,并在 PR 评论中给出下载链接。
流程里最容易出错的是第 2 步。如果只用默认的浅克隆,base 分支的提交历史可能不存在,diff 结果为空,生成的架构图自然也没有变化。
2.4 技术选型建议
下面的最小实现使用 Python,主要原因是生态最省事:
gitdiff 解析可以直接用 subprocess,也可以换pygit2。- 图结构用
networkx,渲染用matplotlib,合成 GIF 用Pillow。 - 如果希望美观,可以换成 Graphviz 的
dot布局,再导成 SVG 或 PNG。
实际落地时也可以选 Node.js 或 Go,但最小闭环的速度会慢一些。技术选型不是关键,关键是中间格式要稳定。
注意:如果原始项目没有给出固定依赖版本,落地前要先确认 Python 版本、networkx、matplotlib、Pillow 之间的兼容性,避免因为 API 差异导致渲染失败。
3. 从零搭建一个最小可运行实现
3.1 环境准备与项目结构
建议使用 Python 3.10 及以上版本,依赖如下:
networkx>=3.0 matplotlib>=3.7 Pillow>=10.0 PyYAML>=6.0项目目录可以按模块拆分:
pr-arch/ ├── pr_arch/ │ ├── __init__.py │ ├── models.py │ ├── diff_parser.py │ ├── snapshot_builder.py │ ├── renderer.py │ └── animator.py ├── cli.py ├── service_registry.json ├── requirements.txt └── .github/workflows/pr-arch.ymlservice_registry.json是文件路径到服务节点的映射表。这个文件服务质量好坏,直接决定了架构图的准确性。
3.2 定义节点与快照模型
先用 dataclass 定义节点和快照。
# pr_arch/models.py from __future__ import annotations from dataclasses import dataclass, field @dataclass class ServiceNode: name: str kind: str = "service" changed: bool = False @dataclass class ArchitectureSnapshot: nodes: dict[str, ServiceNode] = field(default_factory=dict) edges: list[tuple[str, str]] = field(default_factory=list)changed字段用于渲染时高亮。节点集合用 dict 保存,键是服务名,这样在构建快照时可以快速判断节点是否存在。
3.3 从 diff 中提取受影响节点
解析 diff 时可以调用 git 命令,拿到变更文件列表后,再通过注册表映射到服务节点。
# pr_arch/diff_parser.py import subprocess import json def get_changed_files(base: str, head: str) -> list[str]: result = subprocess.run( ["git", "diff", "--name-only", base, head], capture_output=True, text=True, check=True, ) return [line.strip() for line in result.stdout.splitlines() if line.strip()] def load_registry(path: str = "service_registry.json") -> dict: with open(path, encoding="utf-8") as f: return json.load(f) def map_files_to_nodes(changed_files: list[str], registry: dict) -> set[str]: affected: set[str] = set() for path in changed_files: for node_name, config in registry.items(): if any(path.startswith(prefix) for prefix in config["paths"]): affected.add(node_name) return affectedgit diff --name-only base head返回的是文件路径,不含 diff 内容,解析成本低。实际项目中不要在高频循环里反复调用 subprocess,CI 任务里跑一次是合理的。
关键点是映射表。service_registry.json可以是这样的:
{ "api-gateway": { "kind": "gateway", "paths": ["gateway/", "api/"] }, "order-service": { "kind": "service", "paths": ["services/order/"] }, "user-service": { "kind": "service", "paths": ["services/user/"] } }3.4 生成架构图帧
单帧渲染需要把 ArchitectureSnapshot 画成图片。这里用 networkx 建图,matplotlib 渲染。
# pr_arch/renderer.py import networkx as nx import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt def render_snapshot_to_png( snapshot: ArchitectureSnapshot, output_png: str, title: str, changed_nodes: set[str] | None = None, seed: int = 42, ) -> str: changed_nodes = changed_nodes or set() graph = nx.DiGraph() for node in snapshot.nodes.values(): graph.add_node(node.name) graph.add_edges_from(snapshot.edges) pos = nx.spring_layout(graph, seed=seed, k=0.8) colors = [] for node in snapshot.nodes.values(): if node.name in changed_nodes: colors.append("#d9534f") else: colors.append("#5bc0de") plt.figure(figsize=(10, 7)) nx.draw_networkx( graph, pos, node_color=colors, with_labels=True, font_size=9, node_size=1200, arrows=True, ) plt.title(title, fontsize=14) plt.savefig(output_png, dpi=110) plt.close() return output_png说明:
- 固定
seed会让 spring_layout 每次都生成稳定坐标,动画才不会抖动。 - 变更节点用红色,未变更节点用浅蓝色,评审者一眼就能识别。
dpi=110是示例值,生产环境要根据 GIF 目标大小调整。
3.5 合成动画
准备好多张 PNG 后,用 Pillow 合成 GIF。
# pr_arch/animator.py from PIL import Image def compose_gif(png_paths: list[str], output_gif: str, duration: int = 700) -> str: frames = [Image.open(p) for p in png_paths] frames[0].save( output_gif, save_all=True, append_images=frames[1:], duration=duration, loop=0, ) return output_gifduration单位是毫秒,表示每一帧停留时间。PR 评审场景建议 600 到 900 毫秒,太快看不清节点变化,太慢会拖长评审时间。loop=0表示无限循环,适合放在 PR 评论里观察。
3.6 提供命令行入口
命令行入口用 argparse 接收 base、head、输出路径等参数。
# cli.py import argparse import json from pr_arch.models import ArchitectureSnapshot, ServiceNode from pr_arch.diff_parser import get_changed_files, load_registry, map_files_to_nodes from pr_arch.snapshot_builder import build_snapshot from pr_arch.renderer import render_snapshot_to_png from pr_arch.animator import compose_gif def main(): parser = argparse.ArgumentParser(description="Turn PR into animated architecture diagram") parser.add_argument("--base", required=True) parser.add_argument("--head", required=True) parser.add_argument("--output", default="pr-arch.gif") parser.add_argument("--state", default="state.json") parser.add_argument("--max-nodes", type=int, default=50) args = parser.parse_args() registry = load_registry() changed_files = get_changed_files(args.base, args.head) affected_nodes = map_files_to_nodes(changed_files, registry) base_snapshot = build_snapshot(args.base, registry, affected_nodes) head_snapshot = build_snapshot(args.head, registry, affected_nodes) state = { "base": base_snapshot, "head": head_snapshot, "changes": sorted(list(affected_nodes)), } with open(args.state, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) png_paths = [ render_snapshot_to_png(base_snapshot, "frame_base.png", "base", affected_nodes), render_snapshot_to_png(head_snapshot, "frame_head.png", "head", affected_nodes), ] compose_gif(png_paths, args.output) if __name__ == "__main__": main()snapshot_builder.py里需要实现build_snapshot,它会根据分支名读取 architecture.yaml 或注册表来构建完整快照。这里为了演示,可以简化成读取注册表并自动加入“公共依赖边”。实际项目里需要结合源码分析依赖关系,比如解析 import、调用链、OpenAPI 引用等。
注意:上面的代码是一个最小思路示例。真实项目中节点集合和依赖边通常来自架构描述文件或代码分析器,不能只依赖一个静态 JSON 文件。
4. 把工具接入 GitHub Actions
4.1 为什么要放在 CI 里执行
本地跑一次只是验证工具可用,真正让每个 PR 都生成架构图,需要把工具接入 CI。PR 每更新一次 commit,工作流自动执行一遍,评审者在评论里看到最新结果。这样工具才不是一次性脚本,而是评审流程的一部分。
4.2 GitHub Actions 工作流配置
一个完整的工作流文件如下:
name: pr-architecture on: pull_request: types: [opened, synchronize] permissions: contents: read pull-requests: write jobs: generate-architecture: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies run: pip install -r requirements.txt - name: Generate architecture diagram run: | python cli.py \ --base origin/${{ github.event.pull_request.base.ref }} \ --head ${{ github.event.pull_request.head.sha }} \ --output pr-arch.gif \ --state state.json - name: Upload artifact uses: actions/upload-artifact@v4 with: name: pr-arch path: | pr-arch.gif state.jsonfetch-depth: 0是必须的。默认浅克隆只有最近一次提交,无法拿到 base 分支的完整历史,diff 结果会异常。
4.3 在 PR 评论中输出动画
PR 评论接口不能直接上传 GIF 文件。常见做法是把 GIF 作为 release asset 或 CI artifact 发布,然后在评论里给下载链接。PR 评论本身可以通过 GitHub Issues API 创建。
# .github/scripts/comment_pr.py import json import os import urllib.request repo = os.environ["GITHUB_REPOSITORY"] pr_number = os.environ["PR_NUMBER"] token = os.environ["GITHUB_TOKEN"] body = ( "架构变更已生成。\n\n" "- 动画图:pr-arch.gif\n" "- 状态 JSON:state.json\n" "- 受影响节点:order-service, payment-service\n" ) url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments" data = json.dumps({"body": body}).encode("utf-8") req = urllib.request.Request(url, data=data, method="POST") req.add_header("Authorization", f"token {token}") req.add_header("Accept", "application/vnd.github+json") with urllib.request.urlopen(req) as resp: print(resp.status)对应的工作流步骤需要传入 PR 编号:
- name: Comment PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: python .github/scripts/comment_pr.pypermissions里pull-requests: write是评论成功的前提,不能省略。
4.4 失败处理与重试
CI 步骤失败时,首先要保留现场。建议把生成过程中的原始日志、中间 PNG、state.json 都上传为 artifact,方便排查。对于偶发的网络抖动或 git 拉取失败,可以给关键步骤增加重试逻辑,但不要盲目重试大量任务,避免掩盖真实问题。
5. 关键参数说明与调优
5.1 常用参数速查表
一个可维护的工具,参数必须让用户能按仓库规模调整。下面是一个示例参数表。
| 参数 | 默认值 | 作用 | 调大影响 | 调小影响 |
|---|---|---|---|---|
--base | 无 | 对比基准分支 | 无 | 无 |
--head | 无 | 对比目标 commit | 无 | 无 |
--output | pr-arch.gif | GIF 输出路径 | 无 | 无 |
--max-nodes | 50 | 最多渲染节点数 | 图更完整,耗时增加 | 图更小,可能丢失节点 |
--frame-duration | 700 | 每帧停留毫秒数 | 动画更慢,便于观察 | 动画更快,信息密度高 |
--layout-seed | 42 | 布局随机种子 | 固定坐标防止抖动 | 无 |
5.2 如何控制 GIF 大小和生成时间
GIF 文件大小主要取决于帧数、每帧尺寸和颜色数量。常见控制方式:
- 限制
--max-nodes,避免渲染超大图。 - 降低
dpi或图片尺寸,减少每帧字节数。 - 不要生成几十帧,PR 场景 2 到 5 帧足够表达变化。
- 复杂架构可以输出 SVG 或 HTML 报告,代替 GIF。
生成时间方面,最大瓶颈往往不是渲染,而是构建快照。如果每次全量扫描整个仓库,大仓库会在几分钟内超时。推荐做法是只分析变更文件及其直接依赖,避免全量构建。
5.3 如何提高差分精度
文件路径映射表是精度基础,但它只能回答“哪些服务文件变了”,回答不了“哪些服务被间接影响”。提高精度需要引入依赖分析:
- 解析服务间的 import、调用、HTTP 请求路径。
- 识别公共库变更,自动标注所有依赖该库的服务。
- 使用 OpenAPI 或 GraphQL 定义变化识别接口破坏。
建议把影响等级分为直接变更、间接依赖、疑似影响三层。PR 架构图里用不同颜色区分,评审者可以优先关注第一层。
6. 运行验证与结果分析
6.1 本地模拟一次 PR
在本地仓库创建一个测试分支,模拟一次真实变更。
git checkout -b feature/add-payment mkdir -p services/order echo "package order" > services/order/service.go git add . git commit -m "add order service entry"然后运行:
python cli.py \ --base origin/main \ --head feature/add-payment \ --output pr-arch.gif \ --state state.json正常输出应该是一个可打开的 GIF 文件和一个 state.json。
6.2 校验 JSON 快照
打开 state.json,重点检查三条信息:
- base 节点是否完整。
- head 节点是否包含新节点和新增边。
- changes 是否包含预期服务名。
例如:
{ "base": { "nodes": ["api-gateway", "order-service"], "edges": [["api-gateway", "order-service"]] }, "head": { "nodes": ["api-gateway", "order-service", "payment-service"], "edges": [ ["api-gateway", "order-service"], ["order-service", "payment-service"] ] }, "changes": ["order-service", "payment-service"] }如果 changes 为空,说明 diff 解析或映射表出了问题。
6.3 验证动画帧顺序
用任何 GIF 查看器逐帧检查:
- 第 1 帧应该是 base 快照,颜色以未变更节点为主。
- 中间帧应体现新节点出现或新边连接。
- 最后一帧是 head 快照,变更节点高亮。
如果节点位置在帧间跳动,检查是否固定了layout-seed。
6.4 正常与异常结果对照
| 状态 | 预期表现 | 异常表现 |
|---|---|---|
| 正常 | 生成 GIF,变化节点高亮 | 无输出文件或只有一张空图 |
| 无变更 | changes 为空,GIF 两帧相同 | 误报大量变更 |
| 超大仓库 | 渲染时间可控 | 超时或内存不足 |
| 映射缺失 | 部分文件无法归属到节点 | 相关服务完全不出现在图中 |
7. 常见问题排查
7.1 生成的架构图看不到变化
可能原因:
- base 和 head 指向了同一个 commit。
- 浅克隆导致 base 分支历史不存在。
- 文件路径与注册表映射不匹配。
检查方式:先打印 changed_files 列表,确认 diff 非空;再检查注册表前缀是否匹配实际目录。
解决方式:在 CI 中设置fetch-depth: 0;补全注册表映射;用git rev-parse确认 base 和 head 的 commit。
7.2 节点位置每次都在跳动,动画像“抽搐”
原因:每一帧都新建图,布局算法使用了不同的随机种子。解决方式:固定layout-seed,尽量让 base、中间帧、head 使用同一个图实例和同一套坐标。
如果节点数过多,spring_layout 本身不稳定,可以改用 Graphviz 的分层布局,例如dot。
7.3 大仓库生成时间过长
原因:全量构建快照,或者 diff 解析了所有历史提交。解决方式:
- 只分析 PR 涉及的 commit 范围。
- 使用增量缓存,相同 base 和依赖关系不重复构建。
- 设置最大节点数和最大依赖深度。
- 把渲染分辨率降低,先保证生成速度,再考虑视觉精细度。
7.4 在 CI 中报错缺少依赖或没有权限
现象:ModuleNotFoundError、subprocess.CalledProcessError、Resource not accessible by integration。
常见原因与方案如下表:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
ModuleNotFoundError | Python 依赖未安装完整 | 查看 pip install 日志 | 检查 requirements.txt 和运行环境 |
| Graphviz 找不到 | 系统库未安装 | 执行dot -V | 安装 graphviz 系统依赖 |
| API 返回 403 | token 权限不足 | 检查 workflow permissions | 配置pull-requests: write |
| git diff 为空 | 浅克隆 | 查看 checkout 步骤日志 | 添加fetch-depth: 0 |
7.5 评论内容过大或格式错误导致 API 失败
原因:GIF 过大被转成 Base64 后放入评论,或 Markdown 语法错误。解决方式:把 GIF 作为 artifact 或 release asset 托管,评论里只放链接和简短说明;评论脚本对 API 返回做状态码检查并打印响应体。
8. 最佳实践与扩展方向
8.1 学习环境与生产环境的差异
学习环境里,本地跑通最小实现就够了。生产环境还要额外考虑:
- 配置外置化:base 分支、注册表路径、输出目录都用配置文件或环境变量控制。
- 日志和监控:记录 diff 文件数、受影响节点数、生成耗时,便于观察工具本身是否正常。
- 权限收敛:GITHUB_TOKEN 只开放必要权限,不把整个仓库写权限给工作流。
- 产物保留:GIF 和 state.json 定期清理,避免 CI artifact 无限膨胀。
- 失败降级:工具失败时不要阻塞 PR 合入,评论里提示“架构图生成失败”即可。
8.2 建立可复用的架构注册表
文件路径映射表是架构图准确性的基础。建议把它独立成service-registry.yaml,由各服务 owner 维护,而不是靠工具自动猜测。
services: - name: api-gateway kind: gateway paths: - gateway/ - api/ - name: order-service kind: service paths: - services/order/注册表越准确,diff 到节点的影响分析就越可靠。每次新增服务或调整目录结构时,都应该同步更新注册表。
8.3 扩展方向
下一步可以从三个方向扩展:
- 增量影响分析:不只是画图,而是输出“受影响服务清单”“建议回归测试范围”。
- 多渲染后端:支持 Graphviz、SVG、HTML 报告,GIF 用于快速查看,HTML 用于交互式定位。
- 定时基线:除了 PR 时生成,还可以每天对 main 分支生成一张架构图,追踪架构漂移。
如果项目使用 OpenAPI 或 GraphQL,还可以识别接口签名变化,进一步判断是否为破坏性变更。
8.4 对开源项目维护者的建议
开源工具要降低试用门槛,README 里放一个真实的 GIF 示例,比文字描述更直观。同时提供 JSON 输出,方便其他工具消费。CLI 和 CI 集成两种用法都要支持,因为本地调试和自动化执行依赖的入口不同。
对使用者来说,最稳妥的路径是:先跑通命令行最小闭环,再接入 GitHub Actions,最后根据仓库大小调整参数。把“PR 转动画架构图”变成一种基础设施能力后,团队在评审时就不需要再靠脑补系统全貌了。