概要
MongoDBが使っているbson
はomitempty
というstructタグが利用可能で、これを使うことでそのフィールドがzero値の際は
- insert時にフィールドを追加しない(容量の削減)
- update時にフィールドを更新しない(部分アップデートの簡易化)
といったメリットがあります。
しかし以下のようにネストしたドキュメントについては期待する挙動をしないのでその対応方法を紹介します。
type User struct { ID string `bson:"_id"` Name string `bson:"name,omitempty"` Age int `bson:"age,omitempty"` Address Address `bson:"address,omitempty"` } type Address struct { Country string `bson:"country,omitempty"` State string `bson:"state,omitempty"` City string `bson:"city,omitempty"` Zipcode string `bson:"zipcode,omitempty"` }
環境
- Go v1.18.3
- MongoDB v5.0.9
- mongo-go-driver v1.9.1
- flatbson v0.3.0
課題
例えば以下のようなデータがある状態で
User{ ID: "001", Name: "alice", Age: 20, Address: Address{ Country: "Japan", State: "Tokyo", City: "Shibuya", Zipcode: "150-0000", }, }
次のようにAge
とAddress
の一部City,Zipcode
を更新したいとします。
User{ ID: "001", Age: 25, Address: Address{ City: "Ikebukuro", Zipcode: "170-0000", }, },
結果としてはAddress
の他のフィールドが消えてしまいます。
{ "ID": "001", "Name": "alice", "Age": 25, "Address": { "Country": "", "State": "", "City": "Ikebukuro", "Zipcode": "170-0000" } }
部分更新のはずがネストしている部分は他のフィールドも変更してしまうということです。
対応方法
1. read-modify-writeする
1つはUserデータを一度読み込んでからオブジェクトを一部変更し、それをまるっと更新する方法です。
しかしこうすることで
- 一度readが入るのでレイテンシが無駄に増える
- read-modify-writeの整合性保証が必要
といった新たな課題が生まれます。
2. inlineタグを使う
bsonにはinline
タグというものがあります。以下のように使います。
type User struct { ID string `bson:"_id"` Name string `bson:"name,omitempty"` Age int `bson:"age,omitempty"` Address Address `bson:"address,inline,omitempty"` // here }
これ使って先程のように更新してみると
{ "ID": "001", "Name": "alice", "Age": 25, "Address": { "Country": "Japan", "State": "Tokyo", "City": "Ikebukuro", "Zipcode": "170-0000" } }
一見問題なく部分更新できたように見えます。
しかし実際のMongoDBのドキュメントとしては
{ "_id": "001", "age": 25, "city": "Ikebukuro", "country": "Japan", "name": "alice", "state": "Tokyo", "zipcode": "170-0000" }
このように全てフラットに展開されてしまいます。
なので
- スキーマが変わるため既存のネストしたドキュメントには使えない
- フィールドの重複を防ぐため
addressCity
のようにフィールドにprefixを付けたほうが良い
といった課題が生まれます。
3. bson.Mを使う
以下のように更新フィールドを直接bson.Mにマッピングして更新します。
_, err := col.UpdateOne(ctx, bson.M{"_id": u.ID}, bson.M{ "$set": bson.M{ "age": u.Age, "address.city": u.Address.City, "address.zipcode": u.Address.Zipcode, }, }, opt)
当然部分更新は可能になりますが、
- 部分更新のフィールドが増える度に修正が必要
- 生でフィールド名を扱うためtypoの可能性
といった課題が生まれてきます。
FlatBSONを使う
しかし先程のbson.Mを使うやり方を簡便にできるライブラリがあります。
GitHub - chidiwilliams/flatbson: Recursively flatten a Go struct using its BSON tags
これにより元のコードに
func (m *Client) UpdateUser(ctx context.Context, u User) error { col := m.cli.Database(dbName).Collection(colName) opt := options.Update().SetUpsert(true) _, err := col.UpdateOne(ctx, bson.M{"_id": u.ID}, bson.M{ "$set": u, }, opt) if err != nil { return err } return nil }
以下のように追記するだけで部分更新が可能になります。
func (m *Client) UpdateUser(ctx context.Context, u User) error { col := m.cli.Database(dbName).Collection(colName) opt := options.Update().SetUpsert(true) doc, err := flatbson.Flatten(u) // here if err != nil { return err } _, err = col.UpdateOne(ctx, bson.M{"_id": u.ID}, bson.M{ "$set": doc, }, opt) if err != nil { return err } return nil }
これにより透過的に部分更新することができるようになりました。
その他
パフォーマンス影響は?
FlatBSONは内部でreflectを利用するので、当然使う前よりはレイテンシに影響を与えます。
しかし
- 部分更新のときしか利用しない
- ネットワークを経由するMongoDBのI/Oの方が影響が大きい
- 都度自前で書くと可読性も落ち保守も大変
といった点から十分採用できるライブラリと考えます。
サンプルコード
今回のサンプルコードはこちら
まとめ
GoでネストしたMongoDBドキュメントの部分更新をする方法を紹介しました。