[Golang] Modules and Packages
TL;DR
-
Repository:一般指的就是被 git 所管理的專案
- 由一(或多)個 module(但一般建議還是一個 repository 一個 module 比較好版本管理)
-
Module:有
go.mod
的專案,有一個版本,由一個以上的 packages 組成,並且 -
Package:以資料夾組織整理的許多
.go
檔,會用package xxx
定義 package 的名稱
$ go help mod
$ go mod init <module-path> # 建立新的 go module
$ go mod download # 安裝專案用到的套件
$ go mod tidy # 添加遺失的或移除沒用到的套件
$ go mod verify # 驗證 dependency 的正確性
$ go mod why <pkg> # 檢查是誰用到這個 package
Golang 中所謂的 module 和 package 的定義,和 Node.js 中對於 module 及 package 的定義剛好相反。在 golang 中是安裝 module,並使用 module 中的 package;相反的,在 Node.js 中是下載安裝 package,並可以匯入 package 中的 module 來使用。
基本操作
不論是 module 或 package,都可以在本地匯入,不一定要發到遠端 repository。
# 在 hello 資料夾中
$ go mod init example.com/user/hello # 宣告 module_path,通常會和該 repository 的 url 位置一致
$ go install . # 編譯該 module 並將執行檔放到 GOBIN,因此在 GOBIN 資料夾中會出現 hello 的執行檔
$ go get <package_name>@none # 移除 go.mod 中特定 module,但不會移除對應的 binary
💡 安裝到
GOBIN
資料夾的檔案名稱,會是在go.mod
檔案中第一行定義module path
中路徑的最後一個。因此若 module_path 是example.com/user/hello
則在 GOBIN 中的檔名會是hello
;若 module_path 是example/user
則在 GOBIN 資料夾中的檔名會是user
。
若我們在 go module 中有使用其他的遠端(第三方)套件,當執行 go install
、go build
或 go run
時,go 會自動下載該 remote module,並記錄在 go.mod
檔案中。這些遠端套件會自動下載到 $GOPATH/pkg/mod
的資料夾中。當有不同的 module 之間需要使用相同版本的第三方套件時,會共用這些下載的內容,因此這些內容會是「唯讀」。若想要刪除這些第三方套件的 內容,可以輸入 go clean -modcache
。
設定 GOPATH
# ~/.zshrc
export GOPATH=$HOME/go
# 可以直接在 Terminal 執行 GOPATH 中的 bin
export PATH=$PATH:$GOPATH/bin
匯入 Package
概念
在 Golang 中可以透過 import
來匯入某個 Module 中的 Package 來使用,例如:
- 安裝
go.uber.org/zap
這個套件(go get -u go.uber.org/zap
) - 在匯入 package 的時候,我們會用
[module path] + [package path]
,其中go.uber.org/zap
是 module path/zapcore
是 package path
- 在使用 package 的時候,需要根據該 Path 中所定義的
package
名稱才能使用該 package- 例如,這裡 package path 的資料夾名稱是
zapcore
,同時,在這個資料夾裡的 GO 檔,開頭也都用package zapcore
,因此使用的時候,可以直接用zapcore
這個名稱
- 例如,這裡 package path 的資料夾名稱是
package main
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore" // module path + package path
)
func main() {
// ...
logger := zap.New(zapcore.NewCore(
// 這裡之所以可以用 zapcore.xxx 是因為在 `/zapcore` 資料夾中,package name 也是叫 zapcore
zapcore.NewJSONEncoder(encoderCfg),
zapcore.Lock(os.Stdout),
atom,
))
}
然而,如果 package path 的「資料夾名稱」和 「GO 檔案裡定義的 package name」不同時,會要以 Go 檔中所定義的 package name 為主。舉例來說,如果是 import "github.com/pjchender/package_example/do-math"
,當 do-math
資料夾中的 GO 檔案開頭都是寫 package math
,那麼在使用這個 package 時,是要用 math.xxx
而不是 do-math.xxx
。
一般來說,最好把「package name」和包含該 package 的「資料夾名稱」取成一樣的名字,如果才不會 import 的 package path 和實際要使用的 package name 有不一致的情況。
常見用法
要匯入 package 只需要:
// 匯入單一個 package
import "fmt"
// 匯入多個 packages(不用逗號)
import (
"fmt"
"strings"
)
// go-hello-world/main.go
package main
import (
"fmt"
// 把 foo 這個 package 的方法都放到這隻檔案中,如此不用使用 foo.HelloWorld(不建議)
. "go-hello-world/foo"
// 將模組轉換為別名,可以使用 bar.HelloWorld
bar "go-hello-world/foo"
// 沒有用到這個 package,但要 init 它
_ "go-hello-world/foo"
)
func main() {
fmt.Println(bar.HelloWorld())
fmt.Println("Hello main")
}
// go-hello-world/foo/helloworld.go
package foo
import "fmt"
func init() {
fmt.Println("This is init of helloworld")
}
// HelloWorld ...
func HelloWorld() string {
return "Hello World"
}
Packages
Package Scope:同一個 Package 來的變數和方法是共享的
- 在 Go 語言中,並沒有區分
public
、private
或protected
,而是根據變數名稱的第一個字母大小寫來判斷能否被外部引用。 - 在同一個 package 中變數、函式、常數和 type,都隸屬於同一個 package scope,因此雖然可能在不同支檔案內,但只要隸屬於同一個 package,都可以使用(visible)。
- 如果需要讓 package 內的變數或函式等能夠在 package 外部被使用,則該變數的第一個字母要大寫才能讓外部引用(Exported names),否則的話會無法使用
舉例來說,有兩隻檔案,他們在同一個資料夾內,且都屬於 main
這個 package:
// ---
// greetings.go
// ---
package main
import "fmt"
var points = []int{20, 90, 100, 45, 70}
func sayHello(name string) {
fmt.Println("Hello", name)
}
// ---
// main.go
// ---
package main
import "fmt"
func main() {
sayHello("Aaron")
for _, v := range points {
fmt.Println(v)
}
}
雖然 points
和 sayHello
是在 greetings.go
這支檔案,但在 main.go
這支檔案,是可以取用到(visible)這些變數的。
要留意執行程式的時候要寫:
$ go run main.go greetings.go
留意變數是在 Package Scope 或 Function Scope
要留意 package scope 和 function scope 的差別。假設有兩隻檔案 main.go
和 score.go
,當中有一變數叫 score
:
// ---
// score.go
// ---
package main
import "fmt"
func showScore() {
// 在 score.go 中,並沒有 score 這個變數
fmt.Println(score)
}
// ---
// main.go
// ---
package main
// score 這個變數是在 package scope
var score int = 87
func main() {
// showScore 仍然可以取得 score 這個變數
showScore()
}
即時 score
這個變數不是在 main.go
中,只要它是在 main
這個 package scope 下,就可以被取用到,不管 showScore
是在哪裡被執行。
然而,如果是把 score
這個變數放在 Function Scope 下,則可能會無法被其他 function 所取用,例如:
// main.go
package main
func main() {
// 如果變數放在 function scope 內,雖然 showScore 是在同一個 function 內被執行
// 但會無法取用到 score 這個變數
var score int = 87
showScore()
}
Executable or Reusable Package
- Go 的程式碼都是以
package
的方式在組織,一個 package 就是在「同一資料夾」中的許多 GO 檔案。 - 同一個 package 的所有 Go 檔案會放在同一個資料夾內;而這個資料夾內的所有檔案也都會屬於同一個 package,有相同的 package 名稱。
- 通常 package 的名稱會跟著 folder 的名稱,舉例來說,若檔案放在
math/rand
資料夾中,則該套件會稱作rand
。 - 有兩種不同類型的 package:
- executable package:是用來產生我們可以執行的檔案,一定會包含
package main
並且帶有名為main
的函式,只有這個檔案可以被執行(run)和編譯(build),並且不能被其他檔案給匯入。 - reusable package(library package):類似 "helpers" 或常稱作 library / dependency,目的是可以放入可重複使用的程式邏輯,它是不能被直接執行的,可以使用任何的名稱。
- executable package:是用來產生我們可以執行的檔案,一定會包含
在 Go 裡面要區別這兩種 Package 的主要方式就是利用「package 名稱」,當使用 main
當做 package 名稱時,就會被當作 executable package,因此接著執行 go build <fileName>
時,會產生一支執行檔;但是當使用 main 以外的名稱是,都會被當作是 reusable package,因此當使用 go build
指令時,不會產生任何檔案。
在 Golang 中 main
就是程式執行時的進入點(entry point),所以 Golang 在執行時,會先去找到名為 main
的 package,並執行這個 package 中的 main
function。除非你的程式並不是要直接被執 行的(not executable package),否則一定要有 main
。
Package 名稱:規範和慣例
- 👍 package name @ golang blog
- package name @ effective go
- package name @ golang wiki > code review comments
- 在 Go 檔案中的第一行使用
package <xxx>
即可定義 package 名稱 - 同一個資料夾中的 package 名稱必須要是一樣的
- 一般來說,最好把「package name」和包含該 package 的「資料夾名稱」取成一樣的名字,如果才不會 import 的 package path 和實際要使用的 package name 有不一致的情況。
- 除非資料夾名稱包含了 Go identifier 中不合法的字元,例如資料夾名稱是
format-string
,其中-
在 Go 中不是合法的字元(雖然資料夾名稱可以叫format-string
,但 package 名稱並不能使format-string
),這時候就得讓資料夾名稱與套件名稱不同。 - 或者有其他特殊情況,可能是版本的需求,例如,在
controllers/v2
資料夾中的 GO 檔,其套件名稱(package controllers
)仍然用controllers
而不是用v2
- 除非資料夾名稱包含了 Go identifier 中不合法的字元,例如資料夾名稱是
- package 的名稱應該是短而清楚,以小寫(lower case)命名,同時不包含底線(under_scores)或小寫駝峰(mixedCaps),並且通常會是名詞(noun),例如
time
、list
、或http
。
在幫 package 命名的時候,試想自己就是使用該 pkg 的開發者,用這種角度來替自己的 pkg 命名。
另外,由於使用者在匯入該 package 後,使用時一定會需要使用該 package 的 name 作為前綴,因此在 package 中的變數名稱盡可能不要和 package name 重複。例如,
- 在
http
package 中如果要使用 Server,不需要使用http.HTTPServer
,而是可以直接使用http.Server
- 當在 uuid 的 package 要產生一組
uuid.UUID
時,不需要使用uuid.NewUUID()
的方法,而是可以直接使用uuid.New()
,也就是說如果回傳的型別名稱(UUID
)和該 pkg 的名稱相同時,可以直接將該方法命名成New
,而不用是NewOOO
time.Now()
會回傳time.Time
- 如果 pkg 會回傳的 struct 名稱不同於 package 本身的名稱時,則可以使用
NewOOO
time.NewTicker()
uuid.NewRandom()
雖然 pkg 中 variable name 的前綴會盡量不和 package name 重複,但有個例外是,這個匯出的變數名稱可以和套件名稱相同。這在 Go 中算是很常見的情況,例如 context.Context
(interface)、sort.Sort
(function)、time.Time
(struct),其中,最常見的情況是在該 pkg 中有其同名的 struct,也就是 time.Time
。
不好的用法:
-
盡可能不要使用
util
,common
,misc
這類的名稱作為 package name,因為這對使用者來說是沒有意義的名字,而是去想使用者這如果要用這些方法的話,最有可能使用到的關鍵字是什麼。- 舉例來說,如果有
extractName
和formatName
這兩個函式,不要把這個 package 的名稱取做util
,比較好的做法可能是把套件名稱叫做names
,使用者就可以呼叫names.Extract
和names.Format
這兩個方法。
- 舉例來說,如果有
go tool 找 package 的邏輯
go tool 會使用 $GOPATH
來找對應的 package,假設引入的 package 路徑是 "github.com/user/hello"
,那麼 go tool 就會去找 $GOPATH/src/github.com/user/hello
。
Modules
# go mod init [module_path]
$ go mod init example.com/user/hello # 宣告 module path
$ go mod tidy # 移除沒用到的 library
$ go mod download # 下載套件(go build 和 go test 也會自動下載)
$ go get [library] # 新增或更新 package 到 Module 內
$ go get -u ./... # 等同於,go get -u=patch ./...
$ go get foo@master. # 下載特定版本的 go package
$ go list -m all # 印出 module 正在使用的所有套件
$ go list -m -versions [package] # 列出所有此套件可下載的版本
$ go list -u -m all # 檢視有無任何 minor 或 patch 的更新
# 如果有 go.mod 的話,可以使用這個指 令來執行
$ go run .
- 使用
go mod init
初始化 module,這個指令會在專案中建立go.mod
檔 - 在一個專案中通常只會有一個 module(但也可以有多個),並且放在專案的根目錄,module 裡面會集合所有相關聯的 Go packages。在
go.mod
中會宣告module path
,這是用來匯入所有在此 module 中的路徑的前綴(path prefix),同時它也讓 go 的工具知道要去哪裡下載它。 - 總結來說:
- 一個 repository 會包含一個或以上的 Go modules
- 每個 module 會包含一個或以上的 Go packages
- 每個 package 會包含一個或以上的檔案在「單一資料夾」中
- 在執行
go build
或go test
時,會根據 imports 的內容自動添加套件,並更新go.mod
。 - 當需要的時候,可以直接在
go.mod
指定特定的版本,或使用go get
,例如go get <module-path>@v1.2.3
,go get <module-path>@master
,go get <module-path>@e3702bed2
基本結構
go.mod
在 root directory 中會透過 go.mod
來定義 Module,其中包含這些 directives 可以使用:
- 使用
module
來定義 Module Path- module 的路徑,它需要是唯一用來辨別 module 的名字,不可以和其他人寫的 module 撞名
- 為了避免不是唯一,因此一般會把放原始碼的 repository URL 放在裡面
- 模組的路徑會區分大小寫,為了避免困惑,在模組路徑中不要使用大寫
- 使用
go
來定義最小可相容的 go 版本 - 使用
require
定義相依於那些其他的 module- 第一個
require
表示的是專案本身用了哪些 dependencies - 第二個
require
表示套件所用到的套件
- 第一個
replace
exclude
retract
// go.mod
// 定義 module path
module example.com/go-playground
// go 的版本
go 1.22.2
// direct dependencies:定義相依套件的版本
require (
github.com/some/dependency v1.2.3
github.com/another/dependency/v4 v4.0.0
)
// indirect dependencies:這個套件並不是直接在這個專案中被引用,但我們使用的套件會相依到這個套件。
require indirect/dependency/v3 v3.3.1 //indirect
/**
* replace 和 exclude 這兩個命令都只能用在當前模組(即, `main`),否則將會在編譯時被忽略。
**/
// replace
// 將某一個 dependency 的版本換成另一個版本
// exclude
exclude outdated/dependency v3.4.1 // 用來忽略不要使用特定版本的套件
go.sum
目的是要確保相依的套件版本正確且沒有被竄改,確保每次的 build 都是一致、可以被複驗的。
// go.sum
// <module> <version> <file tree hash>
// <module> <version>/go.mod <go.mod hash>
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
go.mod
是在 module 初始化(initialized)是建立,go.sum
則是在 module 第一次 build 時建立。
使用方式
建立 module
# 在哪裡執行這個 command,module 就會建立在哪
# 如果你的 module 就放在 GOPATH 中,或在 GOPATH 外,但已經有 mod init 過的 repo
go mod init
# 如果 module 在 GOPATH 外,且尚未 init
go mod init [path]
module path 會取決於相對於 GOPATH 的路徑或 VCS:
- 如果 module 是在
$GOPATH/src/github.com/x/y
,則你的 module path 會是github.com/x/y
。 - 如果 module 是在 $GOPATH 外,而且這個 repo 已經 mod init 過了
安裝套件(add dependencies)
預設的情況下,套件會安裝在 $GOPATH/pkg/mod
底下。
安裝套件的方式包含:
- 直接在 source code 中加入套件,Go 會在 build 時自動把這些 package 下載
- 使用
go get
安裝 - 如果是測試會用到的套件,則會在執行
go test
時下載
和套件有關的指令
- 下面這些指令在執行時,會「安裝」程式中有用到的套件,但「不會清除」用不到的套件:
go build
,go clean
,go fix
,go fmt
,go generate
,go get
,go install
,go list
,go run
。 - 下面這些指令除了會安裝 build 所需要的套件,也會「安裝」執行測試所需的套件,同樣「不會清除」用不到的套件:
go test
,go vet
- 和 mod 有關,會安裝套件的指令:
go mod tidy
,go mod vendor
,go mod why
,go mod download
其他
- 階層關係上:Repository > Module > Package > Directory
跨檔案引用函式
從下面的例子中可以看到,雖然 main.go
裡面有一個函式是定義在 state.go
的檔案中,但因為它們屬於同一個 package,所以當從 Terminal 執行 go run main.go state.go
時,程式可以正確執行。
或者也可以輸入 go run *.go
:
// main.go
package main
func main() {
printState()
}
// state.go
package main
import "fmt"
func printState() {
fmt.Println("California")
}
套件載入的流程
在 golang 中,使用某一個套件時,go 會先去 GOROOT 找看看是不是內建的函式庫,如果找不到的話,會去 GOPATH 內找,如果都找不到的話,就無法使用。
測試本地開發的套件
假設這是我們的專案結構:
.
└── home/
├── greetings
└── hello
其中 greetings
是一個 package(example.com/greetings
),hello
這是可以直接被執行的程式。
如果要在 hello
中使用 local 的 greetings
可以使用這個指令:
$ go mod edit -replace example.com/greetings=../greetings
這個指令會在 go.mod
中多一行 replace
,表示要把原本從遠端拿的檔案改成從本地拿:
replace example.com/greetings => ../greetings
接者要執行 go mod tidy
,go 就會在本機找到對應的 module,同時在 go.mod
中再多一行 require
:
require example.com/greetings v0.0.0-00010101000000-000000000000
參考
- 10. Modules, Packages, and Imports | Learning Go, 2nd Edition
- How To Write Packages in Go by Gopher Guides @ Digital Ocean Community
- Go: The Complete Developer's Guide (Golang) @ Udemy by Stephen Grider
- Go 語言基礎實戰 (開發, 測試及部署) @ Udemy by Bo-Yi Wu
- Go (Golang): The Complete Bootcamp @ Udemy by Jose Portilla
- Go (Golang) Tutorial #11 - Package Scope @ Youtube by Net Ninja