概要
前回はFieldMaskを使ってオーバーフェッチを避ける方法を説明しました。
今回はMutation(更新)におけるFieldMaskの活用方法を説明します。
環境
- Go v1.18.3
- protoc-gen-go v1.25.0
- protoc v3.19.4
- grpc-go v1.47.0
- MongoDB 5.0.9
課題
前回サーバ側では以下の構造のデータを保持していました
type User struct { ID string `bson:"_id"` Name string `bson:"name"` Email string `bson:"email"` Age int `bson:"age"` Address Address `bson:"address"` } type Address struct { Country string `bson:"country"` State string `bson:"state"` City string `bson:"city"` Zipcode string `bson:"zipcode"` }
ここで例えばEmailだけ変更するAPIが欲しい、となった場合、
- Emailだけ更新するgRPCを作る
- 一度全フィールドを取得し、全フィールド更新するgRPCを呼ぶ
といった選択肢になりがちです。
前者は一部更新したいフィールドが増える度に、どんどんgRPCが増えて開発も保守も大変です。
後者は取得処理が挟まるので、
- レイテンシが無駄に増える
- 整合性が保証されない
と言った課題があります。
FieldMask
これらの課題をFieldMaskで更新したいフィールドを指定して解決します。
実現したい挙動
以下のデータがある状態で、
{ "user": { "id": "001", "name": "alice", "email": "alice@gmail.com", "age": 20, "address": { "country": "Japan", "state": "Tokyo", "city": "Shibuya", "zipcode": "150-0000" } } }
Emailだけ更新したいので、email
だけをFieldMask pathsに含めます(id
は識別のため)。
paths := []string{"id", "email"}
そしてリクエストBodyにはそれらだけ渡します。
{ "user": { "id": "001", "email": "alice_new@gmail.com" } }
するとEmailフィールドのみ更新されます。
{ "user": { "id": "001", "name": "alice", "email": "alice_new@gmail.com", // here "age": 20, "address": { "country": "Japan", "state": "Tokyo", "city": "Shibuya", "zipcode": "150-0000" } } }
他のフィールドはリクエストBodyに含まれていても含まれていなくても、FieldMaskによって除外されるのでDB側のデータには反映されません。
空にしたい場合
データを殻にしたい場合はFieldMask pathsにフィールドを入れて
paths := []string{"id", "email"}
{ "user": { "id": "001" } }
リクエストBodyでフィールドを付けない or 空値で渡すことで
{ "user": { "id": "001", "name": "alice", "email": null, // here "age": 20, "address": { "country": "Japan", "state": "Tokyo", "city": "Shibuya", "zipcode": "150-0000" } } }
空の状態に更新します。
実装
proto
前回のgRPCにUpdateメソッドを追加します。
Updateメソッドのリクエストにgoogle.protobuf.FieldMask
フィールドを追加します。
syntax = "proto3"; package user; import "google/protobuf/empty.proto"; import "google/protobuf/field_mask.proto"; service UserService { rpc Get(GetRequest) returns (GetReply) {} rpc Update(UpdateRequest) returns (google.protobuf.Empty) {} } message UpdateRequest { User user = 1; google.protobuf.FieldMask field_mask = 2; }
サーバ
Goのjson
やbson
ではomitempty
タグを使うことでゼロ値のフィールドはデータに含まないようにすることが可能です。
MongoDBではbson
を使いますが、これを利用することでゼロ値は更新対象に含まないようにできます。
なので各フィールドにomitempty
タグを付けておきます。
package main type User struct { ID string `bson:"_id"` Name string `bson:"name,omitempty"` Email string `bson:"email,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"` }
ハンドラーではまずFieldMaskによってリクエストBodyを必要最低限のデータにフィルタします。
func (s *server) Update(ctx context.Context, in *pb.UpdateRequest) (*empty.Empty, error) { if in.FieldMask != nil { in.FieldMask.Normalize() if in.FieldMask.IsValid(in.User) { fmutils.Filter(in.User, in.FieldMask.GetPaths()) } } data := s.toUserEntity(in.User) err := s.mcli.UpdateUser(ctx, data) if err != nil { return nil, status.Error(codes.Internal, fmt.Sprintf("failed to update user: %s", err)) } return &empty.Empty{}, nil }
そして各フィールドをEntityにマッピングしてDBに書き込みます。
func (s *server) toUserEntity(in *pb.User) User { u := User{ ID: in.Id, Name: in.Name, Age: int(in.Age), Email: in.Email, Address: Address{ Country: in.Address.Country, State: in.Address.State, City: in.Address.City, Zipcode: in.Address.Zipcode, }, } return u }
クライアント
更新したいフィールドをFieldMask pathsに含めます。
func (c *Client) SaveWithMask() error { paths := []string{"id", "email"} fm := fieldmaskpb.FieldMask{Paths: paths} req := &pb.UpdateRequest{ User: &pb.User{ Id: "001", Email: "alice_new@gmail.com", }, FieldMask: &fm, } _, err := c.cli.Update(context.Background(), req) if err != nil { return err } return nil }
動作確認
before
{ "user": { "id": "001", "name": "alice", "email": "alice@gmail.com", "age": 20, "address": { "country": "Japan", "state": "Tokyo", "city": "Shibuya", "zipcode": "150-0000" } } }
after
{ "user": { "id": "001", "name": "alice", "email": "alice_new@gmail.com", // here "age": 20, "address": { "country": "Japan", "state": "Tokyo", "city": "Shibuya", "zipcode": "150-0000" } } }
期待通り更新ができました。
その他
サンプルコード
今回のサンプルコードはこちら
FieldMaskを必須にすべきか
FieldMaskをリクエストにセットしなかった時に
- request bodyに入ったものを使ってまるっと更新する
- FieldMaskで指定されたフィールドがないので何も更新しない
の2つの選択肢が浮かぶと思います。
しかし前者は例えばサーバ側がprotobuf schemaに新しいフィールドを追加したけれど、クライアント側は古いprotobuf schemaを使い続けていた場合に、新しいフィールドが常に空に上書きされてしまいデータの整合性が崩れてしまいます。
なので後者がオススメです。
ただ後者の場合もデメリットがあり、対象フィールドが多い時にFieldMaskのpathsが非常に長くなります。また後からFieldMaskを導入した時に、そのメソッドを使用している箇所全てにFieldMaskを設定しないといけない手間があります。
omitemptyでは空値で更新ができないのでは?
omitemptyはゼロ値の値を無視するため、空で更新したいという要件があるとそのままでは満たせません。
この場合は空値の更新を許容するフィールドのみポインタで扱うというワークアラウンドがあります。
例えばEmailに対して適用すると以下です。
package main type User struct { ID string `bson:"_id"` Name string `bson:"name,omitempty"` Email *string `bson:"email,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"` }
そしてEntity変換部分では以下のようにFieldMaskのpathsを考慮しながら、nilで扱うか空で扱うかをハンドリングします。
func (s *server) toUserEntity(in *pb.User, paths []string) User { u := User{ ID: in.Id, Name: in.Name, Age: int(in.Age), Email: &in.Email, } if in.Address != nil { u.Address = Address{ Country: in.Address.Country, State: in.Address.State, City: in.Address.City, Zipcode: in.Address.Zipcode, } } if len(paths) == 0 { return u } // set nil to omit empty u.Email = nil for i := range paths { if paths[i] == "email" { u.Email = &in.Email } } return u }
こうすると空の値で更新することが可能になります。
まとめ
gRPCでFieldMaskによる部分更新の方法を説明しました。
これを活用することで無駄なgRPCを省いたり、read-modify-writeの実装を避けることが可能になります。