一个项目的构建入口有三个:Makefile 里的 build 目标、scripts/build.sh、以及 CI 配置里内联的一大段 run 脚本。三者做同一件事,但只有其中一个被更新过——剩下两个在半年前就悄悄坏了。
这不是工具选择问题,而是入口数量问题。构建与运维命令最需要的性质不是"灵活",而是只有一处是对的。
1. 两者的能力差异
| 维度 | Makefile | Shell 脚本 |
|---|---|---|
| 依赖表达 | 内建(目标—依赖—命令) | 需自己判断 |
| 增量构建 | 内建(按修改时间) | 需自己实现 |
| 并行 | 内建(-j) |
需自己管理 |
| 参数传递 | 笨拙(VAR=value 或环境) |
自然 |
| 条件与循环 | 语法古怪,难读 | 自然 |
| 可读性(复杂逻辑) | 差 | 好 |
| 跨平台 | 需注意 shell 与路径 | 需注意工具差异 |
这两件能力在真实项目里往往同时需要,所以正确的做法不是二选一,而是分工:Makefile 当门面与依赖图,脚本当实现。混着做的典型症状是"用 Makefile 写了一百行 shell",可读性与可测试性都会下降。
核心判断:Makefile 强在"依赖与增量",脚本强在"过程与参数"。用 Makefile 写复杂逻辑,语法会变得难以维护;用脚本做增量构建,需要自己实现时间戳比较与依赖图。
2. Makefile 该承担什么
| 职责 | 为什么适合 |
|---|---|
作为统一入口(make build / make test) |
名字短、可发现、无需记路径 |
| 声明产物与依赖 | 增量构建由工具负责 |
| 并行执行互不依赖的目标 | -j 免费得到 |
固化环境(make setup) |
新人一条命令上手 |
| 串联多个脚本 | 把脚本当作命令调用 |
还有一项常被低估的能力:自文档。Makefile 的目标名列表天然就是一份可用命令清单,配合约定俗成的 help 目标,可以直接把注释抽出来当文档。
// 从 ## 注释里抽出帮助信息,不依赖任何额外工具
help: ## 显示可用命令
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
// 每条目标后写 ## 说明,make help 就是一份不会过期的命令清单
第一项是它最大的价值:一个团队只需要记住 make xxx,不需要记住每个脚本的路径与参数。这对新人尤其重要。
3. 脚本该承担什么
| 职责 | 为什么适合 |
|---|---|
| 多步骤过程(含错误处理、重试) | 语法自然 |
| 参数解析与校验 | getopts / argparse 一类的成熟做法 |
| 平台差异处理 | 可以内部判断 |
| 复杂条件与循环 | 可读性远好于 Makefile |
| 需要单测的逻辑 | 脚本可被测试框架调用 |
// Makefile 只做入口与依赖,逻辑交给脚本
.PHONY: build test deploy
build: ## 构建产物
@./scripts/build.sh
test: build ## 跑测试(依赖 build)
@./scripts/test.sh
deploy: build ## 部署,需显式传 ENV
@test -n "$(ENV)" || { echo "用法: make deploy ENV=staging" >&2; exit 1; }
@./scripts/deploy.sh "$(ENV)"
注意最后两行:参数校验可以放在 Makefile 里,因为它的价值是"早失败",不需要复杂逻辑。
4. 三个入口为什么必然腐化
| 原因 | 机制 |
|---|---|
| 没有单一真相 | 改一处不知道另两处是否要改 |
| 无人验证 | CI 用的是第三处,前两处长期不跑 |
| 各自演进 | 有人习惯 make,有人习惯直接跑脚本 |
| 文档指向不同 | README 写 make build,运维手册写 bash scripts/build.sh |
人工路径腐化的信号通常很具体:某个脚本的 shebang 指向了已删除的解释器、某个脚本引用的目录结构已经变了、某个脚本依赖的环境变量早就不存在。它们不会报错,只会在执行时给出一个陌生的错误。
第二项是根因:只有被自动化执行的那一条路径会被持续验证。人工路径会静默腐化,直到某天有人真的用它。
解决方式不是"统一写法",而是让入口成为唯一路径:CI 也调用同一个入口,本地也调用同一个入口。
// CI 里不要内联构建命令,而是调用同一个入口
jobs:
build:
steps:
- run: make build // 与本地完全一致
- run: make test
// 好处:入口一旦坏掉,CI 立刻红;不会等到有人手动执行才发现
5. 判断该放哪里的四个问题
// 归属判断:四问,命中即定
1. 这件事有没有"输入文件变了才需要重做"的性质? → Makefile(增量)
2. 是否需要接收多个参数并做校验? → 脚本
3. 是否有多步流程与错误恢复? → 脚本
4. 是否只是把几件事按顺序串起来? → Makefile 作为入口,调用脚本
// 增量构建:只有输入变了才重做,脚本要自己实现时间戳比较
dist/app.jar: $(shell find src -name '*.java') pom.xml ## 构建 jar
./scripts/build.sh
node_modules/.stamp: package.json package-lock.json
npm ci && touch node_modules/.stamp
// 依赖声明即判断条件,不需要写 if [ -nt ] 之类的比较
第一项常被忽略:能表达增量的事情放 Makefile 收益最大。比如"只有当 go.mod 变了才重新拉依赖",用 Makefile 一行依赖声明就能表达,用脚本要自己比较时间戳。
6. 排错:五种"命令跑不通"
| 现象 | 成因 | 处理方向 |
|---|---|---|
make: *** No rule to make target |
目标名拼错或 .PHONY 缺失 |
加 .PHONY,核对目标名 |
| 本地能跑 CI 不能 | 两边入口不同 | CI 调用同一入口 |
| 改了依赖但没重新构建 | 依赖没声明,或用了 .PHONY 覆盖 |
补依赖声明 |
| 变量传不进去 | Makefile 变量与 shell 变量混淆 | 用 $(VAR) 与 $$VAR 区分 |
| 脚本在 Makefile 里行为不同 | 每行独立 shell,cd 不跨行 |
用 .ONESHELL 或写成一行 |
它还有一个连带后果:错误处理不能跨行。set -e 写在第一行不会保护第二行,因为它们是两个 shell。于是"某一步失败了但构建继续"这种问题,恰好出现在用 Makefile 包裹多步流程的时候。
第五项是 Makefile 最经典的坑:每个命令行在独立的 shell 里执行,所以上一行的 cd 对下一行无效。它导致的错误常常表现为"文件找不到",而脚本单独跑完全正常。
7. 边界:什么情况不必引入 Makefile
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单语言、有成熟构建工具 | 工具已提供入口 | 直接用 gradle / go build |
| 只有一两个命令 | 入口数量不成问题 | 一个脚本即可 |
| 纯 Windows 环境 | Make 支持不佳 | 用 ps1 脚本或任务运行器 |
| 团队规模极小 | 靠口头约定即可 | 不做统一 |
还有一条现实中的折中值得记下:入口可以只有一个,实现可以有多个。make deploy 内部按平台调用 deploy-linux.sh 或 deploy-mac.sh,用户看到的仍然是一个入口。这样既保留了平台差异的处理自由度,也没有制造第二条"以为是这样跑"的路径。
判断口径一句话:团队里有多少条"我以为是这样跑"的路径。超过一条,就需要一个统一入口;只有一条,就不必额外引入工具。
8. 自测
- Makefile 与脚本各自强在哪两件事上?为什么用 Makefile 写复杂逻辑会变难维护?
- 为什么"三个入口"必然腐化?根因是哪一条路径会被持续验证?
- CI 调用与本地同一个入口,解决了腐化的哪个环节?
- "能表达增量的事情放 Makefile 收益最大",举出一个具体例子。
- Makefile 每个命令行独立 shell 会导致什么典型错误?两种修法是什么?
- 判断"是否需要引入 Makefile"的那个问题是什么?