这是什么?

Makefile 是一个自动化编译工具的“说明书”

在开发大型软件项目时,通常会有成百上千个源代码文件。如果你每次改动一点代码都要手动输入命令去编译每一个文件,不仅低效,还容易出错。Makefile 的出现就是为了解决这个问题。

1. Makefile 的核心作用

  • 自动化编译:只需输入一个简单的命令(通常是 make),它就会根据 Makefile 里的规则,自动调用编译器(如 gcc, g++)完成整个项目的构建。
  • 增量编译(按需编译):这是它最强大的地方。Makefile 会检查文件的时间戳。它只编译那些被修改过的文件以及受其影响的文件,而不会浪费时间去重新编译没变动的部分。
  • 管理依赖关系:它能清晰地定义文件之间的依赖关系。比如 A 文件引用了 B 文件的函数,当 B 改变时,Makefile 知道必须重新编译 A。

2. Makefile 的基本结构

Makefile 的核心逻辑由一系列“规则”组成,格式如下:

1
2
目标(target): 依赖文件(prerequisites)
命令(command) # 注意:前面必须是一个 Tab 制表符

举个栗子:

假设你有一个项目,包含 main.ctool.c 两个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 最终目标是生成可执行文件 my_program
my_program: main.o tool.o
gcc -o my_program main.o tool.o

# main.o 依赖于 main.c
main.o: main.c
gcc -c main.c

# tool.o 依赖于 tool.c
tool.o: tool.c
gcc -c tool.c

# 清理编译产生的垃圾文件
clean:
rm *.o my_program

3. 为什么现在还要学它?

虽然现在有很多现代化的构建工具(如 CMake, Bazel),但 Makefile 依然是基石:

  1. 工程必备:绝大多数 Linux 内核和开源项目(如 Redis, Nginx)都直接使用 Makefile。
  2. 跨语言通用:虽然它常用于 C/C++,但其实你可以用它管理任何文件的转换任务(比如自动压缩图片、编译 LaTeX 文档等)。
  3. CMake 的底层:在 Linux 环境下,CMake 通常也是先生成 Makefile,然后再调用 make 来执行。

4. 总结:它的价值

维度 手动编译 使用 Makefile
速度 每次都要全量编译,慢 仅编译变动部分,快
准确性 容易漏掉某个文件 严格按依赖逻辑,准
复杂度 命令又长又难记 一个 make 搞定

为啥python项目里也有?

按理说,Python 是解释型语言,不需要像 C/C++ 那样经历复杂的“编译”过程,但 Makefile 在 Python 项目中依然非常流行,原因在于:它把 Makefile 当作了一个“快捷指令集(Task Runner)”来使用。

在 Python 开发中,Makefile 主要解决的是“命令太长记不住”“环境初始化太繁琐”的问题。

1. Python 项目中 Makefile 的常见用途

Python 项目虽然不编译,但有很多重复性的“脏活累活”:

  • 环境管理:创建虚拟环境、安装依赖包。
  • 清理垃圾:删除生成的 __pycache__.pyc 文件或构建产物。
  • 质量检查:运行代码格式化(black)、静态检查(flake8)、类型检查(mypy)。
  • 运行测试:启动 pytest 并生成覆盖率报告。
  • 打包发布:构建 Docker 镜像或上传包到 PyPI。

2. 一个典型的 Python Makefile 示例

你可以看看你项目里的 Makefile,是不是很像下面这种结构:

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
# 定义变量
PYTHON = python3
PIP = pip

# 1. 安装依赖
install:
$(PIP) install -r requirements.txt

# 2. 运行测试
test:
pytest tests/

# 3. 代码格式化
format:
black .
isort .

# 4. 清理 Python 产生的临时文件
clean:
find . -type d -name "__pycache__" -exec rm -rf {} +
rm -rf .pytest_cache
rm -rf dist

# 5. 一键启动项目
run:
$(PYTHON) main.py

这样一来,你只需要输入 make test 就能跑测试,输入 make clean 就能清理环境,而不需要记住后面那一串复杂的参数。

