做一个代码评审工具,最难的不是写检查规则,而是怎么让团队愿意天天用。我去年在团队里推代码评审规范,GitLab上的MR动不动就上百个comment,一半是“这里缩进不对”“这个变量名再想想”,真正有价值的逻辑问题反而被刷没了。后来我自己动手折腾了一个开源的小工具,就是open-code-review,一套专注“变更点审查”的命令行代码评审助手,用了一段时间,效果比预期好很多。
这个工具解决的问题很直接:在CI/CD阶段自动拉取本次提交的差异代码,只针对新增和修改的行做一轮静态规则检查,把能自动化的问题在人工评审之前全部拦掉。适合对象也很明确——被无效评审意见淹没的团队Lead、想给研发流程加一道自动关卡的技术负责人、以及所有想让Code Review回归“讨论设计”而非“挑语法毛病”的开发者。
1. 整体设计思路:为什么我只审“变化的那几行”
市面上不缺代码检查工具,SonarQube、ESLint、Checkstyle这些老牌选手各有各的生态。但实际用下来,我发现它们和“代码评审”这个场景始终隔着一层。
1.1 传统静态检查工具的断层
SonarQube这类工具的本质是“全量体检”,它把整个项目的所有历史代码都纳入扫描范围,找重复代码、找坏味道、找安全漏洞。这套逻辑适合做“定期健康检查”,但放在MR/PR评审场景里就很尴尬。举个例子,一个老项目里本来就有300处TODO注释,SonarQube每天报警,大家早就麻木了。可如果你这次改动正好在某个TODO附近引入了一个边界条件问题,SonarQube不一定会关注,因为它看到的是一整片森林,不是你这棵树的年轮。
人工评审恰恰相反,评审者看的永远只是增量。Code Review的本质是回答一个问题:你这次改动,有没有引入新的问题?这个“新”字非常关键。与其在一堆存量问题里做价值判断,不如直接把范围锁死在diff上——只看你动过的行,只对你动过的行负责。
open-code-review的第一条设计原则就这么定下来了:永远以git diff为输入边界。没有diff就不工作,有diff就只分析diff涉及的文件和行号。这一条就把误报率压到了一个很舒服的水平。
1.2 规则必须“可编程、可沉淀、可量化”
第二个让我下决心自研的原因是,团队评审标准一直“只可意会不可言传”。每个人都有自己的审美,有人受不了var,有人讨厌过长函数,这些主观偏好很难靠开会统一。但代码里有一类问题是绝对客观的,比如日志中打印了完整身份证号、错误被吞掉只剩注释、panic没有兜底、密钥硬编码,这类问题一旦漏到线上就是事故,必须在合入前拦住。
所以工具的第二条设计原则是:把团队约定变成规则文件,规则文件进版本库。新成员来了不用背规范文档,跑一遍工具就全懂了;规则改了有历史记录,谁加的、为什么加,清清楚楚。这就是“可沉淀”的含义。
1.3 技术选型:Go、YAML、单二进制
技术栈上我选了Go,没有特别复杂的原因,就三条:编译出来是单个二进制文件,扔到CI容器里不用装任何运行时依赖;并发扫描diff文件很省事;社区生态里解析各类语言的AST库都比较成熟。
规则引擎用YAML配置而不是写死在代码里。这么做的好处是团队里不会Go的同事也能提规则——你只需要照着文档写一个正则或者配一个AST模式就行。后面我会详细展开规则格式。
整个工具的架构分四层,我尽量用大白话讲:
- 采集层:负责执行
git diff命令,解析出变更文件列表、变更行号、变更内容,同时保留上下文信息。 - 解析层:根据文件后缀选择对应的语言解析器,把源码转成AST(抽象语法树),并记录每个节点的行列号。
- 规则引擎层:把采集层和解析层的数据统一成一种内部结构,逐条跑用户配置的规则,命中后生成问题描述、级别、文件和行号。
- 报告层:把结果输出成终端表格、JSON文件或者Markdown报告,方便CI系统解析,也可以直接推到GitLab/GitHub的MR评论区。
这套设计一开始就定了两个硬指标:单次扫描全项目耗时不超过20秒(一般控制在5秒内),规则命中率(真正被人工确认采纳的比例)不低于70%。后面实测下来,第一个指标很容易做到,第二个得靠规则质量来保证。
2. 规则引擎与核心实现:决定这个工具好不好用的命门
规则引擎是整个项目的灵魂。我见过很多工具死在规则上——要么规则太弱只能做字符串匹配,要么规则太强导致没人会写。open-code-review的规则体系分三层:文本模式、AST模式、脚本模式,从易到难,覆盖不同场景。
2.1 规则配置格式与生命周期
每一份规则文件都是YAML格式,规则本身包含元信息和检查逻辑。一个最简单的规则长这样:
rules: - id: SECRET_KEY_CHECK title: 检测硬编码密钥 severity: error # fatal / error / warning / info category: security # security / bug / style / performance scope: line # line / function / file pattern: type: regex value: "(?i)(api[_-]?key|secret|token)\\s*=\\s*['\"][A-Za-z0-9+/]{16,}['\"]" message: 检测到疑似硬编码密钥,请改用环境变量或密钥管理服务 skip: paths: - "test/**" - "*.md"这个配置的意思是:在每一行变更代码里跑正则,如果匹配到疑似密钥赋值语句,就报一个error级别的问题。scope字段决定规则作用范围,line代表逐行匹配,适合正则类规则;function代表按函数维度检查,适合复杂度、嵌套深度这类问题;file代表整个文件维度,常用来查文件头注释、版权声明这类全局约束。
规则还有一个生命周期概念:从draft(草稿,只记录不阻断)到active(正式生效),再到deprecated(废弃但暂不删除)。新规则建议先从draft试跑一两周,统计误报率之后再转active,不然一上来就拦合入,团队会很抵触。
2.2 内置规则库:那些年我们踩过的线上坑
我内置了大概四十条规则,分成四类。每一条都是我或朋友团队真实踩过坑的总结,不是从别的工具抄来的:
| 规则ID | 级别 | 检查内容 | 典型场景 |
|---|---|---|---|
| PANIC_RECOVER | fatal | 检测新增代码中是否有不安全的panic调用 | Go服务中panic未被recover,直接导致进程退出 |
| PLAINTEXT_PASSWORD | fatal | 检测密码、口令明文写入配置文件或常量 | 数据库连接串里写死密码,代码库一泄露全完 |
| LOG_SENSITIVE | error | 检测日志中打印身份证、手机号、卡号等字段 | 排查问题时日志一拉,用户隐私全出去了 |
| TODO_FIXME | warning | 检测新增的TODO/FIXME注释 | 上线前遗留的临时方案,一忘就是半年 |
| EMPTY_CATCH | error | 检测空的异常捕获块 | catch到异常后什么都不干,问题被静默吞掉 |
| MAGIC_NUMBER | warning | 检测魔法数(非0/1/2等常见值) | 逻辑里裸写一个86400,没人知道是啥 |
| HIGH_CYCLO | error | 检测新增函数的圈复杂度是否超标 | 一个函数塞了十个if-else,评审时没人愿意细看 |
| EMBEDDED_HTML | warning | 检测后端代码中拼接HTML片段 | 明明有模板引擎却手撸字符串,XSS风险极高 |
| DEPRECATED_API | error | 检测使用了废弃接口/方法 | 升依赖后新代码还在调老方法,编译能过但运行隐患 |
| SERIAL_IO | warning | 检测非必要场景下的串行文件读写 | 循环里逐行写文件,性能慢到令人发指 |
每条内置规则都允许你在配置文件里覆盖参数,比如HIGH_CYCLO默认阈值是10,你团队觉得8更合适,直接在规则里加一个params: max: 8就行。
2.3 三种检查模式:从正则到AST再到嵌入式脚本
正则模式是最容易理解的,适合“长得像什么”的规则。比如检测IP地址硬编码:
- id: HARDCODED_IP severity: warning scope: line pattern: type: regex value: "\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"但正则有个天然缺陷:看不懂代码结构。它会把注释里的IP、字符串里的IP、瞎写的示例IP统统报出来。这就是为什么我做了AST模式。
AST模式的核心思路是把源码解析成一棵语法树,然后对树节点做模式匹配。比如检测“空catch块”,正则几乎不可能做对,但AST可以很优雅:
- id: EMPTY_CATCH severity: error scope: function pattern: type: ast language: java query: | (catch_clause block: (block) @body (#eq? @body ""))这条规则用类似tree-sitter的查询语法,找到所有catch语句,检查它的body是否为空。注释里的空catch不会误报,字符串里的也不会,这就是AST相对正则的降维打击。
脚本模式是最后的兜底。有些检查逻辑实在太定制化,比如“controller层的方法必须显式声明@ResponseBody注解”,正则做不了,AST查询也不太方便,那就直接写一段内嵌脚本:
- id: CONTROLLER_RESPONSEBODY severity: error scope: file pattern: type: script language: javascript script: | const methods = context.getMethods('Controller'); for (const method of methods) { if (!method.hasAnnotation('ResponseBody')) { report(method, 'Controller方法缺少@ResponseBody注解'); } }脚本模式本质上是在规则引擎内部暴露了一个受限的SDK,你可以遍历文件、遍历函数、读写上下文,但不能做任何网络请求和文件系统操作,保证规则再复杂也跑得安全。
2.4 处理diff的关键细节:为什么不能只靠git diff的输出
git diff出来的是纯文本,看似能直接读,但实际做增量审查时有几个坑必须处理:
第一个坑是行号偏移。git diff的hunk头里会标注旧文件和新文件的行号范围,但解析时很容易搞混。比如一个hunk头是@@ -20,7 +20,9 @@,意思是旧文件从20行开始共7行,新文件从20行开始共9行,多了两行新增内容。如果你按旧文件行号去AST里定位节点,必然错位。解决方案是同时维护旧行号和新行号的映射表,规则引擎默认只分析新行号,但如果你需要比较“这行是不是从上一版移过来的”,旧行号就是关键线索。
第二个坑是上下文行。默认git diff会输出前后三行上下文,这三行不是你的修改内容,但如果正好命中规则,会非常困惑。所以工具里有个配置项:
diff: context_lines: 3 only_added: true # 只检查新增行,不检查上下文行和删除行only_added: true是默认值,意思很明确:你删掉的那行有问题,那不是你引入的,不用管;上下文行里的历史问题,也不在这次评审范围。这个配置极大减少了“不是我的问题还要我来改”的扯皮。
第三个坑是二进制文件。图片、压缩包、编译产物会在diff里显示为“Binary files differ”,这种情况直接跳过,不然正则扫到一堆乱码字节,规则引擎直接崩溃。
3. 实操:把open-code-review跑起来,并接入GitLab CI
讲了这么多设计,不如直接上手跑一把。下面是一份从零开始的实操记录,包含我在自己服务器上完整部署和接入CI的过程。
3.1 安装与初始化
工具提供了好几种安装方式,我推荐用Docker,因为CI环境里最干净。本地调试用二进制更顺手。
# 方式一:直接下载二进制(Linux amd64示例) curl -L -o ocr https://github.com/yourname/open-code-review/releases/download/v1.0.0/ocr-linux-amd64 chmod +x ocr sudo mv ocr /usr/local/bin/ # 方式二:Docker镜像 docker pull opencodereview/ocr:latest # 验证安装 ocr version初始化配置:
ocr init这条命令会在当前目录生成一份open-code-review.yml,包含完整的默认配置和规则引用。你可以在配置里指定规则仓库:
repo: root: ./ exclude: - vendor/** - node_modules/** - dist/** - .git/** rules: include: ["builtin:all"] # 内置规则全量生效 exclude: ["MAGIC_NUMBER"] # 但魔法数这条先关掉 custom: - path: ./rules/custom/**/*.yml # 自定义规则目录3.2 本地跑一次评审
假设你本地有个Git仓库,刚改完代码还没提交。
# 比较工作区与暂存区 ocr check --staged # 比较暂存区与上次提交 ocr check --commit HEAD # 比较两个分支 ocr check --base main --head feature/auth # 指定语言(默认自动检测) ocr check --staged --lang go跑完之后终端会输出一个表格,类似这样:
[open-code-review] 扫描完成,耗时 3.82s 文件: internal/service/user.go +214 -89 (共12个变更块) 规则命中: 3 个问题 SEVERITY RULE LINE MESSAGE error PANIC_RECOVER 102 新增代码存在panic调用,建议recover或返回error warning LOG_SENSITIVE 156 日志打印了User.IDCard字段,疑似敏感信息 info TODO_FIXME 87 新增TODO: 后续接入Redis缓存(标注了负责人,可接受)看到第一眼的感觉是:这些问题如果放到人工评审里,至少得来回两个回合才能发现。尤其是User.IDCard这个,开发时谁会注意日志里打了一个字段?但隐私合规审查时这就是实打实的隐患。
3.3 接入GitLab CI:一个完整的可复用示例
我团队用的是GitLab,这里给一个可以直接抄的.gitlab-ci.yml片段:
code-review: stage: test image: opencodereview/ocr:latest script: - export BASE_SHA=$CI_MERGE_REQUEST_DIFF_BASE_SHA - ocr check --base $BASE_SHA --head $CI_COMMIT_SHA --format json --output report.json --fail-on error artifacts: paths: - report.json expire_in: 2 weeks when: always rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'这里面有几个细节值得解释:
CI_MERGE_REQUEST_DIFF_BASE_SHA是GitLab在MR管线里自动注入的环境变量,代表目标分支最新的commit哈希。用这个做base,就能保证对比的范围正好是本次MR的改动,而不是整个feature分支的累积提交。
--fail-on error表示只要出现error级别的问题,就让CI任务失败。这样MR页面会直接显示failed,开发者必须回头处理。warning级别不阻断,只记录到报告里,避免把CI变成一个处处卡人的审批机器。
--format json --output report.json是给后续人工检查或数据统计用的。我可以定期拉取这些报告,统计每个规则的历史命中率,用数据判断哪条规则该收严、哪条规则误报太多该调松。
3.4 GitHub Actions的接入方式
如果你用的是GitHub,Actions配置也不复杂:
name: code-review on: pull_request: types: [opened, synchronize] jobs: ocr: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: open-code-review/action@v1 with: base: ${{ github.event.pull_request.base.sha }} head: ${{ github.event.pull_request.head.sha }} fail-on: error format: markdown这里有个关键点,actions/checkout默认只拉取当前分支的最新commit,也就是浅克隆。浅克隆里没有目标分支的完整历史,git diff没法做。所以必须设置fetch-depth: 0,把完整历史拉下来。这个坑我第一次接Actions时踩过,报错信息还很迷惑,说什么HEAD不存在。
3.5 审查报告怎么读、怎么用
工具的默认输出是终端表格,但真正用于复盘时,JSON和Markdown报告更有价值。
JSON报告的结构大致是这样的:
{ "summary": { "files_scanned": 8, "diff_lines": 1240, "issues_found": 3, "errors": 1, "warnings": 2, "duration_ms": 3820 }, "issues": [ { "rule_id": "PANIC_RECOVER", "severity": "error", "file": "internal/service/user.go", "line": 102, "message": "新增代码存在panic调用,建议recover或返回error", "snippet": "if err != nil {\n panic(err)\n}" } ] }这里有个summary.diff_lines字段,它统计的是本次变更总行数。我后来发现这个数字特别有用——它和代码评审效率直接相关。有研究说,一次MR超过400行,人工评审的缺陷发现率会显著下降。所以我给团队定的规矩是:MR的diff行数超过400行,必须拆成多个MR提交。这个判断标准不是靠感觉,而是用工具数据定下来的。
Markdown报告适合直接贴到MR描述里,或者作为评审邮件附件。GitLab的MR页面支持Markdown渲染,所以我在CI脚本里追加了一步,把报告内容用GitLab API推到MR的讨论区,这样评审人打开MR第一眼就能看到机器检查结果。
4. 常见问题与排查技巧:那些文档里不会写的经验
工具看着简单,真用起来还是会遇到各种问题。这一节我把自己和早期用户踩过的坑整理一下,直接当排查手册用。
4.1 规则误报太多,团队开始抵触怎么办
误报是规则引擎逃不开的问题,处理策略比技术本身更重要。我在前面提过,用“草稿规则”机制兜底。具体做法是:每条新规则上线前,先在open-code-review.yml里把severity调成info,fail-on不设,这样它只会出现在报告里,不会让CI变红。跑两周后,统计规则命中里被人为忽略的比例,低于30%就可以升成warning,低于10%才能升成error。
另外一定要用好skip字段。有些规则对历史代码本来就满身问题,比如MAGIC_NUMBER放在一个老旧的业务模块里,几乎每行都能命中。这时候在规则里加一个路径忽略,效果立竿见影:
- id: MAGIC_NUMBER severity: warning skip: paths: - "legacy/**" - "**/migrations/**"还有一种情况要特别注意:第三方生成的代码,比如protobuf生成的.pb.go文件、OpenAPI生成的client代码,这些文件完全不该被规则扫描。在repo.exclude里提前配好,能省掉大量解释和沟通成本。
4.2 大MR性能问题:增量扫描卡成龟速
有用户反馈说,扫描一个大MR要一分多钟,太慢了。排查之后发现,问题出在解析层:工具对每个变更文件都要做完整的AST解析,如果一个文件本来就几千行,就算你只改了一行,也得把整个文件从头到尾解析一遍。
优化的思路是分级处理。文本模式(正则)不依赖AST,可以只针对变更行做匹配,速度快到毫秒级。AST模式才需要全文件解析。所以在规则引擎里我加了一个调度策略:先跑文本类规则,再按需解析AST。配置上可以这么写:
performance: ast_cache: true # 缓存AST,同一个文件多次命中规则时复用 parallel_files: 8 # 并发解析文件数,太大容易吃满IO skip_unchanged: true # 只有变更行触及的文件才做AST解析skip_unchanged这个开关很有用。一个文件如果只有删除行,没有新增行,而且没有AST类规则需要看上下文,那就可以直接跳过AST解析。加上这些优化之后,我本地测试一个200个文件的MR,扫描耗时从47秒降到了6秒左右,完全够用。
4.3 diff对比的坑:为什么明明没改代码,却报出一堆和历史一样的问题
这种情况几乎都是base分支取错了。最常见的是在GitLab CI里直接写--base main,但main分支在本地没有最新拉取,导致对比对象是过时的。另一个常见问题是feature分支没有合并最新main,git diff main...feature和git diff main feature结果完全不同——前者是三点diff,只看feature分支独有的提交;后者是两点diff,会把main上有而feature上没有的改动也算成变更。
在CI里最稳妥的做法,就是使用平台注入的base SHA环境变量,而不是硬编码分支名。GitLab是CI_MERGE_REQUEST_DIFF_BASE_SHA,GitHub是github.event.pull_request.base.sha,这些变量的值由平台计算,保证精确指向MR的fork点。
本地调试时,如果你不确定当前base对不对,先跑一句:
git merge-base main feature/auth拿到这个commit哈希,然后对比确认变更文件列表:
git diff --name-only $(git merge-base main feature/auth) feature/auth如果这步看到的文件列表和MR页面对不上,那一定是环境问题,先修好再跑扫描。
4.4 新语言支持:为什么无法开箱即用
open-code-review内置支持Go、Java、Python、TypeScript/JavaScript、Ruby和PHP这几类常见语言的AST解析,但如果你用的是小众语言,AST模式就用不了,只能退回到正则模式。
正则模式也没有想象中那么弱鸡,很多规则其实根本不关心语法结构。比如检测TODO注释、检测密钥格式、检测硬编码的IP,都是纯文本层面的工作。我实测过,这些小众语言项目里,正则模式能覆盖60%左右的高价值规则。
如果你希望完整支持一门新语言,需要引入对应的tree-sitter grammar,并在配置里声明:
languages: kotlin: extensions: [".kt", ".kts"] grammar: tree-sitter-kotlin version: "0.1.0"4.5 团队落地的节奏:从“嫌弃”到“离不开”
最后聊一点非技术层面的经验。工具再好,推不下去等于零。我的建议是按“三周计划”走:
第一周:只读模式。工具接入CI但绝不fail任何build,报告发到群里,大家当看热闹。这样做的好处是让所有人感知到工具的存在,同时消除“CI又卡我”的对抗情绪。
第二周:warning模式。把--fail-on warning打开,但允许团队在MR描述里写skip-ocr: rule_id来豁免某条规则。豁免请求会被记到日志里,每周复盘时看看豁免理由是否合理。有豁免机制会让人感觉“还有控制权”,抵触情绪会下降一半。
第三周:error模式。只保留极少数的fatal和error规则,比如密钥检测、panic、敏感日志这些不可妥协的红线。其他全部保持warning。
我团队执行三周计划后,MR被“无效样式评论”轰炸的情况基本绝迹,人工评审的comment数量下降了约一半,而真正讨论架构、逻辑、测试覆盖的comment比例显著上升——这才是Code Review该有的样子。
工具本身还在持续迭代,后续我计划加入更多设计评审类的规则,比如检测循环依赖、检测新增接口是否缺少超时控制等等。如果你也在折腾类似的事情,可以一起聊聊,代码评审这件事值得被认真对待。