Golang1.18新特性

郎鸿朗
2023-12-01

Golang1.18新特性

golang1.18已经发布一年多了,目前最新的版本是1.19,但作为生产,需保持谨慎态度,目前把项目的版本从1.16升级到了1.18,同时针对1.1.8的新特性做了一下记录,当然1.1.8更新了不少内容,本文只提炼了一些本人关注的特性。

1. 泛型

golang1.18就加入泛型,通过泛型的支持,将减少很多代码量,但也会带来不少可读性问题,因此泛型在生产中仍然需要谨慎使用,特别是目前golang的泛型还比较基础。

使用泛型

我们来看一个例子,我们需要写一个方法,拼接数值为字符串,类似strings.Join方法,将数值切片通过分隔符组合长字符串

func JoinInt(elems []int, sep string) string {
	switch len(elems) {
	case 0:
		return ""
	case 1:
		return strconv.Itoa(elems[0])
	}

	var b strings.Builder
	b.WriteString(strconv.Itoa(elems[0]))
	for _, s := range elems[1:] {
		b.WriteString(sep)
		b.WriteString(strconv.Itoa(s))
	}
	return b.String()
}

这是支持Int类型的切片方法,但是此时我们如果要支持uint64类型的数值,该方法就不支持了,只有再写两个方法

func JoinUint64(elems []uint64, sep string) string {
	switch len(elems) {
	case 0:
		return ""
	case 1:
		return strconv.FormatUint(elems[0], 10)
	}

	var b strings.Builder
	b.WriteString(strconv.FormatUint(elems[0], 10))
	for _, s := range elems[1:] {
		b.WriteString(sep)
		b.WriteString(strconv.FormatUint(s, 10))
	}
	return b.String()
}

此时的调用方法需要针对不同类型调用不同的方法:

func TestNormalJoin(t *testing.T) {
	var intArr = []int{1, 2, 3, 4, 5}
	var uint64Arr = []uint64{1, 2, 3, 4, 5}

	fmt.Println("join int string", JoinInt(intArr, "-"))
	fmt.Println("join uint64 string", JoinUint64(uint64Arr, "-"))
}

我们再来看看针对上面问题,使用泛型带来的便捷。

func GenericsJoin[v int | uint64| int32](elems []v, sep string) string {
	switch len(elems) {
	case 0:
		return ""
	case 1:
		return strconv.Itoa(int(elems[0]))
	}

	var b strings.Builder
	b.Grow(len(elems))
	b.WriteString(strconv.Itoa(int(elems[0])))
	for _, s := range elems[1:] {
		b.WriteString(sep)
		b.WriteString(strconv.Itoa(int(s)))
	}
	return b.String()
}

我们看到在函数名称GenericsJoin后面紧跟一个大括号[v int | uint64| int32],里面填的是函数支持的类型,即变量v可以是int、uint64、int32中的任意一种类型;入参elems []v则表示elems支持的是v类型的切片。此时传入的切换可以是[]int、[]uint64、[]int32中的任意一种即可。我们再来看调用方法:

func TestGenericsJoin(t *testing.T) {
	var intArr = []int{1, 2, 3, 4, 5}
	var uint64Arr = []uint64{1, 2, 3, 4, 5}

	fmt.Println("generics join int string", GenericsJoin(intArr, "-"))
	fmt.Println("generics join uint64 string", GenericsJoin(uint64Arr, "-"))
}

我们发现,调用者只需要调用GenericsJoin方法即可,虽然是两个类型的切片,但是调用GenericsJoin都是可以的。这就减少了大量的重复代码,起到易用的作用。

声明泛型类型

我们看到前面函数GenericsJoin[v int | uint64| int32](elems []v, sep string)上支持了三种类型,那如果我们要支持更多的类型,岂不是该函数会非常的长,并且不好维护,达不到不用的效果,例如其他的函数也要支持这样的类型。这时候可以声明泛型类型,格式如下:

