Go with module 的项目结构实践

Go初学者经常遇到的一个常见问题是“如何组织我的代码?”,这个问题可以分解如下:

  1. 怎么样的项目结构方便用户导入我的代码?
  2. 编译打包用的命令怎么在代码中放置(译者注:或者说,用户如何运行)?
  3. 使用 Go with module 对原来的项目结构方式有什么影响?
  4. 多个 package 如何在一个 moudule 中共存?

网上能够找到的教程要么代码比较过时,要么过于复杂,因此我想写一个既简单又尽可能新一点的教程,并且提供一个简单的示例。 对于有经验的大佬可能也会带来一些帮助。

通过本文的项目结构可以实现:

  1. 一个 module 包含多个 package,每个 package 都可以单独的 import。同时也支持使用导入同一 module 的其他 package
  2. 只能被同一 modulepackage 导入的 Internal packages
  3. 用户可以使用 go get 安装的命令行或可执行程序。

本文中提到的用户,指的是使用这个模块的开发人员,方法是将模块 import 到他们的代码中,或者通过 go get

前言

本文展示的示例代码地址:https://github.com/eliben/modlib

在这个示例中,项目路径直接设置为和 module 同名,go.mod 文件包含了:

module github.com/eliben/modlib

在 Go 项目中,项目名和Github路径同名是很常见的操作。Go也支持自定义命名,但是这个不在本文的讨论范围。你也可以把 github.com/eliben/modlib 重命名为 github.com/your-handle/your-project 或者 your-project-domain.io,这个都是可以的。

但是模块名称非常重要,因为它就是用户代码中导入的名称:

Import path example with arrows showing module name and package

项目结构

示例项目的文件和目录结构如下:

├── LICENSE
├── README.md
├── config.go
├── go.mod
├── go.sum
├── clientlib
│   ├── lib.go
│   └── lib_test.go
├── cmd
│   ├── modlib-client
│   │   └── main.go
│   └── modlib-server
│       └── main.go
├── internal
│   └── auth
│       ├── auth.go
│       └── auth_test.go
└── serverlib
    └── lib.go

一些解释:

go.mod 是模块定义文件,它包含了所有依赖的其他模块。当然,目前这个示例项目没有任何依赖。模块依赖和本文要讨论的问题也没有太大关系,感兴趣的可以去看下官方的 blog 文章:第一篇第二篇第三篇

go.sum 是模块依赖项的校验和(译者注:用于比对本地依赖模块的版本,如果不一致会触发下载),由 go 工具管理,你不必关心它,但是这个文件应该和 go.mod 一起放在源代码控制中。

config.go 这是第一个需要我们关心的代码文件,包含了一个简单的功能1

package modlib

func Config() string {
  return "modlib config"
}

文件中最关键的是第一行 package modlib。由于这个文件在项目结构中是最顶层,所以将程序包名作为模块的名称。通过这种结构你可以在自己的代码中直接导入 github.com/eliben/modlib,代码如下(Playground Link):

package main

import "fmt"
import "github.com/eliben/modlib"

func main() {
  fmt.Println(modlib.Config())
}

显而易见的,如果你的模块要提供一个 package,或者你要从模块的顶层 package 中导出代码,那么你可以将所有代码放在这个模块的顶层目录中,并且将这个 package 作为模块路径的最后一个部分(除非你使用更灵活的虚导入2)。

其他 packages

接下来介绍下 clientlib 下的代码。

clientlib/lib.goclientlib package 的文件。这个文件的命名不是关键,起什么名字都可以,重要的是代码第一行声明的 package 名称必须声明为 clientlib。文件内容如下:

package clientlib

func Hello() string {
  return "clientlib hello"
}

在用户代码中,可以使用 github.com/eliben/modlib/clientlib 导入这个 packace,如下(Playground Link):

package main

import "fmt"
import "github.com/eliben/modlib"
import "github.com/eliben/modlib/clientlib"

func main() {
  fmt.Println(modlib.Config())
  fmt.Println(clientlib.Hello())
}

serverlib 目录包含了用户可以导入的另外一个 package。那里面没有什么新的知识点,只是展示了多个 package 如何并存在模块中。

关于 package 导入路径说明:根据用户的实际需求,可长可短。用户可见的 package 由模块根目录的相对路径确定,比如我们有个叫 clientlib/tokens 并且其中有代码的目录,如果用户需要的话,可以直接 import 路径为 github.com/eliben/modlib/clientlib/tokenspackage 以使用。

