为什么每个项目都该有一份 Makefile
很多人以为 Makefile 是 C/C++ 时代的产物——其实它在 Java / Go / Python / 前端 项目里同样好用。
它的真正价值不在"编译",而在让项目所有日常操作都有"一行命令入口":
1
2
3
4
5
6
| make run # 启动
make test # 跑测试
make lint # 静态检查
make build # 构建产物
make image # 打 Docker 镜像
make deploy # 发布
|
新人入职第一天看一眼 Makefile,就知道这个项目能干啥、怎么开始。没有 Makefile 的项目,新人靠口口相传或翻 README,效率差得不是一点半点。
本文讲清楚 Makefile 的核心语法、现代项目里的写法、和那些容易踩的坑。
一、Makefile 的最小可用版
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # Makefile
.PHONY: help run test build clean
help:
@echo "Available commands:"
@echo " make run - Run the application"
@echo " make test - Run tests"
@echo " make build - Build artifact"
@echo " make clean - Remove artifacts"
run:
go run main.go
test:
go test ./...
build:
go build -o bin/app main.go
clean:
rm -rf bin/
|
执行:
⚠️ 关键陷阱:Makefile 命令前必须是 Tab 字符——空格会报 missing separator 错误。这是 Makefile 最让人崩溃的语法点。
二、核心概念
1. 目标(target)/ 依赖(prerequisite)/ 命令(recipe)
1
2
| target: prerequisites
recipe
|
例子:
1
2
| build: src/main.go
go build -o bin/app src/main.go
|
意思——“build 这个目标依赖 main.go,要执行 go build"。Make 会比较 main.go 的修改时间和 bin/app 的修改时间——如果 main.go 没变,重复 make build 不会重新构建。
2. 伪目标(.PHONY)
1
2
3
| .PHONY: clean
clean:
rm -rf bin/
|
.PHONY 告诉 Make——这不是一个真实文件,永远执行。否则如果项目目录里恰好有个文件叫 clean,make clean 会被识别成"clean 文件已存在,无需重做”——什么都不做。
所有"动作型"目标都要加 .PHONY,这是工程纪律。
3. 变量
1
2
3
4
5
6
| APP := myapp
VERSION := $(shell git describe --tags --always)
GO := go
build:
$(GO) build -ldflags "-X main.Version=$(VERSION)" -o bin/$(APP)
|
:= 是立即求值(推荐),= 是延迟求值(每次引用才计算,容易意外)。
4. 条件
1
2
3
4
5
| GOOS ?= linux
GOARCH ?= amd64
build:
GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o bin/app
|
?= 表示"如果没设过就用这个默认值"——可以被命令行覆盖:
1
| make build GOOS=darwin GOARCH=arm64
|
三、现代项目的 Makefile 模板
Go 项目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| .PHONY: help run test test-cover lint build image clean
APP := myapp
VERSION := $(shell git describe --tags --always 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -s -w"
DOCKER_TAG := $(APP):$(VERSION)
help:
@echo "Available commands:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
run: ## 启动开发服务器
go run main.go
test: ## 跑单元测试
go test ./... -race -v
test-cover: ## 测试 + 覆盖率报告
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
lint: ## 静态检查
golangci-lint run
build: ## 编译产物
CGO_ENABLED=0 go build $(LDFLAGS) -o bin/$(APP) main.go
image: ## 打 Docker 镜像
docker build -t $(DOCKER_TAG) .
clean: ## 清理产物
rm -rf bin/ coverage.out coverage.html
|
grep -E ... ## ... 这套是个常用技巧——用注释自动生成 help:
1
2
3
4
5
6
7
8
9
| $ make help
Available commands:
run 启动开发服务器
test 跑单元测试
test-cover 测试 + 覆盖率报告
lint 静态检查
build 编译产物
image 打 Docker 镜像
clean 清理产物
|
Java Spring Boot 项目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| .PHONY: help run test build image deploy clean
APP := myapp
PROFILE ?= dev
TAG := $(shell git rev-parse --short HEAD)
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
run: ## 启动应用
./mvnw spring-boot:run -Dspring-boot.run.profiles=$(PROFILE)
test: ## 单元测试
./mvnw test
build: ## 打 jar
./mvnw clean package -DskipTests
image: build ## 打镜像
docker build -t $(APP):$(TAG) .
deploy: image ## 推送 + 部署
docker push registry.internal/$(APP):$(TAG)
kubectl set image deploy/$(APP) $(APP)=registry.internal/$(APP):$(TAG)
clean:
./mvnw clean
rm -rf target/
|
注意 image: build 这种语法——image 依赖 build,所以执行 make image 时会先跑 make build。
前端项目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| .PHONY: help dev test lint build preview clean
PM := pnpm
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
install: ## 装依赖
$(PM) install
dev: install ## 开发模式
$(PM) run dev
test: ## 跑测试
$(PM) run test
lint: ## ESLint + Prettier 检查
$(PM) run lint
$(PM) run format:check
build: install ## 生产构建
$(PM) run build
preview: build ## 预览构建产物
$(PM) run preview
clean:
rm -rf node_modules dist coverage
|
PM 变量让你能在 npm/yarn/pnpm 间切换——make build PM=npm 即可。
四、写 Makefile 的工程纪律
1. 命令前永远是 Tab
最大的坑——空格 vs Tab。配置编辑器:
1
2
3
4
| .editorconfig:
[Makefile]
indent_style = tab
indent_size = 4
|
2. 所有动作目标都加 .PHONY
不加的话哪天目录里有个同名文件就废了。
3. 命令前加 @ 抑制回显
1
2
3
| test:
@echo "Running tests..."
go test ./...
|
不加 @ 的话每条命令都会先打印一遍——输出会很啰嗦。
4. 常用变量放顶部
APP、VERSION、GO、DOCKER_TAG 这种所有人改起来频繁的变量放最上面,方便修改。
5. 提供 help 目标
5 行注释正则就能自动生成——这是 Makefile 体验的一半。
6. 别把环境变量硬编码
1
2
3
4
5
6
7
8
| # ❌ 反例
build:
GOPROXY=https://goproxy.cn go build
# ✓ 推荐
GOPROXY ?= https://goproxy.cn
build:
GOPROXY=$(GOPROXY) go build
|
新人换代理时不用改 Makefile。
7. 错误处理
1
2
3
| deploy:
@./scripts/check-env.sh || (echo "env check failed"; exit 1)
kubectl apply -f deploy.yaml
|
链式命令默认失败就停,但单独的脚本要显式 check。
五、进阶用法
1. 多目录子项目
1
2
3
4
5
6
| SUBDIRS := api worker scheduler
build:
for dir in $(SUBDIRS); do \
$(MAKE) -C $$dir build; \
done
|
$(MAKE) -C dir target 在子目录跑 make。
2. 多平台交叉编译
1
2
3
4
5
6
7
8
9
| PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
cross-build:
@for p in $(PLATFORMS); do \
os=$$(echo $$p | cut -d/ -f1); \
arch=$$(echo $$p | cut -d/ -f2); \
echo "Building for $$os/$$arch..."; \
GOOS=$$os GOARCH=$$arch go build -o bin/app-$$os-$$arch; \
done
|
3. 依赖检查
1
2
3
4
5
6
7
| check-tools:
@command -v go >/dev/null || (echo "go not found"; exit 1)
@command -v docker >/dev/null || (echo "docker not found"; exit 1)
@command -v kubectl >/dev/null || (echo "kubectl not found"; exit 1)
build: check-tools
go build -o bin/app
|
4. include 子模块
1
2
| include scripts/build.mk
include scripts/deploy.mk
|
把 Makefile 拆成多个文件——主 Makefile 只声明几个高阶目标,细节在子 mk 里。
六、Makefile vs 脚本 vs npm scripts
| Makefile | shell 脚本 | package.json scripts |
|---|
| 跨平台 | △ 需要 make | △ 需要 bash | ✓ |
| 依赖关系 | ✓ 内建 | ✗ 自己写 | ✗ 自己写 |
| 自动跳过未变 | ✓ | ✗ | ✗ |
| 可读性 | △ 冷门语法 | ✓ | ✓ |
| 调试 | △ | ✓ | ✓ |
| 已普及度 | ✓ Linux 自带 | ✓ | △ 仅 Node 项目 |
给一个建议:
任何项目(不论语言)都该有 Makefile 作为『统一入口』——前端项目可以同时保留 package.json scripts,但 make dev / make test / make build 也应该能跑。这让"新人进项目"和"CI 配置"都极简。
七、Windows 兼容
Makefile 在 Windows 下需要装 make(通过 chocolatey、Git Bash、WSL 都行)。如果团队有 Windows 用户:
1
2
3
4
5
6
7
8
| ifeq ($(OS),Windows_NT)
RM := rmdir /s /q
else
RM := rm -rf
endif
clean:
$(RM) bin
|
或者更简单——让 Windows 用户用 WSL,业务级开发现在已经是默认配置。
八、CI 集成
GitLab CI / GitHub Actions 直接调 make:
1
2
3
4
5
6
7
8
9
| # .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: make test
- run: make lint
- run: make build
|
CI 配置文件极简——所有逻辑都在 Makefile 里。换 CI 平台(GitLab → GitHub Actions → Jenkins)只要改这一个文件,Makefile 不动。
小结
把全文压一句:
Makefile 不是构建工具,是『项目的遥控器』——让任何人 5 分钟看懂这个项目能做什么、怎么做。
记住几条:
- 命令前是 Tab 不是空格——这是 Makefile 第一坑
- 所有动作目标加
.PHONY - 写 help 目标——用注释 + grep 自动生成
- 变量放顶部,外部可覆盖
- CI 直接调 make——配置文件不要重复逻辑
把项目里那些"git 仓库下完不知道怎么跑"的痛点用 Makefile 一次性解决——这是最便宜的体验提升。