第 26 章 代码质量流水线
这一章解决一个你写 Go 时从没操心过、但写 Python 时一定会被坑的问题:没有编译器替你拦丑代码,你怎么保证团队每个人交上来的东西都干净、能编译、能跑?答案不是靠自觉,是流水线。
墨叔:老哥,你回想一下在 Go 里写代码的感觉。你写完一个函数,gofmt 一按,缩进对齐全齐活;go vet 一跑,可疑写法当场报警;golangci-lint 一轮,谁没处理 error、谁用了不该用的函数,全给你划红线。你几乎不需要「团队约定代码风格」,因为工具链把下限焊死了。换到 Python,你觉得这些谁来管?
老哥:……靠 review?靠自觉?
墨叔:靠 review 就晚了,而且 review 最贵——人看人最容易放过「风格」这种事。Python 圈子里早就把这些活儿拆成一条流水线,在你提交之前、在 CI 跑测试之前,就把脏东西挡在门外。今天咱们把这条流水线拆开看:ruff 一家子包揽 lint 加格式化,black 兜底排版,mypy 看类型,pre-commit 把闸门装在你本机,最后 CI 再验一遍。你品品,这套组合拳和 Go 的工具链是不是同一套思路,只是零件换了名字。
一、ruff 到底是什么(怎么用)
先说最省心的一个:ruff。你以前可能听过 flake8 查风格、isort 排 import 顺序、black 管格式、autoflake 删没用的 import——一堆工具各管一摊,配置文件能铺满一个目录。ruff 是 Rust 写的,把这一摊子一次性吃下:它既能当 linter(查潜在 bug、查风格违规),又能当 formatter(直接帮你改格式),速度比 Python 写的同类快几十倍。
# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]
ignore = ["B008"] # FastAPI 的 Depends 会触发,单独关
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["B011"]E 是 pycodestyle 的错误类(比如该行太长、空格不对),F 是 Pyflakes(查未定义变量、没用的 import、重复定义),I 是 isort 的 import 排序,B 是 flake8-bugbear(查那些「能跑但迟早出事」的写法,比如可变默认参数、except 吞异常),UP 是把代码升级到新语法(比如提示你用 X | None 替代 Optional[X]),SIM 是 flake8-simplify(提示你能写得更简洁的句式)。这一行 select 顶得上你以前装五六个插件。
怎么用?命令行直接跑:
ruff check . # 只查,不改动
ruff check --fix . # 自动修能修的(比如 import 顺序、删废 import)
ruff format . # 直接按规则排版你注意到没有,ruff check --fix 和 ruff format 合起来,几乎就是 gofmt + go vet 的合体。Go 里你没法选「要不要用 gofmt 的风格」,Python 这边也别纠结——团队定一套 line-length 和 select 规则,全员统一,剩下的交给工具。你纠结「单引号还是双引号」「末尾逗号要不要」这种问题,纯属浪费生命,让 ruff 拍板。
老哥:我之前听说 black 是格式化标准,怎么又来个 ruff format?
墨叔:问到点子上了。black 是「不妥协的格式化器」——它几乎没有可配置项,号称「少即是多,你别跟我对着干」。ruff 的 formatter 在 2024 年成熟后,风格和 black 几乎一致,但快得多,而且和 ruff 的 linter 共用一套配置,少装一个依赖。实际项目里,新项目直接用 ruff 一家子最省事;老项目如果已经依赖 black,也完全没问题——两种 formatter 输出基本兼容,别混着用就行。
二、什么场景该用它(对照 Go / 边界)
你可能会想:我一个人写写脚本,需要这些吗?需要,但优先级不同。咱们分场景说。
场景一:你一个人,写工具脚本。 那 mypy 可能先放放,但 ruff 一定装上。为什么?因为它能抓「未定义变量」「import 了没用」「函数定义了没调」这种低级错。你在 Go 里这些根本编不过,在 Python 里解释器跑不到那行就不报错——ruff 等于给你补了个轻量编译器前哨。代价几乎为零:装一个包,跑一条命令。
场景二:三五个人的小团队。 这时候 black/ruff format 的「统一风格」价值就炸了。你回想 Go 团队为什么几乎不吵代码风格?因为 gofmt 是唯一的、没得选的。Python 没有官方强制格式化器,所以「我的风格 vs 你的风格」能吵三天。解决法一样:工具定标准,人闭嘴。选定 ruff 后,PR 里再也不会有人评论「这里该用列表推导」「那个括号多余」——机器早改好了。
场景三:要和 Go 服务混编,或者对外交付库。 这时候 mypy 必须上。你写 Go 习惯了「类型即文档、类型即契约」,到了 Python 如果没有 mypy,别人调你函数时连「该传什么」都得看源码猜。mypy 把类型注解从「给人看的注释」变成「机器能查的契约」,直接对标 Go 的编译期类型检查。
边界在哪?几个坑你提前知道:
- ruff 的
B规则(bugbear)对 FastAPI 的Depends()会误报(写在默认参数位置),要么按上面ignore = ["B008"],要么写成Annotated形式,别被它吓到改错代码。 - mypy 默认是「渐进式」的——你没标类型的地方它不查。想真起作用,得在
pyproject.toml里开strict = true,它会要求所有函数都有注解、不让用Any糊弄。刚开 strict 你会被几百个报错淹没,正确做法是渐进:先disallow_untyped_defs = true从新代码管起。 - 别迷信格式化器能替你想清楚逻辑。ruff 能把
if x: return True else: return False简化成return bool(x),但它看不出你业务逻辑写反了。工具管「怎么写」,人管「写什么」。
对照 Go 一句话总结:ruff ≈ gofmt + go vet + golangci-lint 的查错部分,black/ruff format ≈ gofmt,mypy ≈ 编译器的类型检查(但可渐进、可绕过)。
三、用它的收益与代价(为什么这么设计)
老哥:听起来装一堆工具,值吗?我们 Go 那边一个 go 命令啥都有,Python 这边要凑四五个。
墨叔:值,而且这「分散」本身是有历史原因的。Go 是 Google 一把手的官方语言,工具链内置、标准统一,这是「中央集权的好处」。Python 是社区驱动、三十年演进,PEP 8 只是风格指南不是强制,所以工具各做各的、后来才整合。ruff 的出现就是把「分散」重新收口——用 Rust 重写,一个二进制替你跑完所有活。所以你看到的「要装好几个」,其实是向历史妥协后的最优解,不是缺陷。
收益我给你算笔账:
- review 成本断崖式下跌。 没有格式化器时,reviewer 的精力耗在「这行换行对不对」「import 顺序」上;有了之后,这些全是 green check,人只看「逻辑对不对、设计合不合理」。人的注意力是最贵资源,别浪费在机器能干的活上。
- 新人入伙零摩擦。 你招个 Go 新人,第一天就能提代码,因为风格不用教。Python 项目装上 ruff + 配置,新人 clone 下来、pre-commit 一套,交上来的代码和老人一个样。这点和 Go 的体验是拉平的。
- mypy 把 Python 的「运行时才炸」提前到「提交前就炸」。 你第 1 章就领教了 Python 类型检查在运行时。mypy 是补丁——它不运行代码,只静态读注解,把「传错类型」「返回类型对不上」在写代码那一刻就画红线。代价是你要写注解,但第 2 章说过,注解本来就是给三个月后的自己看的。
代价也得说清楚:
- 配置有学习曲线。
pyproject.toml里 select 哪些规则、ignore 哪些,初上手要琢磨。但这是一次性投入,配好全团队受益。 - mypy 在动态特性多的代码(元类、动态属性)上会「哑火」或报一堆误报,你得学会用
# type: ignore精准压制,而不是一刀切关掉。 - 工具链割裂。 有人用 ruff、有人用 black+flake8,团队必须统一,否则 CI 上跟我本地跑的不一样,又回到吵架。
所以我的建议:新项目,ruff(lint+format)+ mypy + pre-commit + CI,四件套一步到位;老项目,先上 ruff 查错(零风险),再逐步开 format 和 mypy。
四、一个贴近项目的例子
光讲配置没意思,给你一套能直接抄进项目的「质量流水线」完整骨架。假设是个 FastAPI 服务,目录里你放一个 pyproject.toml:
# pyproject.toml(节选,真实项目骨架)
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "ASYNC"] # ASYNC 查 asyncio 误用
ignore = ["B008"]
[tool.mypy]
python_version = "3.11"
strict = true
plugins = ["pydantic.mypy"]
disallow_untyped_defs = true
[tool.pytest.ini_options]
addopts = "-q --cov=app --cov-report=term-missing"
testpaths = ["tests"]然后装一个 .pre-commit-config.yaml,这是把闸门装到每个人本机的关键——git commit 那一刻自动跑,过不了根本提交不出去:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
hooks:
- id: mypy
additional_dependencies: [pydantic]最后 CI(GitHub Actions 举例)再验一遍,防有人跳过 pre-commit 或本地没装:
# .github/workflows/ci.yml
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install ruff mypy pytest pytest-cov
- run: ruff check .
- run: ruff format --check .
- run: mypy app
- run: pytest你看这条链:本地 pre-commit 先拦一道(快,秒级),CI 再兜底一道(防绕过),mypy 查类型、ruff 查风格和 bug、pytest 保证功能没坏。这不就是 Go 项目里 golangci-lint + go test + CI 的 Python 翻版吗?区别只在零件名字和「你得自己把它们串起来」。串好后,团队代码质量的下限是被机器焊死的,不是靠谁自觉。
五、把流水线装进肌肉记忆(练习)
老哥:听着都好,但我以前没用过 pre-commit,装了会不会反而卡我提交?
墨叔:第一次会被拦几次,但都是「帮你改好再让你提」,不是「拒绝你」。习惯两周,你反而会嫌没装的项目脏。给你三个马上能落地的练习:
- 给现有脚本装 ruff。 挑一个你写过的 Python 文件,装 ruff,
ruff check --fix跑一遍,看它帮你改了什么——大概率有没用的 import、能简化的写法。读一遍 diff,体会「机器比人细心」。 - 开一次 mypy strict 看看自己的代码。 哪怕只对一个文件
mypy --strict your_module.py,看它报多少「函数没注解」「返回类型不符」。别慌,这些都是 Go 里编译器白送的检查,Python 只是让你显式补回来。 - 写个最小 pre-commit 配置并提交一次。 故意在代码里写个未使用的 import,提交,看 pre-commit 怎么拦你、怎么自动修好。亲手被拦一次,比听我讲十次都记得住。
六、从老栈迁移到 ruff 一家子(踩坑实录)
老哥:我们老项目已经在用 flake8 + black + isort 三件套了,为了统一换成 ruff,会不会引起一大波改动、把 git blame 搅浑?
墨叔:这个顾虑很实际,但 ruff 的设计就是冲着「无痛替换」去的。它刻意兼容 flake8 的规则编号(E/F 类)、isort 的 import 排序逻辑、black 的格式化输出,所以迁移通常是「删掉几个依赖、改一行配置、跑一遍 --fix」,而不是重写规则。具体步骤我给你捋一遍,都是踩过的路:第一,把 flake8、black、isort 从依赖里移除,换成 ruff;第二,在 pyproject.toml 里把原来散落的 [tool.flake8]、[tool.black]、[tool.isort] 合并成 [tool.ruff] 一节,select 时把原来开的规则类对齐过去;第三,跑 ruff check --fix 让它自动修一遍能修的(import 排序、废 import),再跑 ruff format 把排版统一。这时候大概率会动一大批文件的格式——这正是你担心的 blame 问题。我的做法是:把「格式化那次提交」单独成一笔,commit message 写明「chore: 全面迁移至 ruff 格式化」,团队 Review 时一眼知道这批改动是工具产生的、不是逻辑变更,blame 时也能跳过这笔。后续日常改动就只有增量格式调整,不会再有大波动了。
还有个容易忽略的点:CI 里如果原来分开跑 black --check、flake8、isort --check,要一起换成 ruff format --check 和 ruff check,否则会出现「本地 ruff 过了、CI 还在跑老 black 报格式不一致」的扯皮。迁移的精髓就是「一步到位、全程统一」,别新老工具并行跑——并行最容易造成「到底以哪个为准」的内耗。
另外提醒一句关于 mypy 和 ruff 的分工:ruff 不查类型,它只查风格和浅层 bug;类型检查必须靠 mypy 单独跑。有人误以为上了 ruff 就「类型安全了」,这是危险的错觉。ruff 报「这行太长」「这个 import 没用」,mypy 才报「这个函数返回 int 但你当 str 用」。两者职责不重叠,流水线里要并列存在,缺一不可。对照 Go 的话,ruff 近似 gofmt + go vet 的查错部分,mypy 才是编译器的类型检查那一半——别指望一个工具把两边全干了。
【思考题】
- 你们团队现在 review 时还花大量精力在「代码风格」评论上吗?如果要引入 ruff,你会怎么说服那个「我习惯用 tab / 我喜欢双引号」的同事?能不能用 Go 团队的做法类比为他讲通?
- mypy 的
strict模式对老项目几乎是「开不了」的状态。如果你接手一个两万行、零注解的 Python 老仓库,你会怎么渐进地引入类型检查,既不吓跑同事、又能让质量真正提升? - pre-commit 装在本机,但有人可以
--no-verify跳过它强行提交。既然能绕过,那它在质量体系里到底起什么作用?真正不可替代的那道闸是什么?
【参考答案】
- 核心论点就一句:风格这种「没有对错、只有偏好」的争论,不值得占用最贵的人力资源(人的注意力)。Go 团队为什么几乎不吵风格?因为 gofmt 是唯一标准、没得选,人把脑子省下来想逻辑。Python 这边 ruff/black 扮演的就是这个角色——不是「你的风格 vs 我的风格」,是「机器的标准」。具体说服技巧:别谈审美,谈成本。算一笔账,每次 PR review 在风格上耗的分钟数 × 团队人数 × 一年 PR 数,换成 ruff 全自动后这部分时间归零。再演示一下:
clone 项目 → ruff check --fix → 提交,新人当天就能交规范代码,不用先背一堆约定。把争论从「品味」拉到「工程效率」,同事一般就通了。 - 渐进引入有三个层次,别一上来开 strict。第一层:只在
pyproject.toml设mypy但不 strict,先让 CI 跑mypy app只查「已注解」的部分,零注解的老代码不报错,新代码强制注解(disallow_untyped_defs = true只对新文件生效可用# type: ignore或分目录)。第二层:挑核心模块(比如数据模型、对外 API 层)单独开 strict,用per-module-options给不同目录不同严格度。第三层:随每次改动顺手给碰到的老函数补注解,半年后覆盖率自然上来。关键是「新代码严格、老代码渐进」,既提升质量又不冻结开发。还可以用mypy --incremental缓存,避免每次全量扫两万行拖慢反馈。 - pre-commit 的定位是「第一道快速反馈」,不是「最终防线」。它解决的是「开发者在自己机器上几秒内就知道代码不合格」,省得推到远端 CI 才挂、再回来改、再推,来回折腾。它能被
--no-verify绕过,所以真正不可替代的闸是 CI——CI 上ruff check和mypy是合并门禁,绕过本机 pre-commit 的人会在 CI 红掉、PR 合不进去。两者分工:pre-commit 是「贴心提醒 + 自动修」,CI 是「强制门禁 + 团队兜底」。少了 pre-commit 只是反馈慢一点;少了 CI,质量体系就塌了。所以架构上永远让 CI 重新跑一遍全部检查,绝不信任「我本地过了」。