likes
comments
collection
share

使用 Makefile 构建你的 Go 项目

作者站长头像
站长
· 阅读数 19

使用 Makefile 构建你的 Go 项目

可以遗憾,但不要后悔。 

我们留在这里,从来不是身不由己。 

——— 而是选择在这里经历生活

目录

Makefile 是一个强大且灵活的构建工具,具备自动化构建、处理依赖关系、任务管理和跨平台支持等优点。通过编写和使用 Makefile,开发者可以简化项目的构建过程,提高开发效率,并实现自动化的构建和发布流程。

在许多开源项目和工具中,Makefile 被广泛选择作为构建工具。它的灵活性主要体现在其具有 target(目标)的概念,相比于仅使用 Shell 脚本,Makefile 可以更好地组织和管理构建过程。

此外,Makefile 还能够与其他工具和语言进行集成,例如与 C/C++ 编译器、Go 工具链等配合使用。通过定义适当的规则和命令,可以实现与其他构建工具的无缝集成,进一步提高构建过程的灵活性和效率。

本文旨在帮助读者了解如何使用 Makefile 工具来构建你的 Go 项目。

  1. 基本介绍
  2. Makefile 的优势
  3. Makefile 的发展历史
  4. Makefile 与 Shell 对比
  5. Makefile 的数据类型
  6. 正式开始
  7. 举个例子

基本介绍

Makefile 是由 GNU Make 工具解析执行的配置文件。要调用 Makefile,需要在命令行中使用 make 命令,并指定要执行的目标或规则。下面是 Makefile 的基本语法和调用方式的介绍。

创建 Makefile 文件

在项目目录下创建名为 Makefile 的文件,或者使用其他自定义的文件名(例如 makefileGNUmakefile)。

定义规则和目标

Makefile 由一系列规则组成,每个规则由一个目标(target)、依赖项(prerequisites)和命令(commands)组成。目标是需要生成的文件或执行的操作,依赖项是生成目标所需要的文件或其他目标,命令是执行生成目标的操作。

语法格式:

target: prerequisites
    commands

示例:

hello: main.o utils.o
    gcc main.o utils.o -o hello

main.o: main.c
    gcc -c main.c

utils.o: utils.c
    gcc -c utils.c

💡 在 Makefile 中,目标的命名采用 "蛇型"snake_case)更为常见和推荐。这是因为在 LinuxUnix 系统中,文件名通常使用小写字母和下划线,而不是 "驼峰命名法""中横线命名法"

调用 Makefile

在命令行中使用 make 命令调用 Makefile,并指定要执行的目标。如果未指定目标,默认会执行 Makefile 中的第一个目标。

语法格式:

    make [target]

示例:

    make hello

上述命令会执行 Makefilehello 目标下定义的命令,编译源代码并生成可执行文件 hello

其他常用选项

-f <filename>:指定要使用的 Makefile 文件名,例如 make -f mymakefile

-C <directory>:指定 Makefile 的工作目录,例如 make -C src

Makefile 的优势

Makefile 是一种方便的自动化构建工具,具有以下优点:

  1. 自动化构建:通过定义好的规则和目标,Makefile 可以自动执行代码生成、格式校验和编译打包等任务,从而减少了手动操作的工作量。使用简单的命令 make 可以触发整个构建过程,并自动处理依赖关系,只构建必要的部分,提高了构建效率和开发者的工作效率。
  2. 跨平台支持:Makefile 是一种通用的构建工具,可以在不同的操作系统上运行,如 LinuxmacOSWindows 等。这为项目提供了更大的灵活性和可移植性,使得开发者可以在不同平台上进行构建和部署,无需担心平台差异导致的构建问题。
  3. 规范性和可读性:Makefile 使用结构化的语法和规则来定义构建过程,使得项目的构建逻辑更加清晰和易于理解。它提供了变量、条件语句、循环和函数等功能,使得构建脚本具有良好的可读性和可维护性。开发者可以通过编写规范的 Makefile,提高代码的可维护性和团队协作效率。
  4. 符合社区习惯:在开源社区中,Makefile 是一种常见的构建工具,被广泛应用于各种项目。许多开源软件开发者习惯使用 Makefile 来管理构建和发布流程,这使得开发者能够更轻松地参与和贡献到这些项目中。选择使用 Makefile 可以使项目与社区保持一致,更易于理解和接受。

