 第1章 Go项目如何组织
第1章 Go项目如何组织
  # 第1章 Go项目如何组织
本章主要介绍如何开启一个新项目、组织源代码目录结构以及管理开发程序所需的包。设计良好的项目结构非常重要,因为当其他开发者参与你的项目或尝试使用其中的组件时,他们能够快速、轻松地找到所需内容。本章首先会解答你在开启新项目时可能遇到的一些问题,然后探讨如何使用Go语言的包系统,如何使用标准库和第三方包,以及如何让其他开发者更方便地使用你的包。本章包含以下内容:
- 创建模块
- 创建源代码目录结构
- 构建和运行程序
- 导入第三方包
- 导入特定版本的包
- 使用内部包减少API暴露面
- 使用模块的本地副本
- 工作区
- 管理模块版本
# 模块和包
首先,了解一些关于模块(module)和包(package)的知识会很有帮助。包是数据类型、常量、变量和函数的聚合单元。我们构建和测试的是包,而不是单个文件或模块。当构建一个包时,构建系统会收集并构建所有依赖包。如果包名为main,构建它将生成一个可执行文件。你可以运行main包而无需生成二进制文件(更具体地说,Go构建系统首先构建包,在临时位置生成二进制文件,然后运行它)。要使用另一个包,需要导入它。模块有助于在项目中组织多个包并解决包引用问题。一个模块就是多个包的集合。如果你在程序中导入一个包,包含该包的模块将被添加到go.mod文件中,并且该模块内容的校验和会被添加到go.sum文件中。模块还能帮助你管理程序的版本。
一个包的所有文件都存储在文件系统的单个目录下。每个包都有一个使用package指令声明的名称,包内所有源文件共享该名称。包名通常与包含这些文件的目录名匹配,但并非必须如此。例如,main包通常不在名为main/的目录下。包的目录决定了包的“导入路径”。你使用import <importPath>语句将另一个包导入到当前包中。导入一个包后,你可以使用该包的名称(不一定是目录名)来访问该包中声明的名称。
模块名指向模块内容存储在互联网版本控制系统中的位置。在撰写本文时,这并非硬性要求,所以实际上你可以创建不遵循此惯例的模块名。但为避免未来与构建系统可能出现的不兼容问题,应避免这样做。你的模块名应该是这些模块中包的导入路径的一部分。特别要注意的是,模块名的第一个部分(第一个/之前的部分)没有.的,是为标准库保留的。
这些概念如图1.1所示。