type Numeric interface {
	int | uint64 | int64 | uint | int32
}

Numeric的类型是interface,但是内部元素跟原来的interface有一些区别,内部通过|来分割类型,表明Numeric支持这些类型,
这样在函数调用的时候就不用单独声明每个数值类型了,我们将原来的GenericsJoin函数调整一下:

func GenericsJoinNumeric[v Numeric](elems []v, sep string) string {
	switch len(elems) {
	case 0:
		return ""
	case 1:
		return strconv.Itoa(int(elems[0]))
	}

	var b strings.Builder
	b.Grow(len(elems))
	b.WriteString(strconv.Itoa(int(elems[0])))
	for _, s := range elems[1:] {
		b.WriteString(sep)
		b.WriteString(strconv.Itoa(int(s)))
	}
	return b.String()
}

这样把Numeric当作函数的入参,调用方也不需要调整,这样如果后续需要支持更多的类型,只需要在Numeric内添加即可。当然此时Numeric只支持基础类型,而如果对基础类型进行引用就不支持了,例如我们声明类型type myInt32 int32,就无法使用,这时候可以将Numeric进行简单改造即可,在int32前面添加~表示支持int32的所有类型:

type Numeric interface {
	int | uint64 | int64 | uint | ~int32
}

//调用
func TestGenericsJoinNumeric(t *testing.T) {
	var intArr = []int{1, 2, 3, 4, 5}
	var uint64Arr = []uint64{1, 2, 3, 4, 5}
	type myInt32 int32
	var myInt32Arr = []myInt32{1, 2, 3, 4, 5}

	fmt.Println("generics join int string", GenericsJoinNumeric(intArr, "-"))
	fmt.Println("generics join uint64 string", GenericsJoinNumeric(uint64Arr, "-"))
	fmt.Println("generics join int32 string", GenericsJoinNumeric(myInt32Arr, "-"))
}

当然1.18还新增了any类型,该类型其实就是空接口interface{}的别名。同时新增一个comparable类型表示课比较类型,包括bool、number、string、pointer、channel等可比较的类型。

高级用法

泛型可以与interface结合处很多高级用法,例如数据库操作,可以声明一个接口类型,支持任意类型,该类型只要实现了Scan方法即可。

type DBScanner[T any] interface {
	*T
	Scan() []any
}

此时声明一个Student的表接口,并且实现Scan方法:

type Student struct {
	ID 		string
	CreateTime time.Time
	Name    string
	Age     int
	Address net.IP
}

func (s *Student) Scan() []any {
	return []any{&s.ID, &s.CreateTime, &s.Name, &s.Age, &s.Address}
}

编写数据库查询方法:

import (
	"context"
	"github.com/jackc/pgx/v5"
)

func QueryGenericsScan[T any, ptr DBScanner[T]](
	ctx context.Context,
	tx pgx.Tx,
	sql string, args []interface{}) ([]T, error) {
	return QueryGenerics[T](ctx, tx, func(p ptr) []any {
		return p.Scan()
	}, sql, args)
}

