寫 Web 也可以用 Makefile:好好管理你的環境流程

說到跟別人一起程式專案的協作,我們要解決三個部分的共享:程式碼、環境、流程。程式碼我們大多使用 Git 來整合來自多方的程式碼貢獻,我們也已經盡可能地用 Docker 來解決執行環境的問題。那流程呢?我們常用的一些腳本跟流程該如何協作共享?這時候,Make 就派上用場了。

The GNU logo

你需要具備的內容

閱讀本篇文章,你可能需要具備以下內容,並依據實際所需調整範例:

  • 包含有 Make 的環境(macOS 和多數 Linux 發行版皆有內建)
  • 能使用 Docker 與 Docker Compose 基本指令
  • 能使用 Shell 基本語法與指令
  • 瞭解 .env 檔(dotEnv)的使用情境
  • 能區分 Tab 字元與空白字元的差別

什麼是 Make/Makefile

如果你曾接觸過 Linux 軟體開發,應該對 Make 不陌生。Make 是一個 Cli 工具,透過 Makefile 檔案設計一系列的流程,讓我們執行單一指令就能自動化完成一連串的目的。Make 很常使用在 C/C++ 等編譯語言的軟體,方便其他使用者直接執行開發者設計好的編譯、測試、封裝等流程。

另外,雖然他叫做 Make,但由於 make 本身也是英文動詞,如果要描述或找資料的話,通常也會習慣稱呼其為 Makefile,或是 GNU Make。

為什麼要用 Make

也許你有疑問,那我們直接寫 Shell Script 是不是也可以達到類似目的呢?

如果你只有簡單幾個流程,那也許 Shell Script 就已足夠。但透過 Make,我們可以重複使用類似的邏輯,只專注不同情境或環境下的差異。

這篇文章不是專業的 Make 教學文,但是會整理一些我在 web 開發時常見的 Make 用法,你可以看看 Make 是不是適合你的工具,也歡迎大家跟我分享更多實用的技巧和做法。

Hello World

首先我們以顯示 Hello World 為目標,可以撰寫一個名為 makefile 的檔案,內容如下:

hello:
	echo "Hello World"

注意:Makefile 的縮排應使用 Tab,否則會出現語法問題。

這就是最基本的 Makefile 架構了,接著我們在該目錄執行 make hello,此時應該能看到以下內容:

echo "Hello World"
Hello World

Makefile 主要是由各種規則(Rule)組成,可以細分為作為名稱的「Target」以及規則內容的「Recipe」。以本例來說,「hello」就是 Target,「echo “Hello World”」就是要做什麼事情的 Recipe。在口語上,我們也會直接將「Rule」稱作「Target」。

每個 Target 可以透過 make [target] 來執行,例如本例 make hello 就可以幫我們顯示 Hello World 字樣,過程中執行了什麼指令也會一併顯示在終端機中。

Makefile 的主要本體:Target

我們再舉個跟 Docker Compose 指令搭配的例子,撰寫一個名為 makefile 的檔案,放在 docker-compose.yml 的同級目錄:

up:
	docker compose up -d workspace

stop:
	docker compose stop

zsh:
	docker compose exec workspace zsh

本例有三個 Target:upstopzsh。Makefile 預設將第一個 Target 視為 Goal,是專案的最主要流程,可以直接用 make 執行。以本例來說,執行 makemake up 是一樣的結果。

Target 可以支援多行,空白行以前都是同一個 Target 的範圍。例如我們想在每次啟動主流程前都先複製 .env.example 到 .env,可以改寫成這樣:

up:
	cp .env.example .env
	docker compose up -d workspace

stop:
	docker compose stop

zsh:
	docker compose exec workspace zsh

Prerequisite 與 Phony Target

但其實剛剛複製檔案的例子不是常見的 Make 用法。Make 的強項是在自動判斷有沒有必要執行每個 Target 的流程。例如我們常常將機敏資料放在 .env 中,若 .env 已經存在,就不應該再複製 .env.example 覆寫過去了。這時候我們可以把 .env 做成一個 Target:

up: .env
	docker compose up -d workspace

.env:
	cp .env.example .env

