Go Module Package Workspace 参考笔记

发布时间 2023-09-09 16:11:52作者: 菜皮日记

这篇笔记整理记录了在阅读 go 官方文档中对于依赖管理、包引入、多模块开发时的工作区等相关内容。

module path

module path 可以唯一标识一个 module,也是定位一个 module 下的 package 时的前缀。

module path 应该可以表明该 module 是做什么的以及去哪里可以下载到,一般由代码仓库中的位置以及主版本号组成,当主版本号是 v1 时可省略,从 v2 之后需要明确指出。

module main

go 1.20

package 命名规范

go 的源码被组织成 package 包的形式,包名在 go 源文件中定义,反过来说每一个 go 源文件都需要表明其所在的包。

包名规范

包名应该尽量简短清晰,且使用小写字母,不包含下划线,也不使用驼峰表示法,例如 time 包提供时间相关工具,list 包是对双向链表的实现,http 包提供了 HTTP 客户端和服务端的实现。反观 computeServiceClient 和 priority_queue 都不是一个好的包名。包名可以适当做简略缩写:strconv (string conversion)、syscall (system call)、fmt (formatted I/O)

包中的函数名或变量名不要重复包名,因为当外部客户端引用包内的函数或变量时,一般都是通过包名调用的,所以包内的内容也就不需要再重复一遍包名了。例如 http 包中如果有一个名为 HTTPServer 的方法就不是很好,而应直接叫做 Server 方法就可以了。

如果某个叫 pkg 的包中的方法返回值类型是 pkg.Pkg 或者是 *pkg.Pkg,那么方法名可以简写,例如

start := time.Now()                                  // start is a time.Time
t, err := time.Parse(time.Kitchen, "6:06PM")         // t is a time.Time
ctx = context.WithTimeout(ctx, 10*time.Millisecond)  // ctx is a context.Context
ip, ok := userip.FromContext(ctx)                    // ip is a net.IP

相对的如果函数返回的是 pkg.T,而 T 不是 Pkg 的时候,函数名最好能够体现返回值是什么

d, err := time.ParseDuration("10s")  // d is a time.Duration
elapsed := time.Since(start)         // elapsed is a time.Duration
ticker := time.NewTicker(d)          // ticker is a *time.Ticker
timer := time.NewTimer(d)            // timer is a *time.Timer

避免使用笼统的、无实际意义的名字

无实际意义的名字如 util, common,让使用者无法一眼看出包具体是做什么的。

避免让一个包大而全,尽量拆成独立的小包

这个名字叫 util 很笼统,而内部方法有一些是处理 string set 的,那么应该将 string set 的处理单独提出来成为一个独立的包

package util
func NewStringSet(...string) map[string]bool {...}
func SortStringSet(map[string]bool) []string {...}

这样更加清晰,功能也更聚焦

package stringset
func New(...string) map[string]bool {...}
func Sort(map[string]bool) []string {...}

package 导入路径

常常将 import aa/bb/cc/pkg 说成是导入包,但实际上 import 后面的路径是 module path + 该包内的 go 源文件相对于其 go.mod 的相对路径,所以这里仅仅指向到了路径而并没有具体指向到源文件上,所以确切的说应该叫导入包路径。不过因为默认建议包名与路径名的最后一个元素相同,所以将其称之为“导入包”好像也可以。

举个例子,如下面结构中,go.mod 中指明整个 module 叫做 aaa/bbb/ccc/pkg

x_file.go 相对 go.mod 同级别,可以复用 module path 最后一位直接叫 pkg package pkglib/url.go 路径相对 go.mod 位于子目录 lib 中,所以用 package lib 表示

这里的 x_file.go 文件名主要是体现文件名对包没有影响,重要的是路径

.
├── go.mod
├── lib
│   └── url.go
└── x_file.go
// x_file.go
package pkg

import "fmt"

func Func() {
	fmt.Println("This is pkg package")
}
// lib/url.go
package lib

func Url() string {
	return "www.baidu.com"
}

使用的时候这样 import

package main

import (
	"aaa/bbb/ccc/pkg"
	"aaa/bbb/ccc/pkg/lib"
)

func main() {
	pkg.Func()
	url := lib.Url()
	println(url)
}

也正是因为包名使用包路径中最后一个元素作为包名,这就可能存在包名重复的情况,如 runtime/pprof 和 net/http/pprof 包,包名都是 pprof,遇到重复的情况只需要给其中一个包或者两个包分别起个别名。

import (
    "context"                // package context
    "fmt"                    // package fmt
    "golang.org/x/time/rate" // package rate
    "os/exec"                // package exec
)

main.go 同级 package 导入问题