func QueryGenerics[T any, entity interface{ *T }](
	ctx context.Context,
	tx pgx.Tx,
	scanFun func(entity) []any,
	sql string, args []interface{}) ([]T, error) {
	rows, err := tx.Query(ctx, sql, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	results := make([]T, 0, len(rows.RawValues()))
	for rows.Next() {
		var result T
		if err := rows.Scan(scanFun(&result)...); err != nil {
			return nil, err
		}
		results = append(results, result)
	}
	return results, nil
}

调用查询student数据方法:

func QueryStudent() error {
	pool, err := pgxpool.New(context.Background(), ConnStr)
	if err != nil {
		return err
	}
	defer pool.Close()

	tx, _ := pool.Begin(context.TODO())
	defer tx.Commit(context.TODO())
  
	result, err := QueryGenericsScan[Student](context.TODO(), tx, "select * from gr_student", []interface{}{})
	if err != nil {
		return err
	}

	fmt.Printf("len:%d \n", len(result))
	return nil
}

这样就避免了通过反射以适配不同的表结构。当然可以在目前的基础上继续封装成一个完整的orm库,这里就不再展开,有兴趣的同学可以继续完善。

2. netip

golang1.18版本新增一个标准库net/netip,并增加新IP地址类型Addr对标原来的net.IP类型,以及子网Prefix类型对标原来的net/IPNet。并且netip.Addr类型占用更少的内容,其内容是不可改变的,并且支持comparable可以作为map的key使用。

我们来编写两个性能测试例子,来对比新的neip带来的性能提升

func BenchmarkParseIP(b *testing.B) {
	for i := 0; i < b.N; i++ {
		net.ParseIP("2001::128")
	}
}

func BenchmarkParseAddr(b *testing.B) {
	for i := 0; i < b.N; i++ {
		if _, err := netip.ParseAddr("2001::128"); err != nil {
			b.Error(err)
		}
	}
}

func BenchmarkParseCIDR(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_, _, err := net.ParseCIDR("2001::128/128")
		if err != nil {
			b.Error(err)
		}
	}
}

func BenchmarkParseParsePrefix(b *testing.B) {
	for i := 0; i < b.N; i++ {
		if _, err := netip.ParsePrefix("2001::128/128"); err != nil {
			b.Error(err)
		}
	}
}

执行性能测试命令go test -v -bench=Parse -run=^# -benchmem,得到如下执行结果

➜  netip go test -v -bench=Parse -run=^# -benchmem
goos: darwin
goarch: amd64
pkg: gomoduletest/ip/netip
cpu: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
BenchmarkParseIP
BenchmarkParseIP-8              16439040                70.06 ns/op           16 B/op          1 allocs/op
BenchmarkParseAddr
BenchmarkParseAddr-8            27164858                43.28 ns/op            0 B/op          0 allocs/op
BenchmarkParseCIDR
BenchmarkParseCIDR-8             4986140               232.6 ns/op            96 B/op          4 allocs/op
BenchmarkParseParsePrefix
BenchmarkParseParsePrefix-8     19405418                59.40 ns/op            0 B/op          0 allocs/op
PASS
ok      gomoduletest/ip/netip   5.514s

可以看到netip解析子网和解析ip的性能明显高于net库,并且没有内存分配。

另外,原来的net库并没有提供IP的偏移方法,导致计算ipv4和ipv6的递增位时都需要先转换成数值类型,然后利用位相加运算实现增减以后再转换成net.IP类型,这就非常麻烦,而新的netip提供了方法Next和Prev来实现了IP的增减,我们看如下例子:

func IpV6ToBigInt(ip net.IP) *big.Int {
	value := big.NewInt(0)
	for _, b := range ip {
		value.Lsh(value, 8)
		value.Or(value, big.NewInt(int64(b)))
	}
	return value
}

func BenchmarkAddNet(b *testing.B) {
	bigValue := IpV6ToBigInt(net.ParseIP("2001:1000::120"))
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		bigValue.Add(bigValue, big.NewInt(1))
	}
}

func BenchmarkAddNetip(b *testing.B) {
	addr, _ := netip.ParseAddr("2001:1000::120")
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		_ = addr.Next()
	}
}

运行基准测试以后的结果如下:

go test -v -bench=AddNet -run=^# -benchmem
goarch: amd64
pkg: gomoduletest/ip/netip
cpu: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
BenchmarkAddNet
BenchmarkAddNet-8       28256738                39.65 ns/op            8 B/op          1 allocs/op
BenchmarkAddNetip
BenchmarkAddNetip-8     944860983                1.264 ns/op           0 B/op          0 allocs/op
PASS
ok      gomoduletest/ip/netip   2.616s

