任务编排与 Makefile 实战

把 make 当作任务运行器而非构建工具:目标与依赖的语义、PHONY 与增量判断、并行执行、变量与函数,以及与 just、task 的取舍和迁移路径。

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 去掉了这些,换来更直观的语法。

特性makejusttask
缩进必须 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 专题从基础语法、文本处理、并发控制到任务编排,已经覆盖了日常自动化的主要环节,剩下的就是把这些工具组合成你自己的工程习惯。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 文件监控与事件驱动流水线实战
  2. 结构化数据清洗与报表生成实战
  3. 并发控制与文件锁实战