其他语言都有这样一个比较推荐的项目结构,比如Java就有典型项目结构,开发者在这个项目结构下进行开发即可。Python使用Django和Flask时,框架会直接定义好项目结构。但是Go社区还没有在项目结构这件事上实际达成一致。
虽然已经有一些比较推荐的项目结构出现了,不过普遍推荐的结构对于我的项目并不适用。本文将探讨我的项目最终结构并与标准最佳实践做对比。
用良好的package设计构建项目
第一个最佳实践是,项目中任何可重用的代码都要做成一个package。如何设计package结构和有关package的最佳时间就需要单独写篇文章,我做过一次关于这个内容的分享,ppt连接在下面:
https://go-pkg-structure.dev/
把代码放进一个个package要比仅仅重用代码的好处大得多。从项目结构的角度来说,把代码放进独立package有助于把一个个拥有独立功能的代码进行分组,这样更方便其他参与开发的开发者维护代码,这对开源项目来说意义重大。
独立成一个个package的做法能让项目测试起来更容易。把功能独立成一个个package,就能用更少的依赖对一个个功能进行测试。
在创建和重构项目时,我第一件事就是写好项目需要的package,甚至会在写代码之前创建好基本项目结构。
把应用程序逻辑和接入层逻辑分开
另一个我看到用的比较多的最佳实践是把应用代码和接入层代码分离开,这里接入代码指的是main包和main()方法。
Go和其他语言一样,应用的接入层代码是main方法,当应用开始运行时就是最先执行的一部分逻辑,很可能就把所有初始化逻辑都只写在main方法里了。把各自初始化逻辑放在app包内实现是比全写在main方法里更好的做法。
把初始化逻辑放到各自package下是更好的做法,这样也更方便做测试。比如把Start() Stop() Shutdown()方法都放到app包内,写测试代码时就可以在当前包中调用启动停止这些功能了。
下面是一个app包内的实现例子:
- 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文件夹内实现的逻辑就能被服务端和客户端共享。
然而这个做法对简单的命令行应用不友好,这些应用可能是启动-执行-停止的模式。但我依然选择使用把逻辑放到app目录下的做法,这样可以把运行时逻辑都放在一起,降低了其他开发者对这个项目的理解难度。
main package里该放些什么?
把我们所有应用都放到app包里之后,也要考虑main包里有什么。很简单,main包里只有很少内容。
总的来说,我会把main包限制为“只放与用户交互的代码”。例如,如果我的项目里既有cli又有服务端逻辑, 我通常会将命令行参数解析的逻辑放入main包中。服务端和客户端cli编译的二进制文件会包含不同包,通过解析主程序包中的参数,就可以为不同cli创建独立的选项。
其他需要和用户交互的命令行应用,我也倾向于放进main包,例如:
- 命令行参数的解析
- 用户输入(很简单的输入、不参与核心逻辑)
- 解析配置文件
- 退出逻辑
- 处理信号
下面的代码是一个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)
-
- }
-
- }
一种推荐的项目结构
有种被推荐了很多的一种项目结构如下:
- internal/app - 仅在内部使用的核心应用功能
- internal/pkg/ - 仅在内部使用的package
- pkg/ - 需要和外部代码进行分享的package
- cmd/
- 把main package放在带有app名称的这个目录下
这个推荐结构的一个重点在于把核心代码放在internal/app、入口代码放进cmd/
总的来说这也是个不错的目录结构,但是这个目录结构对我不适用,看看我是怎么改的吧。
我把package放到了其他路径下
我做的与上一段的推荐结构不同的是package的路径。应用程序项目结构的子目录太多,这与独立项目结构不同,我也不喜欢用应用程序项目结构组织代码。我认为,太多子目录阻碍开发者找到功能实现的代码。
子目录多,对代码量很大的重量级项目来可能比较有必要,但最好不要对小型中型项目使用这种项目结构。
我选择把所有package都放在代码根目录这一层,例如我有个Parser包,它的路径就是parser/,ssh包的路径就是ssh/,app包路径是app/。
这个做法使找包和功能都很容易,因为包和代码都在项目第一层。再次强调下,把所有包都放在目录第一层的做法适用于包数量不大的项目,如果项目包数量变多,那还是把包放到pkg/路径下靠谱。
我没有采用internal和pkg模式
我并不觉得把代码放进internal/或者pkg/这种实践好,主要原因在于这种实践是针对app内部包。但是关于app内部包并没有明确的“内”“外”划分。对于仅在内部使用的包,很多开发者就会因为"没有其他人使用这些包"所以根本也没有用最佳实践。
我也不希望开发者在pkg路径下像维护一个个独立项目一样维护代码。实际开发中,这些包内的接口可能和一个个独立项目一样做变动,那么如果这些逻辑真的是一个个分离开的,还不如放到独立的项目里实现。
对我来说把我所有项目内部代码都放到同一个文件夹下更合理。要么是放在顶层目录下要么是放在pkg/目录下。
我没有把所有文件都放进cmd/目录
cmd/目录不适用于我的项目。我这个项目里有一个简单的CLI应用,这个应用要方便使用者下载安装。最快最方便的安装办法是使用go get命令安装:
- $ go get -u github.com/madflojo/efs2
我想要用户只需要用go get加项目url就能安装,但是如果用了cmd目录就需要让用户在url基础上增加/cmd/
- $ go get -u github.com/madflojo/efs2/cmd/efs2
这个url格式比较乱,用户还需要知道我项目结构是怎么样的才能安装。我希望项目结构能让别人更方便而不是更麻烦。所以我就把这个小应用的main.go文件放到了项目的顶层文件夹下,这样用户就可以直接通过go get命令安装应用了,另把应用的功能实现都放在app包内。
总结
本文的模式应用结果如下:
- $ 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
总的来说我对这个结构很满意,新开发者也可以比较快地上手代码。这个结构基本看目录就可以知道里面的功能了。
把代码功能一点点拆分到不同package中也帮助我提升了代码覆盖率,目前main包放在顶层目录的做法还没发现任何弊端。当然这种项目结构可能并不会对所有人适用,项目开发中还是要因地制宜。