3. 为什么不用 Shell 脚本(.sh)?

既然只是快捷指令,为啥不用 .sh 脚本呢?

  1. 统一入口:Makefile 提供了一个标准的入口。不管新同事是用什么系统,只要看到 Makefile,就知道输入 make install 就能开始干活。

  2. 依赖逻辑:Makefile 依然可以处理依赖。例如,你可以规定:运行测试前必须先安装依赖。

    1
    2
    test: install
    pytest

    当你执行 make test 时,如果 install 没跑过,它会自动先跑 install

  3. 习惯使然:在 Linux/Unix 世界里,make 是装机自带的,不需要额外安装工具。

4. 现代替代品

虽然 Makefile 很香,但现在 Python 社区也有一些更“原生”的替代方案:

  • Invoke: 用纯 Python 写的任务执行器。
  • Poetry/Pdm: 自带了部分脚本管理功能。
  • Tox/Nox: 专门用于管理多版本 Python 测试环境。

项目实战

整体层级结构

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
33
34
35
36
37
38
39
graph TD
%% 第一层:Makefile 入口
subgraph Makefile_Layer [Makefile 指挥层]
A[make build] -->|调用| B1(build.sh)
A1[make enc_build] -->|带参数 -u| B1
A2[make cov_build] -->|带参数 -c| B1
A3[make pythonlib] -->|调用| B2(pythonlib.sh)
end
%% 第二层:脚本逻辑层
subgraph Script_Layer [Shell 脚本执行层]
B1 --> C1{解析参数}
C1 -->|默认| D1[标准编译: go build]
C1 -->|-u| D2[加密编译: garble build]
C1 -->|-c| D3[覆盖率编译: goc build]

B2 --> E1[清理旧包: clean]
E1 --> E2[生成 RPC 代码: protoc]
E2 --> E3[修复导入路径: sed]
E3 --> E4[打包: build.sh]
end

