提示:本节课最终代码为:feature/s27

在我们完成了应用框架的构建、功能开发、静态代码检查等基本功能之后,是时候重新梳理下如何高效管理一个相对稳定的项目了。之所以在这个阶段介绍如何管理项目,是因为过早的规划项目管理,会导致考虑不周全,面临后期重构的可能。过晚的规划项目管理会导致不能提前享受高效项目管理带来的开发效率提升。

为什么要通过 Makefile 管理项目?

这里,先来介绍下使用 Makefile 管理项目的必要性。

优秀的开发者在开发一个 Go 项目时,会提前考虑应用的扩展性,在开发之初,甚至功能都是按着大规模应用的标准去设计的。所以,即使 miniblog 是一个小而精的项目,但我们在开发时,仍然要假想未来 miniblog 随着时间的推移,功能的迭代,最后变得异常庞大的场景。

对于一个庞大的应用,可能有众多开发者,每天都在提交代码,每天都在重复执行:静态代检查、代码编译、镜像构建、单元测试、Protobuf 文件编译等操作。

试想如果没有一个统一、高效的项目管理,你很可能会面临以下 2 个核心难题。

  • 执行结果不一致带来一些潜在的问题和沟通成本:这么多开发者,相同的操作每个人执行的命令和结果都是不一样的。不一致的结果,可能会程序运行结果不一致,并带来一些沟通成本。

  • 执行效率低下:我们编译一个 Protobuf 文件需要操作的指令类似于:protoc --proto_path=$(APIROOT) --proto_path=$(ROOT_DIR)/third_party --go_out=paths=source_relative:$(APIROOT) --go-grpc_out=paths=source_relative:$(APIROOT) pkg/proto/miniblog/v1/*.proto。试想,如果没有一个高效的编译方法,每次手动输入这么多命令,是多么低效、烦人的一个操作,并可能因为命令执行错误,导致运行失败。

上面 2 个难题最终导致的结果就是:开发效率低下或者说项目管理效率低下。

所以为了提高开发效率,我们需要有一个统一、高效的项目管理手段。当前,对于一个 Go 项目,有多种项目管理手段,但最普适的方法是使用 Makefile 来管理项目。

所以接下来我们就介绍下如何使用 Makefile 来管理项目。

如何通过 Makefile 管理项目?

那么如何通过 Makefile 来管理项目呢?你可能说编写一个简单的 Makefile 来管理项目即可,例如:

  1. .PHONY: build
  2. build: tidy # 编译源码,依赖 tidy 目标自动添加/移除依赖包.
  3. @go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/miniblog $(ROOT_DIR)/cmd/miniblog/main.go
  4. .PHONY: format
  5. format: # 格式化 Go 源码.
  6. @gofmt -s -w ./
  7. .PHONY: add-copyright
  8. add-copyright: # 添加版权头信息.
  9. @addlicense -v -f $(ROOT_DIR)/scripts/boilerplate.txt $(ROOT_DIR) --skip-dirs=third_party,vendor,$(OUTPUT_DIR)
  10. .PHONY: swagger
  11. swagger: # 启动 swagger 在线文档.
  12. @swagger serve -F=swagger --no-open --port 65534 $(ROOT_DIR)/api/openapi/openapi.yaml
  13. .PHONY: tidy
  14. tidy: # 自动添加/移除依赖包.
  15. @go mod tidy
  16. .PHONY: clean
  17. clean: # 清理构建产物、临时文件等.
  18. @-rm -vrf $(OUTPUT_DIR)

对于一个小型的项目没啥问题,但是对于一个大型的项目却不适用。因为大型项目,在开发时,需要执行的功能众多、条件复杂、命令冗长,如果简单通过功能拼凑放在一个 Makefile 中,可能会提升一些开发效率,但是并不会提升太多。因为一个冗长、难以阅读、操作复杂的 Makefile 同样不利于管理项目。

所以,如果你想通过 Makefile 来高效管理项目,在我看来需要通过以下几个步骤。

  1. 学习 Makefile 基础语法:如果你想编写 Makefile 脚本,基础语法是必须学习的。基础语法知识你可以参考:Makefile基础知识

  2. 学习 Makefile 高级语法:还要熟练使用 Makefile 高级语法,因为高级语法能让你编写更高效、更灵活、功能更强大的 Makefile 脚本。这样的 Makefile 脚本会大大提高你的项目管理效率。高级 Makefile 语法你可以参考陈皓老师编写的 跟我一起写 Makefile (PDF 重制版)

  3. 学习优秀项目的 Makefile 实现:在学习了基础语法和高级语法之后,还要学习优秀开源项目的Makefile实现。这些实现,可能从项目管理思路、Makefile 功能实现方法和技巧、Makefile 结构设计等方面去影响你。例如,你可以学习 Kubernetes 的 Makefile 实现:Kubernetes Makefile、iam 项目的 Makefile 实现 iam Makefile

  4. 设计结构化的 Makefile:对于一个大型的 Go 项目,设计一个结构化的 Makefile,可以使功能具有层次感、脚本更具扩展性、脚本后期维护更轻松。

  5. 编写灵活、可扩展的 Makefile:通过编写灵活、扩展性强的 Makefile,不仅可以让你实现期望的功能,还能让你后期以最小的改动,甚至不改动实现更多类似的功能。

如何设计 Makefile 结构?

一个经典的结构化 Makefile 设计方法如下:

  1. ├── Makefile
  2. └── scripts
  3. ├── coverage.awk
  4. ├── make-rules
  5. ├── common.mk
  6. ├── generate.mk
  7. ├── golang.mk
  8. └── tools.mk
  9. └── test.sh

项目根目录下的 Makefile 起到聚合的作用,将功能的具体实现存放在 scripts/make-rules/ 目录下,通过在 /Makefile 中 include scripts/make-rules/ 目录下的 Makefile 来导入各类功能,例如:

  1. include scripts/make-rules/common.mk
  2. include scripts/make-rules/tools.mk
  3. include scripts/make-rules/golang.mk
  4. include scripts/make-rules/generate.mk

通过这种方式,可以确保 /Makefile 文件清晰可读、易维护。又因为我们将功能按功能类别拆分在scripts/make-rules/ 目录下的多个 Makefile 文件中,也能确保 scripts/make-rules/ 目录下的每个 Makefile 文件代码简短、功能聚焦、易于维护和阅读。

对于复杂的功能,我们可以编写 Shell 脚本来实现,Shell 脚本存放在 /scripts 目录下,并在 Makefile 中引用。编写 Shell 脚本相较于 Makefile 更加容易,在提高编写效率、降低实现难度的同时,又能有效减小 Makefile 中的代码量。

所以,一个合理的 Makefile 结构如下图所示:

21.项目管理:如何通过 Makefile 来高效管理你的项目? - 图1

高效 Makefile 开发实战

根据上面我们的设计思路,我们来实现 miniblog 重构后的 Makefile,并体验其管理方式和效率。

这里假设你已经了解或者掌握了 Makefile 基础语法和高级语法。为了实现一个结构化的 Makefile,我们首先需要将 Makefile 功能进行分类。现有的 Makefile 功能全聚合在一个 Makefile 文件中:Makefile(feature/s26),且 Makefile 没有 make help 这类命令,难以阅读和使用。通过以下命令可以获取当前 Makefile 集成的管理功能:

  1. .PHONY: all
  2. .PHONY: build
  3. .PHONY: format
  4. .PHONY: add-copyright
  5. .PHONY: swagger
  6. .PHONY: tidy
  7. .PHONY: clean
  8. .PHONY: ca
  9. .PHONY: protoc
  10. .PHONY: test
  11. .PHONY: cover
  12. .PHONY: deps
  13. .PHONY: lint

根据上述管理功能,我们可以将 Makefile 中的目标根据功能进行以下分类:

  1. generate:
  2. add-copyright 添加版权头信息.
  3. ca 生成 CA 文件.
  4. protoc 编译 protobuf 文件.
  5. deps 安装依赖,例如:生成需要的代码、安装需要的工具等.
  6. build:
  7. build 编译源码,依赖 tidy 目标自动添加/移除依赖包.
  8. clean:
  9. clean 清理构建产物、临时文件等.
  10. lint and verify:
  11. lint 执行静态代码检查.
  12. test:
  13. test 执行单元测试.
  14. cover 执行单元测试,并校验覆盖率阈值.
  15. hack/tools:
  16. format 格式化 Go 源码.
  17. swagger 启动 swagger 在线文档(监听端口:65534).
  18. tidy 自动添加/移除依赖包.
  19. help 打印 Makefile help 信息.

另外,我们需要将同类功能放在一个 Makefile 在中实现,并将脚本存放在 scripts/make-rules/ 目录下。例如 buildlinttidytestcoverformat 因为跟 go 命令相关,统一放在 scripts/make-rules/golang.mk 文件中,并在 /Makefile 中通过 include 关键字导入,见 Makefile#L15

另外,为了简化 Makefile 实现,我们可以将一些复杂的管理工作,通过 Shell 脚本来实现,并将脚本存放在 /scripts 目录下供 Makefile 使用,见:golang.mk#L69

为了提高 Makefile 的灵活性和扩展性,我们大量使用了 Makefile 的高级语法,例如:

  1. .PHONY: tools.install.%
  2. tools.install.%:
  3. @echo "===========> Installing $*"
  4. @$(MAKE) install.$*

最终,实现的 Makefile 见:feature/s27。执行 make hlep,输出如下:

  1. $ make help
  2. Usage:
  3. make <TARGETS> <OPTIONS>
  4. Targets:
  5. generate:
  6. add-copyright 添加版权头信息.
  7. ca 生成 CA 文件.
  8. protoc 编译 protobuf 文件.
  9. deps 安装依赖,例如:生成需要的代码、安装需要的工具等.
  10. build:
  11. build 编译源码,依赖 tidy 目标自动添加/移除依赖包.
  12. clean:
  13. clean 清理构建产物、临时文件等.
  14. lint and verify:
  15. lint 执行静态代码检查.
  16. test:
  17. test 执行单元测试.
  18. cover 执行单元测试,并校验覆盖率阈值.
  19. hack/tools:
  20. format 格式化 Go 源码.
  21. swagger 启动 swagger 在线文档(监听端口:65534).
  22. tidy 自动添加/移除依赖包.
  23. help 打印 Makefile help 信息.
  24. Options:
  25. BINS The binaries to build. Default is all of cmd.
  26. This option is available when using: make build/build.multiarch
  27. Example: make build BINS="miniblog test"
  28. VERSION The version information compiled into binaries.
  29. The default is obtained from gsemver or git.
  30. V Set to 1 enable verbose build. Default is 0.

make help 的输出中,我们可以知道,新实现的 Makefile 可以通过 make help 命令很清晰地查看 Makefile 实现了什么功能。

另外,在新版的 Makefile 实现中,我们在多个地方实现了灵活、可扩展的功能,例如 help 目标实现如下:

  1. .PHONY: help
  2. help: Makefile ## 打印 Makefile help 信息.
  3. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<TARGETS> <OPTIONS>\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 } /^$$([0-9A-Za-z_-]+):.*?##/ { gsub("_","-", $$1); printf " \033[36m%-45s\033[0m %s\n", tolower(substr($$1, 3, length($$1)-7)), $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' Makefile #$(MAKEFILE_LIST)
  4. @echo -e "$$USAGE_OPTIONS"

上述 awk 命令,解析 /Makefile 文件,根据 ## 解析出需要在 make help 中展示的目标,并归类这些目标。如果你在 /Makefile 中新增一个目标,这时候,你不需要修改 help 目标的任何实现,就能够在make help 输出中看到你新增的目标。

小结

本节课介绍了如何设计和实现一个好的 Makefile 用来管理大型 Go 项目。本节课的最后,通过 miniblog 结构化 Makefile 实现,给你展示了具体该如何去实现结构化的 Makefile。另外,因为 Makefile 语法过多,所以本节课没有介绍,你可以通过 跟我一起写 Makefile (PDF 重制版) 教程,并结合 feature/s27 中的 Makefile 编程实现,来学习 Makefile。