综上所述,Makefile 是一种强大且灵活的构建工具,它具备自动化构建、跨平台支持、规范性和可读性以及符合社区习惯等优点。使用 Makefile 可以简化项目的构建过程,提高开发效率,并与开源社区保持一致,使得项目更易于管理和维护。

Makefile 的发展历史

Makefile 的发展历史可以追溯到上世纪70年代。下面是 Makefile 的主要里程碑和发展阶段:

  1. 早期阶段(1970s-1980s):Makefile 最早出现在贝尔实验室的 Unix 系统中,并作为构建工具用于编译和链接软件。早期的 Makefile 是基于 Make 工具的语法,用于描述源代码文件之间的依赖关系,通过规则定义了编译和链接的步骤。
  2. GNU Make 的出现(1980s-1990s):GNU MakeGNU 项目开发的一款强大的构建工具,取代了早期的 Make 工具。GNU Make 引入了更多功能和特性,如变量、条件判断、循环等,使得 Makefile 更加灵活和可配置。
  3. 跨平台的使用(2000s-至今):随着开源软件的普及和多平台开发的需求,Makefile 的使用逐渐扩展到不同的操作系统和编程语言中。Makefile 成为跨平台构建工具的标准之一,广泛应用于各种开源项目。
  4. 扩展功能和工具链整合:随着软件开发流程的不断演进,Makefile 逐渐引入了更多的功能和工具链的整合。例如,通过 Makefile 可以进行代码生成、运行测试、打包发布、部署等更复杂的构建任务,并与其他工具(如编译器、测试框架、持续集成工具等)进行集成。

总体而言,Makefile 的发展历史可以看作是不断演化和改进的过程,以适应不断变化的软件开发需求。它成为了构建软件的标准工具之一,并在跨平台开发和开源社区中得到广泛应用。

Makefile 与 Shell 对比

Makefile 的语法与 Shell 脚本有相似之处,但它们是不同的语言。MakefileGNU Make 工具的配置文件,用于定义和管理项目的构建规则。它使用一组特定的语法规则、命令和 Make 工具提供的内置函数和变量。

Makefile 中,命令通常以 Tab 键开头,并在每行的结尾添加分号 (;) 或换行符。这些命令由 Make 工具执行,用于构建项目或执行特定任务。与之不同,Shell 脚本是一种编程语言,用于编写命令行脚本。它使用 Shell 解释器执行,用于执行系统命令、操作文件和控制流程等。

尽管 Makefile 的语法与 Shell 脚本相似,但它们具有不同的用途和特定的语法规则。Makefile 用于构建项目和管理依赖关系,而 Shell 脚本用于编写系统级任务和自动化操作。因此,在选择工具和语言时,请注意它们之间的区别,并根据任务的需求选择适合的工具。

虽然 Shell 脚本和其他构建工具(如 PythonsetuptoolsCMake 等)也可以用于构建开源软件,但 Makefile 提供了一种简单、通用且被广泛接受的构建方式,因此在开源软件中被广泛采用。

当然,选择使用何种构建工具仍应根据具体项目的需求和开发团队的偏好来决定。

Makefile 的数据类型

Makefile 中,数据类型并不像常见编程语言那样严格定义。Makefile 中的变量可以存储字符串,并且可以进行字符串操作和替换。下面是一些常见的 Makefile 中的数据类型和特性:

  1. 字符串(String):变量可以存储字符串,如 VAR := hello
  2. 列表(List):通过使用空格分隔的值来定义一个列表,如 LIST := item1 item2 item3。列表可以用于遍历、迭代和批量操作。
  3. 函数(Function):Makefile 提供了一些内置函数,可以在变量中进行字符串操作、替换和转换等操作。例如,$(subst from,to,text) 函数可以将字符串中的某个部分替换为另一个部分。
  4. 条件语句:Makefile 支持条件语句,如 if-else 条件判断。可以根据变量的值或其他条件来执行不同的操作。
  5. 数值型数据:尽管 Makefile 不支持直接定义数值型变量,但可以使用字符串来表示数值,并在需要时进行转换。