对于某些模块来说,有时直接 import 顶级目录就足够了。比如直接导入 modlib,就没有进行子目录的直接 import,但是所有的代码都在 modlibpackage 中,用户可以通过调用时多级调用的方式来使用。

命令行/可执行程序

有时候一些 Go 工程不是用于导出 package,而是直接用于发布可执行程序或者命令行脚本。如果你的项目不是用于这个目的,请忽略这个章节,不要在项目结构中添加 cmd 目录。

项目中提供所有命令行/可执行程序一般都放在 cmd 目录中,在本文示例中的命名为:

Path for commands in a repository

用户可以使用 go 命令行按照以下方式安装:

$ go get github.com/eliben/modlib/cmd/cmd-name

# Go downloads, builds and installs cmd-name into the default location.
# The bin/ directory in the default location is often in $PATH, so we can
# just invoke cmd-name now

$ cmd-name ...

在项目modlib中,提供了2个不同的命令行程序示例:mobile-clientmobile-server。在每一个示例中,代码都在package main中。每个命令行程序的入口文件都是main.go,但这不是必须的,只要是在代码首行指定package main的都可以用作入口文件。

示例的modlib是一个直接能够运行的项目,你可以在你的设备上安装并运行:

$ go get github.com/eliben/modlib/cmd/modlib-client
$ modlib-client
Running client
Config: modlib config
clientlib hello

$ go get github.com/eliben/modlib/cmd/modlib-server
$ modlib-server
Running server
Config: modlib config
Auth: thou art authorized
serverlib hello

# Clean up...
$ rm -f `which modlib-server` `which modlib-client`

现在我们关注一下 modlib-server 的代码

package main

import (
  "fmt"

  "github.com/eliben/modlib"
  "github.com/eliben/modlib/internal/auth"
  "github.com/eliben/modlib/serverlib"
)

func main() {
  fmt.Println("Running server")
  fmt.Println("Config:", modlib.Config())
  fmt.Println("Auth:", auth.GetAuth())
  fmt.Println(serverlib.Hello())
}

这段代码展示了如何从 modlib 模块中,导入其他的代码。请注意这里的用法,在Go语言中使用绝对路径导入是最好的方式。这种方式同时可以用于软件包和命令行/可执行程序。如果 clientlib 中的代码需要导入顶级目录的函数,可以使用导入 github.com/eliben/modlib 的方式。

内部的 Packages

另一个重要概念是在模块内部使用的 package,即不想让用户可以导出的内部(或者私有) package。这个概念非常重要对于符合语义化版本控制规范(SemVer)的 Go with module 项目,因为当你发布v1版本时,如果没有使用内部 packages所有的代码都将成为公开的API,无法满足语义化版本控制规范。因此,需要对用户只暴露需要能够实现功能的最小化API,模块自身的逻辑代码应当私有化。

package 路径中,Go编译器会把包含的 internal 关键字视为特殊路径,同一个 modulepackage 可以正常导入,但是用户(即 module 外的代码)无法导入,强行导入会收到以下错误信息:

use of internal package github.com/eliben/modlib/internal/auth not allowed

在示例的 modlib 项目中,只有一个 内部 packages,在实际的项目中,一般会有一堆内部 packages

在示例的 modlib 项目中,只有一个 内部 packages,在实际的项目中,一般会有一堆内部 packages

如果你想判断一个 package 是否应该设置为内部的,默认的回答应该是 YES。因为一个 内部 package 修改为公开只需要重命名+重新编译即可让用户导入,而一个公开 package 如果要修改为内部时则会非常痛苦(用户可能已经依赖了这个 package)。在符合语义化版本规范的文档 module 中(v1或者更高版本),由于破坏了向下兼容性这将体现为一个主版本号更新

我的建议将尽可能多的内容放入内部管理,不只是 Go packages,例如内部的站点源代码、用于项目内部的工具和脚本等。这个建议确保了用户看到的项目根目录是最小化、最清晰的。在某种程度上,这也是一种自主文档(self-documentation)的方式。用户在Github页面中可以马上看到并且了解他们所需要的内容。由于用户一般不会使用我用来开发模块的东西,因此将这些内容隐藏在内部将很有意义。

附注

  1. 需要注意的是 config.go 这个文件名是自定义的,并不是每个项目都应包含这个文件,这个只是 这个 项目结构的示例。请具体情况具体操作。另外,本文示例的只描述项目结构,所有的package 和文件名都是可以任意命名的。 ↩︎
  2. 译者注:vanity imports,这个名词居然没有比较正式的中文翻译。直接翻译的话是虚荣导入。如果有人有更好的翻译建议的话,可以留言哈~ ↩︎