此時當我們執行 up Target 時,會先去執行冒號右方的每一個 Target,稱為先決條件(Prerequisite,以本例來說是 .env Target),接著才執行 up 本身。

Target 名稱預設是被視為檔名的。Make 之所以稱為 make,就是想要「製作」出指定的 Target,當符合指定條件時(如檔案不存在)才會執行 Target 的內容。

以本例來說,我們執行 up Target 時,如果 .env 不存在,就會先執行 .env Target 以複製出 .env,接著才會啟動 workspace container。如果執行 up Target 時 .env 已經存在,就會略過 .env Target,直接啟動 workspace container。

同樣地,如果我們目錄中有「up」這個檔案, up Target 就不會被執行了。這時我們可以設定 Phony Target,告訴 Make 哪些 Target 不是檔案的名稱,而是單純流程的命名。寫法如下:

.PHONY: up stop zsh

up: .env
	docker compose up -d workspace

stop:
	docker compose stop

zsh:
	docker compose exec workspace zsh

.env:
	cp .env.example .env

第一行看到的 .PHONY 是 Make 的保留字,告訴 Make 這些是不該被跳過執行的 Target,我們在這裡列了 upstopzsh 三個 Target。當我們未來執行這三個 Target 時,就不會因為檔名不小心等同於這些 Target 而被跳過,影響到流程了。

順帶一提,上一段提過 Make 會將第一個 Target 視為 Goal。但 Goal 其實還規定了不能是點(dot)開頭的 Target,所以此例的 Goal 依然是 up Target。

隨著專案的流程越複雜,也許我們會有越多的 Phony Target,這將會使 .PHONY 變得很長,因此 Make 支援多個 .PHONY 的表示法:

up: .env
	docker compose up -d workspace
.PHONY: up

stop:
	docker compose stop
.PHONY: stop

zsh:
	docker compose exec workspace zsh
.PHONY: zsh

.env:
	cp .env.example .env

這樣直接寫在 Target 附近的寫法,可以一眼就看出來哪個 Target 是 Phony Target,大幅提升可讀性跟可維護性。

來點變數

Make 當然也支援變數(Variable),與常見的 Unix 環境變數慣例相同,我們習慣用 SCREAMING_SNAKE_CASE 表示法(全大寫和底線的表示法)。並且在使用時以 $() 包裹變數名稱。

例如我們想要方便啟動指定的 container,可以將 up 改寫成:

CONTAINERS ?= workspace mysql

up:
	docker compose up -d $(CONTAINERS)
.PHONY: up

這裡我們設定了一個變數 CONTAINERS,當我們未指定時,預設值為 「workspace mysql」。例如我們呼叫 make up 時會執行以下指令:

docker compose up -d workspace mysql

當我們想要給予該變數一個值,例如我們想用 up Target 開啟 redis container,可以這樣呼叫:

make up CONTAINERS="redis"

這時 Make 就會幫我們執行以下指令:

docker compose up -d redis

另一種常見的用法是透過變數指定 Docker Compose 的參數,如以下範例:

CONTAINER_USER ?= default
CONTAINERS ?= workspace

zsh:
	docker compose exec --user=$(CONTAINER_USER) $(CONTAINERS) zsh
.PHONY: zsh

這裡預設是以「default」來進入 container。這時我們可以透過指定 CONTAINER_USER 來更改執行指令的使用者,以指定成「root」為例:

make zsh CONTAINER_USER="root"

這時 Make 就會幫我們執行以下指令,以「root」進入 container:

docker compose exec --user=root workspace zsh

做一些條件判斷

想要有一些稍微複雜的邏輯判斷?Make 也支援條件式(Conditional),最常見的是 ifeqifneq,分別對應「如果等於」和「如果不等於」,以下是範例:

IS_ROOT ?= false

zsh:
ifeq ($(IS_ROOT), true)
	docker compose exec --user=root workspace zsh
else
	docker compose exec workspace zsh
endif
.PHONY: zsh

以此例來說,當我們執行 make zsh 時,Make 會判斷 $(IS_ROOT) 是否等於 “true”,若相等的話,就會以 root 的身份進入 workspace container,否則就改以預設的 user 進入。