可以看到netip的IP位增是非常方便的,原来的net库需要将ip转换成big.Int以后再进行位移,因此效率也会更低。

3. fuzzing测试

1.18新增单元测试fuzzing,其目的是通过随机生成的数据进行测试,找出单元测试(unit test)覆盖不到的场景,进而发现潜在的BUG和安全漏洞。

使用说明

  • fuzz测试的函数名称必须是FuzzXXX格式,并且入参是a *testing.F类型,且函数无返回值
  • fuzz测试与常规的单元测试一样,其测试文件必须以_test.go结尾
  • fuzz方法支持的参数:string, bool,[]byte,int, int8, int16, int32/rune,int64,uint, uint8/byte, uint16, uint32, uint64,float32, float64

运行命令

~ go test -fuzz FuzzFoo
fuzz: elapsed: 0s, gathering baseline coverage: 0/192 completed
fuzz: elapsed: 0s, gathering baseline coverage: 192/192 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 202)
fuzz: elapsed: 6s, execs: 680218 (118402/sec), new interesting: 12 (total: 203)
fuzz: elapsed: 9s, execs: 1039901 (119895/sec), new interesting: 19 (total: 210)
fuzz: elapsed: 12s, execs: 1386684 (115594/sec), new interesting: 21 (total: 212)
Failing input written to testdata/fuzz/FuzzReverse/ce2b04545793f5e742b110aaf8543b98bcebeb8c0833965f09b77b499eec3395
FAIL
exit status 1
FAIL	gomoduletest/test/fuzz	0.698s

#携带参数
go test -fuzz={FuzzTestName}
#-fuzztime: 设定模糊测试运行的总时长,默认是无时间限制
#-fuzzminimizetime: 运行模糊测试最小尝试时间,默认是60秒,可以设定-fuzzminimizetime 0来禁用最小时间
#-parallel: 并行模糊测试的内核数量,默认是$GOMAXPROCS
#case
go test -fuzz=Fuzz -fuzztime 30s
  • 第一行表示模糊测试开始之前收集的基线覆盖率,模糊测试通过执行种子库和生成库来确保没有异常错误,并且以达到覆盖率的目的。种子库是我们自行输入的例子,通过f.Add函数进行添加,该阶段不会生成随机数据。
  • elapsed:表示程序执行时间
  • execs:针对模糊目标输入的总数(平均执行次数/秒)
  • new interesting:表示有多少个生成库的例子放入到测试(括号内则是总共有多少个生成库)
  • Failing input:表示失败输入的例子,会在当前测试目录下生成一个文件为ce2b04545793f5e742b110aaf8543b98bcebeb8c0833965f09b77b499eec3395,里面存的就是导致测试未通过的例子

案例

首先编写一段Reverse函数,函数的作用是进行反转字符串

func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

编写常规的单元测试

func TestReverse(t *testing.T) {
	testcases := []struct {
		in, want string
	}{
		{"Hello, world", "dlrow ,olleH"},
		{" ", " "},
		{"!12345", "54321!"},
	}
	for _, tc := range testcases {
		rev := Reverse(tc.in)
		if rev != tc.want {
			t.Errorf("Reverse: %q, want %q", rev, tc.want)
		}
	}
}

显然我们的单元测试用例并没有完全覆盖,而一些未知的例子我,我们无法穷举,因此加入fuzz模糊测试来辅助完善测试用例

func FuzzReverse(f *testing.F) {
	testcases := []string{"Hello, world", " ", "!12345"}
	for _, tc := range testcases {
		f.Add(tc) // Use f.Add to provide a seed corpus
	}

	f.Fuzz(func(t *testing.T, orig string) {
		rev := Reverse(orig)
		doubleRev := Reverse(rev)
		if orig != doubleRev {
			t.Errorf("Before: %q, after: %q", orig, doubleRev)
		}
		if utf8.ValidString(orig) && !utf8.ValidString(rev) {
			t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
		}
	})
}

