Project Layout in Go
剛入門任何一門程式語言開發的人, 應該大多都是參考各路大神們的專案或者公司的專案在學習模仿。 一開始印入眼簾的應該就是 Project布局的各種長相, Project布局關心的是我們怎樣組織 Go project。 這裡針對的是資料夾跟檔案的布局,官方Blog Organizing a Go module這篇文章有給我們建議跟說明。讓我們一起讀這篇吧!
在閱讀這篇之前能先稍微理解Go Module以及Go install的用法。
官方Blog Organizing a Go module這篇文章內把Go專案的內容大致分成Package和Command或者兩個的組合。 Package
就是我們熟知的library, Command
則是executable能編譯出來執行的go檔案。
演化路程如下圖
如果是純library專案給人家import到別人的go project內使用的, 那package這條路線就是我們能參考。 像這個go-linq,該專案都是library。go-redis也是這類型的專案。
如果是需要安裝到主機上變成執行檔的,那command這條路線能參考。 像protoc-gen-go。
Basic package
下面的目錄結構長相是go module和package code都在專案的根目錄中。
project-root-directory/
go.mod
modname.go
modname_test.go
上面的 modname.go
是package code,modname_test.go
則是與modname.go相關的測試程式。
如果把這包程式碼上傳到Github repo上,例如 github.com/someuser/modname
,那麼 go.mod
裡指定該module的路徑也會是 github.com/someuser/modname
,這點之後有機會在介紹go module的細節。
module github.com/someuser/modname
然後因為package code在根目錄,所以沒意外這package code一開始的package name會是 modname
package modname
這樣別人就能透過 go get
github.com/someuser/modname
來取得這公開的package。
也可以該package的程式碼能切分開來在多個檔案中,只要這些檔案都在同一層的目錄中。
project-root-directory/
go.mod
modname.go
modname_test.go
auth.go
auth_test.go
hash.go
hash_test.go
這些檔案除了在同一層目錄中,同時它們的package name也都是modname。
Basic command
如果是要可以執行的程式,那最基本的專案結構會長的像
project-root-directory/
go.mod
auth.go
auth_test.go
client.go
main.go
其中 main.go
會包含 func main
,這應該沒什麼問題,雖然檔名也能叫 modname.go
,只是main.go比較是慣例命名。 這目錄中所有的檔案其package name也會是 main
,畢竟go同一層目錄的package name必須一樣否則編譯會出錯。
同上該專案被上傳到 github.com/someuser/modname
module github.com/someuser/modname
那麼我們就能夠透過 go install
來下載並安裝
go install github.com/someuser/modname@latest
只是通常這種可執行的專案沒那麼單純,我們會有一些程式碼不希望發布上git後被別人的專案給go get來使用那些package。這時就出現 internal
資料夾了。
Internal package
Go在1.4時加入了internal package的機制。 internal package除了不能讓外面的用戶直接import之外,代表著對其他module來說,該internal package對它們來說是不可見
的,所以能做一層隔離,internal package內程式的修改對外部是沒影響的
同時也只能被同一個階層以下
的所有package來存取,所以絕大部分internal package都被放在根目錄,就是希望讓根目錄中所有package都能去導入 internal package。
project-root-directory/
go.mod
main.go
package1/
internal/
interal.go
package1.go
package2/
internal/
interal.go
package2.go
interanl/
version/
version.go
internal.go
這樣的定義下,package1的internal只有package1這層以下的能導入,package2的internal也是,連main都無法導入到這兩個internal package。 但是main可以導入根目錄下的internal package。 package1能夠導入根目錄下的internal package。
如果硬要import,則編譯時會出現編譯錯誤 use of internal package xxxx/internal not allowed
至於如果package1剛好要導入root internal package, 都要給導入internal package一個別名, 才能正常使用;除非引用的是internal package的下一層package。 如導入上面目錄結構的 internal/version
, 就沒問題。 如果是剛好想要導入internal package的internal.go的部份就要給上alias別名。
Package or command with supporting packages
有了internal package的認識後,就能將internal package理解成supporting package,就是我們內部會引用到,但不希望被外部專案給直接引用的package。這時候Go官方就會建議我們放到 internal
目錄中。
project-root-directory/
internal/
auth/
auth.go
auth_test.go
hash/
hash.go
hash_test.go
go.mod
modname.go
modname_test.go
在這樣目的下,我們就能設計modname.go,能導入auth package和hash package。 modname.go能這樣導入
import "github.com/someuser/modname/internal/auth"
但外部只能導入modname package來使用。
Multiple packages
一個專案可以有多個能被導入的package,且每個package都會有自己的目錄結構來進行組織。
project-root-directory/
go.mod
modname.go
modname_test.go
auth/
auth.go
auth_test.go
token/
token.go
token_test.go
hash/
hash.go
internal/
trace/
trace.go
這樣子的結構意味著,別人可以導入
github.com/someuser/modname
github.com/someuser/modname/auth
github.com/someuser/modname/hash
也能導入auth package的下一層token package
github.com/someuser/modname/auth/token
外部專案就是不能導入
github.com/someuser/modname/internal/trace
在這專案結構設計上,剛好internal package放在根目錄中,就是為了能讓專案內的package給導入使用。 例如專案內的package就能導入trace package使用
github.com/someuser/modname/internal/trace
Multiple commands
一個專案能包含多個可執行的程序。像是例子中的prog1/main.go和prog2/main.go
project-root-directory/
go.mod
internal/
... shared internal packages
prog1/
main.go
prog2/
main.go
所以使用者可以透過go install來直接安裝使用
$ go install github.com/someuser/modname/prog1@latest
$ go install github.com/someuser/modname/prog2@latest
但是為了好區別可執行command的目錄名稱,通常會建議放入一個名為 cmd
的目錄內,這樣子會很好區分出哪些是可以被導入的package哪些則是可以被執行的command。這些都是建議,並非必要。
Packages and commands in the same repository
如果一個專案同時提供可以被導入的package和能夠被安裝執行的command,通常會像這樣的結構。
project-root-directory/
go.mod
modname.go
modname_test.go
auth/
auth.go
auth_test.go
internal/
... internal packages
cmd/
prog1/
main.go
prog2/
main.go
這樣子的結構意味著,別人可以導入
github.com/someuser/modname
github.com/someuser/modname/auth
能透過go install來安裝cmd/prog1和cmd/prog2
$ go install github.com/someuser/modname/cmd/prog1@latest
$ go install github.com/someuser/modname/cmd/prog2@latest
以Prometheus這專案為例子
github.com/prometheus/prometheus/
cmd/
prometheus/
main.go
promtool/
main.go
internal/
model/
plugins/
rules/
scrape/
scripts/
storage/
interface.go
Prometheus提供了prometheus能安裝啟動,還提供了promtool這工具,能讓我們安裝來對prometheus進行檢查還有檢查各種prometheus設定文件等作用。
又因為prometheus本身只提供local storage功能, 所以在stoage package內有定義了interace來給其他storage service來導入開發用。 像是Grafana Lab旗下的Mimir,內部就導入很多Prometheus專案內的package。
但你不會看到Mimir去導入prometheus/internal底下的package,因為這些只能被prometheus專案內部所導入。
總結
官方這篇文章主要針對專案根目錄,internal目錄做說明,cmd目錄則是建議。 如果我們的專案需要提供一堆package供別專案導入時,有人也是會建議集中放到 pkg
目錄中,讓根目錄乾淨點,但也只是建議。
對我來說目錄結構反應的也是設計時的一個邊界,怎樣探索出邊界,不是按照這篇Project布局來被強迫設計就這樣塞入,該repo的issue也在討論這份也不是標準Go專案布局。
更多的我想還是回歸場景與需求的設計,像是DDD中提到的subdomain與bounded context, 就是基於`` 業務層面
``的設計布局,在基於這樣業務布局進行專案結構的布局設計,這樣能讓專案設計貼近於業務設計,減低認知與轉化負擔。
令一個層面是關於release,如果該專案內有些package對於其他專案項目有用處,為了方便管理發佈與版本控制,官方是建議拆分成獨立的專案進行管理。只有跟我們這包系統有緊密生命週期相關的package和command才集中在一個專案內,方便開發和部署。