首先要注意的是,只有被執行的指令部分需要 Tab 縮排,條件式相關的語句應該要保持不縮排,因為他是屬於 Make 語法的一部分。另外提醒,雖然本例中使用的是 true/false,但其實 Make 是沒有布林值型態的,在這裡是比對字串有無相等。

控制字串的輸出

接著我們來加一些輸出,讓我們能更容易辨識流程。以下是 Make 標準輸出的 Control Function,稱之為 info

IS_ROOT ?= false

zsh:
ifeq ($(IS_ROOT), true)
	$(info 以 Root 身份進入 workspace)
	docker compose exec --user=root workspace zsh
else
	$(info 以預設身份進入 workspace)
	docker compose exec workspace zsh
endif
.PHONY: zsh

此時若我們執行 make zsh,看到的輸出如下:

以預設身份進入 workspace
docker compose exec workspace zsh
# 接者是 Docker Compose 執行結果

如此透過 Control Function 我們就能更客製化顯示的內容,另外還有 warningerror 兩種輸出,可以參考說明文件。

另外,有時不想要我們的指令干擾畫面的呈現,這時候我們可以在行首加上 @ 符號,阻止 Make Echoing。以文章開始的 Hello World 範例改寫如下:

hello:
	@echo "Hello World"
.PHONY: hello

這時當我們執行 make hello,呈現的結果如下:

Hello World

就不會出現 echo "Hello World" 字樣了。

組合技:管理不同環境的流程

讀到這裡,我們已經掌握了 Make 的基本用法。接者我們來討論看看該怎麼管理不同環境的流程。

假設我們分成「開發環境(dev)」與「正式環境(production)」,啟動專案的流程如下:

  • 兩者環境啟動前都需要 .env 檔,若檔案不存在,dev 環境從 .env.example 複製建立,production 環境從 .env.example.production 複製建立
  • 兩者環境都需要啟動 workspace container,dev 環境還要額外啟用 redis container
  • 啟動時呈現當前環境名稱
  • 啟動流程不顯示指令,但以中文描述動作

以下為其中一種 Makefile 寫法:

ENVIRONMENT ?= dev

up: .env
	$(info 目前環境為 $(ENVIRONMENT))
	$(info 啟動 workspace)
	docker compose up -d workspace
ifeq ($(ENVIRONMENT), dev)
	$(info 啟動 redis)
	docker compose up -d redis
endif
.PHONY: up

.env:
	$(info .env 不存在,建立 .env 檔)
ifeq ($(ENVIRONMENT), dev)
	cp .env.example .env
else
	cp .env.example.production .env
endif

到這裡,我們已經可以開始撰寫針對不同環境的流程了!我們還可以加上一些 Phony Target 來整理常用的指令,例如前面範例提過的 stopzsh,或是執行 Laravel 測試:

test:
	docker compose exec workspace php artisan test
.PHONE: test

克外補充:進階用法分享

由於本篇只是分享我是如何透過 Make/Makefile 管理流程,進階用法就以簡單的方式條列分享,也歡迎將你的使用方式或經驗分享給我!

註解的使用

跟 Shell Script 一樣使用 #

值得注意的是,如果在 Target 中使用 Tab 縮排後的 # ,會被視為是 Shell Script 的註解。

取得當前 Target 名

使用 $@

up:
	$(info 目前執行的 Target 是 $@) # 顯示 up
	docker compose up -d $(CONTAINERS)
.PHONY: up

Make 變數與 Shell Script 變數混用

也許你在流程中想使用 Shell Script 變數,如果要使用 $ 在指令中,跳脫的方法不是 \$,而是 $$

變數內容可以為 Shell 執行結果

請看範例:

MY_IP = $(shell curl -s ipinfo.io/ip)

get-ip:
	$(info 我的 IP:$(MY_IP))
.PHONY: get-ip

變數可以擴充

透過 +=ifeq 可以更簡單的管理環境,請看範例:

ENVIRONMENT ?= dev
CONTAINERS ?= workspace

ifeq ($(ENVIRONMENT), dev)
# 強制 dev 環境會開啟 redis
CONTAINERS += redis
endif

up:
	$(info 目前環境為 $(ENVIRONMENT))
	$(info 啟動 $(CONTAINERS))
	# dev 環境預設會開啟 workspace 和 redis
	docker compose up -d $(CONTAINERS) 
