Go 结构体兼容性实践

kenticny

在上一篇文章中,我们针对 Go 模块开发时对于函数的兼容性问题进行了讨论。那么在实际的开发中,处理函数之外,结构体类型也是使用十分频繁的类型,那么对于模块公开的结构体类型在修改的时候,也需要考虑兼容性的问题。那么本文就针对模块中修改公开结构体类型对兼容性的保证实践进行讨论,主要从修改结构体的一些原则,以及新增结构体字段是需要考虑的字段默认值问题,和在有结构体比较场景时可能遇到的问题,以及如何避免这些问题的进行讨论。

结构体新增字段

结构体是我们在模块中最常用的类型之一,通常会有很多私有结构体和公开结构体,那么对于私有结构体我们不需要考虑兼容性问题,因为没有外部用户可以访问到。而对于公开结构体类型做修改的时候,就需要考虑兼容性:

1
2
3
4
5
6
7
8
// 私有结构体不需要考虑兼容性问题
type internal struct {
...
}
// 公开结构体需要考虑兼容性问题
type User struct {
...
}

那么,对于结构体的修改,我们简单归结为集中,新增字段,修改字段类型,删除字段;在这三种操作中,对于字段类型的修改和删除字段都是无法向后兼容的,所以这里又涉及到一个原则,就是在修改结构体的时候,不要删除字段和修改字段类型。因为如果你修改的字段正好被用户使用到了,那么这时候如果用户更新到最新版本就会出现无法编译通过的问题。这个和保证函数的兼容性时采用添加函数的方法一样,也是尽可能的通过添加字段完成修改。

结构体字段默认值问题

那么如果是在结构体中添加字段,是不是就是万无一失了呢?当然不是。大家都知道,在 Go 语言中有一个特性,任何类型都是有默认值的。同样的,我们在结构体中的字段也是有默认值的,如果新增一个字段的时候,用户更新时,是没有传递这个字段的,也就是会保持默认值。所以我们在结构体中新增字段的时候,一定要注意到字段默认值是否会影响使用逻辑。下面我们举一个例子:

1
2
3
4
5
6
7
8
type User struct {
Email string
Validate bool
Age int // 新增加字段
}
func (u *User) IsValidatedUser() bool {
return u.Validate && u.Age > 18
}

上面代码是我们假设的一个场景,一个用户结构体,然后增加了一个字段 Age,然后在方法 IsValidatedUser 作用为检查用户是否为已经经过验证的,那么原来的逻辑就是检查 User 中 Validate 的值,但是现在增加 Age 后,逻辑变为了 Age 大于 18,并且 Validate 为 true 时,用户才可以通过检查。那么这里如果调用方在更新了模块后,没有对于结构体中传入字段进行修改,那也就是说结构体中的 Age 全部都是默认值 0,那么这样的话在调用 IsValidatedUser方法时就可能会得不到正确的值了。

所以,我们在给结构体增加字段时,一定要考虑到新增加的字段如果没有传入,即为默认值时,是否会对逻辑产生影响。另外,如果新增加的字段不是给用户使用的,那么就以私有字段的形式增加就可以。也就是说,非必要情况下,不要将字段公开

上面提到的这种在结构体中新增字段来保证兼容性的方法,在 Go 标准库中也是很常用的。对于标准库 net 包,相信大家一定不会陌生,在 net 包中有一个配置结构体:ListenConfig,在 Go 1.13 时,Go 团队在这个结构体中加入了 KeepAlive 字段,并且对于未传入该字段时进行了处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {
// ... 省略部分代码 ...
if tc, ok := c.(*TCPConn); ok && d.KeepAlive >= 0 {
setKeepAlive(tc.fd, true)
ka := d.KeepAlive
if d.KeepAlive == 0 {
ka = defaultTCPKeepAlive
}
setKeepAlivePeriod(tc.fd, ka)
testHookSetKeepAlive(ka)
}
return c, nil
}

结构体比较问题

