Go Module 的设计采用了语义化版本规范,语义化版本规范非常流行且具有指导意义,本文就来聊聊语义化版本规范的设计和在 Go 中的应用。

语义化版本规范

语义化版本规范(SemVer)是由 Gravatars 创办者兼 GitHub 共同创办者 Tom Preston-Werner 所建立,旨在解决 依赖地狱 问题。

它清楚明了的规定了版本格式、版本号递增规:

版本格式:采用 X.Y.Z 的格式,X 是主版本号、Y 是次版本号、而 Z 为修订号(即:主版本号.次版本号.修订号),其中 X、Y 和 Z 为非负的整数,且禁止在数字前方补零。

版本号递增规则:

主版本号:当做了不兼容的 API 修改。

次版本号:当做了向下兼容的功能性新增及修改。

修订号:当做了向下兼容的问题修正。

另外,先行版本号版本编译信息 可以加到 主版本号.次版本号.修订号 的后面,作为延伸。

完整版本格式如下:

其中版本号核心部分 X.Y.Z 是必须的,使用 . 连接,先行版本号和版本编译信息是可选的,先行版本号通过 - 与核心部分连接,版本编译信息通过 + 与核心部分或先行版本号连接。

合法的几种版本号格式如下:

  1. 主版本号.次版本号.修订号

  2. 主版本号.次版本号.修订号-先行版本号

  3. 主版本号.次版本号.修订号+版本编译信息

  4. 主版本号.次版本号.修订号-先行版本号+版本编译信息

主版本号必须在有任何不兼容的修改被加入公共 API 时递增。每当主版本号递增时,次版本号和修订号必须归零。

次版本号必须在有向下兼容的新功能出现或有改进时递增,或在任何公共 API 的功能被标记为弃用时也必须递增。每当次版本号递增时,修订号必须归零。

修订号必须在只做了向下兼容的修正时才递增。这里的修正指的是针对不正确结果而进行的内部修改。

存在先行版本号,意味着当前版本不够稳定,且可能存在兼容性问题。先行版本号是一连串以 . 分隔的标识符,由 ASCII 字母数字和连接号 [0-9A-Za-z-] 组成,禁止出现空白符,数字类型则禁止在前方补零。合法示例:1.0.0-alpha、1.0.0-alpha.1、1.0.0-0.3.7、1.0.0-x.7.z.92。

版本编译信息标志符规格与先行版本号基本相同,略有差异的是数字类型前方允许补零。合法示例:1.0.0-alpha+001、1.0.0+20130313144700、1.0.0-beta+exp.sha.5114f85。

除了上面几点说明,还需要额外关注以下几点:

  1. 标记版本号的软件发行后,禁止改变该版本软件的内容。任何修改都必须以新版本发行。

  2. 主版本号为零(0.y.z)的软件处于开发初始阶段,一切都可能随时被改变。这样的公共 API 不应该被视为稳定版。

  3. 1.0.0 的版本号用于界定公共 API 的形成。这一版本之后所有的版本号更新都基于公共 API 及其修改内容。

  4. 社区中还存在一个不成文的规定,对于次版本号,偶数为稳定版本,奇数为开发版本。当然不是所有项目都这样设计。

使用语义化版本规范可能遇到的问题

在使用语义化版本规范过程中,可能人为或程序编写错误导致出现如下几种可预见的问题:

  1. 万一不小心把一个不兼容的改版当成了次版本号发行了该怎么办?

一旦发现自己破坏了语义化版本控制的规范,就要修正这个问题,并发行一个新的次版本号来更正这个问题并且恢复向下兼容。即使是这种情况,也不能去修改已发行的版本。可以的话,将有问题的版本号记录到文档中,告诉使用者问题所在,让他们能够意识到这是有问题的版本。

注意:不到万不得已,不要也不能去修改已发行的版本。

  1. 如果我变更了公共 API 但无意中未遵循版本号的改动怎么办呢?(意即在修订等级的发布中,误将重大且不兼容的改变加到代码之中)

自行做最佳的判断。如果你有庞大的使用者群在依照公共 API 的意图而变更行为后会大受影响,那么最好做一次主版本的发布,即使严格来说这个修复仅是修订等级的发布。记住,语义化的版本控制就是透过版本号的改变来传达意义。若这些改变对你的使用者是重要的,那就透过版本号来向他们说明。

  1. v1.2.3 是一个语义化版本号吗?

v1.2.3 并不是的一个语义化的版本号。但是,在语义化版本号之前增加前缀 v 是用来表示版本号的常用做法。在版本控制系统中,将 version 缩写为 v 是很常见的。比如:git tag v1.2.3 -m "Release version 1.2.3" 中,v1.2.3 表示标签名称,而 1.2.3 是语义化版本号。

如何验证语义化版本规范正确性

官方提供了两个正则可以检查语义化版本号的正确性。

  1. 支持按组名称提取匹配结果
1
^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$

Go 语言示例:

1
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
32
33
34
35
36
package main

import (
"encoding/json"
"fmt"
"regexp"
)

func main() {
version := "0.1.2-alpha+001"
pattern := regexp.MustCompile(`^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
r := pattern.FindStringSubmatch(version)

m := make(map[string]string)
for i, name := range pattern.SubexpNames() {
if i == 0 {
m["version"] = r[i]
} else {
m[name] = r[i]
}
}

result, _ := json.MarshalIndent(m, "", " ")
fmt.Printf("%s\n", result)
}

/*
{
"buildmetadata": "001",
"major": "0",
"minor": "1",
"patch": "2",
"prerelease": "alpha",
"version": "0.1.2-alpha+001"
}
*/
  1. 支持按编号提取匹配结果
1
^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$

Go 语言示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"regexp"
)

func main() {
version := "0.1.2-alpha+001"
pattern := regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
r := pattern.FindStringSubmatch(version)

for i, s := range r {
fmt.Printf("%d -> %s\n", i, s)
}
}

/*
0 -> 0.1.2-alpha+001
1 -> 0
2 -> 1
3 -> 2
4 -> alpha
5 -> 001
*/

Go Module 版本设计

依赖地狱

我们先来看下早期 Go 依赖包存在的依赖地狱问题:

依赖地狱
依赖地狱

首先存在两个包 pkg1pkg2,分别依赖 pkg3v1.0.0 版本和 v2.0.0 版本,现在我们开发一个 app 包,它依赖 pkg1pkg2,那么此时由于 app 包只允许包含一个 pkg3 依赖,所以 Go 构建工具无法抉择应该使用哪个版本的 pkg3。这就是所谓的依赖地狱问题。

语义导入版本

为了解决依赖地狱问题,Go 在 1.11 版本时引入和 Go Module:

Go Module
Go Module

Go Module 解决问题的方式是,把 pkg3v1.0.0 版本和 v2.0.0 版本当作两个不同的包,这样也就允许了 app 包能够同时包含多个不同版本的 pkg3

在使用时,需要在包的导入路径上加上包的主版本号。这里以 go-micro 包使用为例,展示下 Go Module 语义导入版本的用法:

1
2
3
4
5
6
7
8
9
10
11
12
import "go-micro.dev/v4"

// create a new service
service := micro.NewService(
micro.Name("helloworld"),
)

// initialise flags
service.Init()

// start the service
service.Run()

可以看到导入路径为 "go-micro.dev/v4",其中 v4 就代表了需要引入 go-microv4.y.z 版本。

参考

https://github.com/semver/semver
https://semver.org/lang/zh-CN/