我们一直在用Go语言编写的HackerRank项目中的一个项目使用make作为构建工具,并且效果良好。在这篇文章中,我将指出我们使用的GNU Make的一些功能和复杂性,这些功能和复杂性最终提高了我们团队成员的整体生产力。

前言

make 是一个简单的工具,它可以检测大型项目的哪个部分需要重新编译和执行用户定义的命令编译或是其他需要的操作。它也广泛用作构建工具, 您可以在其中指定要运行的一组命令,这些命令本来是用来在命令行上编写的,通常是重复多次执行。下面是本文其余部分的主要内容。

为了这篇文章的目的,我们假设我们正在从事GO项目stringifier , 而且将会编写一个Makefile, 也称为Makefile。

Build and Run

Go程序的这两个指令使用的相当频繁, 所以添加这些目录至我们的Makefile:

build: 
    go build -o stringifier main.go

run:
    go run -race main.go

我在运行命令中添加了-race标志,方便它在运行时在Go代码中检测到race情况。

Cleaning and DRYing

构建二进制文件并运行应用程序后,一切正常, 确保我们在执行其他任何操作之前先清理二进制文件。我们更新Makefile应该看起来像这样:

build:
	go build -o stringifier main.go

run:
	go run -race main.go

clean:
	go clean

我们有两点可以改进,

  1. 我们明确地重用了我们的应用程序名, 很自然我们的应用程序名称将在整个Makefile中的许多地方使用。
  2. 每次构建应用之前,我们需要先执行clean的规则。

改进后的Makefile

APP=stringifier

build: clean
	go build -o ${APP} main.go

run:
	go run -race main.go

clean:
	go clean

更新: 这个例子之前使用的rm -r ${APP}, 但是感谢讲者的建议,现在使用go clean

在顶部定义Makefile变量,当您调用make命令时make将自动引用它们,这样Makefile看起来就更整洁、规范了。

PHONY targets

默认情况下,如果一个前置条件或是目录文件已更改,make将执行规则。但是由于我们不依赖于make来检测文件更改的能力,因此我们会遇到潜在的麻烦。

假设我们的项目目录中有一个名为build的文件, 在这个场景下,当你执行make build, make一定会检查文件build的更改,由于没有前置条件,因此将始终将build文件视为最新的,并且不会执行其规则定义的操作。

为了避免这个问题,你需要先知道.PHONY 特殊目录(target)是什么意思:特殊目标.PHONY的先决条件被视为phony目标(targets)。 当需要运行时,make会无条件运行其规则,而不管该名称的文件是否存在或其最后修改时间是多少。

所以,你可以通过将目标(target)指定为特殊目标.PHONY的先决条件,将目标指定为.PHONY

APP=stringifier


.PHONY: build
build: clean
	go build -o ${APP} main.go

.PHONY: run
run:
	go run -race main.go

.PHONY: clean
clean:
	go clean

现在你已将上述所有的targets指定为phony, 每次你调用任何phony目标(target) 时,make将会执行相应的规则。你还可以一次将所有要指定为phony的目标指定为:

.PHONY: build clean run

但是对于非常大的Makefile,不建议这样做因为这可能导致歧义和无法读取。因此,首选方法是在规则定义之前显式设置phony目标(target)。

Recursive Make targets

现在让我们假设我们在项目中使用的根目录中还有另一个模块tokenizer。现在我们的目录结构是这样的:

~/programming/stringifier
.
├── main.go
├── Makefile
└── tokenizer/
      ├── main.go
      └── Makefile

很自然,某些时候我们也想buildtest我们的tokenizer模块。由于它是一个独立的模块也可能是一个独立的项目,在它的目录有如下内容的一个Makefile是很有必要的:

# ~/programming/stringifier/tokenizer/Makefile

APP=tokenizer

build:
	go build -o ${APP} main.go

现在只要您在stringifier项目的根目录中并且想要构建tokenizer应用程序,你不会想使用诸如cd tokenizer && make build && cd - 这样的易受攻击的命令行技巧,而具体的Makefiles的规则写在子目录中的方式。幸运的是,make可以帮助你解决这个问题。你可以使用-C标志和特殊的${NAME}变量在其他目录中调用make targets。下面是stringifies项目最初的Makefile:

# ~/programming/stringifier/Makefile

APP=stringifier


.PHONY: build
build: clean
	go build -o ${APP} main.go

.PHONY: run
run:
	go run -race main.go

.PHONY: clean
clean:
	go clean

.PHONY: build-tokenizer
build-tokenizer:
	${MAKE} -C tokenizer build

现在只要你运行make build-tokenizermake都将为您处理目录切换,并以更加可读和健壮的方式为您调用正确目录中的正确目标

Targets for Docker commands

现在您希望对应用程序进行容器化,然后为方便起见编写make目标,这是完全可以理解的。

你为docker命令定义了如下规则:

.PHONY: docker-build
docker-build: build
	docker build -t stringifier .
	docker tag stringifier stringifier:tag

.PHONY: docker-push
docker-push: docker-build
	docker push gcr.io/stringifier/stringifier-staging/stringifier:tag

