Protocol Buffersで定義したAPIからTypeScriptを出力して型安全なフロントエンド開発

こんにちはアソビューのフロントエンドのテックリードの井上です。
アソビュー! Advent Calendar 2023の3日目(A面)です。 AC6を2回クリアして軽くロス状態です。3周目は心がくじけました。
アソビューのフロントエンドでProtocol Buffersで定義した型を利用して型安全な開発をしている話をしたいと思います。

アソビューのProtocol Buffers運用

アソビューではマイクロサービス間の通信プロトコルとしてgRPCを使っており、そのIF定義としてはProtocol Buffersを使っています。
APIゲートウェイサーバーとクライアントサイドとの通信はRESTful APIですが、IFの定義としてはProtocol Buffersから生成されるJavaのクラスを参照してAPI実装しています。

アソビューのマイクロサービスおよびモジュラーモノリスについてはこちらの記事に詳細書かれていますのでご参照ください。

eh-career.com

型の無い世界

私自身アソビューでJavaScriptからTypeScriptの導入と移行などを経験してきたので少し振り返ってみたいと思います。

JavaScriptで戦ってた時代

バックエンドのIF把握:レスポンスのモデルを参照もしくはドキュメントで記述
フロントエンドのIF実装:パラメータに設定している値そのもの

たった5、6年前ですがTypeScript化以前はJavaScriptで型の無い世界なのでAPIのIFについてはコンフルやspread sheetのようなドキュメントベースで参考にしながら書き写すように実装するような形でした。(今では考えられませんね、、)

実際IFの実装が合ってるかどうかは通信するまでわからないような状況なわけで、結合すると微妙に間違っていて調査して修正して再度実装して、というサイクルでかなりの時間を費やしていたと思います。

protoからTypeScriptに写経

バックエンドのIF把握:Protocol Buffersファイルおよびそこから生成されるドキュメント
フロントエンドのIF実装:protoファイルを参考にtsの型として目で写経

TypeScript化によってフィールドの型定義は厳密で可読性高くできるようになりましたが、相変わらずProtocol Buffersから生成するドキュメントを見ながらTypeScript定義に書き写すような形でした。(写経)
ヒューマンエラーも起きやすいですし、変更に追随する運用も厳しく、やはりIF合わせの時間は多くかかっていました。

protoからも生成できるはず

個人的に副業などでGraphQL環境で開発したこともあり、スキーマからコマンド一つで生成してフロントエンド、サーバーサイドで参照することで型の厳密性を保つ運用の開発体験の良さを経験していました。
弊社ではGraphQLを採用していませんがGraphQL環境のように生成した型をフロントエンド、サーバーサイドで参照することで型の厳密性を保ったり運用を楽にしたい。。

graphql-codegen的なことがしたい、、できるはず、、!

はい。近しいことができました。

ということで前置きが長くなりましたが、Protocol BuffersからTypeScriptを生成してそれを参照するような運用について次の章から紹介します。
理論的にはできるはずですし、そのためにツールも用意されているのですが、実際組み込もうとすると色々つまづきポイントが有り、意外と世の中にそのTipsが多くなかったので次に実施しようとする人の参考になればと思います。

Protocol BuffersからTypeScriptの型を生成する

注)今回はProtocol Buffers自体の環境構築は設定などには言及せず、あくまでTypeScriptへの出力に特化して書きます。

やりたいこと

  • クライアント、サーバー間の通信はRESTなのでgRPCの接続用クライアントなどの出力は行わずあくまで、型定義だけを吐きたい。
  • proto定義をしたモノリポ内のパッケージへのcommit、pushに応じてCIで型を生成して、参照用のtsを格納するリポジトリにコミットしたい。
  • フロントエンドでは生成した型をnpmのモジュールとして参照し、ブランチ名やタグ名を指定することで固定したバージョンをインポートしたい。

利用したライブラリ

いくつか選択肢があり、デファクトが無い状態でしたが、結局は他でも使っているbufというCLIツールとTS用のプラグインになりました。

  • buf(cli) buf.build protocプラグインを使って各種言語をコードを出力することができます。
    弊社ではJavaとPythonのコード出力にもこちらを利用しています。

  • ts-proto github.com protocに対応したTypeScript生成用のプラグイン

設定内容

ディレクトリの最小構成は下記のようになります。

npmプロジェクトとしてpackage.jsonを用意してbuf をインストールします。

npm init -y
npm install bufbuild/buf/buf

Buf - Install the Buf CLI

ts-protoのnpmモジュールを利用するため、ts-protoをインストールします。

npm install ts-proto

buf.work.yaml には対象のディレクトリを定義します。今回はapisディレクトリを指定します。

Buf - buf.work.yaml

