🌐️

ふんわりざっくり大味で gRPC と tRPC の雰囲気を知る

2022/10/23に公開約16,600字1件のコメント

tRPC をちょっとだけやってみたので、やる前に自分が知りたかったことやセットアップ時にわかりにくかったことを整理して、ふんわりざっくり大味でまとめておきます

ついでなので gRPC についても簡単に整理します

まず RPC とは

RPC ( Remote Procedure Call ) とは、ネットワークで繋がっている別のコンピュータのプログラムを実行できるようにする技術のことです

呼ぶ側 ( = クライアントサイド ) が呼ばれる側 ( = サーバサイド ) を実行するときに、具体的な通信手段やプロトコルについて自分で実装する必要がない点が特徴です

イメージを見てみましょう

普段行っているメソッド呼び出しは、RPC に対して LPC ( Local Procedure Call ) と言います
このようなコードです、カタログはローカルにあるということにします

Local Procedure Call
Item item = getItemFromCatalog(1)

RPC は同じようなメソッド呼び出しでネットワーク上の別のコンピュータの処理を呼び出して結果を得ることができます
クライアントサイドはサーバサイドにあるカタログからアイテムを取得できます

Remote Procedure Call ( Client )
Item item = getItemFromCatalog(1)

一般に、RPC は LPC との違いを意識せずに扱えるようになっています

これだけならただ HTTP 通信ライブラリの実行をメソッドにしただけのようですが、サーバサイドも同じように実装されている点がポイントです

Remote Procedure Call ( Server )
function getItemFromCatalog(int id): Item {
    ....
}

もちろんこれだけではクライアントサイドからサーバサイドのメソッドを実行できませんが、それに必要な処理は RPC の実装が用意してくれる、ということです
通信プロトコルが何かもデータの変換も全部おまかせです

RPC は 1970 年代に発表され 1980 年代には実用化された技術で、いくつかの実装があります

例えば XML-RPC では HTTP を通して XML 形式のデータをやりとりすることができます
これが発展して SOAP と呼ばれるプロトコルになりました、聞いたことある方も多いのではないでしょうか

Unix 系システムではサン・マイクロシステムズが開発した ONC RPC ( Sun RPC ) が実装されていて、例えば NFS ( Network File System ) に使われたりしています
マウントした NFS ボリュームは、リモートにあることを意識せず普段と同じように lsvi ができますね

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 を使ってそれぞれでソースコードを生成してあります

Todo.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;
}

サーバサイド

Server.java
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());
  }
}

クライアントサイド

Client.java
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 を見てみてください

https://github.com/suzuki-hoge/grpc-sample/tree/master

利点

.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/ ではいくつかの点が若干わかりづらかったので、コードの要所が見通しやすいように以下の点を変更しています

  • utilttrpc といった短い変数名を serverTrpcclientTrpc に変更
  • 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.tsxserver/routers/router.ts 以外は一度書いてしまえばほとんど触ることはありません

この構成を図にしてみると、こんな感じでしょう
( 頻繁に編集しないファイルはグレーにしています )

個人的にはサンプルコードより先にこういう図を最初に見たいと感じます

また、実際の HTTP 通信には内部では React Query ( TanStack Query ) が使われることを覚えておくと良いでしょう

サーバサイド

server/router.ts
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

クライアントサイド

pages/index.tsx
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 を見てみてください

https://github.com/suzuki-hoge/trpc-sample/tree/main

利点

インターフェースを定義するファイルを書かなくていいし、サーバサイドを実装したあとに生成コマンドを実行しなくていいので、サーバサイドを書いた瞬間にクライアントサイドでメソッドを利用することができます

また Date や Union Type も普段通りの感覚で使えます

Typescript 同士の連携に特化している恩恵ですね、シームレス感がすごいです

補完も一通り問題ありません

task.all エンドポイントの .input でバリデーションを zodenum にしたので、クライアントサイドでその通り補完できる

task.all エンドポイントの .query で return した型がクライアントサイドで認識できる

task.create エンドポイントも Union Type や Date を含めクライアントサイドで補完できる

エンドポイントと初めて結合するときに API ドキュメントを読まなくていい

引数を間違えたりサーバサイドに変更が発生した場合は、クライアントサイドで即時エラーになる

所感

tRPC は gRPC に比べれば初期構築が大変でした ( とはいえ数時間ですが )
用意するファイルも多く、構成を理解するまでは混乱しがちです

.proto の方が楽にかけるのは間違いないですし、インターフェースの全体を把握しようと思ったら .proto の方が読みやすいでしょう

