Carpe Diem

備忘録

gRPCでFieldMaskを使う(取得編)

概要

クライアントデバイスが多様化する中、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
}

このように取得後

という処理をします。

フィールドのフィルタ処理は以下のライブラリを使うのが便利です。

データ集約前にFieldMaskを使うパターン

データの取得先が複数の場合、pathsの指定によっては不要な取得処理になってしまうこともあります。
例えば先程のデータ構造で、UserAddressが別コレクションで管理されている、といった場合です。

この場合、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"
    }
  }
}

指定したフィールドのみ返ってきました。

その他

サンプルコード

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

github.com

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でフィールドを管理しているため、フィールド名を変更しても型が変わらなければ後方互換性を保つことが可能です。

stackoverflow.com

しかしFieldMaskはフィールド名を使うため、

  • サーバ側で新しいフィールド名を使う
  • クライアント側は古いフィールド名のまま

という状態だと一致しなくなってしまい期待する挙動になりません。

なので両方とも同じprotoから生成したコードを使う必要があります。

まとめ

FieldMaskを使うことでオーバーフェッチを防げるようになりました。

参考