main.go 使用同一个包下另一个文件中的函数,运行 go run main.go 报未定义函数

src/main/main.go:12:2: undefined: LibFunc

.
├── lib.go
└── main.go
---

// lib.go
package main

func LibFunc() {
	println("lib func exec")
}

// main.go
package main

func main() {
	LibFunc()
}

因为 go run main.go 只编译了 main.go 文件所以找不到 LibFunc 的函数定义,可以使用 go run main.go lib.go 指定编译 main.go 和 lib.go,也可以 go run . 编译当前目录下所有文件。

关于 module 版本号的规范

版本号由三个非负整数,从左到右的主要、次要和补丁版本,用点分隔。

例如 v0.0.0 、 v1.12.134 、 v8.0.5-pre 和 v2.0.9+meta 都是有效版本号。

关于版本号升级的规范

版本号每个部分实际上代表着版本是否稳定,以及是否与老版本兼容,发生如下几种情形时需要改变版本号

  • 大版本升级:新版本的接口和功能向后不兼容的时,必须递增主要版本,并且次要版本和修补程序版本必须设置为零。
  • 小版本升级:新版本的接口和功能可以向后兼容时,只需要增加次要版本即可,而补丁版本必须设置为零。即小版本升级。
  • 补丁版本升级:新版本的接口和功能没有变化,只是修改错误和优化,只需要升级补丁版本即可。
  • 预发布版本:预发行后缀表示版本是预发行版本。预发布版本在相应的发布版本之前排序。例如, v1.2.3-pre 在 v1.2.3 之前。
  • 伪版本 pseudo-version:对于不遵守上述规范的情况,go 会对该 module 生成伪版本,伪版本是特殊格式的预发行版本,它对版本控制存储库中有关特定修订的信息进行编码。例如, v0.0.0-20191109021931-daa7c04131f5 是伪版本。基本上就是由虚构的三段版本号+UTC日期+仓库提交哈希的前12个字符

module v2 及以上版本的特殊处理

假设有一个 module 的 v1 版本的 module path 是 example.com/mod,有一天该 module 升级成了 v2,其 v2 版本的 module path 必须在是 example.com/mod/v2。这里强调一下,区分不同 module 就是依靠 module path 来的,module path 不同,也就意味着是两个不同的 module。

这样一来版本 v1 和 v2 就能在同一个项目中共存,且支持同时被引用,这一点可能在有着其他编程语言经验的人看来有点奇怪,至少在 Python 中默认不支持一个项目中同时使用某个库的不同版本,要想支持需要自己处理包引入,手动做一些 track。

不过换个角度来说,按照上一节中规定的版本号升级规范,如果一个 module 升级了大版本,意味着有些功能已经不向后兼容,v1 和 v2 的 module path 已经不同了,说的更彻底一点,可以理解成 v1 和 v2 是两个不同 module,那么如果把 v2 版本当成一个全新的 module 来使用,这样的话 v1 v2 可以共存好像也说得过去。

不过总觉得这种做法有点怪怪的,版本依赖的事儿就应该交给版本管理工具来做,手动在 module path 中人为加入 v2 算咋回事。有的 module 开发者看不惯,会把不兼容的版本换个名字从头再来,或者干脆一直都在 v1 里面开发永远不升级 v2。

让我不太能理解或不好接受的一个根源在于,go 强制把一个普通的版本号 v2 赋予了特殊的含义,使得任意一个 module 升级到 v2 实质上就变成了另一个新 module,而升级到 v3 则又将是另一个新 module。这种将某一符号特殊化的做法很类似魔数或固定写死一段代码的做法,会让我觉得不具备合理性。

这里有一篇吐槽应该说出了不少开发者的心声:https://colobu.com/2021/06/28/dive-into-go-module-2/

go.mod 文件中的 replace 指令

replace 指令可以将依赖的某个 module 替换为来自另一处的 module,这里的“另一处”指的是另一个远程仓库,也可以是本地某个文件路径。

replace 一般用于两种情况,第一种就是替换掉远程仓库 A 里的 module,改用从仓库 B 中获取,也包括替换掉版本号。另一种情况就是将 module 指向本地某个路径。

replace golang.org/x/net v1.2.3 => example.com/fork/net v1.4.5

replace (
    golang.org/x/net v1.2.3 => example.com/fork/net v1.4.5
    golang.org/x/net => example.com/fork/net v1.4.5
    golang.org/x/net v1.2.3 => ./fork/net
    golang.org/x/net => ./fork/net
)

MVS Minimal Version Selection

Go 使用一种称为最小版本选择 (MVS) 的算法,通过 module 的 go.mod 文件,一层层沿着依赖关系图找出符合要求的各个依赖 module 的版本,组成构建列表 build list。