tRPC だとサーバサイドを先に実装しなければならないので、gRPC のように「.proto これだから、クライアントサイドから作っちゃって」みたいなことは難しいですね

逆に tRPC は生成処理がなく Typescript に特化しているので、サーバサイドとクライアントサイド間のシームレス感がすごいです
「少人数で、分担せず全員で全部書くぞ」といったケースではすごい生産性になりそうです

プロダクトの内容やメンバー規模やチームの関係などを考えて、マッチしそうなら導入してみるといいでしょう

あとは React Query ( TanStack Query ) を内部で使っているというか、割とそのまま使う感じっぽいので、useQueryuseMutation は一度 tRPC とは別で触れてあると良さそうだと感じました

懸念 ( 疑問 )

サーバサイドとクライアントサイドで 1 つのコードベースになるので、規模がそれなりに大きくなったときに補完候補が増えすぎたり型名が衝突したりするのではないかと思うんだけど、どうなんでしょう?

「選択肢が増えて選ぶのがめんどくせー」ってくらいならまだいいけど、意図せずクライアントサイドがサーバサイドだけで使うつもりだったファイルを import してしまって「クライアントサイドが壊れるのでサーバサイドが直せない」みたいなことになってしまうと嫌だなぁ

Package Private のような仕組みがあればいいのだけれど...
ESLint のプラグインとかを作って import control とかできないのかな?

あとは「クライアントサイドだけ変更したのに自動テストや CI がサーバサイドも含んでるので遅い」ってならないか、みたいなのもちょっと気になります
工夫でどうにでもできそうだとは思いますが

発展

分割してルールを設ける

tRPC を実際に運用できるかに興味があるので、少しディレクトリを作り分割ルールを考えてみたいと思います
ずっと pages/index.tsxserver/routers/router.ts に書き続けるわけにはいきませんしね

まず server/rooters/ ですが、小さく task-router.tsuser-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 の別のブランチに置いてあります、興味があれば見てみてください
https://github.com/suzuki-hoge/trpc-sample/tree/growth

main ブランチとの差分はこんな感じです
https://github.com/suzuki-hoge/trpc-sample/compare/main..growth

バックエンドの実装について

個人的にはフロントエンドが ( React が...?? ) 結構 FP ( Functional Programming ) っぽいよねという空気なので、それに後押ししてもらって tRPC ならサーバサイドも FP にしてみるのはアリなのではと思っています

tRPC のバックエンドってそんなゴリゴリとコーディングしない規模な気もするけど...
Domain Modeling Made Functional で読んだこととかを導入してみたいですね

Order を関数で処理していく Workflow

成功と失敗をコントロールする Railway Oriented Programming

TypeScript なら割と FP いけるんじゃあないかと思うんだけど、どうかな?[3]

おしまい

gRPC と tRPC がわかったような気がしたでしょうか?

個人的にはどちらも実務経験があるわけでは無いので、指摘やアドバイスがありましたら教えていただきたいと思います

経験数日の初心者によるまとめですが、ふんわりざっくり大味ででも理解が深まったらいいなと思っています

参考

HTTP
https://developer.mozilla.org/ja/docs/Web/HTTP/Basics_of_HTTP/Evolution_of_HTTP

gRPC 公式
https://grpc.io/

Protocol Buffers
https://ja.wikipedia.org/wiki/Protocol_Buffers

tRPC 公式
https://trpc.io/

tRPC GitHub および examples
https://github.com/trpc/trpc

create-t3-app
https://github.com/t3-oss/create-t3-app

useQuery
https://tanstack.com/query/v4/docs/guides/queries

useMutation
https://tanstack.com/query/v4/docs/guides/mutations

zod GitHub
https://github.com/colinhacks/zod

Domain Modeling Made Functional
https://pragprog.com/titles/swdddf/domain-modeling-made-functional/

Railway Oriented Programming
https://fsharpforfunandprofit.com/rop/
https://naveenkumarmuguda.medium.com/railway-oriented-programming-a-powerful-functional-programming-pattern-ab454e467f31

脚注
  1. C#, C++, Dart, Go, Java, Kotlin, Node, Objective-C, PHP, Python, Ruby ↩︎

  2. いくつか見た例ではそうだった
    サーバサイドを npm ライブラリとして公開してクライアントサイドで依存追加をすれば分離できるのかなと言う気もするが、価値も下がるし未検証 ↩︎

  3. やっぱり Scala の for や Haskell の do が欲しくなったりするんだろうか? ↩︎

Discussion

ログインするとコメントできます