一个基本的性能测试

我们以斐波那契数列为例,来看一个基本的性能测试。

需要测试的文件:

1
2
3
4
5
6
7
// fib.go
func fib(n int) int {
if n == 0 || n == 1 {
return n
}
return fib(n-2) + fib(n-1)
}

测试文件:

1
2
3
4
5
6
7
8
// fib_test.go
import "testing"

func BenchmarkFib(b *testing.B) {
for i := 0; i < b.N; i++ {
fib(20)
}
}

我们可以通过执行下面的命令来运行性能测试:

1
go test -bench .

输出结果:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkFib-20 47515 25053 ns/op
PASS
ok go-test 1.451s

说明:

  • 输出字段说明:
    • goos:操作系统。
    • goarch:CPU 架构。
    • pkg:包名。
    • cpu:CPU 信息。
    • BenchmarkFib-20:测试函数的名字。20 表示 GOMAXPROCS(线程数)的值为 20。
    • 47515:测试函数运行的次数。
    • 25053 ns/op:每次运行的平均耗时,也就是每次操作耗时 25053 纳秒。
  • 基准测试函数的名字必须以 Benchmark 开头,后面跟被测试的函数名,函数名的第一个字母必须大写。如上面的 BenchmarkFib
  • 基准测试函数的参数是 *testing.B
  • 运行基准测试的命令是 go test -bench .,其中 . 表示当前目录。
  • b.N 是基准测试框架提供的一个参数,表示基准测试函数运行的次数。

如果我们想知道每次操作中内存的分配情况,可以使用 -benchmem 参数:

1
go test -bench . -benchmem

在输出中就会显示每次操作分配的内存情况。

CPU 性能测试及分析

上面的基准测试,我们是直接输出了测试结果,如果我们想要更详细的分析,可以使用 pprof 工具。

我们可以使用下面的测试命令来生成 CPU 性能分析的文件:

1
go test -bench . -cpuprofile=cpu.out

接着,我们可以使用 go tool pprof 来查看分析结果:

1
go tool pprof cpu.out

输出如下:

1
2
3
4
5
6
File: go-test.test
Type: cpu
Time: May 10, 2024 at 3:21pm (CST)
Duration: 1.61s, Total samples = 1.24s (76.86%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

我们可以使用 top 命令来查看 CPU 占用最高的函数:

1
2
3
4
5
6
7
8
(pprof) top
Showing nodes accounting for 1.24s, 100% of 1.24s total
flat flat% sum% cum cum%
1.24s 100% 100% 1.24s 100% go-test.fib
0 0% 100% 1.24s 100% go-test.BenchmarkFib
0 0% 100% 1.24s 100% testing.(*B).launch
0 0% 100% 1.24s 100% testing.(*B).runN
(pprof)

我们也可以在 top 命令后面加上一个数字,表示显示前几个占用 CPU 时间最多的函数。比如 top3 表示显示前 3 个。

也就是说,我们可以通过 pprof 工具来查看哪些地方占用了比较多的 CPU 时间,从而进行性能优化。

内存性能测试及分析

上一个例子中,我们并没有在函数中分配内存,我们使用下面这个例子来演示内存性能测试及分析。

需要测试的文件:

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

import (
"math/rand"
"testing"
"time"
)

func test(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0, n)
for i := 0; i < n; i++ {
nums = append(nums, i)
}
return nums
}

func BenchmarkItoa(b *testing.B) {
for i := 0; i < b.N; i++ {
test(1000)
}
}

执行下面的命令来运行内存性能测试:

1
go test -bench . -benchmem -memprofile mem.out

输出如下:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 143871 8169 ns/op 8192 B/op 1 allocs/op
PASS
ok go-test 1.266s

我们可以看到,在 BenchmarkItoa 这一行中,多了两列,其中 8192 B/op 表示每次操作(每次调用 test 函数)分配了 8192 字节的内存,1 allocs/op 表示每次操作分配了 1 次内存。

这个输出对我们的意义是,尽量减少内存的分配,很多时候可以提高程序的性能。

同样的,我们可以使用 go tool pprof 来查看内存分析结果:

1
go tool pprof mem.out

我们在交互模式下,可以使用 top 命令来查看内存分配最高的函数:

1
2
3
4
5
6
7
(pprof) top
Showing nodes accounting for 1.20GB, 100% of 1.20GB total
flat flat% sum% cum cum%
1.20GB 100% 100% 1.20GB 100% go-test.test
0 0% 100% 1.20GB 100% go-test.BenchmarkItoa
0 0% 100% 1.20GB 100% testing.(*B).launch
0 0% 100% 1.20GB 100% testing.(*B).runN

通过 http 页面的方式展示性能测试结果

我们上面这两个例子还是过于简单了,在实际的项目中,函数调用可能会非常复杂,我们可以通过 web 界面来展示性能测试结果。同时,交互上也会更加友好。

比如,针对上面的 mem.out,我们可以使用下面的命令来启动一个 http 服务:

1
go tool pprof -http=:8081 mem.out

接着,我们可以在浏览器中输入 http://localhost:8081 来查看性能测试结果:

除了直接看到的结果,还可以操作上面的菜单来实现不同的展示方式,比如选择 VIEW->Top,展示出来的是一个列表:

pprof 的其他功能

在我们使用 go tool pprof 的时候,还有很多其他的功能,比如:

  • top:查看 CPU 或内存占用最高的函数。上面有介绍。
  • listlist 命令后跟函数名称以显示该函数的源代码,突出显示哪些代码占用了最多的 CPU 或内存,如下所示
1
2
3
4
5
6
7
8
9
10
11
12
13
(pprof) list go-test.test
Total: 1.20GB
ROUTINE ======================== go-test.test in /Users/ruby/GolandProjects/go-test/fib_test.go
1.20GB 1.20GB (flat, cum) 100% of Total
. . 9:func test(n int) []int {
. . 10: rand.Seed(time.Now().UnixNano())
1.20GB 1.20GB 11: nums := make([]int, 0, n)
. . 12: for i := 0; i < n; i++ {
. . 13: nums = append(nums, i)
. . 14: }
. . 15: return nums
. . 16:}
(pprof)
  • webweb 命令可以在浏览器中打开一个页面,以图形的形式展示性能测试结果,如下

  • weblistweblist 命令可以在浏览器中打开一个页面,显示函数的源代码,突出显示哪些代码占用了最多的 CPU 或内存,如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ... 其他内容
/Users/ruby/GolandProjects/go-test/fib_test.go