%% 第三层:细节处理
subgraph Build_Detail [编译细节注入]
D1 & D2 & D3 --> F1[注入版本信息: -ldflags]
F1 --> F2[遍历 cmd/* 目录]
F2 --> F3[检查 main.go]
end

%% 第四层:最终产物
subgraph Output_Layer [最终产物]
F3 --> G1[dist/ 目录二进制文件]
E4 --> G2[Python Wheel/Egg 包]
G1 --> G3[写入 CI_COMMIT_SHA 等元数据]
end

%% 样式美化
style Makefile_Layer fill:#f9f,stroke:#333,stroke-width:2px
style Script_Layer fill:#bbf,stroke:#333,stroke-width:2px
style Output_Layer fill:#bfb,stroke:#333,stroke-width:2px

要点解析:

  1. 参数透传:Makefile 里的不同命令(如 enc_build)本质上是在调用 build.sh 时塞入了不同的“旗标”(Flag),比如 -u
  2. 环境准备:在 build.sh 内部,它会先通过 source 加载环境,确保 Go 版本正确,并自动下载缺少的工具(如 garblegrpcurl)。
  3. 版本溯源:无论走哪条路径,最终都会经过 VER_FLAGS 注入。这意味着你拿到的任何一个二进制文件,都能通过内部变量追溯到它是哪次 Git 提交。
  4. 并行与依赖:Makefile 保证了“先清理、后编译”的逻辑顺序(例如 pythonlib 依赖 clean),而 build.sh 则负责具体的“体力活”(循环遍历目录并一个个编译)。

有了这张图,当你下次需要定位编译报错时,只需看报错阶段处于哪个层级,就能快速锁定是 Makefile 规则写错了,还是 Shell 脚本逻辑有问题。

生成其他项目的依赖包

注意,两个build.sh并不一样,idt的那个只是用来生成wheel文件的!生成完之后可以上传到内部的镜像仓库。

重点是这个语句:python3 setup.py bdist_wheel,其中setup.py自己写,一般是去指定版本号等,避免重复。

上传命令:[凭证环境变量] rsync [参数] [源文件] [协议]://[用户名]@[地址]/[目标模块]/

例如,RSYNC_PASSWORD=<密码> rsync a.txt rsync://[用户名]@[地址]/[目标模块]/

整个完整周期为:

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
33
34
35
graph TD
%% 第一阶段:代码生成
subgraph Stage1 [1. 接口转换阶段]
A[identifier.proto] -->|grpc_tools.protoc| B[PB 代码生成]
B --> B1[identifier_pb2.py]
B --> B2[identifier_pb2_grpc.py]
B1 & B2 --> C[sed 修正导入路径]
end

%% 第二阶段:元数据准备
subgraph Stage2 [2. 元数据准备阶段]
D["idt/__init__.py"] -->|提供名称/作者| E[setup.py]
F[datetime.now] -->|提供日期后缀| E
C -->|放入包目录| G["idt/pb/"]
end

%% 第三阶段:构建打包
subgraph Stage3 [3. 构建打包阶段]
E -->|python3 setup.py bdist_wheel| H{Setuptools 工厂}
G --> H
H --> I[build/ 临时编译目录]
H --> J[idt.egg-info/ 依赖记录]
H --> K[dist/xxxx.whl 最终成品]
end

%% 第四阶段:分发
subgraph Stage4 [4. 远程分发阶段]
K --> L[rsync 上传]
L --> M["www.ngsoc.site/pip/"]
M --> N[其他同事: pip install]
end

%% 样式美化
style Stage3 fill:#f96,stroke:#333
style K fill:#00ff00,stroke:#333,stroke-width:4px

而使用侧只需要在其脚本中指定版本号,就能下载到最新的wheel包。一般就是通过前面的版本号,>=某个版本号,就会自动拉取到最新的时间戳对应的wheel包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
graph TD
subgraph Your_Side [你的侧: 产出]
A[make pythonlib] --> B[生成 whl: v1.0.0.20231215]
B --> C[rsync 上传至镜像站]
end

subgraph Mirror_Site [镜像站/文件服务器]
C --> D[www.ngsoc.site/pip/ 下的文件列表]
end

subgraph Colleague_Side [同事侧: 消费]
E[运行管理脚本] --> F{版本号逻辑判断}
F -->|符合区间 >= v1.0| G[从镜像站拉取对应 whl]
G --> H[静默安装: pip install idt.whl]
H --> I[启动业务程序]
end

style D fill:#f9f,stroke:#333
style F fill:#bbf,stroke:#333
style H fill:#dfd,stroke:#333

整个项目的build.sh的流程

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
33
34
35
36
37
38
flowchart TD
Start([开始执行 scripts/build.sh]) --> Init[初始化路径 CURDIR/PRODIR]
Init --> LoadCommon[Source 加载 common/devenvrc 配置]
LoadCommon --> ParseArgs{getopts 解析参数}
ParseArgs -->|无参数| Default[默认: 标准编译]
ParseArgs -->|-o| SetOut[指定输出目录]
ParseArgs -->|-c| SetCov[开启覆盖率模式 cov=1]
ParseArgs -->|-u| SetEnc[开启加密模式 encFlag=1]

SetOut & SetCov & SetEnc & Default --> PreStep[执行 GO_MOD_TIDY & install_tools]

PreStep --> CovCheck{是否开启覆盖率?}
CovCheck -->|Yes| DownloadGoc[下载 goc 工具]
CovCheck -->|No| ScanDir[遍历 cmd/ 和 tools/ 目录]
DownloadGoc --> ScanDir

ScanDir --> LoopStart[循环每一个 app 子目录]
LoopStart --> CheckMain{是否存在 main.go?}

CheckMain -->|No| Skip[LOG WARN: 跳过该目录]
CheckMain -->|Yes| Version[构造 VER_FLAGS: 注入版本/Git/日期]

Version --> BranchMode{判断编译模式}

BranchMode -->|cov=1| BuildCov[调用 goc build 编译]
BranchMode -->|encFlag=1| BuildEnc[安装并运行 garble 混淆编译]
BranchMode -->|Default| BuildStd[标准 go build -ldflags]

BuildCov & BuildEnc & BuildStd --> Install[install 命令创建目录并移动二进制文件]

Install --> LoopNext{是否有下一个 app?}
LoopNext -->|Yes| LoopStart
LoopNext -->|No| End([输出 LOG INFO: Build Success!])

%% 样式美化
style BranchMode fill:#f96,stroke:#333
style ParseArgs fill:#bbf,stroke:#333
style BuildEnc fill:#f66,stroke:#333

这个 build.sh 脚本是整个 Go 项目的核心编译大脑。它的任务不仅仅是运行 go build,还包含了版本注入、代码混淆、覆盖率统计和工具链管理等工业级生产任务。

以下是该脚本的详细功能拆解:

1. 核心编译模式(三种模式)

脚本通过 getopts 接收参数(如 -c, -u),决定了程序以哪种方式“出生”:

模式 触发参数 使用的工具 核心目的
标准模式 默认 go build 正常的开发与生产环境运行。
代码混淆模式 -u (encrypt) Garble 防止逆向工程。通过混淆代码中的字面量和函数名,让别人拿到二进制文件也难以反编译。
覆盖率模式 -c (coverage) Goc 自动化测试。配合测试平台统计代码运行到了哪几行,通常用于 QA 或集成测试阶段。

2. 关键功能点解析

A. 动态版本注入 (VER_FLAGS)

脚本中有一段很长的 VER_FLAGS,利用了 Go 的 -ldflags 特性:

1
-X sangfor.com/xdr/go-idt/internal/version.version=${PROJECT_VERSION} ...
  • 作用:在编译时,强行把当前代码的版本号、Git 提交记录(Commit ID)、编译日期等信息塞进 Go 的变量里。
  • 效果:当你运行 ./my_app -v 时,它能准确报出自己是哪天在哪个分支编译的。

B. 自动化扫描与构建

脚本并没有写死要编译哪个文件,而是遍历了 cmd/tools/ 目录:

1
2
apps=$(ls -d -- ${inDir}/*/)
for app in ${apps}; do ...
  • 逻辑:它会自动寻找这两个文件夹下的每一个子目录。只要目录下有 main.go,它就会为其创建一个文件夹并完成编译。
  • 好处:你每增加一个微服务或工具,只需在 cmd/ 下建个文件夹,脚本会自动识别并打包。