docker命令基本满足需要,但是还有改善的空间,

  • 对于新手,你可以再次重用你的${APP}变量。
  • 接下来,您想要更灵活并确保可以轻松控制将映像推送到哪里,无论是您的私人镜像仓库还是其他地方。
  • 然后,您希望能够根据用户在命令行上的某些输入将镜像(image)分别推送到与预生产和生产环境有关的两个单独的镜像仓库中。
  • 最后,像一个理智的开发人员一样,您想使用当前的git commit sha标记您的镜像(image)。 让我们基于这些问题重新修改下Makefile
APP?=application
REGISTRY?=gcr.io/images
COMMIT_SHA=$(shell git rev-parse --short HEAD)

.PHONY: docker-build
docker-build: build
	docker build -t ${APP} .
	docker tag ${APP} ${APP}:${COMMIT_SHA}

.PHONY: docker-push
docker-push: check-environment docker-build
	docker push ${REGISTRY}/${ENV}/${APP}:${COMMIT_SHA}

check-environment:
ifndef ENV
    $(error ENV not set, allowed values - `staging` or `production`)
endif

现在,让我们回顾下上面的更改:

  • 你开始为应用程序名称,镜像名称,提交sha使用变量。
  • 您使用特殊的shell函数生成了commit sha。 在这种情况下,您运行了git命令,该命令返回了简短的提交sha,并将其分配给变量${COMMIT_SHA},以便稍后在Makefile中使用。
  • 您添加了一个新的规则check-environment,该环境使用make条件检查在调用make时是否指定了ENV变量,这有助于区分预生产及生产环境。

check-environment的规则如下:

check-environment:
ifndef ENV
    $(error ENV not set, allowed values - `staging` or `production`)
endif

使用ifndef指令检查变量ENV是否为空值,如果存在,则使用另一个make的提供内置函数,如果出错了,将会在关键字之后抛出具体的错误消息。

$ make docker-push
Makefile:33: *** ENV not set, allowed values - `staging` or `production`.  Stop.

$ ENV=staging make docker-push
Success

本质上,您要确保docker-push目标具有安全保障,该保障可检查调用目标的用户是否已为ENV变量指定值。

Help target

一个新成员加入了该项目并想知道Makefile中所有规则的作用,为帮助它们您可以添加一个新目标(target),该目标将打印所有目标名称以及它们作用的简短描述:

.PHONY: build
## build: build the application
build: clean
    @echo "Building..."
    @go build -o ${APP} main.go

.PHONY: run
## run: runs go run main.go
run:
	go run -race main.go

.PHONY: clean
## clean: cleans the binary
clean:
    @echo "Cleaning"
    @go clean

.PHONY: setup
## setup: setup go modules
setup:
	@go mod init \
		&& go mod tidy \
		&& go mod vendor
	
.PHONY: help
## help: prints this help message
help:
	@echo "Usage: \n"
	@sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' |  sed -e 's/^/ /'

你先注意下最后一条规则,help 在这里,您只是使用一些sed魔术来解析和在命令行上打印。 但是要做到这一点,您必要在每条规则之前写了目标名称和简短描述作为注释。 注意另一个特殊变量$ {MAKEFILE_LIST},它是您所引用的所有Makefile的列表,在本例中仅是Makefile。

您会将文件Makefile作为输入传递给sed命令,该命令将解析所有帮助注释并以表格格式将其打印到stdout,以便于阅读。 上一个代码段的help目标的输出如下所示:

$ make help
Usage:
	build             Build the application
	clean             cleans the binary
	run               runs go run main.go
	docker-build      builds docker image
	docker-push       pushes the docker image
	setup             set up modules
	help              prints this help message

这些消息很有帮助,对于其他人甚至有时对自己都是一个不错的提示。

Conclusion 结论

Make是一个简单但可高度配置的工具。 在本文中,您遍历了make提供的许多配置和功能,从而为Go应用程序编写了有效而高效的Makefile。

下面是完整的Makefile,其中添加了一些琐碎的规则和变量:

GO111MODULES=on
APP?=stringifier
REGISTRY?=gcr.io/images
COMMIT_SHA=$(shell git rev-parse --short HEAD)



.PHONY: build
## build: build the application
build: clean
    @echo "Building..."
    @go build -o ${APP} main.go

.PHONY: run
## run: runs go run main.go
run:
	go run -race main.go

.PHONY: clean
## clean: cleans the binary
clean:
    @echo "Cleaning"
    @go clean

.PHONY: test
## test: runs go test with default values
test:
	go test -v -count=1 -race ./...


.PHONY: build-tokenizer
## build-tokenizer: build the tokenizer application
build-tokenizer:
	${MAKE} -c tokenizer build

.PHONY: setup
## setup: setup go modules
setup:
	@go mod init \
		&& go mod tidy \
		&& go mod vendor
	
# helper rule for deployment
check-environment:
ifndef ENV
    $(error ENV not set, allowed values - `staging` or `production`)
endif

.PHONY: docker-build
## docker-build: builds the stringifier docker image to registry
docker-build: build
	docker build -t ${APP}:${COMMIT_SHA} .

.PHONY: docker-push
## docker-push: pushes the stringifier docker image to registry
docker-push: check-environment docker-build
	docker push ${REGISTRY}/${ENV}/${APP}:${COMMIT_SHA}

.PHONY: help
## help: Prints this help message
help:
	@echo "Usage: \n"
	@sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' |  sed -e 's/^/ /'

Refer to:

  1. https://danishpraka.sh/2019/12/07/using-makefiles-for-go.html
  2. https://www.gnu.org/software/make/manual/html_node/Special-Targets.html#Special-Targets