Total: 1.20GB 1.20GB (flat, cum) 100%
6 . . "time"
7 . . )
8 . .
9 . . func test(n int) []int {
10 . . rand.Seed(time.Now().UnixNano())
11 1.20GB 1.20GB nums := make([]int, 0, n)
12 . . for i := 0; i < n; i++ {
13 . . nums = append(nums, i)
14 . . }
15 . . return nums
// ... 其他内容
  • peek:显示某一个函数的调用详情,如:
1
2
3
4
5
6
7
8
9
10
(pprof) peek go-test.test
Active filters:
show=go-test.test
Showing nodes accounting for 1.20GB, 100% of 1.20GB total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
1.20GB 100% | go-test.BenchmarkItoa
1.20GB 100% 100% 1.20GB 100% | go-test.test
----------------------------------------------------------+-------------
  • text:以文本的形式展示性能分析结果,如:
1
2
3
4
5
6
7
8
9
(pprof) text
Active filters:
show=go-test.test
Showing nodes accounting for 1.20GB, 100% of 1.20GB total
flat flat% sum% cum cum%
1.20GB 100% 100% 1.20GB 100% go-test.test
0 0% 100% 1.20GB 100% go-test.BenchmarkItoa
0 0% 100% 1.20GB 100% testing.(*B).launch
0 0% 100% 1.20GB 100% testing.(*B).runN
  • 各种形式的输出:pprof 还支持其他的输出形式,比如 pdfpngsvg 等,具体可以查看 help 命令。

testing.B 的其他方法

最后,再简单介绍一下 testing.B 的其他方法:

  • b.ResetTimer():重置计时器,可以在测试函数中的循环体中使用,以避免循环体的初始化时间影响测试结果。
1
2
3
4
5
6
7
8
func BenchmarkFib(b *testing.B) {
// 模拟初始化时间,这一行代码不会计入测试时间
time.Sleep(1 * time.Second)
b.ResetTimer()
for i := 0; i < b.N; i++ {
fib(20)
}
}
  • b.StartTimer():负责启动计时并初始化内存相关计数,测试执行时会自动调用,一般不需要用户启动。
  • b.StopTimer():负责停止计时,并累加相应的统计值。
  • b.ReportAllocs():用于设置是否打印内存统计信息,与命令行参数 -benchmem 一致,但本方法只作用于单个测试函数。
  • b.SetParallelism(p int):设置并行测试的线程数,设置为 p*GOMAXPROCS。影响 b.RunParallel 的并行度。
  • b.RunParallel(body func(*PB)):用于并行测试,body 函数会被并行执行,b.N 会被分配到各个并行体中。通常跟 -cpu 参数一起使用。

其他方法跟 testing.T 有很多重复的,这里不赘述了,可以看上一篇。

高阶用法

指定性能测试时间

可以通过 -benchtime 参数来指定性能测试的时间,如:

1
go test -bench . -benchtime=3s

输出:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 499718 7051 ns/op
PASS
ok go-test 3.602s

指定性能测试执行次数,也就是 b.N

也是通过 -benchtime 参数来指定,但是单位是 x,如:

1
go test -bench . -benchtime=3x

输出:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 3 14378 ns/op
PASS
ok go-test 0.005s

执行多次性能测试

可以通过 -count 参数来指定执行多少次性能测试,如:

1
go test -bench . -count=3

输出:

1
2
3
4
5
6
7
8
9
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 166116 7187 ns/op
BenchmarkItoa-20 165289 7168 ns/op
BenchmarkItoa-20 161872 7146 ns/op
PASS
ok go-test 3.765s

指定性能测试的 CPU 数

可以通过 -cpu 参数来指定性能测试的 CPU 数,如:

1
go test -bench . -cpu=1,2,4

这个命令会执行多次性能测试,分别使用 1、2、4 个 CPU(也就是 GOMAXPROCS 的值分别为 1、2、4)。

输出:

1
2
3
4
5
6
7
8
9
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa 166363 7153 ns/op
BenchmarkItoa-2 170436 7114 ns/op
BenchmarkItoa-4 171088 6976 ns/op
PASS
ok go-test 3.819s

输出内存分配信息

可以通过 -benchmem 参数来输出内存分配信息,如:

1
go test -bench . -benchmem

输出:

1
2
3
4
5
6
7
goos: darwin
goarch: amd64
pkg: go-test
cpu: 12th Gen Intel(R) Core(TM) i7-12700F
BenchmarkItoa-20 163878 7081 ns/op 896 B/op 1 allocs/op
PASS
ok go-test 1.240s

为什么写单元测试?

关于测试,有一张很经典的图,如下:

说明:

测试类型 成本 速度 频率
E2E 测试
集成测试
单元测试

也就是说,单元测试是最快、最便宜的测试方式。这不难理解,单元测试往往用来验证代码的最小单元,比如一个函数、一个方法,这样的测试我们一个命令就能跑完整个项目的单元测试,而且速度还很快,所以单元测试是我们最常用的测试方式。 而 E2E 测试和集成测试,往往需要启动整个项目,然后需要真实用户进行手动操作,这样的测试成本高,速度慢,所以我们往往不会频繁地运行这样的测试。只有在项目的最后阶段,我们才会运行这样的测试。而单元测试,我们可以在开发的过程中,随时随地地运行,这样我们就能及时发现问题,及时解决问题。

一个基本的 Go 单元测试

Go 从一开始就支持单元测试,Go 的测试代码和普通代码一般是放在同一个包下的,只是测试代码的文件名是 _test.go 结尾的。比如我们有一个 add.go 文件,那么我们的测试文件就是 add_test.go

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b
}
1
2
3
4
5
6
7
8
9
10
// add_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 did not equal 3")
}
}

我们可以通过 go test 命令来运行测试:

1
go test

输出:

1
2
PASS
ok go-test 0.004s

注意:

  1. 测试函数的命名必须以 Test 开头,后面的名字必须以大写字母开头,比如 TestAdd
  2. 测试函数的参数是 *testing.T 类型。
  3. go test 加上 -v 参数可以输出详细的测试信息,加上 -cover 参数可以输出测试覆盖率。

go test 命令的参数详解

基本参数

  • -v:输出详细的测试信息。比如输出每个测试用例的名称。
  • -run regexp:只运行匹配正则表达式的测试用例。如 -run TestAdd
  • -bench regexp:运行匹配正则表达式的基准测试用例。
  • -benchtime t:设置基准测试的时间,默认是 1s,也就是让基准测试运行 1s。也可以指定基准测试的执行次数,格式如 -benchtime 100x,表示基准测试执行 100 次。
  • -count n:运行每个测试函数的次数,默认是 1 次。如果指定了 -cpu 参数,那么每个测试函数会运行 n * GOMAXPROCS 次。但是示例测试只会运行一次,该参数对模糊测试无效。
  • -cover:输出测试覆盖率。
  • -covermode set,count,atomic:设置测试覆盖率的模式。默认是 set,也就是记录哪些语句被执行过。
  • -coverpkg pkg1,pkg2,pkg3:用于指定哪些包应该生成覆盖率信息。这个参数允许你指定一个或多个包的模式,以便在运行测试时生成这些包的覆盖率信息。
  • -cpu 1,2,4:设置并行测试的 CPU 数量。默认是 GOMAXPROCS。这个参数对模糊测试无效。
  • -failfast:一旦某个测试函数失败,就停止运行其他的测试函数了。默认情况下,一个测试函数失败了,其他的测试函数还会继续运行。
  • -fullpath:测试失败的时候,输出完整的文件路径。
  • -fuzz regexp:运行模糊测试。
  • -fuzztime t:设置模糊测试的时间,默认是 1s。又或者我们可以指定模糊测试的执行次数,格式如 -fuzztime 100x,表示模糊测试执行 100 次。
  • -fuzzminimizetime t:设置模糊测试的最小化时间,默认是 1s。又或者我们可以指定模糊测试的最小化执行次数,格式如 -fuzzminimizetime 100x,表示模糊测试最小化执行 100 次。在模糊测试中,当发现一个失败的案例后,系统会尝试最小化这个失败案例,以找到导致失败的最小输入。
  • -json:以 json 格式输出
  • -list regexp:列出所有匹配正则表达式的测试用例名称。
  • -parallel n:设置并行测试的数量。默认是 GOMAXPROCS。
  • -run regexp:只运行匹配正则表达式的测试用例。
  • -short:缩短长时间运行的测试的测试时间。默认关闭。
  • -shuffle off,on,N:打乱测试用例的执行顺序。默认是 off,也就是不打乱,这会由上到下执行测试函数。
  • -skip regexp:跳过匹配正则表达式的测试用例。
  • -timeout t:设置测试的超时时间,默认是 10m,也就是 10 分钟。如果测试函数在超时时间内没有执行完,那么测试会 panic
  • -vet list:设置 go vet 的检查列表。默认是 all,也就是检查所有的。

