思考并回答以下问题:
低质量的Makefile文件:1
2
3
4
5
6
7
8
9
10
11
12build: clean vet
@mkdir -p ./Role
@export GOOS=linux && go build -v .
vet:
go vet ./...
fmt:
go fmt ./...
clean:
rm -rf dashboard
上面这个Makefile存在不少问题。例如:功能简单,只能完成最基本的编译、格式化等操作,像构建镜像、自动生成代码等一些高阶的功能都没有;扩展性差,没法编译出可在Mac下运行的二进制文件;没有Help功能,使用难度高;单Makefile文件,结构单一,不适合添加一些复杂的管理功能。
熟练掌握Makefile语法
1 | $ make help |
通常而言,Go项目的Makefile应该实现以下功能:格式化代码、静态代码检查、单元测试、代码构建、文件清理、帮助等等。如果通过docker部署,还需要有docker镜像打包功能。因为Go是跨平台的语言,所以构建和docker打包命令,还要能够支持不同的CPU架构和平台。为了能够更好地控制Makefile命令的行为,还需要支持Options。
为了方便查看Makefile集成了哪些功能,我们需要支持help命令。help命令最好通过解析Makefile文件来输出集成的功能,例如:1
2
3
4
5
6## help: Show this help info.
help: Makefile
@echo -e "\nUsage: make <TARGETS> <OPTIONS> ...\n\nTargets:"
@sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /'
@echo "$$USAGE_OPTIONS"
上面的help命令,通过解析Makefile文件中的##
注释,获取支持的命令。通过这种方式,我们以后新加命令时,就不用再对help命令进行修改了。
设计合理的Makefile结构
建议采用分层的设计方法,根目录下的Makefile聚合所有的Makefile命令,具体实现则按功能分类,放在另外的Makefile中。
我们经常会在Makefile命令中集成shell脚本,但如果shell脚本过于复杂,也会导致Makefile内容过多,难以阅读和维护。并且在Makefile中集成复杂的shell脚本,编写体验也很差。对于这种情况,可以将复杂的shell命令封装在shell脚本中,供Makefile直接调用,而一些简单的命令则可以直接集成在Makefile中。
所以,最终我推荐的Makefile结构如下:
在上面的Makefile组织方式中,根目录下的Makefile聚合了项目所有的管理功能,这些管理功能通过Makefile伪目标的方式实现。同时,还将这些伪目标进行分类,把相同类别的伪目标放在同一个Makefile中,这样可以使得Makefile更容易维护。对于复杂的命令,则编写成独立的shell脚本,并在Makefile命令中调用这些shell脚本。
举个例子,下面是IAM项目的Makefile组织结构:1
2
3
4
5
6
7
8
9├── Makefile
├── scripts
│ ├── gendoc.sh
│ ├── make-rules
│ │ ├── gen.mk
│ │ ├── golang.mk
│ │ ├── image.mk
│ │ └── ...
└── ...
我们将相同类别的操作统一放在scripts/make-rules目录下的Makefile文件中。Makefile的文件名参考分类命名,例如golang.mk。最后,在/Makefile中include这些Makefile。
为了跟Makefile的层级相匹配,golang.mk中的所有目标都按go.xxx这种方式命名。通过这种命名方式,我们可以很容易分辨出某个目标完成什么功能,放在什么文件里,这在复杂的Makefile中尤其有用。以下是IAM项目根目录下,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
27
28
29include scripts/make-rules/golang.mk
include scripts/make-rules/image.mk
include scripts/make-rules/gen.mk
include scripts/make-rules/...
## build: Build source code for host platform.
build:
@$(MAKE) go.build
## build.multiarch: Build source code for multiple platforms. See option PLATFORMS.
build.multiarch:
@$(MAKE) go.build.multiarch
## image: Build docker images for host arch.
image:
@$(MAKE) image.build
## push: Build docker images for host arch and push images to registry.
push:
@$(MAKE) image.push
## ca: Generate CA files for all iam components.
ca:
@$(MAKE) gen.ca
另外,一个合理的Makefile结构应该具有前瞻性。也就是说,要在不改变现有结构的情况下,接纳后面的新功能。这就需要你整理好Makefile当前要实现的功能、即将要实现的功能和未来可能会实现的功能,然后基于这些功能,利用Makefile编程技巧,编写可扩展的Makefile。
这里需要你注意:上面的Makefile通过.PHONY标识定义了大量的伪目标,定义伪目标一定要加.PHONY标识,否则当有同名的文件时,伪目标可能不会被执行。
掌握Makefile编写技巧
技巧1:善用通配符和自动变量
Makefile允许对目标进行类似正则运算的匹配,主要用到的通配符是%。通过使用通配符,可以使不同的目标使用相同的规则,从而使Makefile扩展性更强,也更简洁。
我们的IAM实战项目中,就大量使用了通配符%,例如:go.build.%、ca.gen.%、deploy.run.%、tools.verify.%、tools.install.%等。
这里,我们来看一个具体的例子,tools.verify.%(位于scripts/make-rules/tools.mk文件中)定义如下:1
2tools.verify.%:
@if ! which $* &>/dev/null; then $(MAKE) tools.install.$*; fi
maketools.verify.swagger,maketools.verify.mockgen等均可以使用上面定义的规则,%分别代表了swagger和mockgen。
如果不使用%,则我们需要分别为tools.verify.swagger和tools.verify.mockgen定义规则,很麻烦,后面修改也困难。
另外,这里也能看出tools.verify.%这种命名方式的好处:tools说明依赖的定义位于scripts/make-rules/tools.mkMakefile中;verify说明tools.verify.%伪目标属于verify分类,主要用来验证工具是否安装。通过这种命名方式,你可以很容易地知道目标位于哪个Makefile文件中,以及想要完成的功能。
另外,上面的定义中还用到了自动变量$*
,用来指代被匹配的值swagger、mockgen。
技巧2:善用函数
Makefile自带的函数能够帮助我们实现很多强大的功能。所以,在我们编写Makefile的过程中,如果有功能需求,可以优先使用这些函数。我把常用的函数以及它们实现的功能整理在了Makefile常用函数列表中,你可以参考下。
IAM的Makefile文件中大量使用了上述函数,如果你想查看这些函数的具体使用方法和场景,可以参考IAM项目的Makefile文件make-rules。
技巧3:依赖需要用到的工具
如果Makefile某个目标的命令中用到了某个工具,可以将该工具放在目标的依赖中。这样,当执行该目标时,就可以指定检查系统是否安装该工具,如果没有安装则自动安装,从而实现更高程度的自动化。例如,/Makefile文件中,format伪目标,定义如下:1
2
3
4
5
6
format: tools.verify.golines tools.verify.goimports
@echo "===========> Formating codes"
@$(FIND) -type f -name '*.go' | $(XARGS) gofmt -s -w
@$(FIND) -type f -name '*.go' | $(XARGS) goimports -w -local $(ROOT_PACKAGE)
@$(FIND) -type f -name '*.go' | $(XARGS) golines -w --max-len=120 --reformat-tags --shorten-comments --ignore-generated .
你可以看到,format依赖tools.verify.golinestools.verify.goimports。我们再来看下tools.verify.golines的定义:1
再来看下tools.install.$*
规则:1
通过tools.verify.%规则定义,我们可以知道,tools.verify.%会先检查工具是否安装,如果没有安装,就会执行tools.install.$*
来安装。如此一来,当我们执行tools.verify.%目标时,如果系统没有安装golines命令,就会自动调用goget安装,提高了Makefile的自动化程度。
技巧4:把常用功能放在/Makefile中,不常用的放在分类Makefile中
一个项目,尤其是大型项目,有很多需要管理的地方,其中大部分都可以通过Makefile实现自动化操作。不过,为了保持/Makefile文件的整洁性,我们不能把所有的命令都添加在/Makefile文件中。
一个比较好的建议是,将常用功能放在/Makefile中,不常用的放在分类Makefile中,并在/Makefile中include这些分类Makefile。
例如,IAM项目的/Makefile集成了format、lint、test、build等常用命令,而将gen.errcode.code、gen.errcode.doc这类不常用的功能放在scripts/make-rules/gen.mk文件中。当然,我们也可以直接执行makegen.errcode.code来执行gen.errcode.code伪目标。通过这种方式,既可以保证/Makefile的简洁、易维护,又可以通过make命令来运行伪目标,更加灵活。
技巧5:编写可扩展的Makefile
什么叫可扩展的Makefile呢?在我看来,可扩展的Makefile包含两层含义:
1,可以在不改变Makefile结构的情况下添加新功能。
2,扩展项目时,新功能可以自动纳入到Makefile现有逻辑中。
其中的第一点,我们可以通过设计合理的Makefile结构来实现。要实现第二点,就需要我们在编写Makefile时采用一定的技巧,例如多用通配符、自动变量、函数等。这里我们来看一个例子,可以让你更好地理解。
在我们IAM实战项目的golang.mk中,执行makego.build时能够构建cmd/目录下的所有组件,也就是说,当有新组件添加时,makego.build仍然能够构建新增的组件,这就实现了上面说的第二点。
具体实现方法如下:1
当执行makego.build时,会执行go.build的依赖$(addprefixgo.build.,$(addprefix$(PLATFORM).,$(BINS)))
,addprefix函数最终返回字符串go.build.linux_amd64.iamctl``go.build.linux_amd64.iam-authz-server``go.build.linux_amd64.iam-apiserver...
,这时候就会执行go.build.%
伪目标。
在go.build.%
伪目标中,通过eval、word、subst函数组合,算出了COMMAND的值iamctl/iam-apiserver/iam-authz-server/…,最终通过$(ROOT_PACKAGE)/cmd/$(COMMAND)
定位到需要构建的组件的main函数所在目录。
上述实现中有两个技巧,你可以注意下。首先,通过1
获取到了cmd/目录下的所有组件名。
接着,通过使用通配符和自动变量,自动匹配到go.build.linux_amd64.iam-authz-server这类伪目标并构建。
可以看到,想要编写一个可扩展的Makefile,熟练掌握Makefile的用法是基础,更多的是需要我们动脑思考如何去编写Makefile。
技巧6:将所有输出存放在一个目录下,方便清理和查找
在执行Makefile的过程中,会输出各种各样的文件,例如Go编译后的二进制文件、测试覆盖率数据等,我建议你把这些文件统一放在一个目录下,方便后期的清理和查找。通常我们可以把它们放在_output这类目录下,这样清理时就很方便,只需要清理_output文件夹就可以,例如:1
这里要注意,要用-rm,而不是rm,防止在没有_output目录时,执行makego.clean报错。
技巧7:使用带层级的命名方式
通过使用带层级的命名方式,例如tools.verify.swagger,我们可以实现目标分组管理。这样做的好处有很多。首先,当Makefile有大量目标时,通过分组,我们可以更好地管理这些目标。其次,分组也能方便理解,可以通过组名一眼识别出该目标的功能类别。最后,这样做还可以大大减小目标重名的概率。
例如,IAM项目的Makefile就大量采用了下面这种命名方式。1
技巧8:做好目标拆分
还有一个比较实用的技巧:我们要合理地拆分目标。比如,我们可以将安装工具拆分成两个目标:验证工具是否已安装和安装工具。通过这种方式,可以给我们的Makefile带来更大的灵活性。例如:我们可以根据需要选择性地执行其中一个操作,也可以两个操作一起执行。
这里来看一个例子:1
上面的Makefile中,gen.errcode.code依赖了tools.verify.codegen,tools.verify.codegen会先检查codegen命令是否存在,如果不存在,再调用install.codegen来安装codegen工具。
如果我们的Makefile设计是:1
那每次执行gen.errcode.code都要重新安装codegen命令,这种操作是不必要的,还会导致makegen.errcode.code执行很慢。
技巧9:设置OPTIONS
编写Makefile时,我们还需要把一些可变的功能通过OPTIONS来控制。为了帮助你理解,这里还是拿IAM项目的Makefile来举例。
假设我们需要通过一个选项V,来控制是否需要在执行Makefile时打印详细的信息。这可以通过下面的步骤来实现。
首先,在/Makefile中定义USAGE_OPTIONS。定义USAGE_OPTIONS可以使开发者在执行makehelp后感知到此OPTION,并根据需要进行设置。1
接着,在scripts/make-rules/common.mk文件中,我们通过判断有没有设置V选项,来选择不同的行为:1
当然,我们还可以通过下面的方法来使用V:1
上面,我介绍了VOPTION,我们在Makefile中通过判断有没有定义V,来执行不同的操作。其实还有一种OPTION,这种OPTION的值我们在Makefile中是直接使用的,例如BINS。针对这种OPTION,我们可以通过以下方式来使用:1
也就是说,通过?=来判断BINS变量有没有被赋值,如果没有,则赋予等号后的值。接下来,就可以在Makefile规则中使用它。
技巧10:定义环境变量
我们可以在Makefile中定义一些环境变量,例如:1
这些环境变量和编程中使用宏定义的作用是一样的:只要修改一处,就可以使很多地方同时生效,避免了重复的工作。
通常,我们可以将GO、GO_BUILD_FLAGS、FIND这类变量定义为环境变量。
技巧11:自己调用自己
在编写Makefile的过程中,你可能会遇到这样一种情况:A-Target目标命令中,需要完成操作B-Action,而操作B-Action我们已经通过伪目标B-Target实现过。为了达到最大的代码复用度,这时候最好的方式是在A-Target的命令中执行B-Target。方法如下:1
这里,我们通过$(MAKE)
调用了伪目标tools.install.$*
。要注意的是,默认情况下,Makefile在切换目录时会输出以下信息:1
如果觉得Enteringdirectory这类信息很烦人,可以通过设置MAKEFLAGS+=—no-print-directory来禁止Makefile打印这些信息。
总结
首先,你需要熟练掌握Makefile的语法。我建议你重点掌握以下语法:Makefile规则语法、伪目标、变量赋值、特殊变量、自动化变量。
接着,我们需要提前规划Makefile要实现的功能。一个大型Go项目通常需要实现以下功能:代码生成类命令、格式化类命令、静态代码检查、测试类命令、构建类命令、Docker镜像打包类命令、部署类命令、清理类命令,等等。
然后,我们还需要通过Makefile功能分类、文件分层、复杂命令脚本化等方式,来设计一个合理的Makefile结构。
最后,我们还需要掌握一些Makefile编写技巧,例如:善用通配符、自动变量和函数;编写可扩展的Makefile;使用带层级的命名方式,等等。通过这些技巧,可以进一步保证我们编写出一个高质量的Makefile。
课后练习
1,走读IAM项目的Makefile实现,看下IAM项目是如何通过maketools.install一键安装所有功能,通过maketools.install.xxx来指定安装xxx工具的。
2,你编写Makefile的时候,还用到过哪些编写技巧呢?