ふんわりざっくり大味で gRPC と tRPC の雰囲気を知る
tRPC をちょっとだけやってみたので、やる前に自分が知りたかったことやセットアップ時にわかりにくかったことを整理して、ふんわりざっくり大味でまとめておきます
ついでなので gRPC についても簡単に整理します
まず RPC とは
RPC ( Remote Procedure Call ) とは、ネットワークで繋がっている別のコンピュータのプログラムを実行できるようにする技術のことです
呼ぶ側 ( = クライアントサイド ) が呼ばれる側 ( = サーバサイド ) を実行するときに、具体的な通信手段やプロトコルについて自分で実装する必要がない点が特徴です
イメージを見てみましょう
普段行っているメソッド呼び出しは、RPC に対して LPC ( Local Procedure Call ) と言います
このようなコードです、カタログはローカルにあるということにします
Item item = getItemFromCatalog(1)
RPC は同じようなメソッド呼び出しでネットワーク上の別のコンピュータの処理を呼び出して結果を得ることができます
クライアントサイドはサーバサイドにあるカタログからアイテムを取得できます
Item item = getItemFromCatalog(1)
一般に、RPC は LPC との違いを意識せずに扱えるようになっています
これだけならただ HTTP 通信ライブラリの実行をメソッドにしただけのようですが、サーバサイドも同じように実装されている点がポイントです
function getItemFromCatalog(int id): Item {
....
}
もちろんこれだけではクライアントサイドからサーバサイドのメソッドを実行できませんが、それに必要な処理は RPC の実装が用意してくれる、ということです
通信プロトコルが何かもデータの変換も全部おまかせです
RPC は 1970 年代に発表され 1980 年代には実用化された技術で、いくつかの実装があります
例えば XML-RPC では HTTP を通して XML 形式のデータをやりとりすることができます
これが発展して SOAP と呼ばれるプロトコルになりました、聞いたことある方も多いのではないでしょうか
Unix 系システムではサン・マイクロシステムズが開発した ONC RPC ( Sun RPC ) が実装されていて、例えば NFS ( Network File System ) に使われたりしています
マウントした NFS ボリュームは、リモートにあることを意識せず普段と同じように ls
や vi
ができますね
gRPC と tRPC も同じように RPC の具体的な実装のひとつということになります
gRPC も tRPC も、乱暴に言ってしまえば「昨今の Web サービスで絶対に必要になる HTTP 通信の処理、書くのめんどくさすぎるからなんとかして」にこたえてくれる仕組みということですね
gRPC とは
RPC を実現・実装しやすくするために Google が開発した方法を gRPC と言います
Google PRC ということですね
gRPC ではまず .proto
というインターフェースを定義するファイルを先に書き、これをもとにクライアントサイドとサーバサイドのソースコードの一部を自動で生成します
その生成されたソースコードには HTTP/2 で通信するコードが全部書いてあり、生成されたメソッドをただ呼ぶだけで違うサーバの処理を呼び出すことができるようになります
通信時のシリアライズには Protocol Buffers というフォーマットが使われます
gRPC は複数のプログラミング言語[1]に対応しており、クライアントサイドとサーバサイドでプログラミング言語が異なっても大丈夫です
ソースコードの生成は各言語のライブラリやプラグインなどによって行います
実装例
クライアントサイドとサーバサイドのサンプルコードを見てみましょう
実行するとターミナルに次のように出力されます
( 実際は 2 つのターミナルに別々に出ますが、見やすく整形しています )
server: start
client: start
client: request ( id = E292C512-03D7-4FA8-9D47-7513EDCC8008 )
server: received ( id = E292C512-03D7-4FA8-9D47-7513EDCC8008 )
client: received ( id = E292C512-03D7-4FA8-9D47-7513EDCC8008, title = Lorem ipsum dolor sit amet, limit = 2023-01-01T12:34:56 )
client: end
Hello World では味気ないので、Todo
というデータ構造を取得するエンドポイントを実装します
興味があるのは通信の部分だけなので、DB 読み書きなどの実装は省略します
構成とインターフェース定義
クライアントサイドとサーバサイドで言語を同じにする必要はないのですが、ここではどちらも Java とします
2 つの Gradle プロジェクトを作成し、同じ .proto
を使ってそれぞれでソースコードを生成してあります
syntax = 'proto3';
// .java を生成する場合に記載するオプション
option java_multiple_files = true;
option java_package = 'com.example.todo';
import 'google/protobuf/timestamp.proto';
package todo;
// Todo に関する定義
service TodoService {
// Find というエンドポイントと、リクエストとレスポンスの型を定義
rpc Find (TodoRequest) returns (TodoResponse) {}
}
// リクエストの型 ( 属性を識別するためのユニークな自然数の指定が必要 )
message TodoRequest {
string id = 1;
}
// レスポンスの型 ( 同上 )
message TodoResponse {
string id = 1;
string title = 2;
google.protobuf.Timestamp limit = 3;
}
サーバサイド
public class Server {
public static void main(String[] args) throws Exception {
System.out.println("server: start");
// サーバの準備
Server server = ServerBuilder.forPort(4000)
.addService(new TodoImpl())
.build();
// サーバの起動
server.start();
server.awaitTermination();
}
static class TodoImpl extends TodoServiceGrpc.TodoServiceImplBase {
// Find エンドポイントの実装
@Override
public void find(TodoRequest request, StreamObserver<TodoResponse> observer) {
System.out.printf("server: received ( id = %s )\n", request.getId());
// .proto で定義したパラメータでレスポンスを組み立てる
// ( 本当は DB から取得した内容で組み立てるが、略 )
TodoResponse response = TodoResponse.newBuilder()
.setId(request.getId())
.setTitle("Lorem ipsum dolor sit amet")
.setLimit(parse(LocalDateTime.of(2023, 1, 1, 12, 34, 56)))
.build();
// レスポンスの返却
observer.onNext(response);
observer.onCompleted();
}
}
// 日付は Timestamp でやりとりするため、変換する
private static Timestamp.Builder parse(LocalDateTime value) {
return Timestamp.newBuilder()
.setSeconds(value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
}
}
クライアントサイド
public class Client {
public static void main(String[] args) {
System.out.println("client: start");
// 通信の準備
ManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 4000)
.usePlaintext()
.build();
TodoServiceGrpc.TodoServiceBlockingStub stub = TodoServiceGrpc.newBlockingStub(channel);
// .proto で定義したパラメータでリクエストを組み立てる
TodoRequest request = TodoRequest.newBuilder()
.setId("E292C512-03D7-4FA8-9D47-7513EDCC8008")
.build();
System.out.printf("client: request ( id = %s )\n", request.getId());
// Find エンドポイントを実行してレスポンスを取得
TodoResponse response = stub.find(request);
System.out.printf(
"client: received ( id = %s, title = %s, limit = %s )\n",
response.getId(), response.getTitle(), parse(response.getLimit())
);
System.out.println("client: end");
}
// 日付は Timestamp でやりとりするため、変換する
private static LocalDateTime parse(Timestamp value) {
return Instant
.ofEpochMilli(value.getSeconds())
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
}
}
ほか
実装するのはこの 3 ファイルだけです
これだけの実装で、クライアントサイドからサーバサイドのエンドポイントを呼び出すことに成功しています
localhost だから実感薄いですが、別々に起動した Gradle プロジェクトの間をメソッド呼び出しできていますよね
各種コマンドやディレクトリ構成や Gradle の設定ファイルなど、ほかの部分も見たい方は僕の GitHub を見てみてください
利点
.proto
に従って生成したソースコードのおかげで、HTTP 通信ライブラリを使う処理を自分で書いたりルーティングファイルを自分で書いたりしなくてよく、全てがメソッド呼び出しだけで完結します
またリクエストやレスポンスを組み立てるときの補完がちゃんと効くので、実装がとても楽です
クライアントサイドでリクエストを組み立てるときに Id ( String )
の指定が必要とわかる
得られたレスポンスに Title ( String )
と Limit ( Timestamp )
が入っているとわかる
サーバサイドでレスポンスを組み立てるときに、Title ( String )
と Limit ( Timestamp )
の指定が必要とわかる
そもそも Find
エンドポイントの実装時もメソッドが補完できるので、エンドポイントの空実装が 3 ~ 4 文字でおわる
全て .proto
に従っているので「あれ Id
って String
だっけ int
だっけ」とか「期限って Limit
だっけ Deadline
だっけ」とか「URL タイポしちゃった」とかが起きません
API のドキュメントを見てちまちま Json を作らなくていいし、Json を解析するときに Map<String, Object>
みたいな不安な型にしなくていいです
URL がないので「開発してたら URL が変わってた」ということもないし、エンドポイントやパラメータが変わったら再生成とコンパイルで検知できます
ほかにも HTTP/2 なので HTTP/1.1 より速いなどの恩恵も受けられます
tRPC とは
tRPC は Typescript で RPC を実現するもので、クライアントサイドもサーバサイドも Typescript で実装するならば、その間の HTTP 通信をメソッド呼び出しで実現できるようになる仕組みです
要するに両方が Typescript の gRPC といった感じですね
tRPC の実装には trpc/trpc というライブラリを使います
また、今回は用いませんが create-t3-app
という T3 Stack と呼ばれる技術セットの雛形もあるようです
実装例
クライアントサイドとサーバサイドのサンプルコードを見てみましょう
1 つの Next.js のプロジェクトで Task
というデータ構造を扱うアプリケーションを実装します
gRPC と違い最低限のプロジェクトがどのような形になるのか興味があるので、DB 読み書きの処理も実装します
構成
tRPC では .proto
のようなインターフェース定義ファイルは存在せず、何かを書いたあとに生成するという手順もありません
サーバサイドのルーティングファイルがそのままエンドポイントとリクエスト・レスポンスの定義となります
従って tRPC ではプロジェクトを 2 つ別々に用意せずに 1 つにするのが一般的です[2]
公式サイトのコード例 や 公式 GitHub の examples/
ではいくつかの点が若干わかりづらかったので、コードの要所が見通しやすいように以下の点を変更しています
-
util
やt
やtrpc
といった短い変数名をserverTrpc
やclientTrpc
に変更 - DB 読み書きに Prisma ではなく better-sqlite3 を利用
- スタイルの削除
-
src/
を作り、その下で可能な限りserver/
とclient/
に分離
$ tree .
.
|-- package.json
`-- src
|-- client
| `-- client-trpc.ts // エンドポイントを呼ぶのに使う
|-- pages // Next.js のルーティングディレクトリ
| |-- _app.tsx // Next.js の起動
| |-- api
| | `-- [trpc].ts // エンドポイント API ( 中身は router.ts に全委譲 )
| `-- index.tsx // ページ
`-- server
|-- context.ts // サーバの設定
|-- routers
| `-- router.ts // エンドポイントを実装する
`-- server-trpc.ts // エンドポイントの実装に使う
これらのうち、pages/index.tsx
と server/routers/router.ts
以外は一度書いてしまえばほとんど触ることはありません
この構成を図にしてみると、こんな感じでしょう
( 頻繁に編集しないファイルはグレーにしています )
個人的にはサンプルコードより先にこういう図を最初に見たいと感じます
また、実際の HTTP 通信には内部では React Query ( TanStack Query ) が使われることを覚えておくと良いでしょう
サーバサイド
import Database from "better-sqlite3";
import {v4 as uuidv4} from 'uuid'
// バリデーションライブラリ ( tRPC と直接は関係ない )
import {z} from 'zod'
// エンドポイントを実装するための trpc モジュール
import {serverTrpc} from 'server/server-trpc'
// DB コネクション
const db = new Database('database/dev.db')
// エンドポイントの定義と実装
export const appRouter = serverTrpc.router({
// task エンドポイント群の定義
task: serverTrpc.router({
// task.all エンドポイントの定義
all: serverTrpc.procedure
// zod を使ったリクエストのバリデーション
.input(
z.object({
status: z.enum(['backlog', 'icebox', 'doing'])
}),
)
// エンドポイントの実装
.query(({input}) => {
// DB 処理
return db.prepare(`
select id, text, status, created
from task where status = ?
`).all(input.status)
.map((row) => {
return {
id: row.id,
text: row.text,
status: row.status,
created: new Date(row.created)
}
})
}),
// task.create エンドポイントの定義
create: serverTrpc.procedure
// zod を使ったリクエストのバリデーション
.input(
z.object({
text: z.string().min(1),
status: z.enum(['backlog', 'icebox', 'doing']),
created: z.date()
}),
)
// エンドポイントの実装
.mutation(async ({input}) => {
// DB 処理
db.prepare(`
insert into task (id, text, status, created)
values (?, ?, ?, ?)
`).run(uuidv4(), input.text, input.status, input.created.getTime())
}),
})
})
export type AppRouter = typeof appRouter
クライアントサイド
import type {NextPage} from 'next'
import {useState} from "react";
// エンドポイントを呼び出すための trpc モジュール
import {clientTrpc} from "client/client-trpc";
const Home: NextPage = () => {
const context = clientTrpc.useContext()
// task.all エンドポイントを呼び出す query
let tasksQuery = clientTrpc.task.all.useQuery({status: 'doing'}, {})
// query の実行結果
let tasks = tasksQuery.data ?? []
const [error, setError] = useState('')
// task.create エンドポイントを呼び出す mutation
const taskMutation = clientTrpc.task.create.useMutation({
onSuccess() {
// task.all を更新することで画面を再描画する
context.task.all.invalidate();
setText('')
setError('')
},
onError(error) {
setError(error.message)
}
})
const [text, setText] = useState('')
return (
<>
<h1>tRPC Sample</h1>
<ul>
{* 取得した tasks の描画 *}
{tasks.map(task => <li key={task.id}>{task.text} | {task.status} ( {task.created.toString()} )</li>)}
</ul>
<p>new task</p>
<input type={'text'} value={text} onChange={e => setText(e.target.value)}/>
{* Mutation の実行 *}
<input type={'submit'} value={'submit'} onClick={() => taskMutation.mutate({text: text, status: 'doing', created: new Date()})}/>
<p>
{error ?? <pre>{error}</pre>}
</p>
</>
)
}
export default Home
ほか
ほかのファイルなどが気になる方は、GitHub を見てみてください
利点
インターフェースを定義するファイルを書かなくていいし、サーバサイドを実装したあとに生成コマンドを実行しなくていいので、サーバサイドを書いた瞬間にクライアントサイドでメソッドを利用することができます
また Date や Union Type も普段通りの感覚で使えます
Typescript 同士の連携に特化している恩恵ですね、シームレス感がすごいです
補完も一通り問題ありません
task.all
エンドポイントの .input
でバリデーションを zod
の enum
にしたので、クライアントサイドでその通り補完できる
task.all
エンドポイントの .query
で return した型がクライアントサイドで認識できる
task.create
エンドポイントも Union Type や Date を含めクライアントサイドで補完できる
エンドポイントと初めて結合するときに API ドキュメントを読まなくていい
引数を間違えたりサーバサイドに変更が発生した場合は、クライアントサイドで即時エラーになる
所感
tRPC は gRPC に比べれば初期構築が大変でした ( とはいえ数時間ですが )
用意するファイルも多く、構成を理解するまでは混乱しがちです
.proto
の方が楽にかけるのは間違いないですし、インターフェースの全体を把握しようと思ったら .proto
の方が読みやすいでしょう
tRPC だとサーバサイドを先に実装しなければならないので、gRPC のように「.proto
これだから、クライアントサイドから作っちゃって」みたいなことは難しいですね
逆に tRPC は生成処理がなく Typescript に特化しているので、サーバサイドとクライアントサイド間のシームレス感がすごいです
「少人数で、分担せず全員で全部書くぞ」といったケースではすごい生産性になりそうです
プロダクトの内容やメンバー規模やチームの関係などを考えて、マッチしそうなら導入してみるといいでしょう
あとは React Query ( TanStack Query ) を内部で使っているというか、割とそのまま使う感じっぽいので、useQuery
と useMutation
は一度 tRPC とは別で触れてあると良さそうだと感じました
懸念 ( 疑問 )
サーバサイドとクライアントサイドで 1 つのコードベースになるので、規模がそれなりに大きくなったときに補完候補が増えすぎたり型名が衝突したりするのではないかと思うんだけど、どうなんでしょう?
「選択肢が増えて選ぶのがめんどくせー」ってくらいならまだいいけど、意図せずクライアントサイドがサーバサイドだけで使うつもりだったファイルを import してしまって「クライアントサイドが壊れるのでサーバサイドが直せない」みたいなことになってしまうと嫌だなぁ
Package Private のような仕組みがあればいいのだけれど...
ESLint のプラグインとかを作って import control とかできないのかな?
あとは「クライアントサイドだけ変更したのに自動テストや CI がサーバサイドも含んでるので遅い」ってならないか、みたいなのもちょっと気になります
工夫でどうにでもできそうだとは思いますが
発展
分割してルールを設ける
tRPC を実際に運用できるかに興味があるので、少しディレクトリを作り分割ルールを考えてみたいと思います
ずっと pages/index.tsx
と server/routers/router.ts
に書き続けるわけにはいきませんしね
まず server/rooters/
ですが、小さく task-router.ts
や user-router.ts
などを作り router.ts
から import する形にすると、ある程度のまとまりで管理することができます
client/
には components/
などのディレクトリを作り、普段の Next.js の知見を活かせばいいでしょう
server/
にも workflows/
などのディレクトリを作り、task-router.ts
の中のメインロジックを委譲していくといいと思います
これはただの関数分割です
tRPC の Router と切り離せれば、自動テストがずっと楽になるはずです
最後に、server/
と client/
で共有するデータ構造を domain/
として外に置いてみるといいのではないかと思います
server/
だけで使いたいデータ構造は server/
の下に、共有するものは domain/
に、という方針です
pages/
は Next.js である限りどうにもできないので、パブリックエンドポイントと割り切ってしまいましょう
これらのアップデートを加えた図はこのようになりました
( グレーにしていた意識しなくていい部分は割愛しています )
そのように変更したコードを GitHub の別のブランチに置いてあります、興味があれば見てみてください
main ブランチとの差分はこんな感じです
バックエンドの実装について
個人的にはフロントエンドが ( React が...?? ) 結構 FP ( Functional Programming ) っぽいよねという空気なので、それに後押ししてもらって tRPC ならサーバサイドも FP にしてみるのはアリなのではと思っています
tRPC のバックエンドってそんなゴリゴリとコーディングしない規模な気もするけど...
Domain Modeling Made Functional で読んだこととかを導入してみたいですね
Order を関数で処理していく Workflow
成功と失敗をコントロールする Railway Oriented Programming
TypeScript なら割と FP いけるんじゃあないかと思うんだけど、どうかな?[3]
おしまい
gRPC と tRPC がわかったような気がしたでしょうか?
個人的にはどちらも実務経験があるわけでは無いので、指摘やアドバイスがありましたら教えていただきたいと思います
経験数日の初心者によるまとめですが、ふんわりざっくり大味ででも理解が深まったらいいなと思っています
参考
HTTP
gRPC 公式
Protocol Buffers
tRPC 公式
tRPC GitHub および examples
create-t3-app
useQuery
useMutation
zod GitHub
Domain Modeling Made Functional
Railway Oriented Programming
Discussion
めちゃくちゃどうでもいいんですが、gRPCのgはGoogleではなくリリースバージョンごとに異なっているようですね!