需要注意的是,Makefile 是一种构建工具的描述语言,其主要目的是定义和执行构建规则,而不是处理复杂的数据结构和算法。因此,Makefile 的数据类型相对较简单,主要集中在字符串和列表操作上。

正式开始

字符串输出

# 请确保在每一行命令前面使用实际的 Tab 键而不是空格。这是 Makefile 的语法要求。
# 如果在创建 Makefile 时使用了空格而不是 Tab 键,将会导致语法错误。
hello:
    @echo "Hello, World!"

使用变量

Makefile 中,变量的定义需要使用 := 进行赋值操作。在使用变量时,使用 $(VAR_NAME) 的语法来引用变量。

# 定义变量
GREETING := "Hello, World!"

# 输出变量
variable:
    @echo "$(GREETING)"

Makefile 中,?= 是一个预定义的变量赋值方式,被称为 “延迟求值”(Lazy Evaluation)。

具体来说,这个符号用于设置一个变量的默认值,只有当该变量没有被显式设置时才会使用默认值。如果变量已经被设置了,那么 ?= 将不会起作用,而是保留原来的值。

# 设置编译器
GO ?= go

访问数组

# 定义一个包含多个值的变量
FRUITS := apple orange banana

# 访问列表中的元素
first := $(firstword $(FRUITS))
second := $(word 2, $(FRUITS))
third := $(word 3, $(FRUITS))
last := $(lastword $(FRUITS))

# 输出列表中的元素
array:
    @echo "First: $(first)"
    @echo "Second: $(second)"
    @echo "Third: $(third)"
    @echo "Last: $(last)"

遍历数组

# 定义一个包含多个值的变量
FRUITS := apple orange banana

# 打印数组的每个元素
print:
    @for fruit in $(FRUITS); do \
        echo "$$fruit"; \
    done

遍历+条件

# 定义一个包含多个值的变量
FRUITS := apple orange banana

# 打印数组的每个元素
filter:
    @for fruit in $(FRUITS); do \
        if [ "$$fruit" = "orange" ]; then \
            echo "$$fruit is my favorite fruit!"; \
        elif [ "$$fruit" = "apple" ]; then \
            echo "$$fruit is my secondary fruit of choice!"; \
        else \
            echo "The fruit I hate the most - $$fruit!"; \
        fi \
    done

判断是否等于

# 定义变量
FRUIT := apple

# 注意:ifeq 是定义在 Makefile 文件的顶层范围,而不是定义在目标规则中,也就是说,写在 fruit 内是不被允许的
ifeq ($(FRUIT), apple)
    favorite := "It's an apple!"
else ifeq ($(FRUIT), orange)
    favorite := "It's an orange!"
else ifeq ($(FRUIT), banana)
    favorite := "It's a banana!"
else
    favorite := "Unknown fruit!"
endif

# 判断变量的值
fruit:
    @echo $(favorite)

判断是否定义

# 检查变量是否已定义
DEBUG :=

ifdef DEBUG
    MESSAGE := "Debug is defined"
else
    MESSAGE := "Debug is undefined"
endif

# 打印消息
print_message:
    @echo $(MESSAGE)

嵌入 Python

hello_world := Hello World

python:
    $(eval WORDS := $(shell python3 -c 'import sys; print(sys.argv[1].split())' "$(hello_world)"))
    @echo $(WORDS)" !"

伪目标

Makefile 中,.PHONY 是一个特殊的目标(Target),用于声明指定的目标是“伪目标”(Phony Target)。它不表示一个物理文件或路径,而仅仅是一个逻辑目标。因此,当执行这个目标时,Makefile 不会检查是否存在对应的文件,而直接执行该目标下定义的命令。