图1.1 模块和包
- go.mod文件中声明的模块名是可以找到该模块的存储库路径。
- main.go中的导入路径定义了导入包的位置。Go构建系统将使用此导入路径查找包,然后通过扫描包路径的父目录来定位包含该包的模块。找到模块后,它将被下载到模块缓存中。
- 导入模块中定义的包名是你用于访问该包符号的名称。它可能与导入路径的最后一个部分不同。在我们的示例中,包名是example,但该包的导入路径是github.com/bserdar/go-recipes-module。
- Example函数位于- example包中。
- example包还导入了同一模块中包含的另一个包。构建系统会识别该包属于同一模块,并使用下载的模块版本来解析引用。
# 技术要求
你需要在计算机上安装较新版本的Go语言,才能构建和运行本章中的示例。本书中的示例使用Go 1.22版本进行测试。
# 创建模块
当你开始着手一个新项目时,首先要做的是为它创建一个模块。模块是Go语言管理依赖关系的方式。
# 操作方法
- 创建一个目录来存储新模块。
- 在该目录下,使用go mod init <moduleName>命令创建新模块。go.mod文件标记了模块的根目录。该目录下的任何包都将成为此模块的一部分,除非该目录也有一个go.mod文件。尽管构建系统支持这种嵌套模块,但使用它们并没有太多好处。
- 要导入同一模块中的包,使用moduleName/packagePath。当moduleName与模块在互联网上的位置相同时,就不会对所引用的内容产生歧义。
- 对于模块下的包,模块的根目录是包含go.mod文件的最近父目录。模块内对其他包的所有引用都将在模块根目录下的目录树中查找。
- 首先创建一个目录来存储项目文件。你当前的目录可以位于文件系统的任何位置。我见过有人使用一个通用目录来存储他们的工作,例如$HOME/projects(在Windows系统中是\user\myUser\projects)。你也可以选择使用与模块名相似的目录结构,比如$HOME/github.com/mycompany/mymodule(在Windows系统中是\user\myUser\github.com\mycompany\mymodule)。根据你的操作系统,你可能会找到更合适的位置。
警告
不要在Go安装目录的
src/目录下工作。那是Go标准库的源代码。
提示
你不应该设置
GOPATH环境变量;如果必须保留它,也不要在该路径下工作。这个变量用于旧的操作模式(Go版本<1.13),现在已被弃用,转而使用Go模块系统。
在本章中,我们将使用一个简单的程序,该程序在网页浏览器中显示一个表单,并将输入的信息存储到数据库中。
创建模块目录后,使用go mod init命令。以下命令将在projects目录下创建一个webform目录,并在其中初始化一个Go模块:
$ cd projects
$ mkdir webform
$ go mod init github.com/examplecompany/webform
2
3
这将在该目录下创建一个go.mod文件,内容如下:
module github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform
go 1.21.0
2
使用一个能够描述模块存储位置的名称。始终使用类似<host>.<domain>/location/to/module的URL结构(例如github.com/bserdar/jsonom)。特别要注意的是,模块名的第一个部分应该包含一个点(.)(Go构建系统会检查这一点)。
所以,尽管你可以将模块命名为webform或mywork/webform之类的名称,但不建议这样做。不过,你可以使用类似workspace.local/webform的名称。如果不确定,就使用代码存储库的名称。
# 创建源代码目录结构
创建好新模块后,就该考虑如何组织源文件了。
# 操作方法
根据项目的不同,有几种既定的惯例:
- 使用标准布局,例如 https://github.com/golang-standards/project-layout (opens new window)。
- 功能单一的库可以将所有导出的名称放在模块根目录,实现细节可选择存储在内部包中。生成单个可执行文件且可复用组件较少或没有的模块,也可以使用扁平目录结构。
对于像我们这样生成可执行文件的项目,https://github.com/golang-standards/project-layout (opens new window) 中规定的结构很适用。那么,让我们遵循这个模板:
webform/
	go.mod
	cmd/
		webform/
			main.go
	web/
		static/
	pkg/
		...
	internal/
		...
	build/
		ci/
	package/
	configs/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这里,cmd/webform目录将包含main包。如你所见,这是一个包名与所在目录名不匹配的例子。Go构建系统将使用目录名创建可执行文件,所以当你构建cmd/webform下的main包时,会得到一个名为webform的可执行文件。如果你在单个模块中构建多个可执行文件,可以在cmd/目录下创建一个与程序名匹配的目录,并在其中创建一个单独的main包。
pkg/目录将包含程序中导出的包。这些包可以被导入并在其他项目中复用。
如果你有在项目外部不可用的包,应该将它们放在internal/目录下。Go构建系统会识别这个目录,并且不允许从internal/目录所在目录之外的其他包中导入internal/目录下的包。通过这种设置,我们webform程序的所有包都可以访问internal/目录下的包,但导入这个模块的其他包无法访问。
web/目录将包含任何与Web相关的资源。在这个例子中,我们会有一个web/static目录,用于存放静态网页。如果你有服务器端模板,还可以添加web/templates目录来存储它们。
build/package目录应该包含用于云、容器、打包系统(如dep、rpm、pkg等)的打包脚本和配置。
build/ci目录应该包含持续集成工具脚本和配置。如果你使用的持续集成工具要求其文件存放在特定目录而非此目录,你可以创建符号链接,或者直接将这些文件放在工具所需的位置,而不是/build/ci目录。
configs/目录应该包含配置文件模板和默认配置。你也会看到一些项目将main包放在模块根目录下,省略了cmd/目录。当模块只有一个可执行文件时,这是一种常见的布局:
webform/
    go.mod
    go.sum
    main.go
    internal/
    	...
    pkg/
    	...