性能相关

  • -benchmem:输出基准测试的内存分配情况(也就是 go test -bench . 的时候可以显示每次基准测试分配的内存)。
  • -blockprofile block.out:输出阻塞事件的分析数据。
  • -blockprofilerate n:设置阻塞事件的采样频率。默认是 1(单位纳秒)。如果没有设置采样频率,那么就会记录所有的阻塞事件。
  • -coverprofile coverage.out:输出测试覆盖率到文件 coverage.out
  • -cpuprofile cpu.out:输出 CPU 性能分析信息到文件 cpu.out
  • -memprofile mem.out:输出内存分析信息到文件 mem.out
  • -memprofilerate n:设置内存分析的采样频率。
  • -mutexprofile mutex.out:输出互斥锁事件的分析数据。
  • -mutexprofilefraction n:设置互斥锁事件的采样频率。
  • -outputdir directory:设置输出文件的目录。
  • -trace trace.out:输出跟踪信息到文件 trace.out

子测试

使用场景:当我们有多个测试用例的时候,我们可以使用子测试来组织测试代码,使得测试代码更具组织性和可读性。

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 (
"testing"
)

func TestAdd2(t *testing.T) {
cases := []struct {
name string
a, b, sum int
}{
{"case1", 1, 2, 3},
{"case2", 2, 3, 5},
{"case3", 3, 4, 7},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sum := Add(c.a, c.b)
if sum != c.sum {
t.Errorf("Sum was incorrect, got: %d, want: %d.", sum, c.sum)
}
})
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
➜  go-test go test   
--- FAIL: TestAdd2 (0.00s)
--- FAIL: TestAdd2/case1 (0.00s)
add_test.go:21: Sum was incorrect, got: 4, want: 3.
--- FAIL: TestAdd2/case2 (0.00s)
add_test.go:21: Sum was incorrect, got: 6, want: 5.
--- FAIL: TestAdd2/case3 (0.00s)
add_test.go:21: Sum was incorrect, got: 8, want: 7.
FAIL
exit status 1
FAIL go-test 0.004s

我们可以看到,上面的输出中,失败的单元测试带有每个子测试的名称,这样我们就能很方便地知道是哪个测试用例失败了。

setup 和 teardown

在一般的单元测试框架中,都会提供 setupteardown 的功能,setup 用来初始化测试环境,teardown 用来清理测试环境。

方法一:通过 Go 的 TestMain 方法

很遗憾的是,Go 的测试框架并没有直接提供这样的功能,但是我们可以通过 Go 的特性来实现这样的功能。

在 Go 的测试文件中,如果有 TestMain 函数,那么执行 go test 的时候会执行这个函数,而不会执行其他测试函数了,其他的测试函数需要通过 m.Run 来执行,如下面这样:

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
package main

import (
"fmt"
"os"
"testing"
)

func setup() {
fmt.Println("setup")
}

func teardown() {
fmt.Println("teardown")
}

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 != 3")
}
}

func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
os.Exit(code)
}

在这个例子中,我们在 TestMain 函数中调用了 setupteardown 函数,这样我们就实现了 setupteardown 的功能。

方法二:使用 testify 框架

我们也可以使用 Go 中的第三方测试框架 testify 来实现 setupteardown 的功能(使用 testify 中的 suite 功能)。

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
package main

import (
"fmt"
"github.com/stretchr/testify/suite"
"testing"
)

type AddSuite struct {
suite.Suite
}

func (suite *AddSuite) SetupTest() {
fmt.Println("Before test")
}

func (suite *AddSuite) TearDownTest() {
fmt.Println("After test")
}

func (suite *AddSuite) TestAdd() {
suite.Equal(Add(1, 2), 3)
}

func TestAddSuite(t *testing.T) {
suite.Run(t, new(AddSuite))
}

go test 输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜  go-test go test
Before test
After test
--- FAIL: TestAddSuite (0.00s)
--- FAIL: TestAddSuite/TestAdd (0.00s)
add_test.go:22:
Error Trace: /Users/ruby/GolandProjects/go-test/add_test.go:22
Error: Not equal:
expected: 4
actual : 3
Test: TestAddSuite/TestAdd
FAIL
exit status 1
FAIL go-test 0.006s

我们可以看到,这里也同样执行了 SetupTestTearDownTest 函数。

testing.T 可用的方法

最后,我们可以直接从 testing.T 提供的 API 来学习如何编写测试代码。

基本日志输出

  • t.Log(args ...any):打印信息,不会标记测试函数为失败。
  • t.Logf(format string, args ...any):打印格式化的信息,不会标记测试函数为失败。

可能有读者会有疑问,输出不用 fmt 而用 t.Log,这是因为:

  • t.Logt.Logf 打印的信息默认不会显示,只有在测试函数失败的时候才会显示。又或者我们使用 -v 参数的时候才显示,这让我们的测试输出更加清晰,只有必要的时候日志才会显示。
  • t.Logt.Logf 打印的时候,还会显示是哪一行代码打印的信息,这样我们就能很方便地定位问题。
  • fmt.Println 打印的信息一定会显示在控制台上,就算我们的测试函数通过了,也会显示,这样会让控制台的输出很乱。

例子:

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// add_test.go
package main

import (
"testing"
)

func TestAdd(t *testing.T) {
t.Log("TestAdd is running")
if Add(1, 2) != 3 {
t.Error("Expected 3")
}
}

输出:

1
2
3
➜  go-test go test
PASS
ok go-test 0.004s

我们修改一下 Add 函数,让测试失败,再次运行,输出如下:

1
2
3
4
5
6
7
➜  go-test go test
--- FAIL: TestAdd (0.00s)
add_test.go:8: TestAdd is running
add_test.go:10: Expected 3
FAIL
exit status 1
FAIL go-test 0.004s

我们可以发现,在测试成功的时候,t.Log 打印的日志并没有显示,只有在测试失败的时候才会显示。

如果我们想要在测试成功的时候也显示日志,可以使用 -v 参数:go test -v

