Go 命令行工具項目結(jié)構(gòu)最佳實踐
最近我在重構(gòu)早期實現(xiàn)的命令行工具項目,在對項目結(jié)構(gòu)做改動的過程中我沒看到有一個Go 語言項目結(jié)構(gòu)最佳實踐。
其他語言都有這樣一個比較推薦的項目結(jié)構(gòu),比如Java就有典型項目結(jié)構(gòu),開發(fā)者在這個項目結(jié)構(gòu)下進行開發(fā)即可。Python使用Django和Flask時,框架會直接定義好項目結(jié)構(gòu)。但是Go社區(qū)還沒有在項目結(jié)構(gòu)這件事上實際達成一致。
雖然已經(jīng)有一些比較推薦的項目結(jié)構(gòu)出現(xiàn)了,不過普遍推薦的結(jié)構(gòu)對于我的項目并不適用。本文將探討我的項目最終結(jié)構(gòu)并與標準最佳實踐做對比。
用良好的package設計構(gòu)建項目
第一個最佳實踐是,項目中任何可重用的代碼都要做成一個package。如何設計package結(jié)構(gòu)和有關package的最佳時間就需要單獨寫篇文章,我做過一次關于這個內(nèi)容的分享,ppt連接在下面:
https://go-pkg-structure.dev/
把代碼放進一個個package要比僅僅重用代碼的好處大得多。從項目結(jié)構(gòu)的角度來說,把代碼放進獨立package有助于把一個個擁有獨立功能的代碼進行分組,這樣更方便其他參與開發(fā)的開發(fā)者維護代碼,這對開源項目來說意義重大。
獨立成一個個package的做法能讓項目測試起來更容易。把功能獨立成一個個package,就能用更少的依賴對一個個功能進行測試。
在創(chuàng)建和重構(gòu)項目時,我第一件事就是寫好項目需要的package,甚至會在寫代碼之前創(chuàng)建好基本項目結(jié)構(gòu)。
把應用程序邏輯和接入層邏輯分開
另一個我看到用的比較多的最佳實踐是把應用代碼和接入層代碼分離開,這里接入代碼指的是main包和main()方法。
Go和其他語言一樣,應用的接入層代碼是main方法,當應用開始運行時就是最先執(zhí)行的一部分邏輯,很可能就把所有初始化邏輯都只寫在main方法里了。把各自初始化邏輯放在app包內(nèi)實現(xiàn)是比全寫在main方法里更好的做法。
把初始化邏輯放到各自package下是更好的做法,這樣也更方便做測試。比如把Start() Stop() Shutdown()方法都放到app包內(nèi),寫測試代碼時就可以在當前包中調(diào)用啟動停止這些功能了。
下面是一個app包內(nèi)的實現(xiàn)例子:
- package app
- import (
- "fmt"
- )
- var ErrShutdown = fmt.Errorf("application was shutdown gracefully")
- func Start(...) error {
- // Application runtime code goes here
- }
- func Shutdown() {
- // Shutdown contexts, listeners, and such
- }
如果你的命令行工具項目里,既有服務端代碼也有客戶端代碼,在一個app文件夾內(nèi)實現(xiàn)的邏輯就能被服務端和客戶端共享。
然而這個做法對簡單的命令行應用不友好,這些應用可能是啟動-執(zhí)行-停止的模式。但我依然選擇使用把邏輯放到app目錄下的做法,這樣可以把運行時邏輯都放在一起,降低了其他開發(fā)者對這個項目的理解難度。
main package里該放些什么?
把我們所有應用都放到app包里之后,也要考慮main包里有什么。很簡單,main包里只有很少內(nèi)容。
總的來說,我會把main包限制為“只放與用戶交互的代碼”。例如,如果我的項目里既有cli又有服務端邏輯, 我通常會將命令行參數(shù)解析的邏輯放入main包中。服務端和客戶端cli編譯的二進制文件會包含不同包,通過解析主程序包中的參數(shù),就可以為不同cli創(chuàng)建獨立的選項。
其他需要和用戶交互的命令行應用,我也傾向于放進main包,例如:
- 命令行參數(shù)的解析
- 用戶輸入(很簡單的輸入、不參與核心邏輯)
- 解析配置文件
- 退出邏輯
- 處理信號
下面的代碼是一個main方法例子:
- // main runs the command-line parsing and validations. This function will also start the application logic execution.
- func main() {
- // Parse command-line arguments
- var opts options
- args, err := flags.ParseArgs(&opts, os.Args[1:])
- if err != nil {
- os.Exit(1)
- }
- // Convert to internal config
- cfg := config.New()
- cfg.Verbose = opts.Verbose
- // more taking command line options and putting them into a config struct.
- if opts.Pass {
- // ask the user for a password
- }
- // Run the App
- err = app.Run(cfg)
- if err != nil {
- // do stuff
- os.Exit(1)
- }
- }
一種推薦的項目結(jié)構(gòu)
有種被推薦了很多的一種項目結(jié)構(gòu)如下:
- internal/app - 僅在內(nèi)部使用的核心應用功能
- internal/pkg/ - 僅在內(nèi)部使用的package
- pkg/ - 需要和外部代碼進行分享的package
- cmd/<app_name> - 把main package放在帶有app名稱的這個目錄下
這個推薦結(jié)構(gòu)的一個重點在于把核心代碼放在internal/app、入口代碼放進cmd/<app_name>。這種結(jié)構(gòu)對于一次編譯出好幾個二進制文件的項目來說非常友好,比如一次編譯出server和cli的項目。cmd/<app_name>應當包含cli的main方法,cmd/<app_name>-server目錄下放服務端的main方法。這兩者可以在internal/app目錄下共享其他代碼。
總的來說這也是個不錯的目錄結(jié)構(gòu),但是這個目錄結(jié)構(gòu)對我不適用,看看我是怎么改的吧。
我把package放到了其他路徑下
我做的與上一段的推薦結(jié)構(gòu)不同的是package的路徑。應用程序項目結(jié)構(gòu)的子目錄太多,這與獨立項目結(jié)構(gòu)不同,我也不喜歡用應用程序項目結(jié)構(gòu)組織代碼。我認為,太多子目錄阻礙開發(fā)者找到功能實現(xiàn)的代碼。
子目錄多,對代碼量很大的重量級項目來可能比較有必要,但最好不要對小型中型項目使用這種項目結(jié)構(gòu)。
我選擇把所有package都放在代碼根目錄這一層,例如我有個Parser包,它的路徑就是parser/,ssh包的路徑就是ssh/,app包路徑是app/。
這個做法使找包和功能都很容易,因為包和代碼都在項目第一層。再次強調(diào)下,把所有包都放在目錄第一層的做法適用于包數(shù)量不大的項目,如果項目包數(shù)量變多,那還是把包放到pkg/路徑下靠譜。
我沒有采用internal和pkg模式
我并不覺得把代碼放進internal/或者pkg/這種實踐好,主要原因在于這種實踐是針對app內(nèi)部包。但是關于app內(nèi)部包并沒有明確的“內(nèi)”“外”劃分。對于僅在內(nèi)部使用的包,很多開發(fā)者就會因為"沒有其他人使用這些包"所以根本也沒有用最佳實踐。
我也不希望開發(fā)者在pkg路徑下像維護一個個獨立項目一樣維護代碼。實際開發(fā)中,這些包內(nèi)的接口可能和一個個獨立項目一樣做變動,那么如果這些邏輯真的是一個個分離開的,還不如放到獨立的項目里實現(xiàn)。
對我來說把我所有項目內(nèi)部代碼都放到同一個文件夾下更合理。要么是放在頂層目錄下要么是放在pkg/目錄下。
我沒有把所有文件都放進cmd/目錄
cmd/目錄不適用于我的項目。我這個項目里有一個簡單的CLI應用,這個應用要方便使用者下載安裝。最快最方便的安裝辦法是使用go get命令安裝:
- $ go get -u github.com/madflojo/efs2
我想要用戶只需要用go get加項目url就能安裝,但是如果用了cmd目錄就需要讓用戶在url基礎上增加/cmd/<app_name>才能安裝:
- $ go get -u github.com/madflojo/efs2/cmd/efs2
這個url格式比較亂,用戶還需要知道我項目結(jié)構(gòu)是怎么樣的才能安裝。我希望項目結(jié)構(gòu)能讓別人更方便而不是更麻煩。所以我就把這個小應用的main.go文件放到了項目的頂層文件夾下,這樣用戶就可以直接通過go get命令安裝應用了,另把應用的功能實現(xiàn)都放在app包內(nèi)。
總結(jié)
本文的模式應用結(jié)果如下:
- $ tree -L 2
- .
- ├── CONTRIBUTING.md
- ├── Dockerfile
- ├── LICENSE
- ├── Makefile
- ├── README.md
- ├── app
- │ ├── app.go
- │ └── app_test.go
- ├── config
- │ ├── config.go
- │ └── config_test.go
- ├── dev-compose.yml
- ├── go.mod
- ├── go.sum
- ├── main.go
- ├── parser
- │ ├── parser.go
- │ └── parser_test.go
- ├── ssh
- │ ├── ssh.go
- │ └── ssh_test.go
- └── vendor
- ├── github.com
- ├── golang.org
- └── modules.txt
- 7 directories, 18 files
總的來說我對這個結(jié)構(gòu)很滿意,新開發(fā)者也可以比較快地上手代碼。這個結(jié)構(gòu)基本看目錄就可以知道里面的功能了。
把代碼功能一點點拆分到不同package中也幫助我提升了代碼覆蓋率,目前main包放在頂層目錄的做法還沒發(fā)現(xiàn)任何弊端。當然這種項目結(jié)構(gòu)可能并不會對所有人適用,項目開發(fā)中還是要因地制宜。