代码审查这件事,团队里人人都知道重要,但真正能坚持做好的并不多。要么是流程太重,提个PR要等半天;要么是审查流于形式,最后变成“LGTM”走过场。我花了不少时间折腾出一套开源方案,把它叫做 open-code-review,核心思路就一句话:用自动化杠杆撬动人工审查的深度。把静态分析、风格检查、复杂度过高的拦截全部交给机器去干,让人的精力只花在真正的逻辑评审上。这篇博文就把这套方案的完整设计思路、工具选型、落地步骤和踩过的坑全部拆开讲清楚,团队可以直接照着搭。
这套方案不只是装几个工具那么简单。你把它跑起来之后,会发现代码审查的“前置门槛”被砍掉了一大半:低级的格式问题在提交阶段就被拦下,PR描述里自动填好了变更影响范围,审查者打开页面看到的是“人话”而不是密密麻麻的diff。适合谁用?小团队没有专职QA和基建人手的那种,或者想给现有流程加一道自动化护栏的开发者,看完都能直接上手。
1. 项目概述与核心思路
1.1 代码审查的三大痛点
我先说说为什么非要自己折腾一套方案。早年我在团队里推代码审查的时候,遇到的阻力基本来自三个方面。
第一,审查效率低。一个功能分支改了几十个文件,审查者要一个个文件点开看,但里面可能有十处是空格、缩进、命名这种低级问题,真正需要动脑的逻辑审查被淹没在噪音里。人是有惰性的,看久了自然开始敷衍,最后只剩一句“看起来没问题”。
第二,标准不统一。团队里有人喜欢双引号,有人喜欢单引号,有人喜欢换行前加逗号。每个人提交的代码风格都不一样,Review的时候大家争论的往往不是架构和逻辑,而是这些无关紧要的风格偏好在消耗精力。
第三,经验没有沉淀。一个团队里总有新人和老人,新人容易踩的坑(比如忘记释放资源、异常处理太粗糙)其实很多是重复的。但如果团队没有把这些经验固化成规则,每次都在Review时口头提一遍,效率极低。
open-code-review 就是要同时解决这三件事:把机器能判断的问题全部自动化,把风格问题交给统一的格式化工具,把常见的“坑”固化成自定义规则。整个方案跑通之后,人工Review的负担能明显降下来,而且每次审查的质量会稳定得多。
1.2 核心定位:自动化检查与人工审查的分层协作
这套方案最重要的设计决策,是明确划分了“机器做的事”和“人做的事”。我把它分成三层:
- 第一层:格式与风格层——Prettier、ESLint这类工具负责统一代码风格,不涉及任何逻辑判断,纯机械执行。
- 第二层:静态分析与复杂度层——SonarQube、ESLint的复杂规则插件负责检查潜在Bug、圈复杂度、重复代码。
- 第三层:逻辑与架构层——这部分必须由人来完成,讨论的是模块划分、接口设计、性能隐患、未来扩展性。
这三层的优先级是逐级递增的。前面两层不过关,根本不需要人去看代码。只有前面两层全部通过,人的精力才能全部聚焦在最重要的第三层上。
这里要特别强调一个理念:自动化不是替代人,而是替人挡掉低维度的噪音。我见过有些团队把自动化规则调得极其严格,什么“变量名必须超过三个字母”“函数行数不能超过十行”,结果就是所有人都在跟规则较劲,反而忽略了代码真正的质量问题。所以规则一定要分级、分阶段地引入。
2. 环境准备与工具链选型
2.1 为什么选择这套开源工具链
工具选型上,我考察过不少商业方案,但最终还是锁定了整套开源工具链。原因有三点:开源社区的规则生态更丰富,自定义规则更灵活,还不会受厂商锁定。下面是我实际使用下来站在“好用”角度做的对比。
| 功能需求 | 选型 | 替代方案 | 选择原因 |
|---|---|---|---|
| 格式统一 | Prettier | Black(Python)、gofmt(Go) | 前端生态集成度最高,格式化逻辑零配置可选 |
| 静态检查 | ESLint + typescript-eslint | TSLint(已废弃)、JSHint | 规则最强、插件生态最全,支持自定义规则 |
| 复杂度控制 | ESLint complexity 插件 | SonarQube 的 Cognitive Complexity | 轻量级、运行快,适合在本地和CI中同时启用 |
| 深度扫描 | SonarQube Community | Semgrep、CodeQL | 支持多语言,自带重复代码检测和Bug检测 |
| 审查辅助 | Reviewdog | PullRequest评论的自研脚本 | 自动把检查结果以评论形式发布到PR,省去反复切换页面 |
这套组合跑下来,单次全量检查的时间控制在3-5分钟以内,增量检查更是秒级完成。对比过一些重量级商业方案,部署成本和CI时长都友好太多。
2.2 规则分级:从“提醒”到“阻断”
工具装好了,下一步是配置规则。这里最容易踩的坑就是“一把抓”:把所有规则全部开成Error,结果CI天天红,团队怨声载道。
我的做法是把规则分成三个等级。
Warn级别(黄色警告):这类规则不影响合并,只负责提醒。比如某个函数的圈复杂度超过了10,但还在可接受范围;或者某个变量命名不够语义化。这类问题可以延迟到“技术债清理”阶段统一处理。
Error级别(红色报错):这类规则必须满足才能合并,覆盖的是那些确定性的问题。比如禁止使用eval()、禁止出现明显的类型转换错误、禁止未使用的变量。这些问题没有讨论余地,必须修。
Inline级别(行内提示):这类规则通过Reviewdog在PR评论中呈现,不进入CI的判定逻辑。典型场景是“这里可能有空指针风险,请确认”。它更像是一个AI助手在提醒你,而不是一个严厉的考官在拦你。
规则的分级设计,直接影响团队的接受度。我见过太多团队死于“第一版规则就搞了1000条Error”,最后不得不一夜回滚,留下一地鸡毛。先从少量高价值的规则开始,再逐步加严,这是最稳妥的路径。
2.3 规则集配置实战:一份能直接用的ESLint配置
我举一个实际的例子,以JavaScript/TypeScript项目为例,配置.eslintrc.json:
{ "root": true, "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2022, "sourceType": "module", "project": ["./tsconfig.json"] }, "plugins": ["@typescript-eslint", "import", "promise"], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:import/recommended", "plugin:import/typescript", "plugin:promise/recommended", "prettier" ], "env": { "node": true, "jest": true }, "rules": { "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/explicit-function-return-type": "warn", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-floating-promises": "error", "promise/always-return": "error", "promise/no-return-wrap": "error", "import/no-unresolved": "error", "import/order": ["error", { "alphabetize": { "order": "asc" } }], "complexity": ["warn", { "max": 10 }], "max-lines-per-function": ["warn", { "max": 150 }], "no-console": "off" }, "settings": { "import/resolver": { "typescript": { "alwaysTryTypes": true } } } }这份配置看起来简单,但每一行都有讲究。
no-floating-promises这一项,我会直接设为error,因为它能拦截掉那些“忘记await的Promise调用”,这类问题在排查Bug时是最让人头秃的。no-explicit-any设为warn,因为刚接手老项目时会有大量any,直接设成error会让CI彻底跑不动,先警告着逐步清理更现实。complexity设成warn而不是error,原因是圈复杂度这个东西比较主观——有些函数天生就复杂,强制改反而影响可读性,不如让开发者在看到警告时自己判断是否需要重构。
prettier放在 extends 的最后一位,是为了让它覆盖掉ESLint里跟格式化冲突的规则。很多人误以为 Prettier 是“没有用的额外工具”,但它恰恰是跟ESLint配合最默契的:ESLint管代码质量,Prettier管代码美化,两者互不干扰。这套配置在团队里跑了两周之后,代码review里关于命名和格式的争论明显减少了。
3. 实操过程与核心环节实现
3.1 本地检查脚本:提交前先把好第一道关
工具链配置好了之后,第一个要做的是把检查集成到本地开发流程中。不能只靠CI去拦,开发者本地就要能快速自检。我在项目根目录维护了一个package.json,里面的 scripts 如下:
{ "scripts": { "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", "format": "prettier --write src", "check": "npm run lint && npm run format && npm run typecheck" } }开发者在提交代码之前,只需要执行npm run check,就能自动完成代码规范检查和格式修复。这个脚本的设计核心是“快”——控制在30秒以内,否则开发者不会愿意跑。typecheck用的就是 TypeScript 的tsc --noEmit,单独拎出来一个命令,而不是依赖构建流程,因为构建时会做很多额外的事,跑起来会很慢。
另外,我在package.json里还配了一个lint-staged的钩子,配合 husky,只检查本次改动的文件:
{ "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*.{json,md}": ["prettier --write"] } }这个组合的最大好处是:只有改动的文件会被检查,不会因为老代码的历史债导致新代码无法提交。很多人用lint-staged初期都会遇到“明明是新代码没问题但lint不过”的困惑,原因就是规则跑到了全量文件上。把范围收敛到增量文件,是保证开发者体验的核心。
3.2 CI集成:让每次PR都自动获得“审查报告”
本地检查做好之后,CI才是这套方案的主战场。我用的是 GitHub Actions + Reviewdog 的组合,配置示例如下:
name: Code Review on: pull_request: types: [opened, synchronize, reopened] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node uses: actions/setup-node@v3 with: node-version: 18 cache: 'npm' - name: Install dependencies run: npm ci - name: Run ESLint with Reviewdog uses: reviewdog/action-eslint@v1 with: reporter: github-pr-review level: warning filter_mode: nofilter fail_on_error: true这条工作流的执行顺序是:先 checkout 代码,再装依赖,最后跑 ESLint。Reviewdog 会把 ESLint 的报错以机器评论的形式直接发到PR对应的代码行上。审查者打开PR时,每行有问题的代码旁边都会有一句清晰的注释提示,而不是只能去查看CI日志。
filter_mode: nofilter这个参数意思是提交所有审查意见,包括本次变更之外的代码问题。如果只想看新增代码的问题,可以改成filter_mode: diff_context。我建议初期使用nofilter,让老代码里积累的问题也浮出水面,团队可以排队清理。后期稳定下来再改成diff_context,避免噪音过多。
3.3 增量检查逻辑是怎样帮你省时间的
刚才提到全量扫描和增量扫描,这里要展开说说它们之间微妙的博弈。
全量扫描的好处是全面,但代价是慢。一个中大型项目跑一次 SonarQube 扫描,动辄五分钟起步,在快速迭代的流水线上会变成明显的卡点。增量扫描的好处是快,但可能会漏掉跨文件的调用链问题。
折中方案是:PR阶段跑增量,主干合并时跑全量。具体做法是,在GitHub Actions里用actions/github-script获取这次PR改了哪些文件,把文件列表传给ESLint和SonarQube,只检查这些文件。而主干合并时(比如main分支收到 push),跑一次完整的全量扫描,作为每日定时任务执行。
实现增量扫描核心逻辑的简易脚本如下:
const core = require('@actions/core'); const { execSync } = require('child_process'); const github = require('@actions/github'); async function run() { const pr = github.context.payload.pull_request; const files = pr.files.map(f => f.filename); const changedSrcFiles = files.filter(f => f.endsWith('.ts') || f.endsWith('.tsx')); if (changedSrcFiles.length > 0) { execSync(`npx eslint ${changedSrcFiles.join(' ')} --format json`, { stdio: 'pipe' }); } } run();这个脚本并不复杂,但它解决了一个实际问题:避免每次PR都花五分钟做全量检查,同时还能保证“本次提交影响的范围”被100%覆盖到。很多团队没有意识到,把全量扫描放到PR里才是CI变慢的罪魁祸首。
3.4 复杂规则的自定义实现:拦截你们团队特有的坑
工具自带的规则只能覆盖通用场景,真正体现团队经验的地方是自定义规则。我们团队踩过最痛的一个坑是:调用一个异步函数时忘了加await,结果数据还没返回就直接用了后面的代码。这种问题ESLint的no-floating-promises能拦住一部分,但如果我们自己的业务代码里有一个内部的request()方法,没有返回标准的Promise,那ESLint就识别不出来。
解决思路有两种:第一种是修改内部方法让它返回严格的Promise类型,第二种是写一条自定义规则。我倾向于先改类型定义,让TS的类型系统发挥约束力;实在不行才写自定义规则。
下面是个自定义ESLint规则的基本骨架,用于检查fetchData调用后是否被await(用TypeScript写插件):
import { Rule } from 'eslint'; export const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'Ensure fetchData is awaited', }, messages: { requireAwait: 'Please await fetchData before using its result.', }, }, create(context) { return { CallExpression(node) { if (node.callee.type === 'Identifier' && node.callee.name === 'fetchData') { const parent = node.parent; if (parent.type !== 'AwaitExpression') { context.report({ node, messageId: 'requireAwait', }); } } }, }; }, };在ESLint配置里注册这条规则:
{ "plugins": ["local-rules"], "rules": { "local-rules/require-await-fetch-data": "error" } }这个例子想说明的是:你自己的代码里那些反复出现的历史Bug,值得用自动化工具来固定住。不要每次都靠人肉review去提醒“这里要加await”,把规则写一次,以后所有人都会自动遵守。这比任何新人培训文档都管用。
4. 常见问题与排查技巧实录
4.1 高频踩坑与解决对照表
实操一个多月,我整理了下面这份问题对照表,基本都是别人问我最多的问题。
| 问题现象 | 根因 | 解决办法 |
|---|---|---|
| ESLint 检查通过,但 Prettier 总是改格式 | 两者规则冲突,extends 顺序不对 | 把prettier放到 extends 的最后一位,消除冲突 |
| Reviewdog 没有在 PR 里评论 | token 权限不足 | 设置GITHUB_TOKEN为write权限,permissions: pull-requests: write |
| SonarQube 扫描特别慢 | 每次都做全量扫描 | 改为增量扫描,只在主干合并时全量扫,或拆成多模块并行 |
| 自定义规则不生效 | 插件没有正确注册 | 在 Node 环境本地写单测,确认 AST 类型是否匹配 |
| CI 报错 "Cannot read properties of undefined" | TypeScript 类型问题,与 ESLint 规则无关 | 先单独跑tsc --noEmit,把类型错误修完再去看 ESLint |
| 规则太严格,团队抗议 | 一次性启用了太多 Error 级规则 | 降级为 Warn,先跑两周,再逐渐升级为 Error |
| Prettier 不格式化某些文件 | .prettierignore里配置了排除 | 核对.prettierignore,不要误排除源文件 |
| 多语言项目支持不全 | ESLint 主要面向 JS/TS | 用 aslant(Astro)或 dart-format 等按语言拆分工具,不要试图一把梭 |
4.2 规则误报与“逃逸代码”的高效处理
误报是规则引擎永远绕不开的话题,处理不好就会让团队对这套流程失去信任。
我的策略是三层处理。第一层,在规则里加豁免声明,比如// eslint-disable-next-line并写明理由。这适合单个例外的场景。第二层,记录误报到一个单独的技术债文档,每两周复盘一次,看看某个规则的误报率是否过高。如果超过30%,就直接调整或关掉这条规则。第三层,针对常见误报模式做特殊处理,比如在ESLint配置里用overrides对不同目录设置不同的规则强度。
再补充一个处理技巧:当一条规则在一个文件里出现多次误报时,不要用/* eslint-disable */粗暴关掉,因为这会让该文件之后所有的审查都失效。更好的做法是定位到真正有问题的代码行,用行级注释精准豁免,这样后面再做代码审查时,豁免理由一目了然,不会造成黑盒。
4.3 让团队养成代码审查习惯的运营心得
工具只能改变流程,改变不了习惯。open-code-review 落地后,我在团队里做了三个微小的调整,效果出乎意料地好。
第一,把“审查通过”和“合并权限”解耦。很多人不做Review是因为觉得“反正最后合并还是要经过我”,干脆抱团放水。我把规则改成必须有两名成员通过才能合并,同时两个成员不能都是同一个功能模块的负责人。这个微小改动逼着大家去关注别人的代码,因为只顾自己的模块是没办法达成“两人通过”的。
第二,把审查者暴露在错误面前。我在PR模板里加了一个必填字段:“本次变更的主要风险点是什么”。这个字段的提示效果很直接——开发者写PR时如果不清楚风险点在那里,说明Ta自己还没想清楚这段代码的核心矛盾,审查者自然会更认真地看一看。
第三,定期复盘规则集。每个月的最后一个周五,我们会花半小时看一下本月被审查出来的最高频问题分类,然后针对前三个高频问题调整规则集。这个“规则集跟业务迭代”的思路非常关键,代码审查体系不是一个静态的配置文件,它是需要和代码一起进化的活物。
4.4 维护成本与长期演进的思考
整个方案跑了一年多,我觉得最容易被低估的是“规则集的维护成本”。很多人以为搭完CI、配好规则就一劳永逸了,其实不是。
随着项目演进,依赖升级、框架变更、业务复杂化,都会让旧规则变得不再适用。比如ESLint从v8升级到v9,很多旧插件就不再兼容。SonarQube的规则集也是每隔几个版本就会调整默认值。我现在的做法是:每季度花半天时间做一次规则集的“体检”——看看最近合并的PR里有没有因为规则冲突而反复改代码的情况,看看哪些警告长期没有被处理掉,然后统一做调整。
这个机制保证的是:规则集永远服务于团队,而不是反过来团队服务规则。
5. 扩展方向与个人经验总结
5.1 从“检查代码”到“审查AI生成代码”
近一年AI编程工具流行起来之后,代码审查的挑战又上了一个台阶。AI生成的代码看着很完整,但常常在细节上出错:用了过时的API、逻辑分支不完整、误用了类型断言。open-code-review 这套体系在应对AI代码时反而有了天然优势——因为AI代码的“人味”很淡,而规则引擎恰好是最擅长捕捉这类不合规模式的工具。
我的建议是,当团队引入AI编程工具之后,把规则集的Error级别门槛再收紧一档,尤其是资源释放、类型安全、异常处理这三类规则。AI生成的代码跑出来的SonarQube检测报告,往往能比人工Review更早发现潜在Bug。
5.2 给刚入坑的开发者三个实战建议
第一,从小处着手。不要追求一上来就编排十几个工具的复杂流水线。先跑一个ESLint + Prettier + Reviewdog的最小闭环,让团队感受到“被机器指出的问题少了”和“Review自己的代码体验好了”,再逐步加入更多规则。
第二,把规则当代码来维护。所有规则的调整都要走PR流程,要有清晰的提交记录和理由说明。为什么定这个阈值、为什么加这条规则,都要像Review业务代码一样被审视。这套流程本身就是在建立团队对代码审查体系的信任。
第三,永远保持“人能绕开规则”的通道。纯机器审核的代码审查体系,最终会把人逼疯。我始终坚持的原则是:规则是助手,不是警察。遇到确实不应该被规则拦住的特殊情况,要有合理申诉的通道——哪怕只是简单的一行eslint-disable加注释。给人留有余地,团队才会愿意与这套体系长期共处。
open-code-review 这个项目走到今天,最让我满意的不是跑了多少条规则,而是团队从一个“为了改代码而改代码”的团队,变成了一个“为了写好代码而改代码”的团队。审查不再是流程的终点,而是质量意识的起点。这套方案完全可以按需裁剪,核心逻辑和工具组合都能直接复用,希望能帮到正被代码审查折磨的团队。