NTTテクノクロスの上原です。
この記事はNTTテクノクロスアドベントカレンダー2022、19日目の記事です。昨日は@kanaza-sさんの記事「PostgreSQL15のロジカルレプリケーションを触ってみる」でした。
tRPCとは何か
最近話題のtRPCですが、あらためて簡単に説明します。
tRPCは、TypeScriptの静的型指定情報を積極的に利用した、遠隔プロシージャ呼び出しのライブラリです。つまり別のサーバ上のコードの呼び出しを、プロシージャ呼び出しのように書くことができる機能です。
tRPCの特徴は、TypeScriptに依存していることと、スタブ・スケルトンといったコード生成を行わないことです。設定ファイルやIDLもありません。コードだけです。
tRPCとは何でないか
- RESTではない
- POST,GET,PUT,DELETEなどHTTPのメソッドを使いわけない。使いわけは、query(ボディがなくリクエストURLで転送する)のときはGETと、mutation(ボディのある)のときはPOSTとなるぐらい。
- JSON-RPCベース。バイナリプロトコルではなく転送形式にはJSONが使用される。
- 呼び出しのバッチ化が可能。1リクエスト=1プロシージャ呼び出しではない。複数のリクエスト-レスポンスは積極的にまとめられる。
- JSON-RPCに準拠したシリアライズを行う。遠隔呼び出しの引数や返り値として転送されるのはJSONであり、JSONに乗らないデータは表現できない。が、Data Transformersという機能があり、superjsonを呼びだすことでMapやDate,Set型などを送受もできるし、自前のコンバータを定義して組み込んで利用することもできる。
- GraphQLのような言語独立の技術ではない。
- 言語独立な形で明示的にスキーマ・IDLを書かない。Zodという型のバリデーションを行うライブラリ等を使用する。後述。
- TypeScript以外の言語でサーバ、クライアントを書くことを想定していない。不可能ではないかもしれないが、そうしたいならたぶんtRPC以外の技術を使ったほうがいい。
- 標準化機関が制定するような標準仕様ではない(JSON-RPCには準拠的)。
- 実装=仕様であるようなオンリーワン製品である(少なくとも今のところは)。
- フル機能の遠隔呼び出し技術ではない
- リモートリファレンスはない。
- クロージャのシリアライゼーションやモバイルエージェントなどはサポートしない
- WebSocketをトランスポートとして利用する場合、サーバから非同期でクライアントを呼び出せる。
コード例
tRPCでの遠隔呼び出しの具体例を、 https://trpc.io/docs/quickstart からの抜粋で示します。
クライアントサイドのtRPC遠隔呼び出しの記述の例は以下のとおり。(抜粋)
const user = await trpc.userById.query('1');
const createdUser = await trpc.userCreate.mutate({ name: 'sachinraja' });
上記のクライアントコードからtRPCで呼び出されるサーバサイド記述の例は以下のとおり。(抜粋)
interface User {
id: string;
name: string;
}
const userList: User[] = [
{
id: '1',
name: 'KATT',
},
];
:
userById: t.procedure // プロシージャuserById定義
.input((val: unknown) => { // .input
if (typeof val === 'string') return val;
throw new Error(`Invalid input: ${typeof val}`);
})
.query((req) => { // .query
const input = req.input;
const user = userList.find((it) => it.id === input);
return user;
}),
userCreate: t.procedure // プロシージャuserCreate定義
.input(z.object({ name: z.string() })) // .input
.mutation((req) => { // .mutation
const id = `${Math.random()}`;
const user: User = {
id,
name: req.input.name,
};
userList.push(user);
return user;
}),
ここでは2つの遠隔プロシージャ、userByIdとuserCreateを定義しています。
それぞれの「.input(...)
」のところがリモート呼び出しできるプロシージャの入力パラメタを表していて、useByIdでは型の絞り込みのコードが記入されており、返り値がstring型だと静的に確定させています(「型がstringならvalを返し、そうじゃなければ例外なので、inputの型はstringだと確定できる)。つまりこのプロシージャの引数の型はstring
だということです。
一方、userCreateの方ではZodのスキーマとして「z.object({ name: z.string() })
」のように指定しています。こちらはプロシージャの引数の型は{name:string}
になります。
「.query()
」、「.mutation()
」のところがサーバ側で実行する処理本体です。.queryはGETメソッドで呼びだされ、.mutation()はPOSTで呼び出されます。ブラウザなどではGETで送れるURLの長さに制約がありますが、その場合.mutation()の方が渡せる引数の容量が大きいということになります。
上記の呼び出し側、呼ばれる側の引数や戻り値はTypeScriptの型が付いていて、矛盾があればVSCodeなどのIDEでリアルタイムにチェックをすることができるし、補完もかかります。
userByIdの方について、型チェックの様子を図示すると以下のとおりです。
Zod(等)によるバリデーション
先の例では、userInputではinputの指定が
.input((val: unknown) => {
if (typeof val === 'string') return val;
throw new Error(`Invalid input: ${typeof val}`);
})
だったのに対して、userCreateでは
.input(z.object({ name: z.string() }))
のようにZodライブラリを用いて型を表現したものになっています。Zodはバリデーションのライブラリです。Zodを使うことで、たとえば
.input(z.object({ name: z.string().min(3).max(5) }))
などのように入力が従わなければならない値のチェックを簡易に行うことができます。なお、以下のように.output()
を追加することでプロシージャの引数だけではなく、返り値としての出力のチェックのバリデーションを行うこともでき、「本来含めてはいけないキーの値が含まれていないか」などをチェックすることもできます。
.output(
z.object({
greeting: z.string().max(3),
}),
)
なお、Zod以外にもYupやSuperstructのバリデーションライブラリが使用可能です。
TypeScriptの型をZodスキーマに変換する
https://transform.tools/typescript-to-zodというサイトでTypeScriptの型定義からZodのスキーマ記述への変換を試すことができます。
他のJSフレームワークとの連携
tRPCは単純な通信処理として任意のフレームワークと組合せて使用できますが、キャッシュ処理などををサポートするTanstack Queryとの連携機能Hooksが標準で準備されています。また、サーバサイドについてはSSR,SSGに関するNext.jsとのアダプタが準備されています。
他、SvelteKit, Remix, SolidJS, SolidStart, Nuxt, Astro, uWebSockets, jotai, serverless, koa, iron-session, OpenAPI といった他のライブラリと連携するコミュニティベースのライブラリ群があります。
GraphQLを使わなくても良い、のか
GraphQLにはさまざまなメリットがあったはずで、GraphQLではなくtRPCを採用することによってできなくなることがあります。
まず明らかな一つはtRPCはTypeScript依存なので、それ以外の言語やクライアント言語、サーバサイド言語を広く許容できなくなることです。この制限は回避不能なので、プロジェクト制約として許容できなければtRPCを採用できなくなるような支配的な条件ではあるでしょう。たとえば、クライアントをKotlinやSwift、FlutterなどのTypeScript以外の言語で書きたい場合には向かないでしょう。もしやるとするなら、モバイル側のクライアント実装言語をReact NativeやIonic、Tauri Mobileなどに倒していくことが期待されるでしょう。またサーバサイドもAWS LAMBDAやExpressなどでTypeScriptベースで記述する必要が出てきます。
次に、GraphQLには多数のREST APIの呼び出しに相当するクエリを、クライアントの都合にあわせた1つのクエリにまとめる機能があります。しかしこの点はtRPCのRequest Batchingを使用することである程度はtRPCでも対応可能と言えるでしょう。
最後に、GraphQLは1つの柔軟なエンドポイントさえ準備しておけば、クライアントの機能拡張において、それがデータの読み書きの範囲であればサーバを修正しなくても良くなる(場合が多い)、という開発の柔軟性が得られるということがあります。tRPCにとってこの点は重要なトレードオフとなる場合もあるでしょう。逆に言えば、tRPCではクライアントの機能拡張や変更が、サーバやAPIの変更を引き起してしまいやすいであろうことが、GraphQLを採用した場合と比べて、開発プロセスにおけるデメリットになり得ると思えます。
一方、GraphQLで起きやすいN+1問題などがtRPCでは隠蔽されずにそのまま見えるので、サーバサイドコードとして直接取り組めることはある種のメリットになるかとも思われます。
このようなことを含めて、tRPCでは「どのような方針でリモートプロシジャ群を設計していくか」は重要な課題になるでしょう。クライアントとサーバの「近さ」から、うかつに開発すればサーバ側コードがアドホックに乱雑に追加されていくことにもなりかねません。初期段階で、設計方針の問題として検討すべきことが多々あると言えるでしょう。たとえば
- (1) 「クライアントの画面で必要になる情報」が出てくるたびごとに画面に対応するプロシージャを追加するのか。
- (2)ドメインロジックを表現する層としてリモートプロシジャ群を設計していくのか
- (3)RESTでいう「リソース」概念を主軸にとらえCRUDメソッド群を整理していくのか
などの複数の方針が考えられます。その上で考えると、最低限の(3)を用意した上で(2)をベースにしてサーバ機能を整理し、主要な画面機能には(1)を用意するなどベストミックスを見つけていくのが良いという気はするのですが、経験を積みたいところです。知見をお持ちの方、コメント頂けますと幸いです。
まとめ
tRPCの良いところは、一つには、なんといってもサーバサイドとクライアントで型が普通に共有されているので、VSCodeなどのエディタでAPIの引数や返り値の型がリアルタイムでチェックされ補完されることでしょう。この利点を活かすためにも、サーバサイドとクライアントサイドのコードがmonorepoで管理されているべきでしょう。
利点の二番目として挙げたいのが、基本としてTypeScriptの知識があれば良いというラーニングカーブのなだらかさ、見通しの良さです。GraphQLとか別言語を覚えるようなものですからね。
以上より、tRPCは言語についてTypeScriptに限るという限定があり、汎用性が高いわけではありませんが、条件がはまれば開発速度を顕著に高めることができ得る技術だと感じました。また、Deno、Bunといった「TypeScriptネイティブ」なサーバサイドJS実行系が増えてきているので、TypeScriptを両サイドで利用できるような状況は今後増えていきそうだし、相乗的に、将来はもっと人気が出てきそうな技術かと思います。
以上、tRPCの採否を検討するための情報を簡単にまとめてみました。
明日は@korodroidさんです。たのしみですね。
ではまた来年!。