Carpe Diem

備忘録

GoでネストしたMongoDBドキュメントの部分更新をする

概要

MongoDBが使っているbsonomitemptyという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",
        },
}

次のようにAgeAddressの一部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データを一度読み込んでからオブジェクトを一部変更し、それをまるっと更新する方法です。
しかしこうすることで

といった新たな課題が生まれます。

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の方が影響が大きい
  • 都度自前で書くと可読性も落ち保守も大変

といった点から十分採用できるライブラリと考えます。

サンプルコード

今回のサンプルコードはこちら

github.com

まとめ

GoでネストしたMongoDBドキュメントの部分更新をする方法を紹介しました。

参考