通常情况下,使用 .PHONY 是为了避免与同名文件产生冲突,或者为了在构建时强制重新执行某些操作。例如,在以下示例中:

.PHONY: clean
clean:
    rm -rf *.o *.out

.PHONY: clean 表示 clean 是一个伪目标,不需要检查是否存在 clean 文件。如果没有这个声明,执行 make clean 命令时,可能会出现如下错误提示:

make: 'clean' is up to date.

因为 Makefile 会认为 clean 已经被构建过了,所以不再执行 rm -rf *.o *.out 命令。

伪目标写在哪?

.PHONY 目标通常放在 Makefile 的顶部或底部,这样做有以下几个好处:

  1. 易于查找和识别:放在顶部或底部可以方便地找到所有伪目标。
  2. 代码规范和可读性:按照惯例,Makefile 的第一行应该是文件注释(File Comment),用于提供文件的概述、作者、版本等信息。因此,将 .PHONY 放在 Makefile 的第二行或之后可以使文件更符合代码规范和可读性要求。
  3. 避免误解和错误:如果将 .PHONY 放在中间某处,可能会导致 Makefile 中的其他目标被误认为是实际存在的文件,从而引发构建错误或其他问题。

总之,.PHONY 目标放在 Makefile 的顶部或底部都是可以的,但是建议放在顶部,以便更方便地查找和阅读。

依赖构建

$(BUILD_DIR) 是一个 Makefile 变量,在这里表示构建目录的路径,例如 ./build/

mkdir -p $@ 是一个 Shell 命令,用于创建指定的目录。其中,-p 参数表示递归创建子目录,如果目录已经存在则不会报错也不会覆盖原有文件。

$@ 是一个自动化变量,表示当前目标的名称,这里是 $(BUILD_DIR)。当这个目标被执行时,Makefile 会将其解析为 Shell 命令 mkdir -p ./build/,从而创建指定的构建目录。

这种写法常用于定义 Makefile 的目标(Target),它可以确保所需的目录在执行后存在,并且不需要手动创建。例如:

.PHONY: build

BUILD_DIR := ./build/
OUTPUT_DIR := ./output/

build: $(BUILD_DIR)
    go build -o $(OUTPUT_DIR)/bin/app main.go

$(BUILD_DIR):
    mkdir -p $@

在这个示例中,build 目标依赖于 $(BUILD_DIR) 构建目录,也就是说,在执行 build 目标之前,必须先创建 $(BUILD_DIR) 目录。如果 $(BUILD_DIR) 已经存在,则直接跳过该步骤。

通过这种方式,我们可以在 Makefile 中定义多个目标,并通过依赖关系和自动化变量来管理它们之间的关系和依赖,从而构建一个完整的构建流程。

举个例子

脚本示例

忽略某些目录(或文件)后遍历项目目录

方式1

# 设置要排除的目录列表
EXCLUDE_DIRS := \
    ./vendor \
    ./.git \
    ./.idea \
    ./examples \
    ./test