version: v1
directories:
  - apis

buf.gen.yaml で指定するts-protoのオプション設定が味噌になりますが先に結論から書くと下記のような設定になります。

version: v1
plugins:
  - name: ts
    out: ./generated
    path: ./node_modules/ts-proto/protoc-gen-ts_proto
    opt:
      - onlyTypes=true
      - stringEnums=true
      - unrecognizedEnum=false
      - useDate=string

あとはpackage.jsonで定義したnpm scriptを実行するだけです。

  "scripts": {
    "gen": "buf generate"
  }
npm run gen

出力結果

元のproto

syntax = "proto3";

package test.mech;

import "google/protobuf/timestamp.proto";

service MechService {
    rpc GetMech(GetMechRequest) returns (Mech) {}
}

message GetMechRequest {
    string mechId = 1;
}

message Mech {
    string mechId = 1;
    string name = 2;
    LegsType type = 3;
    google.protobuf.Timestamp createdAt = 4;
}

enum LegsType {
    UNKNOWN_LEGS_TYPE = 0;
    BIPEDAL = 1;
    REVERSE_JOINT = 2;
    TETRAPOD = 3;
    TANKS = 4;
}

出力されるTypeScript

export const protobufPackage = "test.mech";

export enum LegsType {
  UNKNOWN_LEGS_TYPE = "UNKNOWN_LEGS_TYPE",
  BIPEDAL = "BIPEDAL",
  REVERSE_JOINT = "REVERSE_JOINT",
  TETRAPOD = "TETRAPOD",
  TANKS = "TANKS",
}

export interface GetMechRequest {
  mechId: string;
}

export interface Mech {
  mechId: string;
  name: string;
  type: LegsType;
  createdAt: string | undefined;
}

export interface MechService {
  GetMech(request: GetMechRequest): Promise<Mech>;
}

この型をフロントエンドで参照してAPI呼び出し時に指定するようにすれば、protoファイルで定義した通りのリクエストパラメータで型を固定できるようになります。

// レスポンスとリクエストの型をジェネリクスで指定できるようにuseSWRを拡張したカスタムhooksに設定しています。
const { data } = useSWRCustom<Mech, GetMechRequest>('/api/MechService/GetMech', { mechId: 'id_0001' })

/*
  data
   {
    mechId: "id_000001",
    name: "LIGER TAIL",
    type: LegsType.TETRAPOD,
    createdAt: "2023-11-27T00:00:00Z",
  };
*/

ts-protoのオプションについて

ts-protoにはかなり多くのオプションが用意されており、利用用途に合わせて柔軟に設定して出力結果を調整することができます。
何も指定しないとgRPC Webのクライアントを実装するためのユーティリティなどが出力されてしまい、今回のように実際にはRESTで通信するため、APIに実装に使われたIF定義の型だけを参照したいというニッチな(?)ニーズにはマッチしていません。 https://github.com/stephenh/ts-proto#supported-options

今回指定したオプションと出力結果について紹介します。

version: v1
plugins:
  - name: ts
    out: ./generated
    path: ./node_modules/ts-proto/protoc-gen-ts_proto
    opt:
      - onlyTypes=true
      - stringEnums=true
      - unrecognizedEnum=false
      - useDate=string

onlyTypes=true

こちらはtrueを指定すると下記をまとめて設定したことと同義になり、まさに今回のようなtypeだけ参照したい場合に不要なオプションをOFFにしてくれるようなものになっていますので、型だけ使いたい場合はとりあえずこれをtrueにしましょう。

  • outputJsonMethods=false
  • outputEncodeMethods=false
  • outputClientImpl=false
  • nestJs=false

stringEnums=true

// stringEnums=true
export enum LegsType {
  UNKNOWN_LEGS_TYPE = "UNKNOWN_LEGS_TYPE",
  BIPEDAL = "BIPEDAL",
  REVERSE_JOINT = "REVERSE_JOINT",
  TETRAPOD = "TETRAPOD",
  TANKS = "TANKS",
}

// stringEnums=false
export enum LegsType {
  UNKNOWN_LEGS_TYPE = 0,
  BIPEDAL = 1,
  REVERSE_JOINT = 2,
  TETRAPOD = 3,
  TANKS = 4,
}

バックエンドとして待ち受けているのはenumの文字列であり LegsType.BIPEDAL のような形で参照することでパラメーター設定したいのでtrueに設定しました。

unrecognizedEnum=false

export enum LegsType {
  UNKNOWN_LEGS_TYPE = "UNKNOWN_LEGS_TYPE",
  BIPEDAL = "BIPEDAL",
  REVERSE_JOINT = "REVERSE_JOINT",
  TETRAPOD = "TETRAPOD",
  TANKS = "TANKS",
  UNRECOGNIZED = "UNRECOGNIZED",  // ←これ
}