标记测试函数为失败

  • t.Fail():标记测试函数为失败,但是测试函数后续代码会继续执行。(让你在测试函数中标记失败情况,并收集所有失败的情况,而不是在遇到第一个失败时就立即停止测试函数的执行。)
  • t.FailNow():标记测试函数为失败,并立即返回,后续代码不会执行(通过调用 runtime.Goexit,但是 defer 语句还是会被执行)。
  • t.Failed():返回测试函数是否失败。
  • t.Fatal(args ...any):标记测试函数为失败,并输出信息,然后立即返回。等价于 t.Log + t.FailNow
  • t.Fatalf(format string, args ...any):标记测试函数为失败,并输出格式化的信息,然后立即返回。等价于 t.Logf + t.FailNow

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"testing"
)

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Fatal("Expected 3")
}

if Add(2, 3) != 5 {
t.Fatal("Expected 4")
}
}

这里只会输出第一个失败的测试用例,因为 t.Fatal 会立即返回。

标记测试函数为失败并输出信息

  • t.Error(args ...any):标记测试函数为失败,并打印错误信息。等价于 t.Log + t.Fail
  • t.Errorf(format string, args ...any):标记测试函数为失败,并打印格式化的错误信息。等价于 t.Logf + t.Fail

这两个方法会让测试函数立即返回,不会继续执行后面的代码。

测试超时控制

  • t.Deadline():返回测试函数的截止时间(这是通过 go test -timeout 60s 这种形式指定的超时时间)。

注意:如果我们通过 -timeout 指定了超时时间,当测试函数超时的时候,测试会 panic

跳过测试函数中后续代码

作用:可以帮助测试代码在特定条件下灵活地跳过测试,避免不必要的测试执行,同时提供清晰的信息说明为什么跳过测试。

  • t.Skip(args ...any):跳过测试函数中后续代码,标记测试函数为跳过。等同于 t.Log + t.SkipNow
  • t.Skipf(format string, args ...any):跳过测试函数中后续代码,并打印格式化的跳过信息。等同于 t.Logf + t.SkipNow
  • t.SkipNow():跳过测试函数中后续代码,标记测试函数为跳过。这个方法不会输出内容,前面两个会输出一些信息
  • t.Skipped():返回测试函数是否被跳过。

测试清理函数

  • t.Cleanup(f func()):注册一个函数,这个函数会在测试函数结束后执行。这个函数会在测试函数结束后执行,不管测试函数是否失败,都会执行。(可以注册多个,执行顺序类似 defer,后注册的先执行)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"testing"
)

func TestAdd(t *testing.T) {
t.Cleanup(func() {
fmt.Println("cleanup 0")
})
t.Cleanup(func() {
fmt.Println("cleanup 1")
})
}

输出:

1
2
3
4
5
➜  go-test go test
cleanup 1
cleanup 0
PASS
ok go-test 0.004s

使用临时文件夹

  • t.TempDir():返回一个临时文件夹,这个文件夹会在测试函数结束后被删除。可以调用多次,每次都是不同的文件夹。
1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
"testing"
)

func TestAdd(t *testing.T) {
fmt.Println(t.TempDir())
fmt.Println(t.TempDir())
}

输出:

1
2
3
4
5
➜  go-test go test
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/001
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/002
PASS
ok go-test 0.004s

临时的环境变量

  • t.Setenv(key, value string):设置一个临时的环境变量,这个环境变量会在测试函数结束后被还原。

在单元测试中,使用 Setenv 函数可以模拟不同的环境变量设置,从而测试代码在不同环境下的行为。例如,你可以在测试中设置特定的环境变量值,然后运行被测试的代码,以验证代码在这些环境变量设置下的正确性。

子测试

可以将一个大的测试函数拆分成多个子测试,使得测试代码更具组织性和可读性。

  • t.Run(name string, f func(t *testing.T)):创建一个子测试,这个子测试会在父测试中执行。子测试可以有自己的测试函数,也可以有自己的子测试。

获取当前测试的名称

  • t.Name():返回当前测试的名称(也就是测试函数名)。

t.Helper()

  • t.Helper():标记当前测试函数是一个辅助函数,这样会让测试输出更加清晰,只有真正的测试函数会被标记为失败。

例子:

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b + 1
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// add_test.go
package main

import (
"testing"
)

func test(a, b, sum int, t *testing.T) {
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}

func TestAdd(t *testing.T) {
test(1, 2, 3, t)
test(2, 3, 5, t)
}

输出如下:

1
2
3
4
5
6
7
8
➜  go-test go test -v
=== RUN TestAdd
add_test.go:10: Add(1, 2) = 4; want 3
add_test.go:10: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s

我们可以看到,两个测试失败输出的报错行都是 test 函数里面的 t.Errorf,而不是 test 函数的调用者 TestAdd,也就是说,在这种情况下我们不好知道是 test(1, 2, 3, t) 还是 test(2, 3, 5, t) 失败了(当然我们这里还是挺明显的,只是举个例子),这时我们可以使用 t.Helper()

1
2
3
4
5
6
7
func test(a, b, sum int, t *testing.T) {
t.Helper() // 在助手函数中加上这一行
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}

输出如下:

1
2
3
4
5
6
7
8
➜  go-test go test -v
=== RUN TestAdd
add_test.go:16: Add(1, 2) = 4; want 3
add_test.go:17: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s

这个时候,我们就很容易知道是哪一个测试用例失败了,这对于我们需要封装 helper 函数的时候很有用。

如果我们想使用 pm2 来管理多个应用,可以通过配置文件来实现。

初始化配置文件

1
pm2 init simple

这个命令会在当前目录下生成一个 ecosystem.config.js 文件,这个文件就是 pm2 的配置文件。

文件内容大概如下:

1
2
3
4
5
6
module.exports = {
apps : [{
name : "app1",
script : "./app.js"
}]
}

根据配置文件来管理应用

  • pm2 reload 重新加载应用程序,但不会停止正在运行的应用程序实例。它会重新启动应用程序,但在新实例准备就绪之前,旧实例将继续提供服务。这意味着在应用程序重新加载期间,可能会有短暂的服务中断。
  • pm2 restart 停止当前运行的应用程序实例,并启动一个新的实例来替代它。这意味着在重启过程中会有一个短暂的服务中断,因为在新实例启动之前,旧实例将停止提供服务。

启动所有应用

1
pm2 start ecosystem.config.js

停止所有应用

1
pm2 stop ecosystem.config.js

重启所有应用

1
pm2 restart ecosystem.config.js

重载所有应用

1
pm2 reload ecosystem.config.js

删除所有应用

1
pm2 delete ecosystem.config.js

启动特定应用

1
pm2 start ecosystem.config.js --only app1

指定多个:

1
pm2 start ecosystem.config.js --only app1,app2

不同的环境(env)

你可以通过 env_* 来指定不同的环境,比如:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
apps : [{
name : "app1",
script : "./app.js",
env_production: {
NODE_ENV: "production"
},
env_development: {
NODE_ENV: "development"
}
}]
}

然后启动时指定环境:

1
2
pm2 start ecosystem.config.js --env production
pm2 start ecosystem.config.js --env development

配置项详解