# 添加匹配的子目录到排除的目录列表中
EXCLUDE_DIRS += $(foreach dir,$(EXCLUDE_DIRS),$(dir)/*)

# 查找所有非排除目录的目录
SRC_DIRS := $(shell find . -type d $(foreach dir,$(EXCLUDE_DIRS),! -path "$(dir)"))

# 在这里添加要执行的命令
print_dirs:
    @for dir in $(SRC_DIRS); do \
        echo "Processing directory: $$dir"; \
        (cd $$dir && pwd); \
    done

方式2

# 设置要排除的目录列表
EXCLUDE_DIRS := \
    ./vendor \
    ./.git \
    ./.idea \
    ./examples \
    ./test

# 添加匹配的子目录到排除的目录列表中
EXCLUDE_DIRS += $(foreach dir,$(EXCLUDE_DIRS),$(dir)/*)

# 查找所有非排除目录的目录(定义函数)
define find_src_dirs
    $(shell find . -type d $(foreach dir,$(EXCLUDE_DIRS),! -path "$(dir)"))
endef

# 打印非排除目录的目录
print_dirs:
    @$(foreach dir,$(call find_src_dirs), \
        (cd $(shell pwd)/$(dir) && pwd); \
    )

方式3

# 显示目录的序号
print_dirs:
    @count=0; \
    @for dir in $(SRC_DIRS); do \
        count=$$((count + 1)); \
        echo "· $$(printf "%02d" $$count) - Checking: $$dir"; \
    done

实际场景

项目目录结构

➜ tree
.
├── Makefile
└── scripts
    └── start.sh

start.sh 文件

#!/usr/bin/env bash

# 可在任意目录位置进行 sh 执行
curdir=`dirname $(readlink -f $0)`
basedir=`dirname $curdir`"/"

# 执行 make generate 命令时,使用 --no-builtin-rules 参数来禁用内置规则,这有时可以解决一些奇怪的行为。
make --directory ${basedir} --no-builtin-rules generate

#EOF

Makefile 文件

一个基础的示例:

# TBD...

# 设置变量
GOCMD := go
GOBUILD := $(GOCMD) build
GOCLEAN := $(GOCMD) clean
GOTEST := $(GOCMD) test
GODEPS := $(GOCMD) mod download
GOGENERATE := $(GOCMD) generate
GOLINTER := golangci-lint run
BINARY_NAME := yourprojectname
MAIN_FILE := main.go

# 设置要排除的目录列表(根据实际情况更改)
EXCLUDE_DIRS := ./vendor ./.git ./.idea ./examples ./test

# 查找所有非排除目录的目录
SRC_DIRS := $(shell find . -type d $(foreach dir,$(EXCLUDE_DIRS),-not -path "$(dir)*"))

# 构建目标:生成代码
generate:
    @for dir in $(SRC_DIRS); do \
        echo "Generating code in directory: $$dir"; \
        (cd $$dir && $(GOGENERATE) -v); \
    done

# 构建目标:代码格式检测
lint:
    $(GOLINTER) ./...

# 构建目标:运行测试
test:
    $(GOTEST) ./...

# 构建目标:编译代码
build:
    $(GOBUILD) -o $(BINARY_NAME) $(MAIN_FILE)

# 构建目标:清理项目
clean:
    $(GOCLEAN)
    rm -f $(BINARY_NAME)

# 构建目标:安装依赖
deps:
    $(GODEPS)

# 构建目标:执行所有构建步骤
all: generate lint test build

# 声明所有目标,确保它们被视为伪目标而不是实际的文件名
.PHONY: generate lint test build clean deps all

上述代码是一个示例的 Makefile 文件,用于构建一个 Go 项目。下面对其中的构建目标进行说明:

  • 生成代码(generate):通过遍历 SRC_DIRS 中的目录,执行 go generate 命令来生成代码。在每个目录中执行生成代码命令之前,会先输出要生成代码的目录信息。
  • 代码格式检测(lint):执行 golangci-lint 工具对代码进行格式检测。在这个示例中,使用 $(GOLINTER) 表示执行 golangci-lint run 命令。
  • 运行测试(test):执行 go test 命令来运行项目中的测试。
  • 编译代码(build):使用 go build 命令编译项目的代码,并指定输出的可执行文件名称为 $(BINARY_NAME)
  • 清理项目(clean):执行 go clean 命令来清理项目,并删除生成的可执行文件。
  • 安装依赖(deps):执行 go mod download 命令来下载项目的依赖。
  • 执行所有构建步骤(all):通过依次执行 generatelinttestbuild 目标来执行所有的构建步骤。
  • 声明所有目标为伪目标:通过 .PHONY 指令声明所有的目标,确保它们被视为伪目标而不是实际的文件名。

这个示例的 Makefile 文件提供了一些常见的构建目标,使得可以通过简单的命令来执行不同的构建操作,如生成代码、代码格式检测、运行测试、编译代码、清理项目等。根据实际需要,可以根据这个示例进行修改和扩展。