trueだとUNRECOGNIZEDが自動付与されます。今回不要だったのでfalseにしました。

useDate=string

protoファイルでgoogle.protobuf.Timestampで定義している場合にuseDate=stringと指定するとDateにマッピングされずstring型として出力されます。 RESTで返す場合はISO 8601規格などの文字列かJSON timestamp形式(その場合はuseDate=false)を利用すると思いますので適切な方を選択してください。

こちらに詳しく説明されています。

https://github.com/stephenh/ts-proto#timestamp

最低限以上の設定を行えばProtocol Buffers定義からtypeだけを利用したい場合の出力は問題なく行えると思います!

CIでの出力とフロントエンドでの活用方法について

生成方法については以上の説明の通りですが、実際はCIで自動で出力しています。
protoファイルを修正して、特定のprefixをつけたブランチ名でリモートにpushするとCIの中で別で用意している生成されたTypeScriptを管理する専用のプライベートリポジトリに同じブランチ名でコミット&プッシュします。

あとは生成されたTypeScriptのプライベートリポジトリをnpmのdependenciesに設定し、インストールしてnode_modulesのにシンボリックリンクを貼った上でtsconfigのpathsに指定ようにしています。
参照方法については他にも方法はあると思いますが、こちらがライトで運用しやすかったです。

package.json

"proto-to-typescript": "asoview/proto-to-typescript#awesome-branch"

シンボリックリンク

src/@proto -> ../node_modules/proto-to-typescript/

tsconfig.json

    "paths": {
      "@proto/*": ["src/@proto/*"],
    },

この設定の場合TypeScriptでのimportは下記のような形になります。IDEのサジェストなども問題なく効きます。

import { MechRequest, Mech } from '@proto/test/mech';

Protocol Buffers→ TypeScript出力の活用と効果

今回はこの仕組みを一年以上前に立ち上がったとある大規模新規開発PJに合わせて導入しました。
設計と実装が並行し、開発メンバーの人数も多く、多くのAPIを複数チームに分かれての並行開発になるため認識齟齬等による手戻りなどが懸念されましたが この仕組みによってそれらが緩和されたものと思います。
そちらでどのようにこの環境が役に立ったのかを紹介します。

プロジェクトのProtocol Buffers定義を一つのブランチに集約

まず、このPJでの開発用のブランチを用意し、バックエンド、フロントエンドともにそちらを参照(dependencyに指定)して実装する形を取りました。(フロントであればyarn.lockなどで固定されるので適宜更新して最新化)

型を基準にしたモックベース開発と結合

バックエンドとフロントエンドの開発が平行することから一旦APIのレスポンスについてはmockを準備(mswを活用しています)しており、モックの定義で型を参照することでバックエンドとの共通性を担保しています。
もし変更があった場合はdependenciesを最新化するとtsのコンパイルでエラーになって気づけます。
型に厳密なモックベースで双方の開発を進めていくことで双方の結合時のIFの単純なミスなどがかなり減ったと実感しています。(こういったやり取りはチリツモでかなりの工数を取りますよね、、経験談)

// mswで利用するモック例
import { Mech } from '@proto/test/mech';
 
const res: Mech = {
  mechId: "id_000001",
  name: "LIGER TAIL",
  type: LegsType.TETRAPOD,
  createdAt: "2023-11-27T00:00:00Z",
};

export default res

proto定義から始める実装設計

今回複雑なドメイン設計をしながらの開発でありましたが、protcol bufferの作成(場合によってはフロントエンド側でやることも)から入ることでUIでどう使うかとデータ構造や得られる情報がどういったものかという側面でバックエンドとフロントエンドが実装前にコミュニケーションを取ってドメイン知識を深めたり考慮できていなかった事項に気づいて設計に反映できるという場面があったように感じました。
大規模新規のPJだからこそこういった進め方は有効でしたし、実装上バックエンド、フロントエンドがオーバーラップする接点になるという意味でも生成の仕組みを作って良かったと思えました。

まとめ

導入したプロジェクトの振り返りでもメンバーからこの仕組みがあってよかった、ミスが防げたなどの声が有り、工数削減という側面のみならず開発体験も向上することもできたかなと思っております。 今後も導入するとチームや組織の生産性を向上するような仕組みをどんどん取り入れて、顧客に価値を届けるスピードと質を上げて、集中すべきところに集中できるような開発ができる環境にしていきたいと思っております!

アソビューでこのように仕組みをアップデートしながらプロダクトを開発していくようなエンジニアリングにチャレンジしてみませんか?
興味がある方はぜひこちらのエンジニア採用ページをご覧ください!

www.asoview.com

speakerdeck.com