🤖

Goの構造体コピーやDiff出力をシュッと行うためのライブラリを書いた話

2022/12/16に公開
2

はじめに

これは Go Advent Calendar 2022 16日目の記事です。

株式会社CastingONEでHR領域のSaaS開発を行っている @takashabe です。
BtoB SaaSをやっていると各テナントで設定をカスタマイズしたい、業務上必要となるニッチな機能といった要望が多く出てくるように感じています。
ここではその是非については言及しませんが、複数の構造体を使ったユーティリティ的なパッケージがうまく業務にはまったのでその紹介をしたいと思います。

作ったもの

  • https://github.com/takashabe/structs を作りました
  • 一部ですが主な機能には次のようなものがあります
    • 構造体Aと構造体Bで同じフィールドを持つとき、そのフィールドをコピーできる
    • 構造体Aと構造体Bで同じフィールドを持つとき、そのフィールドが何かをリストアップできる (diff)
    • 上記のコピーやdiffを出すとき、フィールドがデフォルト値の場合のみ、特定の値を持つ場合のみ、などフィルタをかけることができる
  • イメージ的には https://github.com/jinzhu/copier のような処理をフィルタをかけながら行うことができる感じです
    • 実際 jinzhu/copier も内部で使わせてもらっています

ユースケース

画面ごとのカスタマイズ設定を伝播したい

ユーザが持つ情報の中でこのデータは使う、使わないなどをサービス全体あるいは機能単位でオンオフできるような機能を考えます。
例えば全体設定ではユーザの名前と生年月日だけ利用することにし、ユーザ一覧表示ではその中から名前だけ利用する、といった感じです。

コードで表すとこんなイメージです。

type UserSetting struct {
  Name      bool
  BirthDate bool
}

type UserListSetting struct {
  Name      bool
  BirthDate bool
}

// 全体設定
setting := &UserSetting{
  Name:      true,
  BirthDate: true,
}

// 一覧表示設定
listSetting := &UserListSetting{
  Name:      true,
  BirthDate: false,
}

ここで全体設定で利用オフになっているものは一覧設定ではそもそも利用できないようにする、といった考慮が考えられます。

// 愚直に書くとこんな感じ
if !setting.Name {
  listSetting.Name = false
}
if !setting.BirthDate {
  listSetting.BirthDate = false
}

例にあげたケースであればフィールド数が少ないので手書きしても良いですが、これが100個くらいの設定値を持つようになってくるとしんどくなってきます。
ここで takashabe/structs パッケージを使うとこんな感じになります。

_, err := structs.PropagateValues(
  setting,
  listSetting,
  structs.WithValue(false),
)

これで WithValue フィルタを使って第一引数の setting の各値が false のときのみ値を listSetting にコピーする、といった表現ができます。

2つのアイテムの差分を出したい

よく似た商品(これも例によって多くのフィールドを持つ)があったとして、商品のどの情報が異なっているのかを出力するような機能を考えてみます。

これもコードで表すと以下のようなイメージです。

type Item struct {
  SKU   string `json:"sku"`
  Name  string `json:"name"`
  Price int    `json:"price"`
}

item1 := &Item{
  SKU:   "A001",
  Name:  "わかば",
  Price: 1000,
}

item2 := &Item{
  SKU:   "A002",
  Name:  "スシコラ",
  Price: 2000,
}

これを差分があるフィールド名を集めたいとして愚直に書くとこんな感じでしょうか。

var diffFields []string

if item1.SKU != item2.SKU {
  diffFields = append(diffFields, "sku")
}
if item1.Name != item2.Name {
  diffFields = append(diffFields, "name")
}
if item1.Price != item2.Price {
  diffFields = append(diffFields, "price")
}

こちらもフィールド数に応じてコードが増えるのでしんどそうです。では takashabe/structs パッケージを使うとどうなるか見てみましょう。

diffFields, _ := structs.DiffFields(item1, item2)
fmt.Println(diffFields) // -> [sku name price]

はい、diffを取るために作ったのでまぁこのくらいになりますね。フィールド名は任意のstruct tag(デフォルトはjson)を参照して埋める感じになっています。

ひとつ応用的な例だと上記の商品の場合だとSKUが常にユニークになることが想定されそうです。そのような場合は以下のように特定のフィールドを除外して、それ以外のdiffを取るといったことが可能です。

diffFields, _ := structs.DiffFields(
  item1,
  item2,
  structs.WithIgnoreFields("SKU"),
)
fmt.Println(diffFields) // -> [name price]

おわりに

構造体同士のコピーやDiffといった機能をフィルタ付きで提供する https://github.com/takashabe/structs を作りました。要件が合致すればまあまあ便利そうな気がしています。
ただし実装にはリフレクションを使用しているのでパフォーマンスを考慮する必要があります。例えばレイヤードアーキテクチャを採用した場合の構造体詰替えなどはほぼ必ず発生するのでコード生成するアプローチの何かを採用したほうが幸せになれそうです。

CastingONEでは適材適所でリフレクションを使ってシュッと実装をしたいソフトウェアエンジニアを募集しています。

https://www.wantedly.com/projects/1130967
https://www.wantedly.com/projects/768663

Discussion

yuki2006yuki2006

こんにちは、

記事を見まして、便利そうなので使ってみようと思ったのですが、

値が同じの場合もそのままdiffとして出てしまいませんかね

	item1 := &Item{
		SKU:   "A001",
		Name:  "わかば",
		Price: 1000,
	}

	item2 := &Item{
		SKU:   "A001",
		Name:  "わかば",
		Price: 1000,
	}
	got, _ := structs.DiffFields(
		item1,
		item2,
		structs.WithIgnoreFields(""),
	)
	fmt.Printf("%+v\n", got)

[sku name price]

https://go.dev/play/p/pQS3DGG_hay