2223 字
约 7 分钟
20
S01-A17 · Makefile 与脚本的分工:构建入口该放在哪

一个项目的构建入口有三个: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. 自测

  1. Makefile 与脚本各自强在哪两件事上?为什么用 Makefile 写复杂逻辑会变难维护?
  2. 为什么"三个入口"必然腐化?根因是哪一条路径会被持续验证?
  3. CI 调用与本地同一个入口,解决了腐化的哪个环节?
  4. "能表达增量的事情放 Makefile 收益最大",举出一个具体例子。
  5. Makefile 每个命令行独立 shell 会导致什么典型错误?两种修法是什么?
  6. 判断"是否需要引入 Makefile"的那个问题是什么?
S01-A17 · Makefile 与脚本的分工:构建入口该放在哪
https://www.clxhxhhr.top/posts/6406/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。