配置项 类型 示例 说明
name string "app1" 应用名称
script string "./app.js" 启动脚本
cwd string "/path/to/app" 启动应用程序的目录
args string "-a 13 -b 12" 传递给脚本的参数
interpreter string "/usr/bin/python" 解释器
interpreter_args string "-u" 传递给解释器的参数

高级配置项

  • exec_mode 说明:
    • 在 fork 模式下,每个应用程序实例都在单独的进程中运行。这意味着每个应用程序实例都有自己的内存空间和资源。
    • 在 cluster 模式下,应用程序会以集群的方式运行,使用 Node.js 的 cluster 模块来创建多个子进程。这些子进程共享同一个端口,可以充分利用多核处理器的优势。
配置项 类型 示例 说明
instances number 4 启动多少个实例
exec_mode string "cluster" 执行模式,默认 fork
watch boolean true 是否监视文件变化,文件变动的时候重启进程
ignore_watch array ["node_modules", "logs"] 忽略监视的文件或目录
max_memory_restart string "1G" 当内存使用超过多少时重启进程
env object { NODE_ENV: "development" } 环境变量
env_production object { NODE_ENV: "production" } 生产环境的环境变量,当指定 --env 参数的时候生效
appendEnvToName boolean true 是否将环境变量追加到应用名称后面

日志配置

配置项 类型 示例 说明
log_date_format string "YYYY-MM-DD HH:mm Z" 日志日期格式
error_file string "/path/to/error.log" 错误日志文件,默认 $HOME/.pm2/logs/<app name>-error-<pid>.log
out_file string "/path/to/out.log" 标准输出日志文件,默认 $HOME/.pm2/logs/<app name>-out-<pid>.log
log_file string "/path/to/combined.log" 组合日志文件(标准输出+错误输出)
pid_file string "/path/to/pid" pid 文件,默认 $HOME/.pm2/pids/<app name>-<pid>.pid
combine_logs boolean true 日志文件名不添加 pid 后缀
time boolean true 在日志中添加时间戳

控制流程

配置项 类型 示例 说明
min_uptime number 1000 应用程序在多少毫秒内被认为是启动成功的
listen_timeout number 8000 应用程序启动后多少毫秒没有监听端口就认为启动失败
kill_timeout number 1600 pm2 发送 kill 信号给应用程序后多少毫秒后强制杀死进程
shutdown_with_message boolean false 是否在关闭进程时发送消息给进程
wait_ready boolean false
max_restarts number 10 启动失败之后,最大重试次数
autorestart boolean true 是否自动重启
cron_restart string "0 0 * * *" 定时重启,参考 cron
vizion boolean false 是否启用版本控制
post_update list ["npm install"] 更新后执行的命令
force boolean false 强制启动应用程序

部署

配置项 类型 示例 说明
key string "/path/to/key" ssh 私钥
user string ssh 用户名
host string ssh 主机
ssh_options object { "StrictHostKeyChecking": "no" } ssh 选项
ref string "origin/master" git 分支
repo string git 仓库
path string "/path/to/deploy" 部署路径
pre-setup string "echo 'commands or script to run before setup on target host'" 部署前执行的命令
post-setup string "echo 'commands or script to run after setup on target host'" 部署后执行的命令
pre-deploy-local string "echo 'commands or script to run on local machine before the setup process starts'" 本地部署前执行的命令
post-deploy string "echo 'commands or script to run on target host after the deploy process finishes'" 部署后执行的命令

在之前的《gitlab ci cd 不完全指南》一文中,我们讲了 gitlab ci 中的一些基本用法。 本文会继续介绍一些在使用 gitlab ci 过程中的优化方法,帮助大家减少在 gitlab ci 上的等待时间。

本文会从以下几个方面来介绍 gitlab ci 的优化方法:

  1. gitlab runner 的配置优化:包括 executor 的选择、concurrent 的设置等
  2. 依赖缓存:如何使用 cache 来加速构建
  3. 依赖使用国内的源:如何使用国内的源来加速依赖的下载
  4. 多个 job 同时执行
  5. job 配置优化:cache policy、GIT_STRATEGY、dependencies
  6. 网络优化:减少同步代码的时间
  7. 升级 gitlab 及 runner 版本

gitlab runner 的配置优化

concurrent 配置

gitlab runner 中有一个很重要的配置是 concurrent,它表示 gitlab runner 同时运行的 job 数量。默认情况下,concurrent 的值是 1,也就是说 gitlab runner 同时只能运行一个 job。如果你的 gitlab runner 有多个 executor,那么可以将 concurrent 设置为大于 1 的值,这样可以让 gitlab runner 同时运行多个 job,从而减少等待时间。

具体可参考:https://docs.gitlab.com/runner/configuration/advanced-configuration.html

executor 的选择

比较常见的是 docker 和 shell 类型的 executor,docker executor 的优势是可以在不同的环境中运行 job,比如在不同的镜像中运行 job,这样可以避免环境的不一致性。 而 shell executor 的优势是可以直接在 gitlab runner 的机器上运行 job,不需要额外的环境,在较老的 gitlab 版本中,shell executor 是很快的,但 shell executor 的可靠性较低,依赖于 gitlab runner 的机器,如果机器出现问题,那么 job 就会失败。

在 16.10 版本的实际使用中,docker executor 的速度有了明显提升,所以不必为了速度而选择 shell executor。

具体可参考:https://docs.gitlab.com/runner/executors/

docker executor 的 pull policy

docker executor 在运行 job 时,会拉取 docker 镜像,这个过程会耗费一些时间。我们可以通过设置 pull_policy 来控制是否每次都拉取镜像。默认情况下,pull_policy 的值是 always,也就是每次都会拉取镜像。 如果我们的镜像不经常更新(比如那种用来 build 项目的 job 所依赖的镜像),那么可以将 pull_policy 设置为 if-not-present,这样只有在本地没有镜像的时候才会拉取镜像。

可选值:

  • always: 每次都拉取镜像
  • if-not-present: 本地没有镜像的时候才会拉取镜像
  • never: 从不拉取镜像

具体可参考:https://docs.gitlab.com/runner/executors/docker.html#configure-how-runners-pull-images

依赖缓存

如果我们的项目需要下载一些第三方依赖,比如 npm、composer、go mod 等,那么我们可以使用 cache 来加速构建。cache 会将我们下载的依赖缓存到 gitlab runner 中,下次构建时就不需要重新下载依赖了。

下面是一个前端项目的例子:

1
2
3
4
5
6
7
8
9
build:
stage: build
cache:
key:
files:
- package.json
- package-lock.json
paths:
- node_modules/

上面这个例子的含义是:

  • 当 package.json 或 package-lock.json 文件发生变化时,就会重新下载依赖(不使用缓存)
  • 将 node_modules 目录缓存到 gitlab runner 中

具体可参考:https://docs.gitlab.com/ee/ci/caching/

需要注意的是:通过监测多个文件的变动来决定是否使用缓存的这个配置,在较新版本的 gitlab 中才有,具体忘记什么版本开始支持

依赖使用国内的源

还是拿部署前端项目作为例子,我们在下载依赖时,可以使用国内的源来加速下载。比如使用淘宝的 npm 镜像:

1
2
3
4
5
6
7
build:
stage: build
script:
- npm config set sass_binary_site https://npmmirror.com/mirrors/node-sass
- npm config set registry https://registry.npmmirror.com
- npm install
- npm run build

