1. 把 make 当任务运行器
一句话总结: make 的本质是「按依赖图执行命令,并跳过已是最新的目标」,把它当任务运行器用,就是用文件时间戳做缓存键的自动化脚本集合。
make 的原始用途是编译 C 程序,但它的模型——目标、依赖、命令、时间戳判断——对任何「有依赖关系的任务集合」都适用。当成任务运行器用时,关键是理解「目标名就是任务名」。
# 最简任务运行器
.PHONY: build test clean
build:
@echo "构建中"
go build -o bin/app ./...
test: build
@go test ./...
clean:
@rm -rf bin
1.1 目标、依赖与命令
一句话总结:
target: prerequisites后跟以 Tab 开头的命令,依赖会先被递归构建,命令只在目标「过期」时执行。
# 依赖会先执行,且只执行一次(即使被多个目标依赖)
app: main.o util.o
cc -o app main.o util.o
# main.o 依赖源文件,源文件更新后才会重新编译
main.o: main.c
cc -c main.c
1.2 为什么必须用 Tab
一句话总结: make 用行首的 Tab 区分「命令行」与「普通行」,空格缩进会报
missing separator,这是新手最常踩的坑。
# 正确:命令行以 Tab 开头
build:
go build ./...
# 错误:空格缩进会报 missing separator
# build:
# go build ./...
2. PHONY 与增量语义
一句话总结: 任务是「动作」而非「文件」,必须声明
.PHONY,否则一旦目录里恰好有同名文件,make 就会认为目标已是最新而跳过。
# 不声明 PHONY 的陷阱:若存在名为 test 的文件,make test 会静默跳过
.PHONY: test
test:
go test ./...
2.1 增量判断的正确用法
一句话总结: 目标由文件产出时,让 make 用时间戳判断是否重建;纯动作则一律 PHONY,不要试图用假文件模拟。
# 真增量:dist/app.tar.gz 比所有源文件新则跳过
dist/app.tar.gz: $(shell find src -type f)
@mkdir -p dist
tar -czf $@ -C src .
# 纯动作:永远执行,用 PHONY 明确声明
.PHONY: fmt
fmt:
gofmt -w .
2.2 自动变量速查
一句话总结:
$@是目标名、$<是第一个依赖、$^是全部依赖、$?是比目标新的依赖,它们在规则里表达「对谁做什么」。
# 自动变量
report.txt: data.csv
@echo "目标: $@" # report.txt
@echo "首依赖: $<" # data.csv
@echo "全部依赖: $^"
@echo "更新过的: $?"
awk -F, '{s+=$2} END {print s}' "$<" > "$@"
3. 变量与函数
一句话总结: make 变量有
=、:=、?=、+=四种赋值,展开时机不同;内置函数$(shell)、$(wildcard)、$(patsubst)提供字符串与文件处理能力。
# 四种赋值
CC = cc # 递归展开:使用时才展开
CFLAGS := -O2 # 立即展开:定义时展开
DEBUG ?= 0 # 未定义才赋值
CFLAGS += -Wall # 追加
# 常用内置函数
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
DATE := $(shell date +%F)
3.1 命令行覆盖与条件逻辑
一句话总结: 命令行传入的变量优先于文件内赋值,配合
ifeq可以做环境分支,这是「同一个 Makefile 多环境」的基础。
# 命令行覆盖:make build ENV=prod
ENV ?= dev
# 条件分支
ifeq ($(ENV),prod)
CONFIG := config/prod.yaml
else
CONFIG := config/dev.yaml
endif
# 用 $(if) 函数做内联判断
LOG_LEVEL := $(if $(filter prod,$(ENV)),warn,debug)
3.2 错误处理与前置检查
一句话总结: 每个命令单独一个 shell,所以
cd与变量不会跨行保留;用&&串联或用.ONESHELL才能共享状态。
# 每行是独立 shell:cd 不会影响下一行
bad:
cd build
pwd # 仍在原目录
# 正确:同一行内串联
good:
cd build && pwd
# 或声明 ONESHELL(GNU make 3.82+)
.ONESHELL:
good2:
cd build
pwd
4. 并行与任务图
一句话总结:
-j按依赖图并行执行互不依赖的目标,依赖关系写得准,并行才安全。
# 并行执行,N 为并发度
make -j8 all
# 依赖图决定了哪些任务可以并行:a 与 b 无依赖,可并行;c 依赖两者
all: c
c: a b
a:
@echo a
b:
@echo b
4.1 依赖顺序与竞态
一句话总结: 若两个目标会写同一文件,make 不会自动串行化它们,必须显式建立依赖,否则并行时互相覆盖。
# 危险:a 与 b 都写 out/ 且无依赖关系,-j 时会竞态
# 正确:显式串行
all: b
a:
@mkdir -p out && echo a > out/a.txt
b: a
@echo b > out/b.txt
4.2 用 .NOTPARALLEL 局部串行
一句话总结: 某些目标天然必须串行(如数据库迁移),可用
.NOTPARALLEL或让它们依赖一个公共前置目标来强制排序。
# 全局串行(不推荐,浪费并行度)
.NOTPARALLEL:
# 局部串行:让必须串行的步骤形成依赖链
deploy: migrate
migrate: build
build:
@echo build
5. just 与 task 的对比
一句话总结: make 的 Tab、隐式规则与时间戳语义对「纯任务运行器」是负担;just 与 task 去掉了这些,换来更直观的语法。
| 特性 | make | just | task |
|---|---|---|---|
| 缩进 | 必须 Tab | 无要求 | 无要求 |
| 增量判断 | 有 | 默认无 | 有 sources 字段 |
| 依赖声明 | 时间戳 | 显式 deps | 显式 deps |
| 参数传递 | 变量 | 位置参数 | 变量与 CLI 参数 |
# just:语法直观,默认不做增量
# build:
# go build ./...
# test: build
# go test ./...
# task:YAML 描述,支持 sources 增量
# tasks:
# build:
# cmds: [go build ./...]
# sources: ["**/*.go"]
# generates: ["bin/app"]
5.1 何时仍应选 make
一句话总结: 需要真正的文件级增量、生态里已有大量 Makefile、或要跨语言统一入口时,make 依然是最省事的选择。
# make 的优势场景:C/C++ 项目、需要 .o 级增量、团队已熟悉
# just 的优势场景:纯任务编排、无文件产出、语法要求友好
# task 的优势场景:需要 YAML 描述与内置增量、跨平台一致
5.2 从 make 迁移的路径
一句话总结: 保留 Makefile 作为「薄入口」转发到 just 或 task,逐步迁移而非一次性重写。
# 过渡期:Makefile 只做转发
.PHONY: build test
build:
just build
test:
just test
6. 工程化实践
一句话总结: 帮助目标、变量校验、日志与失败即停,是一个可交付 Makefile 的四个标配。
# 默认目标:make 不带参数时执行 help
.DEFAULT_GOAL := help
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
.PHONY: build
build: ## 构建二进制
go build -o bin/app ./...
6.1 变量校验与失败即停
一句话总结: 在关键目标开头校验必需变量,用
set -e或&&串联让失败立刻中断,避免带着错误状态继续。
.PHONY: deploy
deploy: build
@test -n "$(ENV)" || { echo "必须指定 ENV" >&2; exit 1; }
@set -e; \
echo "部署到 $(ENV)"; \
./scripts/deploy.sh --env "$(ENV)"
6.2 与 CI 集成
一句话总结: 让 CI 只调用 make 目标,把「怎么跑」的知识集中在 Makefile 里,本地与 CI 行为一致。
# CI 里
# make lint test build
# Makefile 里统一封装
.PHONY: lint test ci
lint:
shellcheck scripts/*.sh
test:
go test -race ./...
ci: lint test build
7. 实战:多语言项目的统一任务入口
一句话总结: 用一个 Makefile 统一前端、后端、基础设施的入口,团队成员只需记住 make 目标而不必记住各语言工具链。
SHELL := /bin/bash
.DEFAULT_GOAL := help
FRONT_DIR := web
API_DIR := api
ENV ?= dev
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
.PHONY: install
install: ## 安装所有依赖
cd $(FRONT_DIR) && npm ci
cd $(API_DIR) && go mod download
.PHONY: build
build: ## 构建全部产物
cd $(FRONT_DIR) && npm run build
cd $(API_DIR) && go build -o ../bin/api ./cmd/api
7.1 分环境与分模块目标
一句话总结: 用变量区分环境、用前缀区分模块,目标命名保持可预测,团队协作成本最低。
.PHONY: web-build api-build
web-build: ## 仅构建前端
cd $(FRONT_DIR) && npm run build
api-build: ## 仅构建后端
cd $(API_DIR) && go build -o ../bin/api ./cmd/api
.PHONY: test-unit test-e2e
test-unit: ## 单元测试
cd $(API_DIR) && go test ./...
test-e2e: build ## 端到端测试
./scripts/e2e.sh --env $(ENV)
7.2 清理与并行
一句话总结: 把互不依赖的构建步骤交给
-j并行,用.PHONY标注动作目标,用clean保证可重复。
.PHONY: clean
clean: ## 清理产物
rm -rf bin $(FRONT_DIR)/dist
.PHONY: all
all: web-build api-build ## 并行构建(make -j all)
8. 总结
| 环节 | 要点 |
|---|---|
| 心智模型 | make 是按依赖图执行并跳过最新目标的执行器 |
| 语法 | 命令行必须 Tab 开头,否则报 missing separator |
| PHONY | 动作型目标必须声明,避免被同名文件干扰 |
| 增量 | 有文件产出时用时间戳判断,纯动作一律 PHONY |
| 变量 | = 延迟展开,:= 立即展开,命令行可覆盖 |
| shell | 每行独立 shell,用 && 或 .ONESHELL 共享状态 |
| 并行 | -j 按依赖图并行,写同一文件的目标要显式串行 |
| 取舍 | 要文件级增量选 make,纯任务编排选 just 或 task |
任务运行器的价值,是把「这个项目怎么跑」从口头约定变成可执行、可发现、可组合的代码。make 用四十年前的模型做到了这一点,just 与 task 则用更友好的语法重做了它;选哪个并不重要,重要的是团队有一个唯一入口,而不是散落在 README 与各人终端历史里的命令。走到这里,shell 专题从基础语法、文本处理、并发控制到任务编排,已经覆盖了日常自动化的主要环节,剩下的就是把这些工具组合成你自己的工程习惯。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。