第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版本进行测试。本章的代码可以在https://github.com/PacktPublishing/Go-Recipes-for-Developers/tree/main/src/chp1 (opens new window) 找到 。
# 创建模块
当你开始着手一个新项目时,首先要做的是为它创建一个模块。模块是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)),可获取关于依赖管理的详细讨论。
在下一章中,我们将开始处理文本数据。