实际上其实就是我们在 npm install 之前设置了一下 registry,这样就会使用国内的源来下载依赖。

类似的,其他常用语言的包管理工具一般都有国内源,比如 go mod 有七牛云、composer 有阿里云的源等。

多个 job 同时执行:一个 stage 的多个 job

这里说的是那种没有相互依赖的 job,可以同时执行。比如在我们的后端项目中,build 这个 stage 中有两个 job,一个是用来生成 api 文档的,另一个是用来安装依赖的。 因为生成文档这个操作只是依赖于源码本身,不需要等到依赖安装完成,所以可以同时执行。

这种情况实际上就是把多个 job 放到一个 stage 中,这样 gitlab ci 就会同时执行这些 job:

1
2
3
4
5
6
7
8
9
10
11
12
stages:
- build

composer:
stage: build
script:
- composer install

apidoc:
stage: build
script:
- php artisan apidoc

注意:如果 job 之间有依赖,或者可能会读写相同的文件,那么可能会有异常。

job 配置优化

cache policy

这在之前那篇文章有说过,这里再重复一下。默认是 pull-push。意思是在 job 开始时拉取缓存,push 是在 job 结束时推送缓存,这样会保留我们在 job 执行过程中对缓存目录的变更。

但是实际上有时候我们是不需要在 job 结束的时候更新缓存的,比如我们的 job 不会更新缓存目录,那么我们可以设置为 pull,这样在 job 结束的时候就不会推送缓存了。

1
2
3
4
5
6
7
8
9
10
# 只是拉取缓存,然后同步到服务器的 job,不会更新缓存
sync:
cache:
key:
files:
- composer.json
- composer.lock
paths:
- "vendor/"
policy: pull

GIT_STRATEGY: none

跟上面这一小点类似,如果我们的 job 并不需要拉取代码,那么可以设置 GIT_STRATEGYnone,这样就不会拉取代码了。

1
2
3
4
deploy:
stage: deploy
variables:
GIT_STRATEGY: none

在实际中的应用场景是:部署跟发布分离的时候,发布的 job 并不需要拉取代码,只需要通过远程 ssh 命令执行发布的脚本即可。如果我们的 git 仓库比较大,那么这样可以减少一些时间。

dependencies: []

我们知道,gitlab ci 中的 job 可以产出一些构建的产物,比如前端项目 build 出来的静态文件、go 项目编译出来的二进制文件等,这些产物可以被其他 job 使用,只要我们通过 artifacts 配置即可。

但并不是所有的 job 都需要这些 artifacts 的,这个时候我们可以通过 dependencies: [] 来告诉 gitlab ci 这个 job 不需要依赖其他 job 的产物。

这样就可以节省下下载 artifacts 的时间。

网络优化

网络状况的好坏直接影响了同步代码的时间,这也是最容易做到的优化方式了。如果我们需要同步的机器比较多,而且同步的文件比较大的时候,网络优化带来的效果就更加明显了。

升级 gitlab 及 runner 版本

最近将 gitlab 从 15.9 升级到 16.10 后,发现使用 docker 的 executor 的时候,初始化容器的速度相比旧版本有明显了提升。这也说明了 gitlab 在不断的优化中,所以及时升级 gitlab 及 runner 版本也是一个不错的选择。

原来可能要 5~10s,如果 job 的数量多,这点提升就会比较明显了。

当然我们可以选择一个 stage 多个 job,但是有很多时候一些 job 是没有办法并行的,因为会相互影响。

原因

在 15.9 版本中,gitlab ci 中的 job 无法在失败之后进行重试,表现为失败之后进入 pending 状态,一直持续。从而导致了在有时候 job 偶尔的失败需要手动去重试,非常不方便。有时候还会因为来不及手动重试会直接影响线上服务。

gitlab 15.9 使用的 docker 配置

gitlab 使用的是 docker 部署,docker-compose 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
gitlab:
container_name: gitlab
image: 'gitlab/gitlab-ce:15.9.3-ce.0'
restart: always
hostname: '192.168.2.168'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.2.168:8200'
pages_external_url 'http://192.168.2.168:8300'
gitlab_rails['gitlab_shell_ssh_port'] = 2222
ports:
- '8200:8200'
- '8300:8300'
- '2222:22'
volumes:
- '/usr/lnmp/gitlab/config:/etc/gitlab'
- '/usr/lnmp/gitlab/logs:/var/log/gitlab'
- '/usr/lnmp/gitlab/data:/var/opt/gitlab'
shm_size: '256m'

要解决的几个关键问题

因为是 docker 配置,所以本来打算直接用旧的文件夹来启动一个新版本的容器,不过在启动新版本的容器的时候起不来。 因为版本跨度过大,可能中间太多不兼容,最后决定起一个新的容器,然后把旧的数据手动迁移过去。

在本次迁移中,为了保证旧的用户密码在迁移后依然可用,直接使用了旧的配置文件来启动新的 gitlab 容器,也就是上面 docker-compose 配置中的 /user/lnmp/gitlab/config 这个文件夹。

gitlab 没有什么工具可以用来迁移数据的,所以比较麻烦。

在迁移过程中,有几个很关键的问题需要解决:

  1. 用户和组迁移
  2. 用户的 ssh key 迁移
  3. 项目迁移
  4. 用户权限迁移

用户权限这个没有什么好的办法,只能手动去设置。如果用户太多的话,可以看看 gitlab 有没有提供 API,使用它的 API 可能会方便一些。

其他的几个问题可以稍微简单一点,本文会详细介绍。

gitlab 获取 token 以及调用 API 的方法

我们需要使用管理员账号来创建 token,其他账号是没有权限的。

gitlab 提供了很多 REST API,可以通过这些 API 来获取到 gitlab 的数据,以及对 gitlab 进行操作。 我们的一些迁移操作就调用了它的 API 来简化操作,同时也可以避免人为操作导致的一些错误。

但是调用这些 API 之前,我们需要获取到一个 token,从 gitlab 个人设置中获取到 token,然后使用这个 token 来调用 API(Preferences -> Access Tokens,添加 token 的时候把所有权限勾上就好)。

通过 REST API 导出分组的示例:

1
curl --request POST --header "PRIVATE-TOKEN: glpat-L1efQKvKeWu" "http://192.168.2.168:8200/api/v4/groups/1/export"

说明:

  • PRIVATE-TOKEN header:就是我们在 gitlab 个人设置中获取到的 token。
  • /api/v4/groups/1/export:这个是 gitlab 的 API,可以通过这个 API 导出分组,这里的 1 是分组的 ID。

我们可以将这个 curl 命令通过使用自己熟悉的编程语言来调用,这样可以很方便地对获取到的数据进行后续操作。比如我在这个过程中就是使用 python 来调用 gitlab 的 API。

启动一个新的 gitlab 16.10 版本的容器

