文章

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输出,你可以参考官方文档进一步了解。

本文由作者按照 CC BY 4.0 进行授权

Comments powered by Disqus.