.PHONY: up

想要抽成 function?

如果有一直重複的指令前綴可以抽成變數,參數也可以抽成另一個變數方便執行時替換:

COMPOSE_FLAGS ?= -d
EXEC_CONTAINER ?= workspace
EXEC ?= docker compose exec $(COMPOSE_FLAGS) $(EXEC_CONTAINER)

zsh:
	$(EXEC) zsh
.PHONY: zsh

bash:
	$(EXEC) bash
.PHONY: bash

我們也許可以這樣執行:

make zsh COMPOSE_FLAGS="-d -T"

當然 flags 也可以用前面提到的 += 概念去組合。

除了一般變數的用法外,還有多行變數(define)搭配 Call Function $(call [variable]) 的用法。

剛提到了 info、shell 和 call,還有沒有其他神奇 Function

還蠻多的,例如 filter、subst、realpath⋯⋯。想看各種 Function 的介紹請參考 Function 說明文件。

另外,所有 Make 內建的 Function、變數、指令可以 在此查表

如果想要中間才執行 Prerequisite?

可以使用 Double-Colon Rules 語法,主要是把 : 改成 ::,將 Target 拆開成兩部分,例如:

up::
	$(info 我先顯示這句後才想製作 .env)
up:: .env
	$(info 製作 .env 後才啟動 workspace)
	docker compose up -d workspace
.PHONY: up

.env:
	cp .env.example .env

更多 Prerequisite 用法

可以使用變數決定 Prerequisite,Target 也可以是路徑:

PREREQUISITE ?= .env ../laravel/.env

up: $(PREREQUISITE)
	docker compose up -d workspace
.PHONY: up

.env:
	# Docker 的 .env
	cp .env.example .env

../laravel/.env:
	# 隔壁目錄的 .env
	cp ../laravel/.env.example ../laravel/.env

進階變數使用

變數宣告另外還有 =:= 等用法。

另外 Target 是可以給定值的,要附在 Target 前(請見範例)。但這種寫法我覺得維護上有很多問題,我都盡量避免使用。

Makefile 範例一:

TEXT ?= default

hello: hey
	$(info hello: $(TEXT))
.PHONY: hello

hey: TEXT ?= hey
hey:
	$(info hey: $(TEXT))
.PHONY: hey

Makefile 範例一執行結果,hey 認為 TEXT 已經給過值,就不會套用 hey 值:

# 執行 make
hey: default
hello: default

# 執行 make TEXT=Jack
hey: Jack
hello: Jack

Makefile 範例二,將 hey 給值由 ?= 改為 =

TEXT ?= default

hello: hey
	$(info hello: $(TEXT))
.PHONY: hello

hey: TEXT = hey
hey:
	$(info hey: $(TEXT))
.PHONY: hey

Makefile 範例二執行結果,此時 hello 不受影響:

# 執行 make
hey: hey
hello: default

若在流程中改值, = 的用法是會先展開取得最終結果,才確定整個流程的變數內容是什麼,從頭到尾值都會保持一致,請見範例三。

Makefile 範例三,改成 hello 給值:

TEXT ?= default

hello: TEXT = hello
hello: hey
	$(info hello: $(TEXT))
.PHONY: hello

hey:
	$(info hey: $(TEXT))
.PHONY: hey

Makefile 範例三執行結果,因為是 =,即使 hello 執行順序比較後面,依然影響到前面的 hey 取值:

# 執行 make
hey: hello
hello: hello

Makefile 也有個 官方範例 說明 = 展開的概念:

foo = $(bar)
bar = $(ugh)
ugh = Huh?

all:
	@echo $(foo) # 輸出結果為 Huh?

:= 的用法與一般程式語言的等號賦值比較類似。讓我們修改一下剛剛的官方範例,將 = 改成 :=

foo := $(bar)
bar := $(ugh)
ugh := Huh?

all:
	@echo $(foo) # 輸出結果為空白行

結語

感謝你看到這裡,以上是我使用 Make/Makefile 管理流程的做法。歡迎跟我分享交流,你是怎麼管理你的流程呢?



創用 CC 授權條款
本著作由小克製作,以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。