在开始迁移之前,需要先启动一个新的 gitlab 16.10 版本的容器,这个容器是全新的,没有任何数据。但配置文件是复制了一份旧的配置文件。新的 docker-compose.yml 配置文件如下:

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
37
38
39
40
41
42
43
gitlab:
container_name: gitlab
image: 'gitlab/gitlab-ce:15.9.3-ce.0'
restart: always
hostname: '192.168.2.168'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.2.168:8200'
pages_external_url 'http://192.168.2.168:8300'
gitlab_rails['gitlab_shell_ssh_port'] = 2222
ports:
- '8200:8200'
- '8300:8300'
- '2222:22'
volumes:
- '/usr/lnmp/gitlab/config:/etc/gitlab'
- '/usr/lnmp/gitlab/logs:/var/log/gitlab'
- '/usr/lnmp/gitlab/data:/var/opt/gitlab'
shm_size: '256m'
networks:
- elasticsearch

gitlab-new:
container_name: "gitlab-new"
image: 'gitlab/gitlab-ce:16.10.0-ce.0'
restart: always
hostname: '192.168.2.168'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.2.168:8211'
pages_external_url 'http://192.168.2.168:8311'
gitlab_rails['gitlab_shell_ssh_port'] = 2233
ports:
- '8211:8211'
- '8311:8311'
- '2233:22'
volumes:
- '/usr/lnmp/gitlab-new/config:/etc/gitlab'
- '/usr/lnmp/gitlab-new/logs:/var/log/gitlab'
- '/usr/lnmp/gitlab-new/data:/var/opt/gitlab'
shm_size: '256m'
networks:
- elasticsearch

在个配置文件中:

  1. 旧的配置保持不变
  2. 旧的配置目录复制了一份 /usr/lnmp/gitlab/config => /usr/lnmp/gitlab-new/config
  3. /usr/lnmp/gitlab-new 目录下只有 config 文件夹,没有 logsdata 文件夹,这两个文件夹会在容器启动过程中生成。

这样我们就可以在旧的 gitlab 运行过程中,先进行用户、组等数据的迁移。

用户迁移

Gitlab 提供了获取用户信息的 API,可以通过这个 API 获取到用户的信息(从旧的 gitlab),然后再通过 API 创建用户(在新的 gitlab)。

我们可以在 https://docs.gitlab.com/16.10/ee/api/users.html 查看 gitlab 的用户 API 详细文档。

我们会用到它的几个 API:

  1. 获取用户信息:GET /users:通过这个 API 我们可以获取到所有用户的信息(从旧的 gitlab)。
  2. 获取用户的 ssh key:GET /users/:id/keys:通过这个 API 我们可以获取到用户的 ssh key(从旧的 gitlab)。
  3. 创建用户:POST /users:通过这个 API 我们可以创建用户(到新的 gitlab)。
  4. 创建用户的 ssh key:POST /users/:id/keys:通过这个 API 我们可以创建用户的 ssh key(到新的 gitlab)。

从旧的 gitlab 获取用户信息

完整代码太长了,这里只放出关键代码:

1
2
3
# http://192.168.2.168:8200 是旧的 gitlab 地址
response = requests.get("http://192.168.2.168:8200/api/v4/users?per_page=100", headers={'Private-Token': tk})
users = response.json()

这里的 tk 是我们在 gitlab 个人设置中获取到的 token。

它返回的数据格式如下(users):

1
2
3
4
5
6
7
8
9
10
11
[
{
"id": 33,
"username": "x",
"name": "x",
"state": "deactivated",
"avatar_url": null,
"web_url": "http://192.168.2.168:8200/x"
// 其他字段....
}
]

从旧的 gitlab 获取用户的 ssh key

在上一步获取到所有的用户信息之后,我们可以通过用户的 ID 来获取用户的 ssh key。

1
2
3
4
# http://192.168.2.168:8200 是旧的 gitlab 地址
for user in users:
ssh_keys_response = requests.get(f"http://192.168.2.168:8200/api/v4/users/{user['id']}/keys", headers={'Private-Token': tk})
user['keys'] = ssh_keys_response.json()

这里的 user['keys'] 就是用户的 ssh key 信息,格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[
{
"user_id": 6,
"username": "ming",
"name": "ming",
"keys": [
{
"id": 24,
"title": "win7",
"created_at": "2023-04-10T03:15:54.428Z",
"expires_at": null,
"key": "ssh-rsa AAAAB3Nza...",
"usage_type": "auth_and_signing"
}
]
}
]

这一步,我们获取了用户的 ssh key 并关联到了 user 中了。

在新的 gitlab 上创建用户

在获取到了用户信息之后,我们就可以在新的 gitlab 上创建用户了。

1
2
3
4
5
6
7
8
9
10
11
12
# http://192.168.2.168:8211 是新的 gitlab 的地址
create_user_response = requests.post("http://192.168.2.168:8211/api/v4/users", headers={'Private-Token': tk_new}, json={
'username': user['username'],
"email": user['email'],
"name": user['name'],
"password": '12345678Git*',

'note': user['note'],
'bio': user['bio'],
'commit_email': user['commit_email'],
})
user['new_user'] = create_user_response.json()

这个接口会返回新创建的用户信息,格式如下,我们可以通过这个信息来获取到新创建的用户的 ID。

因为新旧 gitlab 的用户 ID 会不一样,所以这里需要获取新的 ID 来创建 ssh key。

1
2
3
4
5
6
7
8
9
10
{
"id": 2,
"username": "xx",
"name": "xx",
"state": "active",
"locked": false,
"avatar_url": "",
"web_url": "http://192.168.2.168:8211/",
// 其他字段
}

在新的 gitlab 上创建用户的 ssh key

创建了用户之后,我们就可以创建用户的 ssh key 了。

1
2
3
4
5
6
7
8
# http://192.168.2.168:8211 是新的 gitlab 的地址
for ssh_key in user['keys']:
create_keys_response = requests.post(
f"http://192.168.2.168:8211/api/v4/users/{user['new_user']['id']}/keys",
headers={'Private-Token': tk_new},
json={"title": ssh_key['title'],"key": ssh_key['key']}
)
print(create_keys_response.json())

到这里,我们就把用户迁移过来了。

新旧 gitlab 用户密码不一样的问题

在现代的应用中,密码一般会使用诸如 APP_KEY 这种应用独立的 key 来做一些加密,如果是不同的 key,那么加密出来的密码就会不一样。 出于这个考虑,我们在迁移的过程中,直接保留了旧的配置,里面包含了应用的一些 key,这样我们就可以直接从旧系统的数据库中提取出加密后的密码来使用, 因为加密的时候使用的 key 是一样的,所以放到新的系统中是可以直接使用的。

从旧的 gitlab 数据库中获取用户密码

  1. 进入 gitlab 容器内部:docker exec -it gitlab bash
  2. 进入 gitlab 数据库:gitlab-rails dbconsole
  3. 获取用户密码:select username, encrypted_password from users;

这会输出所有用户的用户名和加密后的密码,我们可以把这个密码直接放到新的 gitlab 中:

1
2
3
4
  username  |                      encrypted_password
------------+--------------------------------------------------------------
a | $2a$10$k1/Cj2qUCyWgwalCmzTFo.iqYgnqIoFmQVuT2S6mBuQF0Nql0CRGm
b | $2a$10$iWWXEcPJpyZVxfmIU6nv6e46JRj2dYnwGP6GXclryEAeEXJvOZ5aC

