go-cmp对比go对象差异
谷歌开源的go对象diff对比神器,强大、灵活、自定义性强,适用于各种需要diff go对象的场景。
go-cmp包介绍
日常开发中,我们经常会因为代码升级、重构等原因需要对比新旧响应对象是否存在差异,以及找出差异点在哪里等需求。如果只是简单的保证两个对象完全一致,那可以使用reflect.DeepEqual标准库方法。但通常情况下我们还有一些额外的诉求:比如忽略某些字段,或者允许某些类型即使存在一些差异也可认为是相等的等。如果自己实现,往往比较复杂、且不够通用和完善。刚好,谷歌开源的 go-cmp 能解决这个棘手的问题。
核心Feature介绍
- 支持添加自定义的相等判定函数
- 支持通过给类型添加
Equal方法来自定义相等判定 - 如果上面两个都没有,则会递归判定两个类型是否相等,这点类似
reflect.DeepEqual。
注意:跟
reflect.DeepEqual不同的点在于:go-cmp默认不对比非导出的字段。如果diff的两个类型存在非导出字段,会直接导致panic。除非手动指定Ignore(cmpopts.IgnoreUnexported)或者添加AllowUnexported条件。
安装
1
go get -u github.com/google/go-cmp/cmp
使用go-cmp的几个场景
1. 普通使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func TestGoCmp(t *testing.T) {
a, b := Student{
Name: "小李",
Age: 1,
}, Student{
Name: "小利",
Age: 1,
}
diff := cmp.Diff(a, b)
t.Log("student a dismatch with b (a-,b+)" + diff)
}
// student a dismatch with b (a-,b+) hello_go.Student{
// - Name: "小李",
// + Name: "小利",
// Age: 1,
// }
go-cmp库很智能,如果需要对比的字段很多,diff输出会省略掉没有diff的大部分字段/对象,只保留有diff的字段上下文部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 第三行,显示省略掉10个无diff的数组对象
cmp_test.Client{
... // 10 identical elements
{Hostname: "macchiato", IPAddress: s"192.168.0.153", LastSeen: s"2009-11-10 23:39:43 +0000 UTC"},
{Hostname: "espresso", IPAddress: s"192.168.0.121"},
{
Hostname: "latte",
- IPAddress: s"192.168.0.221",
+ IPAddress: s"192.168.0.219",
LastSeen: s"2009-11-10 23:00:23 +0000 UTC",
},
+ {
+ Hostname: "americano",
+ IPAddress: s"192.168.0.188",
+ LastSeen: s"2009-11-10 23:03:05 +0000 UTC",
+ },
},
2. 自定义相等
2.1 自定义判定函数Comparer
有些情况下,我们需要自定义两个类型是否相等,比如float64类型,如果我们直接diff,即使两个数字差异很小,也会认为是不等的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestGoCmp(t *testing.T) {
a, b := Student{
Name: "小李",
Age: 1,
Score: 65.5,
}, Student{
Name: "小利",
Age: 1,
Score: 65.5000001,
}
diff := cmp.Diff(a, b)
t.Log("student a dismatch with b (a-,b+)" + diff)
}
//student a dismatch with b (a-,b+) hello_go.Student{
// - Name: "小李",
// + Name: "小利",
// Age: 1,
// - Score: 65.5,
// + Score: 65.5000001,
// }
可以使用自定义的比较函数的cmp.Comparer,来自定义float64类型的比较结果:
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
func TestGoCmp(t *testing.T) {
a, b := Student{
Name: "小李",
Age: 1,
Score: 65.5,
}, Student{
Name: "小利",
Age: 1,
Score: 65.5000001,
}
// 差异/平均值 < 0.00001 则认为两个浮点数是相等的
opt := cmp.Comparer(func(x, y float64) bool {
delta := math.Abs(x - y)
mean := math.Abs(x+y) / 2.0
return delta/mean < 0.00001
})
diff := cmp.Diff(a, b, opt)
t.Log("student a dismatch with b (a-,b+)" + diff)
}
//student a dismatch with b (a-,b+) hello_go.Student{
// - Name: "小李",
// + Name: "小利",
// Age: 1,
// Score: 65.5,
// }
可以看到,输出diff中不再包含Score。
2.3. 自定义类型,添加Equal方法
对比上面的例子,我们也可以使用自定义的MyFloat类型,并添加Equal方法来自定义float64是否相等:
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
type Student struct {
Name string
Age int
Score MyFloat
}
type MyFloat float64
func (x MyFloat) Equal(y MyFloat) bool {
xv, yv := float64(x), float64(y)
delta := math.Abs(xv - yv)
mean := math.Abs(xv+yv) / 2.0
return delta/mean < 0.00001
}
func TestGoCmp(t *testing.T) {
a, b := Student{
Name: "小李",
Age: 1,
Score: 65.5,
}, Student{
Name: "小利",
Age: 1,
Score: 65.5000001,
}
diff := cmp.Diff(a, b)
t.Log("student a dismatch with b (a-,b+)" + diff)
}
//student a dismatch with b (a-,b+) hello_go.Student{
// - Name: "小李",
// + Name: "小利",
// Age: 1,
// Score: 65.5,
// }
2.4. 类型转换Transformer
如果需要比较的类型是外部库定义的,且存在非预期的Equal方法,我们可以使用类型转换:Transformer Option将这个类型转换为我们自定义的类型来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// otherString 假设为外部定义
type otherString string
// Equal 判断两个字符串是否相等,忽略大小写
func (x otherString) Equal(y otherString) bool {
return strings.EqualFold(string(x), string(y))
}
func main() {
// 我们自定义类型
type myString otherString
// 使用Transformer转换类型
trans := cmp.Transformer("", func(in otherString) myString {
return myString(in)
})
x := []otherString{"foo", "bar", "baz"}
y := []otherString{"fOO", "bAr", "Baz"}
fmt.Println(cmp.Equal(x, y)) // true
fmt.Println(cmp.Equal(x, y, trans)) // false
}
2.5. EqualEmpty 空值判定
有时候,我们需要将map,slice nil类型非nil但是长度为0(即使容量不为0)的时候认为是相等的。这时候可以使用cmpopts.EquateEmpty:
1
2
3
4
5
6
7
8
9
10
11
12
13
func TestEquateEmptyOps(t *testing.T) {
type S struct {
A []int
B map[string]bool
}
x := S{nil, make(map[string]bool, 100)}
y := S{make([]int, 0, 200), nil}
z := S{[]int{0}, nil} // []int has a single element (i.e., not empty)
opt := cmpopts.EquateEmpty()
fmt.Println(cmp.Equal(x, y, opt)) // true
fmt.Println(cmp.Equal(y, z, opt)) // false
fmt.Println(cmp.Equal(z, x, opt)) // false
}
2.6. NaN 判定
默认情况下,即使两个值都是 math.NaN ,也是不相等的。这时候可以用cmpopts.EquateNaNs:
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 (
"fmt"
"math"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func main() {
opt := cmpopts.EquateNaNs()
x := []float64{1.0, math.NaN(), math.E, 0.0}
y := []float64{1.0, math.NaN(), math.E, 0.0}
z := []float64{1.0, math.NaN(), math.Pi, 0.0} // Pi constant instead of E
fmt.Println(cmp.Equal(x, y, opt)) // true
fmt.Println(cmp.Equal(y, z, opt)) // false
fmt.Println(cmp.Equal(z, x, opt)) // false
}
类似的,可以用cmpopts.EquateApprox来判定Float64 NaN相等且近似相等。
2.7. 数组/Map排序后相等
有时候,两个slices的顺序是不重要的,我们可以使用cmpopts.SortSlices/cmpopts.SortMaps Option来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main
import (
"fmt"
"sort"
"github.com/google/go-cmp/cmp"
)
func main() {
var trans = cmpopts.SortSlices(func(x, y int) bool { return x < y })
x := struct{ Ints []int }{[]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}
y := struct{ Ints []int }{[]int{2, 8, 0, 9, 6, 1, 4, 7, 3, 5}}
z := struct{ Ints []int }{[]int{0, 0, 1, 2, 3, 4, 5, 6, 7, 8}}
fmt.Println(cmp.Equal(x, y, trans)) // true
fmt.Println(cmp.Equal(y, z, trans)) // false
fmt.Println(cmp.Equal(z, x, trans)) // false
}
2.8 指定类型直接使用==判断
例如 netip.Addr 类型,go文档说明支持使用 == 判断是否相等:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func TestEquateComparable(t *testing.T) {
a := []struct{ P netip.Addr }{
{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
{netip.AddrFrom4([4]byte{1, 2, 3, 5})},
{netip.AddrFrom4([4]byte{1, 2, 3, 6})},
}
b := []struct{ P netip.Addr }{
{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
{netip.AddrFrom4([4]byte{1, 2, 3, 5})},
{netip.AddrFrom4([4]byte{1, 2, 3, 6})},
}
opt := cmpopts.EquateComparable(netip.Addr{})
t.Log(cmp.Equal(a, b, opt)) // true
}
3. 自定义过滤
go-cmp支持排除diff某些结构体字段,或者map中的某些key等
3.1 过滤结构体字段 IgnoreFileds
使用cmpopts.IgnoreFields可以忽略结构体的字段,也支持嵌套,如Foo.Bar
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func TestIgnore(t *testing.T) {
a, b := Student{
Name: "小李",
Age: 1,
Score: 65.5,
}, Student{
Name: "小利",
Age: 1,
Score: 65.5000001,
}
ignoreFields := cmpopts.IgnoreFields(Student{}, "Name")
diff := cmp.Equal(a, b, ignoreFields)
t.Log(diff) // true
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type MyStruct struct {
Foo Foo
}
type Foo struct {
Bar string
}
func TestIgnoreFields(t *testing.T) {
a, b := MyStruct{
Foo: Foo{Bar: "a"},
}, MyStruct{Foo: Foo{Bar: "b"}}
ignoreFields := cmpopts.IgnoreFields(MyStruct{}, "Foo.Bar")
t.Log(cmp.Equal(a, b, ignoreFields)) // true
}
3.2 过滤特定类型 IgnoreTypes
使用cmpopts.TestIgnoreTypes指定不过滤的类型:
1
2
3
4
5
func TestIgnoreTypes(t *testing.T) {
a, b := []interface{}{5, "same"}, []interface{}{6, "same"}
ignoreFields := cmpopts.IgnoreTypes(0)
t.Log(cmp.Equal(a, b, ignoreFields)) // true
}
3.3 过滤特定接口类型 IgnoreInterfaces
使用cmpopts.IgnoreInterfaces可以指定过滤一些接口类型,接口需要通过一个匿名接口体包装:
1
2
3
4
5
6
func TestIgnoreInterfaces(t *testing.T) {
a := struct{ mu sync.Mutex }{}
b := struct{ mu sync.Mutex }{}
ignore := cmpopts.IgnoreInterfaces(struct{ sync.Locker }{})
t.Log(cmp.Equal(a, b, ignore)) // true
}
3.4 过滤含有未导出字段的结构体类型 IgnoreUnexported
go-cmp默认不对比非导出的字段。如果diff的两个类型存在非导出字段,会直接导致panic。但是如果非要diff,可以手动添加类型到IgnoreUnexported中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func TestIgnoreUnexported(t *testing.T) {
type (
privateStruct struct{ Public, private int }
PublicStruct struct{ Public, private int }
ParentStruct struct {
*privateStruct
*PublicStruct
Public int
private int
}
)
a := ParentStruct{Public: 1, private: 2}
b := ParentStruct{Public: 1, private: -2}
opt := cmpopts.IgnoreUnexported(ParentStruct{})
t.Log(cmp.Equal(a, b, opt)) // true
}
除此之外,还可以用cmp.AllowUnexported指定允许含有未导出字段的类型。
3.5 过滤Slices特定元素 IgnoreSliceElements
有时候,数组中的元素不需要参与diff,可以通过cmpopts.IgnoreSliceElements实现。IgnoreSliceElements接收一个丢弃函数确定哪些元素不参与diff:
1
2
3
4
5
6
func TestIgnoreSliceElements(t *testing.T) {
a := []int{1, 0, 2, 3, 0, 4, 0, 0}
b := []int{0, 0, 0, 0, 1, 2, 3, 4}
opt := cmpopts.IgnoreSliceElements(func(v int) bool { return v == 0 })
t.Log(cmp.Equal(a, b, opt)) // true
}
3.6 过滤Map特定key IgnoreMapEntries
可以通过cmpopts.IgnoreMapEntries过滤掉map中特定不参与diff的key,接收一个丢弃函数确定哪些key不参与diff:
1
2
3
4
5
6
func TestIgnoreMapEntries(t *testing.T) {
a := map[string]int{"one": 1, "TWO": 2, "three": 3, "FIVE": 5}
b := map[string]int{"one": 1, "three": 3, "TEN": 10}
opt := cmpopts.IgnoreMapEntries(func(k string, v int) bool { return strings.ToUpper(k) == k })
t.Log(cmp.Equal(a, b, opt)) // true
}
4. 高级自定义
以上介绍的各种过滤/排序/自定义比较的选项,都是基于下面几个函数实现的:
- FilterPath(f func(Path) bool, opt Option)
- FilterValues(f interface{}, opt Option)
- Transformer(name string, f interface{})
- Ignore()
- Comparer(f interface{})
go-cmp中compare的选项Option分为两种:过滤和执行。其中FilterPath/FilterValues是过滤Option,Transformer/Ignore/Comparer为执行Option。两者结合,则可对diff路径和diff值进行转换/忽略/自定义对比等操作。
比如EquateEmpty,利用FilterValues+Comparer来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
func equateAlways(_, _ interface{}) bool { return true }
// EquateEmpty 认为长度为0和nil的maps/slices是相等的
func EquateEmpty() cmp.Option {
return cmp.FilterValues(isEmpty, cmp.Comparer(equateAlways))
}
func isEmpty(x, y interface{}) bool {
vx, vy := reflect.ValueOf(x), reflect.ValueOf(y)
return (x != nil && y != nil && vx.Type() == vy.Type()) &&
(vx.Kind() == reflect.Slice || vx.Kind() == reflect.Map) &&
(vx.Len() == 0 && vy.Len() == 0)
}
基于此原理可以实现自定义的高级需求。如需要判断Bar结构体中的Timestamp字段,时间差不超过3秒可认为其是相等的,则可以实现为:
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main
import (
"math"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
)
type (
MyStruct struct {
Foo *Foo
}
Foo struct {
Bar *Bar
}
Bar struct {
Timestamp int
}
)
func TestFilterPath(t *testing.T) {
a := MyStruct{
Foo: &Foo{
Bar: &Bar{
Timestamp: 1726325931,
},
},
}
b := MyStruct{
Foo: &Foo{
Bar: &Bar{
Timestamp: 1726325933,
},
},
}
barType := reflect.TypeOf(Bar{})
ignoreTimestampTsOp := func() cmp.Option {
// 过滤函数
return cmp.FilterPath(func(p cmp.Path) bool {
// 倒数第二个Path为Bar类型
var tye = p.Index(-2).Type()
if tye != nil && tye.AssignableTo(barType) {
// 倒数第一个Path为Timestamp字段
if sf, ok := p.Last().(cmp.StructField); ok &&
sf.Name() == "Timestamp" {
return true
}
}
return false
// 判断函数
}, cmp.Comparer(func(x, y int) bool {
return math.Abs(float64(x-y)) < 3
}))
}
t.Log(cmp.Equal(a, b, ignoreTimestampTsOp())) // true
}
其他
go-cmp还支持自定义diff输出,你可以参考官方文档进一步了解。

Comments powered by Disqus.