Featured image of post Makefile 在现代项目的价值与实践

Makefile 在现代项目的价值与实践

Makefile 是项目的『遥控器』——常用命令一行入口,新人 5 分钟跑起来。本文讲清楚现代项目里 Makefile 的写法和实践

为什么每个项目都该有一份 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/

执行:

1
2
make help
make test

⚠️ 关键陷阱: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——这不是一个真实文件,永远执行。否则如果项目目录里恰好有个文件叫 cleanmake 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. 常用变量放顶部

APPVERSIONGODOCKER_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

Makefileshell 脚本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 一次性解决——这是最便宜的体验提升。

使用 Hugo 构建
主题 StackJimmy 设计