那么在新增结构体字段时,如果我们考虑到字段的默认值问题以后,是不是就万事大吉了呢?当然不是!除了我们在模块中要考虑到的兼容性问题,其实在用户使用上也可能会导致兼容性问题,我们看下下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 模块定义的结构体
type User struct {
Email string
Tags []string // 新增加字段
}
func NewUser(email string, tags []string) User {
return User{ Email: email, Tags: tags }
}

// 用户使用
u1 := NewUser("u1@a.com", nil)
u2 := NewUser("u1@a.com", nil)
if u1 == u2 {
// do something...
}

在上面的例子中,用户在使用时,将两个结构体对象进行比较,如果两个结构体相等,那么回去处理一些逻辑,但是如果在结构体中我们增加了 Tags 字段,定义为一个字符串切片类型,那么这里会发生什么呢?

这里涉及到一个概念,就是 Go 里面的“可比较类型”(这个概念这里不做过多描述,大家可以自行去查阅相关资料)。如果一个结构体中所有的字段都是“可比较类型”,那么这个结构体本身也是可以比较的,反之则结构体不可比较。而切片类型就是一个不可比较的类型,所以这里会报出一个编译错误。

这是一个比较容易发现的现象,但是还有一种场景是没有那么容易发现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 模块
type Tags struct {
...
}
type User struct {
Email string
Tags *Tags // 新增加字段
}
func NewUser(email string, tags *Tags) User {
u := User{ Email: email }
if tags == nil {
tags = new(Tags)
}
u.Tags = tags
return u
}

// 用户使用
u1 := NewUser("u1@a.com", nil)
u2 := NewUser("u1@a.com", nil)
if u1 == u2 {
// do something...
}

这里我们 User 结构体中加入的 Tags 字段变成了指针类型,而指针类型是可比较类型,所以这里代码不会有编译错误。但是!这里我们对于新增加字段进行了默认值的处理,如果没有传入新字段,那么就会生成一个 Tags 对象作为默认值,而 Tags 本身是指针类型,所以每次新创建指针都是不同的,所以这里就会导致对比的逻辑错误。

可以看到,这种问题很容易蒙混过关而导致我们的代码BUG,所以如果我们想要在源头上扼杀这种场景,那么就需要我们将结构体本身变成一个不可比较的类型。如果结构体中本身存在切片,数组,map或函数之类不可比较的对象,那么我们不用考虑这个问题;但是如果结构体中所有的字段都是可比较类型的,我们可以在结构体中做如下处理:

1
2
3
4
5
type User struct {
_ [0]func() // 添加的占位符
Email string
Age int
}

我们在结构体重添加了一个长度为0的函数数组,所以这个结构体变成不可比较类型的了。在 Go 语言中,长度为 0 的数组是不占用空间的,所以这里我们这样实现也不会影响本身的内存占用。

这里可能有人会问了,为什么我们平时很少看到这种写法啊。确实,在我们平时的代码中,很少能看到这样的写法,那么什么时候我们需要这样处理,而什么时候又不需要呢?大家可以回看下上面的代码,我们提供的 NewUser 函数返回的是一个 User 结构体,那么这样就让用户有了去比较两个 User 的可能性,那么如果我们 NewUser 返回的是一个 User 指针类型,就没有这个问题了,因为新创建的对象指针永远是不同的,所以也就无法拿来做比较了。所以我们平时的代码中,大多数场景这种NewXXX的函数返回的类型都是一个指针类型,这样就不需要上面的操作了。

结尾

到这里我们就把结构体在修改时可能遇到的兼容性问题整理完了,总结一下几个需要注意的点:

  1. 要通过添加字段的方法修改结构体,不要删除字段或修改字段类型
  2. 如果一个字段不需要用户使用到,那就将它设置为私有的
  3. 新增字段要考虑字段默认值可能带来的问题,和对于是否允许结构体进行比较。

至此,我们已经完成了对于函数和结构体在修改时的兼容性问题考虑,那么后续我们会再接着对于接口中可能存在的兼容性问题以及其解决方法进行讨论。欢迎大家持续关注。

  • 本文标题:Go 结构体兼容性实践
  • 本文作者:kenticny
  • 创建时间:2021-09-18 00:31:34
  • 本文链接:https://luyun.io/2021/09/18/go-mod-compatibility-struct/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论