2
3
4
5
6
7
8
还有一些模块没有main包。这些通常是可以导入到你的项目中的库。例如,https://github.com/google/uuid (opens new window) 使用扁平目录结构,包含了流行的UUID实现。
# 构建和运行程序
现在你已经有了一个模块以及包含一些Go文件的源代码树,接下来就可以构建或运行程序了。
# 操作方法……
- 使用go build构建当前包。
- 使用go build ./path/to/package构建指定目录中的包。
- 使用go build <moduleName>构建一个模块。
- 使用go run运行当前的主包(main package)。
- 使用go run ./path/to/main/package构建并运行指定目录中的主包。
- 使用go run <moduleName/mainpkg>构建并运行指定目录下模块的主包。
我们来编写启动HTTP服务器的主函数。以下代码片段来自cmd/webform/main.go:
package main
import (
    "net/http"
)
func main() {
    server := http.Server{
        Addr:    ":8181",
        Handler: http.FileServer(http.Dir("web/static")),
    }
    server.ListenAndServe()
}
2
3
4
5
6
7
8
9
10
11
12
13
目前,main函数仅导入了标准库中的net/http包。它启动了一个服务器,用于提供web/static目录下的文件服务。请注意,要使该程序正常运行,必须从模块根目录运行:
$ go run ./cmd/webform
始终运行主包,避免使用go run main.go。使用go run main.go会运行main.go文件,但不包含主包中的任何其他文件。如果主包中还有包含辅助函数的其他.go文件,这种方式将会失败。
如果你从其他目录运行这个程序,它将无法找到web/static目录,因为这是一个相对路径,会相对于当前目录进行解析。
当你通过go run运行程序时,程序的可执行文件会被放置在一个临时目录中。要构建可执行文件,请使用以下命令:
$ go build ./cmd/webform
这会在当前目录中创建一个二进制文件。二进制文件的名称将由主包的最后一段路径决定,在这个例子中是webform。要构建一个不同名称的二进制文件,请使用以下命令:
$ go build -o wform ./cmd/webform
这将构建一个名为wform的二进制文件。
# 导入第三方包
大多数项目都会依赖于必须导入的第三方库。Go模块系统(Go module system)用于管理这些依赖项。
# 操作方法……
- 找到项目中需要使用的包的导入路径。
- 在使用外部包的源文件中添加必要的导入语句。
- 使用go get或go mod tidy命令将模块添加到go.mod和go.sum文件中。如果该模块之前未下载过,此步骤还将下载该模块。
提示
你可以使用 https://pkg.go.dev (opens new window) 来查找包。它也是发布你所发布的Go项目文档的地方。
让我们为上一节的程序添加一个数据库,以便存储网页表单提交的数据。在这个练习中,我们将使用SQLite数据库。
修改cmd/webform/main.go文件,导入数据库包并添加必要的数据库初始化代码:
package main
import (
    "net/http"
    "database/sql"
    _ "modernc.org/sqlite"
    "github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp1/webform/pkg/commentdb"
)
func main() {
    db, err := sql.Open("sqlite", "webform.db")
    if err != nil {
        panic(err)
    }
    
    commentdb.InitDB(db)
    server := http.Server{
        Addr:    ":8181",
        Handler: http.FileServer(http.Dir("web/static")),
    }
    
    server.ListenAndServe()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_ "modernc.org/sqlite"这一行将SQLite驱动程序导入到项目中。下划线是空白标识符,这意味着sqlite包不会被此文件直接使用,导入它只是为了其副作用。如果没有这个空白标识符,编译器会抱怨导入未被使用。在这个例子中,modernc.org/sqlite包是一个数据库驱动程序,当你导入它时,其init()函数会向标准库注册所需的驱动程序。
下一个声明从我们的模块中导入commentdb包。请注意,导入包时使用了完整的模块名称。构建系统会将这个导入声明的前缀识别为当前模块名称,并将其转换为本地文件系统引用,在这个例子中是webform/pkg/commentdb。
在db, err := sql.Open("sqlite", "webform.db")这一行,我们使用database/sql包中的Open函数启动一个SQLite数据库实例。sqlite指定了数据库驱动程序,它是由导入的_ "modernc.org/sqlite"注册的。
commentdb.InitDB(db)语句将调用commentdb包中的一个函数。
现在,让我们看看commentdb.InitDB函数的内容。这是webform/pkg/commentdb/initdb.go文件:
package commentdb
import (
    "context"
    "database/sql"
)
const createStmt = `create table if not exists comments (
    email TEXT,
    comment TEXT)`
func InitDB(conn *sql.DB) {
    _, err := conn.ExecContext(context.Background(), createStmt)
    if err != nil {
        panic(err)
    }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
如你所见,这个函数会在数据库表尚未创建时创建它们。
请注意InitDB的大小写形式。如果一个包中声明的符号名称的首字母是大写字母,那么这个符号可以从其他包中访问(即它是导出的)。如果不是大写字母,该符号只能在声明它的包内使用(即它未被导出)。createStmt常量未被导出,对其他包来说是不可见的。
让我们构建这个程序:
$ go build ./cmd/webform
cmd/webform/main.go:7:2: no required module provides package
modernc.org/sqlite; to add it:
go get modernc.org/sqlite
2
3
4
你可以运行go get modernc.org/sqlite将该模块添加到项目中。或者,你也可以运行以下命令:
$ go get
这将获取所有缺失的模块。另外,你还可以运行以下命令:
$ go mod tidy
go mod tidy会下载所有缺失的包,使用更新后的依赖项更新go.mod和go.sum文件,并删除对任何未使用模块的引用。go get只会下载缺失的模块。
# 导入特定版本的包
有时,由于API不兼容或依赖特定行为,你需要使用第三方包的特定版本。
# 操作方法……
- 要获取包的特定版本,请指定版本标签:
$ go get modernc.org/sqlite@v1.26.0
- 要获取包的特定主版本的最新发布版本,可使用以下命令:
$ go get gopkg.in/yaml.v3
或者,使用这个命令:
$ go get github.com/ory/dockertest/v3
- 要导入最新可用版本,可使用以下命令:
$ go get modernc.org/sqlite
- 你还可以指定不同的分支。如果存在devel分支,以下命令将从该分支获取模块:
$ go get modernc.org/sqlite@devel
- 或者,你可以获取特定的提交版本:
$ go get modernc.org/sqlite@a8c3eea199bc8fdc39391d5d261eaa3577566050
如你所见,你可以使用@revision约定获取模块的特定修订版本:
$ go get modernc.org/sqlite@v1.26.0
URL中的修订部分由版本控制系统(在这个例子中是Git)进行评估,所以任何有效的Git修订语法都可以使用。
提示:
你可以查看Go安装目录下的
src/cmd/go/alldocs.go文件,了解支持哪些版本控制系统。
这也意味着你可以使用分支:
$ go get modernc.org/sqlite@master
提示 https://gopkg.in (opens new window) 服务会将版本号转换为与Go构建系统兼容的URL。有关如何使用它的说明,请参考该网站上的内容。
# 使用模块缓存
模块缓存(module cache)是Go构建系统存储下载的模块文件的目录。本节将介绍如何使用模块缓存。
# 操作方法……
模块缓存默认位于$GOPATH/pkg/mod目录下,当未设置GOPATH时,其路径为$HOME/go/pkg/mod:
- 默认情况下,Go构建系统在模块缓存中创建只读文件,以防止意外修改。
- 要验证模块缓存未被修改且反映了模块的原始版本,可使用以下命令:
go mod verify
- 要清理模块缓存,可使用以下命令:
go clean -modcache
有关模块缓存的权威信息来源是Go Modules Reference(https://go.dev/ref/mod (opens new window))。
# 使用内部包减少API暴露面
并非每一段代码都是可复用的。较小的API暴露面(API surface)能让其他人更轻松地适配和使用你的代码。因此,你不应导出特定于你程序的API。
# 操作方法……
创建内部包(internal package)以向其他包隐藏实现细节。内部包下的任何内容只能从包含该内部包的包下的其他包中导入,也就是说,myproject/internal下的任何内容只能从myproject下的包中导入。
在我们的示例中,我们将数据库访问代码放在了一个包中,其他程序可以访问该包。然而,将HTTP路由暴露给其他人是没有意义的,因为它们特定于这个程序。所以,我们将把它们放在webform/internal包下。
这是internal/routes/routes.go文件:
package routes
import (
    "database/sql"
    "github.com/gorilla/mux"
    "net/http"
)
func Build(router *mux.Router, conn *sql.DB) {
    router.Path("/form").
        Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            http.ServeFile(w, r, "web/static/form.html")
        })
    router.Path("/form").
        Methods("POST").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            handlePost(conn, w, r)
        })
}
func handlePost(conn *sql.DB, w http.ResponseWriter, r *http.Request) {
    email := r.PostFormValue("email")
    comment := r.PostFormValue("comment")
    _, err := conn.ExecContext(r.Context(), "insert into comments (email,comment) values (?,?)",
        email, comment)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    http.Redirect(w, r, "/form", http.StatusFound)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
然后,我们修改main.go文件以使用内部包:
package main
import (
    "database/sql"
    "net/http"
    "github.com/gorilla/mux"
    _ "modernc.org/sqlite"
    "github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp1/webform/internal/routes"
    "github.com/PacktPublishing/Go-Recipes-for-Developers/src/chp1/webform/pkg/commentdb"
)
func main() {
    db, err := sql.Open("sqlite", "webform.db")
    if err != nil {
        panic(err)
    }
    
    commentdb.InitDB(db)
    r := mux.NewRouter()
    routes.Build(r, db)
    server := http.Server{
        Addr:    ":8181",
        Handler: r,
    }
    server.ListenAndServe()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 使用模块的本地副本
有时,你会同时处理多个模块,或者从代码仓库下载一个模块,对其进行一些修改,然后希望使用修改后的版本,而不是代码仓库中的版本。
# 操作方法……
在go.mod文件中使用replace指令,指向包含模块的本地目录。
让我们回到之前的示例——假设你想对sqlite包进行一些修改:
- 克隆它:
$ ls
	webform
$ git clone git@gitlab.com:cznic/sqlite.git
$ ls
    sqlite
    webform
2
3
4
5
6
7
8
9
10
- 修改项目下的go.mod文件,指向模块的本地副本。go.mod文件内容变为如下:
module github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform
go 1.22.1
replace modernc.org/sqlite => ../sqlite
require (
    github.com/gorilla/mux v1.8.1
    modernc.org/sqlite v1.27.0
)
2
3
4
5
6
7
8
9
10
- 现在,你可以在系统上的sqlite模块中进行修改,这些修改将被构建到你的应用程序中。
# 处理多个模块——工作区(workspace)
有时,你需要处理多个相互依赖的模块。一种便捷的方法是定义一个工作区。工作区就是一组模块。如果工作区内的一个模块引用了同一工作区中另一个模块的包,它将在本地进行解析,而不是通过网络下载该模块。
# 操作方法……
- 要创建一个工作区,你必须有一个包含所有工作模块的父目录:
$ cd ~/projects
$ mkdir ws
$ cd ws
2
3
- 然后,使用以下命令启动一个工作区:
$ go work init
这将在该目录中创建一个go.work文件。
3. 将你正在处理的模块放入这个目录。
让我们用示例来演示。假设我们有以下目录结构:
$HOME/
    projects/
    	ws/
    		go.work
    		webform
    		sqlite
2
3
4
5
6
现在,我们想将webform和sqlite这两个模块添加到工作区。为此,使用以下命令:
$ go work use ./webform
$ go work use ./sqlite
2
这些命令会将两个模块添加到你的工作区。现在,webform模块中对sqlite的任何引用都将解析为使用该模块的本地副本。
# 管理模块版本
Go工具使用语义化版本控制系统。这意味着版本号采用X.Y.z的形式,具体分解如下:
- X:在进行重大版本发布时增加,重大版本发布不一定向后兼容。
- Y:在进行次要版本发布时增加,次要版本发布是增量式的,且向后兼容。
- z:在进行向后兼容的补丁(patch)发布时增加。
你可以在https://semver.org (opens new window)上了解更多关于语义化版本控制的信息。
# 操作方法……
- 要发布补丁版本或次要版本,使用新的版本号标记包含你修改的分支:
$ git tag v1.0.0
$ git push origin v1.0.0
2
- 如果你想发布一个与之前版本的API不兼容的新版本,你应该增加该模块的主版本号。要发布模块的新主版本,使用一个新分支:
$ git checkout -b v2
然后,在go.mod文件中更改模块名称,使其以/v2结尾,并更新源文件树中的所有引用,以使用模块的/v2版本。
例如,假设你发布了webform模块的第一个版本v1.0.0。然后,你决定添加新的API端点。这不是一个破坏性的更改,所以你只需增加次要版本号——v1.1.0。但后来发现你添加的一些API导致了问题,所以你删除了它们。现在,这是一个破坏性的更改,所以你应该发布v2.0.0版本。你该怎么做呢?
答案是,在版本控制系统中使用一个新分支。创建v2分支:
$ git checkout -b v2
然后,更改go.mod文件以反映新版本:
module github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform/v2
go 1.22.1
require (
    ...
)
2
3
4
5
6
7
如果模块中有多个包,你必须更新源文件树,以便模块内对包的任何引用也使用v2版本。
提交并推送新分支:
$ git add go.mod
$ git commit -m "New version"
$ git push origin v2
2
3
要使用新版本,你现在必须导入包的v2版本:
import "github.com/PacktPublishing/Go-Recipes-for-Developers/chapter1/webform/v2/pkg/commentdb"
# 总结与延伸阅读
本章重点介绍了设置和管理Go项目的概念及操作方法。这绝不是一份详尽的参考资料,但这里介绍的方法应该能让你掌握有效使用Go构建系统的基础知识。
Go模块的权威指南是Go Modules Reference(https://go.dev/ref/mod (opens new window))。查看Managing dependencies链接(https://go.dev/doc/modules/managing-dependencies (opens new window)),可获取关于依赖管理的详细讨论。
在下一章中,我们将开始处理文本数据。
