更新処理の普遍的な考えとFieldMaskによる一例
はじめに
はじめまして、GOGENでソフトウェアエンジニアをしている伊藤(s4s7)です。
皆さんは、更新処理をどのように設計してますか?
下記のようなパターンのJSONをハンドリングするにはどのように実装すればいいでしょうか?
{user_id: 1, age: 20, name: 太郎} // nameを太郎で更新
{user_id: 1, age: 20, name: ""} // nameを""で更新
{user_id: 1, age: 20, name: null} // nameをnullで更新
{user_id: 1, age: 20} // nameは更新しない(既存の値を引き継ぐ)
GOGENでは、Connectとモジュラモノリスを採用(詳しくはこちら)してますが、この題材自体は、シリアライズフォーマット(JSON、protobuf)や、APIプロトコル(REST、GraphQL、gRPC、Connect)、システムアーキテクチャ(モノリス、モジュラモノリス、マイクロサービス)の種類を問わず重要になってきます。
本記事では、更新処理を考える際に活用できる普遍的な考え方に触れつつ、protobufのベストプラクティスであり、Netflixでも採用されているFieldMaskというデザインパターンを紹介します。
理想的な更新処理とは
1. フィールドとして存在しない vs null を区別できる
{user_id: 1, age: 20, name: null} // nameをnullで更新
{user_id: 1, age: 20} // nameは更新しない(既存の値を引き継ぐ)
フィールドとして存在しない事(missing値)とnullが指定された事を区別することは非常に重要です。更新処理であれば、フィールドが存在しない場合は既存の値を引き継ぎ、nullが指定された場合はnullで更新したいところです。また、マイクロサービスで新規登録する場合でも、フィールドが存在しなければ他のサブシステムからデータを補完する必要が出てくるかもしれません。
2. 単一のエンドポイントでアトミックに処理できる
適切に分けることが大事な場面もありますが、なるべく一つのエンドポイントでさまざまなフィールドの更新処理を動的に実現できることが理想です。下記のように更新したいフィールド毎にミューテーションAPIを定義するのは実装工数や認知負荷の観点でスケーラブルな策ではありません。
-
特定のフィールドに特化したエンドポイントが存在する
- UpdateUserName
- UpdateUserAge
-
紐づいているサブシステム(or サブモジュール)ごとにエンドポイントが存在する
- UpdateUserPermission
- UpdateUserPayment
3つの実現方法
1. 事前にデータを送る側が既存のフィールドを全て取得
この方法は、HTTPメソッドではPUTに該当し、データを送る側が既存のデータを取得後、特定のフィールドを更新して送信します。このようにすることで、受け取り側でデータを引き継ぐための処理が不要になり、Nullとの区別を考慮する必要がなくなります。
更新対象が限定的であれば最もシンプルな方法である一方で、運用していく中で2つの問題が顕在化する可能性があります。
-
不要なデータ取得が増加する
データを送信する側が既存のデータを事前に取得する必要があるため、場合によっては不要なAPIコールが連鎖的に発生する可能性があります。計算コストの増加に加え、データ送信側がフロントエンドの場合は状態管理が複雑化します。 -
新しいフィールドを追加したときに予期せぬ更新が発生する可能性がある
全てのフィールドがリクエストに含まれることを前提としてAPIが実装されているため、スキーマのみが変更され、クライアント側のコードの修正が漏れた場合、emailがnullになってしまった様に、新たに追加されたフィールドも意図せずnullに更新されてしまいます。
2. 関心のあるフィールドをホワイトリストで指定する (FieldMask)
この方法は部分更新(Partial Update)と呼ばれ、HTTPメソッドではPATCHに該当します。1つのエンドポイントで実装するには、GraphQLであればクエリで表現できますが、REST、gRPC、connectでは代わりに更新対象をホワイトリスト(FieldMask)で指定します。
FieldMaskというパラメータは増えますが、既存の全てのフィールドの値を事前に取得する必要がなくなり、APIコールを削減できます。また、新しいフィールドを追加した場合でもFieldMaskの値が変わらなければデグレは発生せず堅牢なAPIとなります。
RESTの例
curl -X PATCH "http://localhost:8080/v1/users/1?update_mask=age&update_mask=name" \
-H "Content-Type: application/json" \
-d '{
"user_id": 1,
"age": 20,
"name": null
}'
Google Cloudでは、update_maskという名前でFieldMaskが使われており、クエリパラメータで指定します。
Connect, gRPCの例
syntax = "proto3";
import "google/protobuf/field_mask.proto";
package example.v1;
message User {
int64 user_id = 1;
int32 age = 2;
string name = 3;
}
message UpdateUserRequest {
User user = 1;
google.protobuf.FieldMask field_mask = 2;
}
protobufの場合は、Well-Known Typesにfield_maskがあるのでそのままimportして使います。
3. フィールド毎に判断
OpenAPIなどのRESTではこの方法を使うことが多いです。自動生成するプログラミング言語によって実装方法は変わりますが、Go言語であればNullString, NullIntといったカスタムタイプを定義し、それぞれにMarshal, Unmarshalのメソッドを定義します。
type NullString struct {
Value *string
Set bool
}
func (v NullString) MarshalJSON() ([]byte, error) {
if v.Set {
return json.Marshal(v.Value)
}
return json.Marshal(nil)
}
func (v *NullString) UnmarshalJSON(data []byte) error {
v.Set = true
// マジックストリングが必要
if string(data) == "null" {
return nil
}
if err := json.Unmarshal(data, &v.Value); err != nil {
return err
}
return nil
}
型ごとに定義する必要があったり、マジックストリングが必要になりますが、API側はフィールド毎にNullString.Setの値でハンドリングできるため、より柔軟にロジックを実装できます。
GOGENの活用事例
GOGENでは、protobufのベストプラクティスに習い、FieldMaskを使った部分更新を実装してます。
下記の例は同じAPI(updateUserMutation)に対して、フロントエンドから更新対象だけ指定してNullでも更新できるAPIコールをしている例です。
// GOGENのフロントエンドは、Next.js, TypeScript, TanstackQueryを使ってます
// その1
const userPartialUpdate: Partial<UserUpdateRequest> = {
firstName: form.firstName,
lastName: form.lastName,
status: form.status,
};
updateUserMutation.mutate(
// fieldMaskパターンによる部分更新
{
userId: userID,
...userPartialUpdate,
fieldMask: {
// paths: ["first_name", "last_name", "status"] と同じ
paths: Object.keys(userPartialUpdate).map(formatToSnakeCase),
},
},
// ...
// その2
const userPartialUpdate: Partial<UserUpdateRequest> = {
firstName: data.firstName,
lastName: data.lastName,
tel: data.tel || undefined,
realEstateLicenseNumber: data.realEstateLicenseNumber || undefined,
avatarUrl: userInfo.avatarUrl,
status: userInfo.status,
userStatusMessage: data.userStatusMessage || '',
};
updateUserMutation.mutate(
// fieldMaskパターンによる部分更新
{
userId: userID,
...userPartialUpdate,
fieldMask: {
paths: Object.keys(userPartialUpdate).map(formatToSnakeCase),
},
},
// ...
API側はFieldMaskに基づいてハンドリングします。
// フィールドマスクの各値をチェック
if req.Msg.FieldMask != nil {
req.Msg.FieldMask.Normalize()
if !req.Msg.FieldMask.IsValid(req.Msg) {
return nil, cerr.NewError(cerr.InvalidArgument, "invalid field mask", fmt.Errorf("field mask is not matching to field name: %v", req.Msg.FieldMask))
}
}
// クライアントが指定したフィールドのみ更新する
for _, path := range req.Msg.FieldMask.GetPaths() {
// 更新対象の候補となるフィールドを全て列挙
// refrectパッケージでutil化可能
switch path {
case "tel":
existingUser.Tel = req.Msg.Tel
case "last_name":
existingUser.LastName = req.Msg.LastName
case "first_name":
existingUser.FirstName = req.Msg.FirstName
case "status":
existingUser.Status = UserStatusFromProto(req.Msg.Status)
case "avatar_url":
existingUser.AvatarUrl = req.Msg.AvatarUrl
case "real_estate_license_number":
existingUser.RealEstateLicenseNumber = req.Msg.RealEstateLicenseNumber
case "user_status_message":
existingUser.UserStatusMessage = req.Msg.UserStatusMessage
}
}
まとめ
GOGENでは、複雑で柔軟な不動産取引のユースケースに対応するため、API設計においても拡張性と明示性を両立させるアプローチを模索しています。FieldMaskのような設計手法は、そうした思想の一端を体現するものであり、日々の開発では「細かいけれど大事なこと」と向き合う連続です。まだまだ手探りな部分もありますが、試行錯誤を重ねながら、プロダクトを一歩ずつ前に進めています。
GOGENではエンジニアを募集しています!
現在、プロダクトを共に開発していくソフトウェアエンジニアを募集中です!
Discussion