MVS 从 main module 开始并遍历该图,跟踪每个模块依赖的最高版本,在遍历结束后,所需的最高版本所组成的就是满足整个 module 要求的最低依赖版本了。下图 MVS 最终返回的构建列表有 A 1.2、B 1.2、C 1.4 和 D 1.2

如果依赖管理中存在 replace 的情况,replace 进来的新 module 可能会有不同的依赖关系,MVS 也会把这种情况考虑进去,如图,原本的 C1.4 版本被 replace 成了 R,而 R 依赖 D1.3,这样综合下来最后的构建列表结果就是 A 1.2、B 1.2、R 和 D 1.3

如果依赖管理中有 exclude 的情况,那么会将被移除的 module 的版本要求顺延到下一个更高的版本,如图,C1.3 被 exclude 了,对 C module 的要求就变成了 1.3 下一个高版本即 1.4,最后的构建列表为: A 1.2、B 1.2、C 1.4 和 D 1.2

go get 可以用来升级一组 module,如果做升级操作,MVS 也会做出相应调整

go get 也可以用来降级 module,如发现 C1.4 有问题需要回退到 C1.3,那么依赖 C1.4 的上层 module 也会被回退。

workspaces go.work 工作空间/工作区与多 module 引用管理

说实话 golang 的包管理真的很绕,一开始没有包管理,后来遇到点问题解决点问题,逐步改进,导致包管理相关规范或实践改动了好几版。

一开始没有包管理,全都要求将依赖包放到 GOPATH 环境变量下面。

在 go 1.11 版本中引入了 module 的概念,通过 go mod 命令来管理依赖,不再强制要求将包放在 GOPATH 下,算是一个飞跃式的进步。

go mod 还是有一些不方便的地方,例如依赖一个未发布的包,或者本地测试中的临时包,需要通过 replace 指令将包名替换为本地路径,既然是本地路径,这就导致一个问题,多人协作开发时,不同的人可能开发系统不同,环境不同,导致本地路径也不同,这就给代码仓库不一致问题的产生提供了可能。

go work 工作区提出另一个方案,将本地多个 module 组成一个工作区,上述情况中如果在工作区内引用,则不再需要指定 replace,而是交给工作区自行解析处理。

举个例子,首先看没有 workspaces 的情况,一个 example 包想要引用另一个 pkg 包的做法

.
├── example
│   ├── go.mod
│   └── main.go
├── x_path
│   ├── go.mod
│   └── x_file.go

x_path/x_file.go

package pkg

func Func() {
	println("This is pkg package")
}

x_path/go.mod

module aaa/bbb/ccc/pkg

go 1.20

x_path 中定义了一个名叫 aaa/bbb/ccc/pkg 的模块,其中含有一个 package 名叫 pkg。

example/main.go 来引用 pkg 的 Func

package main

import "aaa/bbb/ccc/pkg"

func main() {
	pkg.Func()
}

目前运行 cd example && go run main.go 可定会报错,因为 golang 把 aaa/bbb/ccc/pkg 当成了系统包,在 GOROOT 环境变量指向的路径中找不到这个包

main.go:3:8: package aaa/bbb/ccc/pkg is not in GOROOT (/usr/local/go/src/aaa/bbb/ccc/pkg)

假设将 x_path 的包名改成 xxx.com/ddd/eee/pkg 则 go 默认会将其视为一个仓库地址,会让你 go get 拉一下,不过根本问题跟上面一样,都是说明 go 找不到合格 pkg 在哪,此时就需要用 replace 指令了。

在 example/go.mod 中指定,使用 go mod edit -replace aaa/bbb/ccc/pkg=../x_path 来生成替换指令,使用 go mod tidy 来指定一个 Pesudo 版本号

module main

go 1.20

replace aaa/bbb/ccc/pkg => ../x_path

require aaa/bbb/ccc/pkg v0.0.0-00010101000000-000000000000

之后再执行 go run main.go 就可以得到结果了。

$ go run main.go       
This is pkg package

到了 workspace 这边,只需要声明 example 和 x_path 两个文件夹处于同一个工作空间中,那么 go 会自动解决搜索 package 和导入 package 的工作。

首先把 example/go.mod 中 require 和 replace 都删掉不再需要了,之后在 example 和 x_path 级别的目录中执行 go work init ./example ./x_path,会生成一个 go.work 文件,其中

go 1.20

use (
	./example
	./x_path
)

接着就可以执行 go run example/main.go 或者 cd example && go run main.go 了

参考

https://go.dev/blog/package-names

https://go.dev/ref/mod

本文由mdnice多平台发布