在fuzz测试中加入单元测试的例子,然后执行命令go test -fuzz=FuzzReverse,此时会输出如下内容

➜  fuzz go test -fuzz=FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/12 completed
fuzz: elapsed: 0s, gathering baseline coverage: 12/12 completed, now fuzzing with 8 workers
fuzz: minimizing 33-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzReverse (0.04s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_test.go:37: Reverse produced invalid UTF-8 string "\xad\xc7"
    
    Failing input written to testdata/fuzz/FuzzReverse/31f82ffa1dd61be6ed134da15613dcf7d36ad04dd7bffeb65730b2817f38a122
    To re-run:
    go test -run=FuzzReverse/31f82ffa1dd61be6ed134da15613dcf7d36ad04dd7bffeb65730b2817f38a122
FAIL
exit status 1
FAIL    gomoduletest/test/fuzz  0.724s

表示执行测试失败,并且fuzz已经将失败的例子放入testdata/fuzz/FuzzReverse/31f82ffa1dd61be6ed134da15613dcf7d36ad04dd7bffeb65730b2817f38a122,就在当前目录下,打开该文件即可看到对应的失败用例

go test fuzz v1
string("\U00056bdc")

该字符明显不是常规的格式,我们打印一下输入内容,然后执行go test -run=FuzzReverse/31f82ffa1dd61be6ed134da15613dcf7d36ad04dd7bffeb65730b2817f38a122

func Reverse(s string) string {
  fmt.Printf("input: %q utf-8:%t \n", s, utf8.ValidString(s))
	b := []byte(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

此时打印如下信息,说明在生成库的例子中存在中文和非utf8的格式,因此我们要对函数做出调整,支持utf8中文,但是非utf8类型的我们不支持,抛出异常

➜  fuzz     go test -run=FuzzReverse/31f82ffa1dd61be6ed134da15613dcf7d36ad04dd7bffeb65730b2817f38a122

input: "\U00056bdc" utf-8:true 
input: "\x9c\xaf\x96\xf1" utf-8:false 
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/31f82ffa1dd61be6ed134da15613dcf7d36ad04dd7bffeb65730b2817f38a122 (0.00s)
        fuzz_test.go:47: Reverse produced invalid UTF-8 string "\x9c\xaf\x96\xf1"
FAIL
exit status 1
FAIL    gomoduletest/test/fuzz  0.188s

调整以后的函数如下

func Reverse(s string) (string, error) {
	if !utf8.ValidString(s) {
		return s, errors.New("input is not valid UTF-8")
	}

	b := []rune(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b), nil
}

由于多了一个返回参数,因此需要调整单元测试以及模糊测试

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc) // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err1 := Reverse(orig)
        if err1 != nil {
            return
        }
        doubleRev, err2 := Reverse(rev)
        if err2 != nil {
            return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

总结

fuzz模糊测试的作用是非常巨大的,可以发现我们程序中潜在的BUG,特别是人为的测试用例是无法覆盖完全的,借助fuzz可以起到很好的辅助作用,但是目前fuzz支持的参数只是一些基础类型,还不够完善,待到完善以后将会给程序的安全以及稳定性带来显著提升。

4.命令变化

go get命令已弃用,并且被go install替代

#old command
go get example.com/cmd

#new command
go install exmple.com/cmd@latest

go work

新增work工作空间,类似与go.mod,不过这个工作空间可以用于切换本地与远程的调试。特别是引用一些本地包的时候就可以使用。例如我们引用了utils内的方法,但是要修改utils的方法以后再使用utils,原来需要在go.mod内使用replace进行调试,调试完毕以后还要修改回去。现在只需要使用go work即可。

# 初始化工作空间
go work init xxx
# 将单个module引入工作空间
go work use xxx
# 将所有的module引入工作空间
go work use -r
# 同步工作空间的module
go work sync
 类似资料: