Project Layout in Go

·

2 min read

剛入門任何一門程式語言開發的人, 應該大多都是參考各路大神們的專案或者公司的專案在學習模仿。 一開始印入眼簾的應該就是 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才集中在一個專案內,方便開發和部署。

參考資料

Go doc Organizing a Go module

Go doc go1.4 Internal packages

Go 项目目录该怎么组织?官方终于出指南了!