因为 username 是唯一的,所以我们可以通过 username 来找到新的 gitlab 中对应的用户(肉眼查找法),最后更新这个用户的 encrypted_password 字段即可。

当然,也可以复制出来做一些简单的文本处理:

1
2
3
4
5
6
7
8
9
10
11
12
text = """
a | $2a$10$k1/Cj2qUCyWgwalCmzTFo.iqYgnqIoFmQVuT2S6mBuQF0Nql0CRGm
b | $2a$10$iWWXEcPJpyZVxfmIU6nv6e46JRj2dYnwGP6GXclryEAeEXJvOZ5aC
"""

lines = text.strip().split("\n")
result = []
for line in lines:
kvs = [v.strip() for v in line.strip().split('|')]
result.append({kvs[0]: kvs[1]})

print(result)

这样我们就可以得到一个字典列表:

1
2
[{'a': '$2a$10$k1/Cj2qUCyWgwalCmzTFo.iqYgnqIoFmQVuT2S6mBuQF0Nql0CRGm'},
{'b': '$2a$10$iWWXEcPJpyZVxfmIU6nv6e46JRj2dYnwGP6GXclryEAeEXJvOZ5aC'}]

在新的 gitlab 中更新用户密码

同样地,需要进入新的 gitlab 容器内部,然后进入数据库,然后更新用户密码:

  1. 进入 gitlab 容器内部:docker exec -it gitlab-new bash
  2. 进入 gitlab 数据库:gitlab-rails dbconsole
  3. 获取用户密码:select id, username from users;

拿到上一步的字典后,我们可以通过 username 找到新的 gitlab 中对应的用户 ID,然后更新密码:

  1. 通过新的 gitlab 的用户 API 获取用户列表
  2. 循环这个用户列表,如果 username 在上一步的字典中,那么就更新这个用户的密码
1
2
3
4
# http://192.168.2.168:821 是新的 gitlab 地址
# tk_new 是新的 gitlab 上的 token
response = requests.get("http://192.168.2.168:8211/api/v4/users?per_page=100", headers={'Private-Token': tk_new})
users = response.json()

因为连接到 gitlab 的数据库又比较麻烦,所以这里只是生成了的 update 语句,然后手动在新的 gitlab 数据库中执行:

1
2
3
4
5
6
7
8
9
# users 是新 gitlab 中的用户
for user in users:
if user['username'] in result:
# 通过 username 找到旧的密码
encrypted_password = result[user['username']]
# 生成 update 语句
# 这个 update 语句会将新系统中的用户密码更新为旧系统中的密码
# 因为是使用相同的 key 加密的,所以迁移后也依然可以使用旧的密码来登录
print(f"update users set encrypted_password='{encrypted_password}', reset_password_token=null, reset_password_sent_at = null,confirmation_token=null,confirmation_sent_at=null where id={user['id']};")

这会生成一些 update 语句,我们可以复制出来在新的 gitlab 中执行(也就是本小节的 gitlab-rails dbconsole)。 到这一步,用户的迁移就算完成了。

Group 的迁移

Group 的迁移和用户的迁移类似,只是 Group 的 API 不同,我们可以在 https://docs.gitlab.com/ee/api/group_import_export.html 查看 gitlab 的 Group API 文档。

注意:如果我们的 Group 中除了项目,没什么东西的话,直接自己手动在 gitlab 上创建 Group,然后把项目迁移过去就好了。 使用它的 API 是因为它可以同时迁移:milestone、label、wiki、子 Group 等信息。

  1. 我们首先需要知道在旧的 gitlab 中的 Group ID,然后通过 API 导出 Group(旧的 gitlab)。
  2. 调用了导出的 API 之后,需要等待系统导出完成,然后下载导出的文件(旧的 gitlab)。
  3. 调用下载导出文件的 API,获取到导出的分组(旧的 gitlab)。
  4. 最后,调用导入分组的 API,将分组导入到新的 gitlab 中(新的 gitlab)。

下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 从旧的 gitlab 导出 Group,1 是 Group ID
curl --request POST --header "PRIVATE-TOKEN: glpat-abc" "http://192.168.2.168:8200/api/v4/groups/1/export"

# 下载导出的分组
# --output 指定下载的文件名
# `api/v4/groups/1/export/download` 中的 1 是 Group ID
curl --request GET \
--header "PRIVATE-TOKEN: glpat-abc" \
--output download_group_42.tar.gz \
"http://192.168.2.168:8200/api/v4/groups/1/export/download"

# 导入分组(在新的 gitlab)
# --form 指定 form 表单参数
# `name` 是新的 Group 名称(给人看的名称)
# `path` 是新的 Group 路径(体现在 git 仓库的路径上)
# `file` 是下载的文件(上一步导出的 Group 文件)
curl --request POST --header "PRIVATE-TOKEN: glpat-def" \
--form "name=g1" --form "path=g2" \
--form "file=@/Users/ruby/Code/devops/download_group_42.tar.gz" "http://192.168.2.168:8211/api/v4/groups/import"

这样就可以把 Group 迁移过来了。

项目迁移

项目的迁移没有找到什么好的方法,只能手动迁移了。

gitlab 的项目因为有很多分支、release、issue,所以不能只是简单地把 git 仓库拷贝过去就好了,还需要把这些信息也迁移过去。 这就需要将项目导出,然后导入到新的 gitlab 中。

具体操作步骤如下:

  1. 在项目主页的 Settings -> General -> Advanced -> Export project 中导出项目
  2. 点击导出之后,需要等待导出完成,然后下载导出的文件,还是在 Settings -> General -> Advanced -> Export project 中,点击下载
  3. 在新的 gitlab 中,点击 New Project,然后选择 Import project,选择上一步下载的文件,导入项目(导入的类型选择 Gitlab export
  4. 填写项目名,命名空间选择跟旧的 gitlab 一样的 Group,选择上一步下载的文件,然后点击 Import project,等待导入完成即可

权限迁移

这个也是得手动设置,没有什么好的办法。 也许有 API,但是用户少的时候,还是手动设置更快。

gitlab runner 迁移

这个也是看着旧的 gitlab runner 配置,手动配置一下就完了,没几个。

需要注意的是,gitlab runner 配置的 docker 类型的 runner 的时候,需要加上 pull_policy = ["if-not-present"],这样会在执行 job 的时候快很多,不然每次都会去拉取镜像。

1
2
3
4
[[runners]]
name = "docker-runner"
[runners.docker]
pull_policy = ["if-not-present"]

总结

最后,再回顾一下迁移过程的一些关键操作:

  1. 用户迁移:通过 API 从旧的 gitlab 获取用户信息、ssh key,然后在新的 gitlab 中通过 API 创建用户、创建 ssh key。
  2. 用户密码可以进入容器中使用 gitlab-rails dbconsole 来获取用户密码,然后在新的 gitlab 中更新用户密码。
  3. Group 迁移:通过 API 导出 Group,然后下载导出的文件,最后导入到新的 gitlab 中。
  4. 项目迁移:通过 gitlab 的项目导出、导入功能来迁移项目。这种迁移方式会保留项目的 issues、分支 等信息。
0%