C. 工具链自给自足 (install_tools)

脚本会检查并安装 grpcurl(一个类似 curl 但用于调试 gRPC 的工具):

  • 逻辑:如果 dist 目录下没有这个工具,它会自动 go install 并在本地备份。这保证了任何开发环境只要运行一次编译,调试工具就全齐了。

3. 脚本执行流程图

  1. 环境检查:加载 devenvrc,指定使用 Go 1.21 版本。
  2. 清理与预备:运行 go mod tidy 整理依赖。
  3. 判断路径
    • 如果带了 -u:安装并执行 garble 混淆编译。
    • 如果带了 -c:从内部仓库(Nexus)下载 goc 并进行带统计信息的编译。
    • 默认:执行 go build -ldflags "-s -w"(其中 -s -w 是为了减小生成的二进制体积,删掉调试符号)。
  4. 产物输出:所有编译好的二进制文件会被统一放在 dist/ 目录下。

4. 总结:为什么要写这么复杂的脚本?

如果你只写一个 go build,你得到的是一个“裸”的程序。而通过这个脚本,你得到的是:

  1. 可追踪的(知道是谁在什么时候编译的)。
  2. 安全的(如果是发布给外部,代码已被混淆)。
  3. 可监控的(支持覆盖率测试)。
  4. 标准化的(所有开发者产出的二进制格式完全一致)。

你想知道在 Makefile 里是如何给这个脚本传递 -u-c 参数的吗?(提示:这对应你之前看到的 enc_buildcov_build 目标)