Carpe Diem

備忘録

gRPCでFieldMaskを使う(更新編)

概要

christina04.hatenablog.com

前回は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のjsonbsonでは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"
    }
  }
}

期待通り更新ができました。

その他

サンプルコード

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

github.com

FieldMaskを必須にすべきか

FieldMaskをリクエストにセットしなかった時に

  • request bodyに入ったものを使ってまるっと更新する
  • FieldMaskで指定されたフィールドがないので何も更新しない

の2つの選択肢が浮かぶと思います。

しかし前者は例えばサーバ側がprotobuf schemaに新しいフィールドを追加したけれど、クライアント側は古いprotobuf schemaを使い続けていた場合に、新しいフィールドが常に空に上書きされてしまいデータの整合性が崩れてしまいます。
なので後者がオススメです。

ただ後者の場合もデメリットがあり、対象フィールドが多い時にFieldMaskのpathsが非常に長くなります。また後からFieldMaskを導入した時に、そのメソッドを使用している箇所全てにFieldMaskを設定しないといけない手間があります。

omitemptyでは空値で更新ができないのでは?

omitemptyはゼロ値の値を無視するため、空で更新したいという要件があるとそのままでは満たせません。

この場合は空値の更新を許容するフィールドのみポインタで扱うというワークアラウンドがあります。

stackoverflow.com

例えば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の実装を避けることが可能になります。

参考