概要
クライアントデバイスが多様化する中、UIに必要なデータもそれぞれ異なるためAPIのオーバーフェッチ(不要なデータの取りすぎ)が課題になってきます。
またマイクロサービス間の通信でも、例えばマスタ系データのうち必要なデータだけ取得して利用したいケースは多々あります。
かと言ってその度に必要最低限のデータを取得するメソッドを用意するのは開発が大変ですし、逆に細かいデータばかりのgRPCを用意すると今度はアンダーフェッチ(データが足りなくて複数回コールが必要になる)が発生します。
GraphQLでは欲しいデータをクライアント側が明示的に要求するのでオーバーフェッチが発生しないというメリットがありますが、gRPCではFieldMaskという機能があります。
今回はFieldMaskを使って余分なデータを返さない方法を説明します。
環境
- Go v1.18.3
- protoc-gen-go v1.25.0
- protoc v3.19.4
- grpc-go v1.47.0
- mennanov/fmutils v0.2.0
FieldMask
挙動
FieldMaskがどういった挙動をするかというと、例えば以下のようなデータがある際に
f { a : 22 b { d : 1 x : 2 } y : 13 } z: 8
次のようなFieldMaskを渡すと
paths: "f.a" paths: "f.b.d"
FieldMaskで指定したデータのみ抽出されるようになります。
f { a : 22 b { d : 1 } }
prefix matchに対応している
例えば先程の中でf.b
のようにネストしたフィールドについては、
paths: "f.b"
と指定すると
f { b { d : 1 x : 2 } }
とprefixで抽出され、その下の構造が全て返ってきます。
Goでの実装
MongoDBに以下のデータを持っているとします。
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"` }
これをサーバを経由して取得するようにします。
proto
FieldMaskを使う際は、google/protobuf/field_mask.proto
をimportしてgoogle.protobuf.FieldMask
という型のフィールドを追加する必要があります。
syntax = "proto3"; package user; import "google/protobuf/field_mask.proto"; service UserService { rpc Get(GetRequest) returns (GetReply) {} } message User { string id = 1; string name = 2; string email = 3; int64 age = 4; Address address = 5; } message Address { string country = 1; string state = 2; string city = 3; string zipcode = 4; } message GetRequest { string id = 1; google.protobuf.FieldMask field_mask = 2; // here } message GetReply { User user = 1; }
サーバ
データ集約後にFieldMaskを使うパターン
データの取得先が1つであれば、以下の書き方がシンプルにかけます。
func (s *server) Get(ctx context.Context, in *pb.GetRequest) (*pb.GetReply, error) { user, err := s.mcli.GetUser(ctx, in.Id) if err != nil { return nil, status.Error(codes.Internal, fmt.Sprintf("failed to get user: %s", err)) } resp := &pb.GetReply{ User: s.toUserProto(user), } if in.FieldMask != nil { in.FieldMask.Normalize() if in.FieldMask.IsValid(resp.User) { fmutils.Filter(resp.User, in.FieldMask.GetPaths()) } } return resp, nil }
このように取得後
- Normalize()でprefixマッチするpathを除外
- IsValid()でpathsが全て正しいかチェック
- fmutils.Filter()でpaths以外のフィールドのデータをフィルタ
という処理をします。
フィールドのフィルタ処理は以下のライブラリを使うのが便利です。
- GitHub - mennanov/fieldmask-utils: Protobuf Field Mask Go utils
- GitHub - mennanov/fmutils: Golang protobuf FieldMask missing utils
データ集約前にFieldMaskを使うパターン
データの取得先が複数の場合、pathsの指定によっては不要な取得処理になってしまうこともあります。
例えば先程のデータ構造で、User
とAddress
が別コレクションで管理されている、といった場合です。
この場合、address
がFieldMaskに含まれていなければ、Address
コレクションへの取得処理は無駄になってしまいます。
なのでGetPaths()で対象となるpathが含まれているかチェックし、必要な場合のみ取得するロジックにするのが良いでしょう。
クライアント
クライアント側はfieldmaskpb.FieldMask.Paths
に対象となるpaths sliceを入れてリクエストを送ります。
func (c *Client) GetWithMask() error { paths := []string{"name", "age", "address.city"} fm := fieldmaskpb.FieldMask{Paths: paths} req := &pb.GetRequest{ Id: "001", FieldMask: &fm, } resp, err := c.cli.Get(context.Background(), req) if err != nil { return err } log.Println(resp) return nil }
動作確認
FieldMaskを使わない場合
全てのデータが返ってきました。
{ "user": { "id": "001", "name": "alice", "email": "alice@gmail.com", "age": 20, "address": { "country": "Japan", "state": "Tokyo", "city": "Shibuya", "zipcode": "150-0000" } } }
FieldMaskを使った場合
paths := []string{"name", "age", "address.city"}
というFieldMaskをかけた場合です。
{ "user": { "name": "alice", "age": 20, "address": { "city": "Shibuya" } } }
指定したフィールドのみ返ってきました。
その他
サンプルコード
今回のサンプルコードはこちら
Normalize()はnil pointer exceptionが発生する
珍しく?Normalizeメソッドはレシーバがnilのケースのハンドリングがされておらず、in.Fieldmask==nilの際に
panic: runtime error: invalid memory address or nil pointer dereference
が発生します。
ちなみにIsValidの方はレシーバがnilでも大丈夫です。
なので
if in.Fieldmask != nil { in.Fieldmask.Normalize() } if in.FieldMask.IsValid(resp.User) {
や、前述のように
if in.Fieldmask != nil { in.Fieldmask.Normalize() if in.FieldMask.IsValid(resp.User) {
といった形で全体を囲うなどしてハンドリングする必要があります。
FieldMaskを使うとフィールド名を変更した場合の後方互換性が失われる
protobufはindexでフィールドを管理しているため、フィールド名を変更しても型が変わらなければ後方互換性を保つことが可能です。
しかしFieldMaskはフィールド名を使うため、
- サーバ側で新しいフィールド名を使う
- クライアント側は古いフィールド名のまま
という状態だと一致しなくなってしまい期待する挙動になりません。
なので両方とも同じprotoから生成したコードを使う必要があります。
まとめ
FieldMaskを使うことでオーバーフェッチを防げるようになりました。