protobufをcmp.Diffする際はcmpoptsではなくprotocmpを使おう


:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

## はじめに
皆様、初めまして。
viviON開発部 基盤チーム所属のniiと申します。

普段はマイクロサービスの開発や運用を行っており、その一環で詰まった部分について記事にしてみました。
何卒よろしくお願いします。

## TL;DR
- protobuf比較にはcmpoptsではなくprotocmpを使おう
- protocmpにはcmpoptsのようにソート機能や特定のフィールドを無視する機能などがある


## 🤔 入れ子構造になっているものにソートが効かない
### 前提
下記のサンプルProtoファイルを用いて説明します！
UserとArticleが1対多の簡単なモデルです。

```proto

message Article{
  int64 id = 1;
  string title = 2;
  string body = 3;
}

message User{
  int64 id = 1;
  string name = 2;
  repeated Article articles = 3;
}

```

### テスト

Golangにて、上記のUserやArticleを取得するrpcのテストを書きたい場合を考えてみます。

```go
// イメージ
got := GetUserRPC()

if cmp.Diff(want, got, protocmp.Transform()); diff != "" {
  t.Errorf("diff: %v",diff)
}
```

ここで、`GetUserRPC()`の返り値であるUser内のArticle順序は保証していない場合はどうなるでしょうか？(mapなどを用いて操作しており、順序が保証されないというケースなど)

```go

got := protobuf.User{
    Id:   1,
    Name: "テストユーザー",
    Articles: []*protobuf.Article{
        // 順序がバラバラ
        &article1,
        &article4,
        &article3,
        &article2,
    },
}

want := protobuf.User{
    Id:   1,
    Name: "テストユーザー",
    Articles: []*protobuf.Article{
        &article1,
        &article2,
        &article3,
        &article4,
    },
}

diff := cmp.Diff(want, got, protocmp.Transform())
```

```text:結果
diff:  Service.User(Inverse(protocmp.Transform, protocmp.Message{
  	"@type": s"Service.User",
  	"articles": []protocmp.Message{
- 		s`{id:1, title:"サンプル記事1", body:"Hello World!"}`,
  		{"@type": s"Service.Article", "body": string("Hello World!"), "id": int64(2), "title": string("サンプル記事2")},
+ 		s`{id:1, title:"サンプル記事1", body:"Hello World!"}`,
+ 		s`{id:4, title:"サンプル記事4", body:"Hello World!"}`,
  		{"@type": s"Service.Article", "body": string("Hello World!"), "id": int64(3), "title": string("サンプル記事3")},
- 		s`{id:4, title:"サンプル記事4", body:"Hello World!"}`,
  	},
  	"id":   int64(1),
  	"name": string("テストユーザー"),
  }))

```

順序が違うため、当然ながら差分が出てしまっています。

順序が違うだけで入っているデータは同一であるため、
差分を検出しないように改修します。

今回の例ですと、ArticleのIdでSortしてあげればうまくいきそうです。
素直にcmpライブラリ内にある`cmpopts.SortSlices`を使用してみます。

```go
// うまく動かない例
sortOpts := cmpopts.SortSlices(func(i, j *protobuf.Article) bool {
    return i.Id < j.Id
})

diff := cmp.Diff(want, got, protocmp.Transform(), sortOpts)
```

ただしこれだと想定したように動かず、差分は消えません。
下記のようにArticle自体を比較すれば回避することもできますが、可能であればUserごとにまとめて確認したいところです。

```go
cmp.Diff(want.Articles, got.Articles, protocmp.Transform(), sortOpts)
```

## 💡 解決策: protocmp.SortRepeated()を使おう
protocmpパッケージ内に`protocmp.SortRepeated`というソート機能がありました！
https://pkg.go.dev/google.golang.org/protobuf/testing/protocmp#SortRepeated

ソート可能なのは下記の型です。
- protobufスカラー型(例: bool, int32, int64, uint32, uint64, float32, float64, string,[]byte)
- protoreflect.Enumを実装する具体的なenum型
- proto.Messageを実装する具体的なMessage型

今回はMessage型であるArticleをSortしたいため、下記のように記載します。
```go
protoSortOpts := protocmp.SortRepeated(func(i, j *protobuf.Article) bool {
    return i.Id < j.Id
})

diff = cmp.Diff(want, got, protocmp.Transform(), protoSortOpts)
```

```text:結果
diff:  // 空文字
```

想定した動作通り、差分が出なくなりました！🎉

## 👀 その他の例:フィールドを無視したい場合
登録系RPCのテストを実施したい場合はどうでしょうか？
登録したUserを返すStoreUserRPC()の結果を比較してみます。

```go
// イメージ
got := StoreUserRPC(input)

if cmp.Diff(want, got, protocmp.Transform(), protoSortOpts); diff != "" {
  t.Errorf("diff: %v",diff)
}
```

登録RPCを実行する前はIdが不明なので、一旦wantデータのIdは0にします。

```go
want := protobuf.User{
		Id:   0,
		Name: "テストユーザー",
		Articles: []*protobuf.Article{
			&article1,
			&article2,
			&article3,
			&article4,
		},
	}
```

ただこのままテストを実行すると、当然ながらIdの差分が検出されます。

```text:結果
+ 	"id":       int64(100),  // idは登録されるまでわからない
```

今回は特定のフィールドを無視するオプションを使ってあげれば良さそうです。

ここまで読んだ皆様ならもうお分かりですね。
今回も`cmpopts.IgnoreFields` ではなく、 `protocmp.IgnoreFields`を使ってあげましょう！
先ほど指定してあげた`protocmp.SortRepeated`と重ねて使用することもできます。

```go
optsList := []cmp.Option{
    protocmp.Transform(),
    protocmp.SortRepeated(func(i, j *protobuf.Article) bool {
        return i.Id < j.Id
    }),
    protocmp.IgnoreFields(&protobuf.User{}, "id"),
}

diff := cmp.Diff(want, got, optsList...)
```

```text:結果
diff:  // 空文字
```

## さいごに
ここまで読んでいただきありがとうございました！
protoの比較にはprotocmpを使う事を頭の片隅に置いていただけると幸いです。

それでは良きprotobufライフを！

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、マイクロサービスを推進するエンジニアを募集しています。

https://vivion.jp/recruit/

また、マイクロサービスに限らず、バックエンド・フロント・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。