ts-protoで出力するコードにファクトリメソッドを追加して開発者体験を向上する

こちらはアソビュー! - Qiita Advent Calendar 2024 - Qiitaの8日目(A面)の記事です。

こんにちは。プロダクト本部のkaorun343です。フロントエンドエンジニアのチームでは継続的に開発環境の改善活動をおこなっています。今回はこの活動において実施した、ts-protoの出力結果の修正を紹介します。

背景

アソビューでは、バックエンドのシステムを複数のマイクロサービスで構成しています。これらのマイクロサービス間の通信にはgRPCを採用していて、protoファイルを使ってインターフェースを定義しています。

また、API GatewayでgRPCをJSONに変換する仕組みを提供しています。したがって、Webフロントエンドの実装においても共通のスキーマを参照して実装を進めています。

Webフロントエンドの実装においては、ts-protoを用いてprotoファイルからTypeScriptの型定義を生成し、それを参照しています。

この仕組みはAPI通信を利用する際に型チェックの恩恵をもたらしましたが、一方で課題も生じていました。すなわち、protoの仕様では非破壊的変更であるにもかかわらず、TypeScriptで型定義だけ生成していることで、TypeScript上では破壊的変更になってしまう、というものです。

応答メッセージへのフィールド追加によりTypeScriptで型エラーが生じる

gRPCにおいては、応答メッセージへのフィールドの追加は非破壊的変更に当たります。

もしクライアントから送られてきたデータの中にあるフィールドのデータが入っていない場合は、規定値を得ることになります。

以下のprotoファイルを例に説明します。

syntax = "proto3";

service TeamService {
    rpc GetPlayer(GetPlayerRequest) returns Player;
}

message GetPlayerRequest {
    string name = 1;
}

message Player {
    string name = 1;
    int32 age = 2;
    repeated MatchResult results = 3;

    message MatchResult {
        string date = 1;
        int32 at_bats = 2;
    }
}

上記のprotoファイルから、ts-protoは以下のようなTypeScriptの型定義を出力します。

export interface TeamService {
  GetPlayer(request: GetPlayerRequest): Promise<Player>
}

export interface UpdatePlayerRequest {
  name: string
}

export interface Player {
  name: string
  age: number
  results: Player_MatchResult[]
}

export interface Player_MatchResult {
  date: string
  atBats: number
}

アソビューではMock Service Workerを利用してAPI通信をモックしています。

import { http, HttpResponse } from 'msw'
import { Player } from './path/to/generated/grpc'


export const handleGetPlayer = http.post('/path/to/GetPlayer', (request) => {
  const player: Player = {
    name: 'Taro',
    results: [],
  }

  return HttpResponse.json(player)
}

このような方法で型定義を利用していると、 Player メッセージに新しいフィールドが追加されるたびに、モックで player オブジェクトを作成している箇所でフィールドが足らない旨のコンパイルエラーが生じます。よって都度モックの実装を書き換える必要があります。

要求メッセージへのフィールド追加によりTypeScriptで型エラーが生じる

gRPCにおいては、要求メッセージへのフィールドの追加は非破壊的変更に当たります。

例えば、 Player メッセージを使って UpdatePlayerRequest メッセージを定義するとします。

syntax = "proto3";

service TeamService {
    rpc GetPlayer(GetPlayerRequest) returns Player;
    rpc UpdatePlayer(UpdatePlayerRequest) returns Player;
}

message UpdatePlayerRequest {
    Player player = 1;
}

// 以下省略

Webフロントエンド上では、以下のようなコードを書くことになります。

export async function updatePlayer(name: string) {
  const request: UpdatePlayerRequest = {
    player: {
      name,
      results: [],
    },
  }

  await requestToGateway('/path/to/UpdatePlayer', request)
} 

このとき Player メッセージに対して、応答メッセージだけで用いるフィールドを追加すると、この updatePlayer 関数においても実装を修正しなければなりません。

方針

ts-protoはファクトリメソッドを出力することができます。この方法で出力したメソッドを使って、上記の課題を解決します。

設定ファイル

https://github.com/stephenh/ts-proto?tab=readme-ov-file#supported-options を参照して、buf.gen.yaml の設定を以下のようにしました(v1の設定ファイルです)。

version: v1
plugins:
  - name: ts
    out: ./platform-proto-typescript/proto
    strategy: all
    path: ./node_modules/ts-proto/protoc-gen-ts_proto
    opt:
+      - outputEncodeMethods=false
+      - outputClientImpl=false
+      - outputJsonMethods=from-only
      - stringEnums=true
      - unrecognizedEnum=false
      - useDate=string

outputEncodeMethods=false

アソビューではJavaScriptで実装したgRPCサーバーが存在しないので、gRPCサーバーを実装するために必要なコードは出力しないようにしました。

outputClientImpl=false

アソビューではJavaScriptで実装したgRPCクライアントが存在しないので、gRPCクライアントを実装するために必要なコードは出力しないようにしました。

outputJsonMethods=from-only

outputJsonMethods=falseとしてしまうと、ファクトリメソッドが出力されません。これを回避するために、 fromJSON メソッドのみ出力するようにしました。

以上のような設定を追加すると、具体的には以下のようなコードを出力します。

function createBasePlayer(): Player {
  return { name: '', age: 0, results: [] }
}

export Player: MessageFns<Player> = {
  fromJSON(object: any): Player {
    // 省略
  }
  create<I extends Exact<DeepPartial<Player>, I>>(base?: I): Player {
    return Player.fromPartial(base ?? ({} as any));
  },
  fromPartial<I extends Exact<DeepPartial<Player>, I>>(object: I): Player {
    const message = createBasePlayer();
    message.name = object.name ?? "";
    message.age = object.age ?? 0;
    message.results = object.results?.map((e) => Player_MatchResult.fromPartial(e)) || [];
    return message;
  },

}

// 省略

このようにして、 createメソッドおよび fromPartial メソッドを得ることができました。

DeepPartialをそのまま使う方法について

最初は再帰的に各フィールドをオプショナルな型に変更する DeepPartial という手法を検討しました。要求メッセージの課題については解消できますが、応答メッセージのモック実装においては、配列型のデフォルト値を入れてくれる方が嬉しいです。したがって今回は採用しませんでした。

結果

これまで例にあげたコードを書き換えていきます。

応答メッセージの例

デフォルト値さえあれば良いフィールドは、記述を省略でき、protoの定義に新規にフィールドが追加された場合でも、型エラーを回避できます。

import { http, HttpResponse } from 'msw'
import { Player } from './path/to/generated/grpc'


export const handleGetPlayer = http.post('/path/to/GetPlayer', (request) => {
  const player: Player = Player.create({
    name: 'Taro',
    // results: [], 明示的に列挙しなくて済む
  })

  return HttpResponse.json(player)
}

要求メッセージの例

同様に、フィールドの記述を省略でき、フィールドが追加されても型エラーを回避できます。一方で、必須なフィールドについて気づくのが難しくなったという懸念は生じました。

export async function updatePlayer(name: string) {
  const request: UpdatePlayerRequest = {
    player: {
      name,
      // results: [], 明示的に列挙しなくて済む
    },
  }

  await requestToGateway('/path/to/UpdatePlayer', request)
} 

最後に

今回はts-protoの出力内容を変更してファクトリメソッドを得る方法を紹介しました。

アソビュー株式会社では新しいメンバーを随時募集していますので、ご興味ある方はぜひご連絡ください。

www.asoview.com