从零搭建代码评审工具:事件流数据模型与规则引擎设计实践

发布时间:2026/10/21 8:54:51
从零搭建代码评审工具:事件流数据模型与规则引擎设计实践
1. 从零搭建代码评审工具为什么我要造这个轮子第一次接触代码评审这件事是在一个十来人的小团队里。那时候大家提交代码基本靠自觉谁写完了在群里喊一嗓子有空的人就点开看看没空就算了。结果就是线上事故频发回滚成了家常便饭。后来团队大了开始用商业化的代码托管平台自带的评审功能问题确实少了很多但新的麻烦又来了——评审意见散落在各个提交记录里想统计一下“这个月大家一共提了多少条有效意见”“哪类问题最常出现”几乎无从下手。这就是我做open-code-review这个项目的起点。它不是一个要替代现有代码托管平台的工具而是一个轻量级、可自托管、专注于评审数据沉淀与流程编排的辅助系统。核心目标很明确把代码评审从“平台功能”变成“团队资产”。它适合那些已经有一定评审流程、但希望把评审数据用起来的中小团队也适合想学习代码评审系统底层设计的开发者。说白了这个项目解决的是三个具体问题第一评审意见的结构化存储让每条意见都能被检索、被统计第二评审流程的可配置化不同团队对“什么算通过”的定义不一样不能一刀切第三评审质量的可度量通过数据反推流程哪里卡住了。我见过太多团队把评审当成走过场点个“同意”就完事。这个项目的价值就在于它逼着你把评审这件事想清楚——你的通过标准是什么谁来把关意见怎么闭环想不清楚配置就写不出来。下面我把整个搭建和使用的过程拆开讲包括我踩过的坑和后来总结出来的经验。2. 核心架构拆解评审数据到底该怎么存2.1 为什么不用现成的数据库表结构刚开始我图省事直接照着代码托管平台的API返回结构建表。提交记录一张表评论一张表评审人一张表看起来挺规整。跑了一个月就发现不对劲同一个提交在不同分支上可能有不同的评审状态同一个评论可能被多次回复形成讨论串评审人还可能中途换人。这些关系用简单的三张表根本表达不清楚。后来我重新设计了数据模型核心思路是把评审事件当成不可变的日志流。每一次状态变更——提交发起评审、添加评论、修改结论、重新指派——都作为一条独立的事件记录写入。当前状态通过回放事件流计算得出。这样做的好处是任何历史时刻的评审状态都能还原统计口径也不会因为状态覆盖而丢失数据。具体来说核心表有这么几张表名作用关键字段review_event存储所有评审事件event_id, review_id, event_type, actor, payload, created_atreview_snapshot定期快照加速查询review_id, snapshot_data, versionreviewer_profile评审人画像reviewer_id, expertise_tags, active_hoursrule_config评审规则配置rule_id, scope, condition, action事件类型我定义了十来种常用的有review_created、comment_added、comment_resolved、approval_given、change_requested、reviewer_assigned。每种事件的 payload 结构不同但都遵循同一个原则只记录发生了什么不记录当前是什么。这个设计决策在后面做统计报表的时候帮了大忙。2.2 评审规则的配置化设计不同团队对评审的要求差异巨大。有的团队要求至少两人同意才能合并有的团队只要求一个人看过就行还有的团队对特定目录的修改要求特定人员审批。如果把这些逻辑硬编码在代码里每换一个团队就要改一次代码维护成本太高。我的做法是设计一套基于条件的规则引擎。每条规则由三部分组成作用范围、触发条件、执行动作。作用范围可以是仓库、分支、目录甚至文件类型触发条件可以是修改行数超过阈值、涉及敏感文件、提交者不在白名单等执行动作可以是要求额外审批人、自动添加标签、阻止合并等。规则配置用 YAML 格式举个例子rules: - name: 核心模块双人评审 scope: paths: - src/core/** condition: min_changed_lines: 50 action: required_approvals: 2 notify: - core-team - name: 文档修改快速通道 scope: paths: - docs/** - *.md condition: max_changed_lines: 100 action: required_approvals: 1 auto_merge: true这套规则引擎的实现并不复杂核心就是一个条件匹配器加一个动作执行器。但设计的时候要注意规则的优先级和冲突处理必须明确。我的做法是规则按顺序匹配第一条匹配上的规则生效后面的不再检查。这样虽然牺牲了一些灵活性但避免了规则冲突带来的不可预测行为。2.3 评审意见的结构化处理评审意见最麻烦的地方在于它天然是非结构化的文本。一句“这里逻辑有问题”到底指的是什么是边界条件没处理还是变量命名不规范还是算法复杂度太高如果不做结构化统计出来的数据毫无意义。我的方案是半自动结构化。在提交评论的时候系统会提示选择问题类型逻辑错误、性能问题、代码风格、安全问题、可读性建议等同时允许自由文本补充。对于没有选择类型的评论后台会用一个简单的关键词匹配器做初步分类准确率大概七成左右剩下的靠人工补标。这里有个经验不要试图用纯自动的方式做分类。我试过用文本分类模型训练数据不够的情况下准确率还不如关键词匹配。后来改成“人工标注为主、自动补标为辅”数据质量立刻上来了。而且标注这个动作本身就在提醒评审人你提的这条意见到底属于哪一类想清楚了再写意见质量也会提高。3. 环境搭建与部署从裸机到跑通第一条评审3.1 依赖选择与版本锁定这个项目后端用 Python 写Web 框架选了 FastAPI数据库用 PostgreSQL缓存和消息队列用 Redis。选 FastAPI 的原因很简单异步支持好自动生成 API 文档类型提示友好。PostgreSQL 是因为需要 JSONB 字段来存事件 payload同时事务支持完善。Redis 用来做事件流的缓冲和实时通知。版本锁定这件事我必须单独强调。早期我没锁版本结果某次部署的时候 FastAPI 从 0.95 升到 0.100中间有个破坏性变更导致所有路由的依赖注入全部报错。从那以后我学乖了所有依赖都用pip-compile生成锁定文件部署时严格按锁定文件安装。# 生成锁定文件 pip-compile requirements.in --output-file requirements.txt # 部署时安装 pip install -r requirements.txt数据库迁移用 Alembic每次改表结构都生成一个迁移脚本绝不手动改数据库。这个习惯在多人协作的时候尤其重要否则你永远不知道生产环境的表结构到底是什么样。3.2 最小可运行配置如果你想快速跑起来看看效果不需要完整的生产环境配置。一个最小的可运行配置只需要三样东西一个 PostgreSQL 实例、一个 Redis 实例、一份配置文件。配置文件我放在config/local.yaml内容大概长这样database: host: localhost port: 5432 name: open_code_review user: reviewer password: reviewer_pass redis: host: localhost port: 6379 db: 0 server: host: 0.0.0.0 port: 8000 debug: true review: default_required_approvals: 1 auto_assign: true notification_channel: log启动步骤就三步建库、迁移、起服务。# 建库 createdb open_code_review # 迁移 alembic upgrade head # 起服务 uvicorn app.main:app --reload --host 0.0.0.0 --port 8000跑起来之后访问http://localhost:8000/docs就能看到自动生成的 API 文档。我建议先用文档里的接口手动发几条评审事件感受一下数据流是怎么走的。这比直接看代码理解得快。3.3 接入现有代码仓库的方式这个项目本身不托管代码它通过 Webhook 和 API 与现有的代码托管平台对接。对接方式有两种一种是平台主动推送事件到本系统另一种是本系统定时拉取平台的评审数据。推送方式实时性好但需要平台支持 Webhook 配置。拉取方式通用性强但会有延迟。我的建议是两者结合Webhook 负责实时事件定时拉取负责补偿丢失的事件和同步历史数据。对接的时候有个坑要注意不同平台的 Webhook payload 结构差异很大而且同一个平台不同版本的事件格式也可能不同。我的做法是在接入层做一层适配器把各种平台的 payload 统一转换成内部事件格式。适配器用插件方式加载新增一个平台只需要写一个适配器类不用改核心代码。class BaseAdapter: def parse_event(self, raw_payload: dict) - ReviewEvent: raise NotImplementedError class PlatformAAdapter(BaseAdapter): def parse_event(self, raw_payload: dict) - ReviewEvent: # 把平台A的格式转成内部格式 return ReviewEvent( event_typeself._map_type(raw_payload[action]), actorraw_payload[sender][login], payloadself._extract_payload(raw_payload), )适配器模式在这里的价值在于核心逻辑不依赖任何具体平台。哪天要换平台或者同时对接多个平台只需要增加适配器核心的评审流程和统计逻辑完全不用动。4. 评审流程编排从提交到合并的完整链路4.1 评审发起与自动指派评审发起这个环节最容易出问题的是指派谁。手动指派的话发起人可能总是找那几个熟人导致评审负载不均而且容易形成小圈子。自动指派的话如果算法太简单比如轮询又可能把前端代码指派给后端工程师评审质量堪忧。我的方案是基于专家标签的加权指派。每个评审人有一组专家标签比如frontend、backend、database、security每个仓库或目录也有一组标签。指派的时候计算评审人标签与目标标签的匹配度再结合当前负载待评审数量做加权排序选 top N 作为候选人。def assign_reviewers(review, candidates, n2): scored [] for c in candidates: match_score compute_tag_match(review.tags, c.expertise_tags) load_penalty c.pending_reviews * 0.1 score match_score - load_penalty scored.append((c, score)) scored.sort(keylambda x: x[1], reverseTrue) return [c for c, _ in scored[:n]]这个算法不复杂但效果比轮询好很多。实测下来评审意见的专业度明显提升因为指派给的人确实懂这块。当然标签体系需要维护新来的同事要记得打标签这个靠流程约束。4.2 评论与讨论的线程管理评审评论不是孤立的它们会形成讨论串。一个评论可能引出回复回复又引出新的回复最后形成一个树状结构。如果只是简单地把评论按时间排序平铺阅读体验很差而且容易漏掉关键讨论。我的做法是显式维护评论的父子关系。每条评论有一个parent_comment_id字段根评论的该字段为空。前端展示的时候按树形结构渲染支持折叠和展开。后端查询的时候用递归 CTE 一次性把整棵树拉出来避免 N1 查询。WITH RECURSIVE comment_tree AS ( SELECT * FROM review_comment WHERE parent_comment_id IS NULL AND review_id :review_id UNION ALL SELECT c.* FROM review_comment c INNER JOIN comment_tree ct ON c.parent_comment_id ct.comment_id ) SELECT * FROM comment_tree ORDER BY created_at;这里有个细节评论的解决状态要能独立追踪。一条根评论可能引出五条回复其中三条讨论的是同一个问题另外两条是另一个问题。如果只给根评论标记“已解决”粒度太粗。我的做法是允许任意评论被标记为“已解决”并且解决状态会向上冒泡——当一条评论的所有子评论都解决后父评论自动标记为已解决。这样既保留了细粒度又不会让根评论一直挂着未解决状态。4.3 审批门槛与合并拦截审批门槛的配置前面讲过了这里重点说合并拦截的实现。当评审状态不满足合并条件时系统需要阻止合并操作。但这里有个矛盾如果拦截太严格会阻塞正常流程如果太宽松又失去了把关的意义。我的策略是分级拦截。把不满足的条件分成“硬性阻断”和“软性警告”两类。硬性阻断的条件比如“必需审批人未审批”“存在未解决的严重问题评论”软性警告的条件比如“评审人数量少于建议值”“评审时长超过平均值”。硬性条件不满足时直接拒绝合并请求软性条件不满足时允许合并但记录一条警告日志。def check_merge_eligibility(review): blockers [] warnings [] if review.approvals review.required_approvals: blockers.append(审批人数不足) if review.has_unresolved_critical_comments(): blockers.append(存在未解决的严重问题) if review.reviewer_count review.suggested_reviewers: warnings.append(评审人数量偏少) return { eligible: len(blockers) 0, blockers: blockers, warnings: warnings, }这个设计的好处是紧急情况下可以绕过软性警告但硬性阻断必须解决。实际使用中硬性阻断真正拦住的问题并不多但它的存在本身就是一种威慑让大家知道有些底线不能碰。5. 数据统计与质量度量让评审效果看得见5.1 评审效率指标的计算口径统计这件事口径比算法重要。同样叫“平均评审时长”从提交到第一次评论算和从提交到最终合并算结果可能差好几倍。我的做法是明确定义每个指标的计算口径并在界面上标注清楚。常用的几个指标指标名称计算口径用途首次响应时长提交到第一条评论的时间差衡量评审人的响应速度评审闭环时长提交到最终合并/关闭的时间差衡量整体流程效率评论密度评论数 / 变更行数衡量评审细致程度问题发现率标记为问题的评论数 / 总评论数衡量评审有效性返工率合并后回滚或修复的提交数 / 总合并数衡量评审质量这些指标单独看意义不大要结合趋势和对比才有价值。比如首次响应时长突然变长可能是评审人负载过高问题发现率突然下降可能是评审人开始敷衍了。我在系统里做了一个简单的趋势图按周展示这些指标的变化异常波动会自动标红。5.2 评审人画像与负载均衡评审人画像这个功能一开始我觉得是锦上添花后来发现是刚需。画像的核心数据包括擅长领域、平均响应时长、评论质量评分、当前负载。这些数据汇总起来就能回答“这个评审该派给谁”和“为什么最近评审变慢了”这两个问题。评论质量评分是个主观指标我的做法是让提交者给评论打分。每条评论旁边有一个“有帮助/没帮助”的按钮提交者可以快速反馈。积累一段时间后每个评审人的平均得分就出来了。这个分数不公开排名只用于内部参考和负载调整。负载均衡方面我设置了一个软上限当某个评审人的待评审数量超过阈值时自动指派会跳过该评审人除非没有其他候选人。阈值默认是 5可以根据团队规模调整。这个机制有效防止了“忙的人越来越忙闲的人越来越闲”的马太效应。5.3 从数据反推流程改进数据统计的最终目的是驱动流程改进。我每个月会看一次评审数据重点关注三个问题哪些环节耗时最长哪类问题反复出现哪些规则从未被触发举个例子有段时间我发现“首次响应时长”的中位数是 4 小时但 90 分位是 26 小时。这说明大部分评审响应很快但有一小部分拖了很久。进一步分析发现拖久的评审集中在周五下午提交的。于是我们调整了自动指派策略周五下午提交的评审会优先指派给下周一周二有空的人并且自动发送提醒。调整之后90 分位降到了 12 小时。另一个例子是我发现“代码风格”类评论占比很高但这类评论对代码质量的提升有限。于是我们在提交前增加了一个自动格式化检查把风格问题拦截在评审之前。调整之后评审评论中“逻辑错误”和“安全问题”的占比明显上升评审的有效性提高了。6. 踩坑实录那些让我熬夜排查的问题6.1 事件流乱序导致的统计偏差事件流设计有个天然问题事件到达顺序不等于发生顺序。Webhook 推送可能延迟定时拉取可能补录旧事件多个来源的事件混在一起如果不做排序处理统计结果就会出错。我遇到过一次典型的事故某天的“评审闭环时长”突然变成负数。排查发现一个补录的历史事件被当成了新事件它的时间戳比当前时间早导致计算时长时出现了负值。修复方案是在事件写入时强制按事件发生时间排序并且对乱序到达的事件做缓冲处理——如果新事件的时间戳早于最近一条事件就插入到正确位置而不是追加到末尾。def insert_event(event): last_event get_last_event(event.review_id) if last_event and event.occurred_at last_event.occurred_at: # 乱序事件需要重新排序 reorder_events(event.review_id) else: append_event(event)这个坑的教训是不要假设事件到达顺序等于发生顺序。任何分布式系统里这个假设都不成立。6.2 评论树递归查询的性能问题评论树用递归 CTE 查询在评论数量少的时候没问题。但当一条评审有几百条评论、嵌套层级很深的时候递归查询的性能急剧下降。我遇到过一条评审加载了 8 秒才出来用户体验极差。优化方案是限制递归深度 懒加载。默认只加载前三层评论更深的层级在用户点击“展开”时再按需加载。同时给parent_comment_id加索引给review_id加索引查询速度提升了一个数量级。CREATE INDEX idx_comment_parent ON review_comment(parent_comment_id); CREATE INDEX idx_comment_review ON review_comment(review_id);另一个优化是缓存评论树。评论树的变化频率不高可以缓存起来有新的评论事件时再失效。我用 Redis 缓存序列化后的评论树命中率大概 85%效果很好。6.3 规则引擎的优先级冲突规则引擎刚上线的时候我收到一个反馈某个目录的修改既匹配了“核心模块双人评审”规则又匹配了“文档修改快速通道”规则结果系统不知道该用哪个随机选了一个执行。这显然不可接受。修复方案是显式定义规则优先级。每条规则有一个priority字段数值越小优先级越高。匹配的时候按优先级排序第一条匹配的规则生效。同时增加了一个规则冲突检测工具在配置加载时检查是否有规则的作用范围重叠且优先级相同如果有就报错。rules: - name: 核心模块双人评审 priority: 10 scope: paths: [src/core/**] # ... - name: 文档修改快速通道 priority: 20 scope: paths: [docs/**, *.md] # ...这个坑的教训是任何配置系统都要考虑冲突处理。用户不会按照你设想的顺序写配置你必须假设最坏情况。6.4 通知风暴与静默策略通知功能上线后有个同事抱怨说他一天收到了 200 多条通知根本看不过来。排查发现每次评论、每次状态变更都会触发通知而且没有聚合。一个人如果参与了多个评审通知量就会爆炸。解决方案是通知聚合 静默期。同一个评审在 30 分钟内的多条通知合并成一条摘要发送。非工作时间的通知延迟到下一个工作时间发送。用户还可以自定义通知偏好比如只接收“被指派”和“被提及”的通知其他的一律静默。def should_notify(user, event): if user.preferences.get(mute_all): return False if event.type in user.preferences.get(muted_types, []): return False if is_quiet_hours(user.timezone): return False return True这个功能看起来简单但对用户体验的影响巨大。通知太多和通知太少一样糟糕关键是让用户感觉到“每一条通知都值得看”。7. 一些让系统更好用的细节打磨7.1 评审模板与检查清单评审质量参差不齐很多时候是因为评审人不知道该看什么。我加了一个评审模板功能发起评审时可以选择模板模板里预置了一组检查项。评审人逐项确认系统自动记录哪些项已检查、哪些项有问题。模板内容可以自定义我常用的一个后端评审模板包含这些检查项边界条件是否处理空值、越界、并发错误处理是否完善异常捕获、日志记录、用户提示数据库操作是否有索引支持是否有硬编码的配置项是否有安全风险注入、越权、敏感信息泄露这个功能上线后评审意见的覆盖面明显变广以前经常被忽略的安全问题现在被频繁提及。而且新同事上手更快照着清单看就行不用凭感觉。7.2 评审意见的闭环追踪评审意见提了之后有没有被处理这个问题如果不追踪评审就变成了“提了白提”。我的做法是给每条问题评论一个状态机待处理、处理中、已解决、已忽略。提交者可以更新状态评审人可以确认解决。状态变更会触发通知而且未解决的问题会阻塞合并前面提到的硬性阻断。这个机制确保了每条意见都有回音不会石沉大海。class CommentStatus(Enum): PENDING pending IN_PROGRESS in_progress RESOLVED resolved IGNORED ignored def can_merge(review): unresolved [c for c in review.comments if c.status CommentStatus.PENDING] return len(unresolved) 0实际使用中“已忽略”这个状态很有用。有些意见确实不需要处理比如“这个变量名可以更好”但当前名字也没问题。允许忽略但要求填写忽略理由这样既尊重了评审人的意见又给了提交者灵活度。7.3 与现有工作流的集成方式这个系统不是孤立的它需要和现有的工作流集成。我提供了几种集成方式命令行工具、API、Webhook 回调。命令行工具适合本地开发时快速查看评审状态API 适合和其他系统对接Webhook 回调适合触发外部动作比如自动部署。命令行工具我用 Python 的click库写用起来大概是这样# 查看待我评审的列表 ocr list --assignee me # 查看某个评审的详情 ocr show REVIEW-123 # 快速审批 ocr approve REVIEW-123 --comment LGTM这个工具虽然简单但日常使用频率很高。开发者不用打开浏览器就能完成大部分操作效率提升明显。8. 关于这个项目的一些个人体会做这个项目最大的收获不是技术上的而是对代码评审这件事本身的理解。以前我觉得评审就是找bug后来发现找bug只是其中一小部分。评审更重要的是知识传递、规范落地、风险预警。一个健康的评审流程能让新人在几个月内熟悉代码库能让团队的技术规范真正被执行能在问题进入生产环境之前拦住它。技术层面我最大的体会是数据模型的设计决定了系统的上限。事件流的设计让后面的统计和回溯变得容易如果当初用了状态覆盖的方式很多分析根本做不了。另一个体会是配置化不是万能的规则引擎虽然灵活但配置本身也需要维护规则太多太复杂反而会成为负担。我的建议是先从最简单的规则开始遇到具体问题再加规则不要一开始就设计一个大而全的规则体系。如果你打算自己搭一套类似的系统我的建议是先跑通最小闭环再逐步加功能。最小闭环就是“提交评审 → 添加评论 → 审批通过 → 记录数据”这四步。把这四步做扎实后面的统计、画像、规则都是锦上添花。反过来如果最小闭环都没跑通加再多功能也是空中楼阁。最后分享一个我常用的调试技巧在开发阶段我会写一个事件回放脚本把生产环境的事件流导出到本地然后回放一遍观察系统状态的变化。这个脚本帮我发现了无数边界情况比单元测试覆盖的场景还全面。如果你也在做类似的数据流系统强烈建议你写一个这样的工具。