使用 Makefile 構建你的 Go 項目

作者:掘金——Pokeya

https://juejin.cn/post/7241395886856028197

Makefile 是一個強大且靈活的構建工具,具備自動化構建、處理依賴關係、任務管理和跨平臺支持等優點。通過編寫和使用 Makefile,開發者可以簡化項目的構建過程,提高開發效率,並實現自動化的構建和發佈流程。

在許多開源項目和工具中,Makefile 被廣泛選擇作爲構建工具。它的靈活性主要體現在其具有 target(目標)的概念,相比於僅使用 Shell 腳本,Makefile 可以更好地組織和管理構建過程。

此外,Makefile 還能夠與其他工具和語言進行集成,例如與 C/C++ 編譯器、Go 工具鏈等配合使用。通過定義適當的規則和命令,可以實現與其他構建工具的無縫集成,進一步提高構建過程的靈活性和效率。

本文旨在幫助讀者瞭解如何使用 Makefile 工具來構建你的 Go 項目。

基本介紹

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 命令時,可能會出現如下錯誤提示:

Makefile
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 項目。下面對其中的構建目標進行說明:

這個示例的 Makefile 文件提供了一些常見的構建目標,使得可以通過簡單的命令來執行不同的構建操作,如生成代碼、代碼格式檢測、運行測試、編譯代碼、清理項目等。根據實際需要,可以根據這個示例進行修改和擴展。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Z13uk3EgOmfEkNRJJM3TPQ