動機
前から少し気になっていたのと、時雨堂の方のTwitterを見て、gRPC-connectがこれから来るのではないかと思ったので、使用感を確かめてみた。
bufbuild/connect-crosstest: Connect's gRPC and gRPC-Web interoperability test suite. https://t.co/GXcTOprLll これ素晴らしいな ... 。真面目に buf / connect 調べてみよう。connect-es にも Cloudflare Workers で動かしたければ意見くれって書いてあるのが良い。
— V (@voluntas) February 26, 2023
(上記ツイートは、WebSocket, Long polling, server sent event, gRPC-connectの比較の際に出てきた発言なので、これらgRPC-connect以外のものについても今後理解を深めていきたい。
c.f https://qiita.com/Takagi_/items/a9f2ac0b2bfae309f735)
それとたまたま本記事をかいてる3/1にnodeの対応がv1.0になったようだ。
Connect is now full-stack TypeScript! We're excited to launch the beta of Connect for Node.js today, a library for serving Connect, gRPC, and gRPC-Web APIs using Node.js: https://t.co/CIwWuHApJg
— Buf (@bufbuild) February 28, 2023
Getting start
connect.build をなぞる。
フォルダの作成とライブラリのインストール
mkdir connect-example cd connect-example npm init -y npm install typescript tsx npx tsc --init npm install @bufbuild/buf @bufbuild/protoc-gen-es @bufbuild/protobuf @bufbuild/protoc-gen-connect-es @bufbuild/connect
Protoの作成
mkdir -p proto && touch proto/eliza.proto
syntax = "proto3"; package buf.connect.demo.eliza.v1; // packageで指定した文字列がURLのアクセスポイントになる // message型の変数を定義 message SayRequest { string sentence = 1; //他のメンバ変数とかぶらない識別子をつける必要がある // string以外にもint32など取れる // 詳しくはgRPCのProtocol Bufferを見ると良い } message SayResponse { string sentence = 1; } // API(RPC)の定義 service ElizaService { rpc Say(SayRequest) returns (SayResponse) {} }
Proto定義ファイルからTypeScriptのコードを生成
buf.gen.yaml
を作成し、以下を記載
version: v1 plugins: - name: es opt: target=ts out: gen - name: connect-es opt: target=ts out: gen
コマンドを実行してファイルを生成
npx buf generate proto
gen
フォルダが自動で作られ、その配下に.ts
ファイルが生成される
gRPC-connectのサービスを実装
connect.tsを作成
// touch connect.ts import { ConnectRouter } from "@bufbuild/connect"; import { ElizaService } from "./gen/eliza_connect"; export default (router: ConnectRouter) => // registers buf.connect.demo.eliza.v1.ElizaService router.service(ElizaService, { // protoで定義したsay関数の実装 // protoではSayと大文字だったが、こちらは小文字になっていてもいいようだ。 async say(req) { return { sentence: `You said: ${req.sentence}` } }, });
サーバを立ち上げる
gRPC-connectの公式ではnode, express, fastifyのプラグインを提供している。
ここ本ドキュメントではfastifyで実施している。
fasifyのプラグインをインストール
npm install fastify @bufbuild/connect-fastify
// server.ts import { fastify } from "fastify"; import { fastifyConnectPlugin } from "@bufbuild/connect-fastify"; import routes from "./connect"; const server = fastify(); await server.register(fastifyConnectPlugin, { routes, }); server.get("/", (_, reply) => { reply.type("text/plain"); reply.send("Hello World!"); }); await server.listen({ host: "localhost", port: 8080 }); console.log("server is listening at", server.addresses());
サーバ起動
npx tsx server.ts
自分の環境だとここでエラーが出た。
エラー対処: Top-level await is currently not supported with the "cjs" output format
tsのエラーではトップレベルの 'await' 式は、'module' オプションが 'es2022'、'esnext'、'system'、'node16' または 'nodenext' に設定されていて、'target' オプションが 'es2017' 以上に設定されている場合にのみ使用できます。
と出てくる。
自分の環境のデフォルトのtsconfig.json
では、"target": "es2016"
、 "module":"commonjs"
となっている。
エラーの通り、トップレベルのawaitはcommonjs
では許可されていないので、エラーの通りにtsconfig.json
を書き換える。
cf. CommonJSとES Modulesについてまとめる https://zenn.dev/yodaka/articles/596f441acf1cf3
しかし実行時に依然としてエラーが出たので、tsconfig.json
をデフォルトに戻し、top level await
にならないよう即時実行関数として以下のように書き直した。
// await server.listen({ host: "localhost", port: 8080 }); (async () => { await server.listen({ host: "localhost", port: 8080 }); })();
これでgRPC-connectを待ち受けるサーバができた。
続いてクライアントからこのgRPC-connectを叩く。
クライアント1: cURL
一番手っ取り早くcURLから叩く。
curl \ --header 'Content-Type: application/json' \ --data '{"sentence": "I feel happy."}' \ http://localhost:8080/buf.connect.demo.eliza.v1.ElizaService/Say
出力
{"sentence":"You said: I feel happy."}
クライアント2: サーバサイドから叩く
client.ts
をnode
上に作成し以下を記載。
// client.ts import { createPromiseClient } from "@bufbuild/connect"; import { ElizaService } from "./gen/eliza_connect"; import { createConnectTransport } from "@bufbuild/connect-node"; const transport = createConnectTransport({ baseUrl: "http://localhost:8080", httpVersion: "1.1" }); async function main() { const client = createPromiseClient(ElizaService, transport); const res = await client.say({ sentence: "I feel happy." }); console.log(res); } void main();
実行
npx tsx client.ts
クライアント3: webブラウザから叩く
クライアント2でかいたのとほぼ同じ。インポートするライブラリをconnect-web
に変える程度で良い。
本家のgRPCと違って、httpでやり取りできるので、webブラウザから叩くためのサーバを立ち上げる必要がなく非常に使いやすい印象。
import { createPromiseClient } from "@bufbuild/connect"; import { ElizaService } from "./gen/eliza_connect"; import { createConnectTransport } from "@bufbuild/connect-web"; const transport = createConnectTransport({ baseUrl: "http://localhost:8080", // Not needed. Web browsers use HTTP/2 automatically. // httpVersion: "1.1" }); async function main() { const client = createPromiseClient(ElizaService, transport); const res = await client.say({ sentence: "I feel happy." }); console.log(res); } void main();
TypeScriptの型定義ファイル
型定義ファイルがどこにもないなと思ったが、それもそのはず、gRPC(-connect)自体はprotoから型定義を生成はしない。
別のライブラリを使う必要がある。
ts-proto-gen
https://github.com/improbable-eng/ts-protoc-gen
GitHub - improbable-eng/ts-protoc-gen: Protocol Buffers Compiler (protoc) plugin for TypeScript and gRPC-Web.
使い方はこちらのサイト様を参照すれば良い。
Protocol Buffers から TypeScript の型定義を作る
型定義とは関係ないが、上記サイトを調べる途中で見つけたおもしろいサイトをここにメモする。
自前でgRPCサーバを作成している。Protoco Bufferの仕様から、自分でシリアライザ/でシリアライザを作っていてすごい。
なんだかんだガチプロトタイピングにはTypeScript楽だしgRPCを手作りする - lilyum ensemble
おわりに
gRPC-connectは予想以上にかんたんに使うことができて、普通にRestAPIを作成するのと同程度と感じた。
サーバ間同士はこれでいいんじゃないかなと思う。
クライアントに提供するAPIとしては、RestAPIとどう住み分けるといいのか知見をためたい。
今の所感としては以下のようであればgRPC-connect
を採用してもいいのかもしれない。
- サーバー側のFWが
gRPC-connect
対応している- もしくは、新たにgRPC-connect用サーバを立てても問題ない
tRPC
やMagicOnion
といった、サーバ・クライアントが同じ言語であることを活かした便利な通信ライブラリを使わない
最後になるが、gRPC
があるのになぜわざわざgRPC-conncet
が作られたのか、詳しい解説をしてくださるサイト様。これを読むと使える環境であればgRPC-connect
使っておけばいいのでは?と思わされる。
https://symthy.hatenablog.com/entry/2022/09/24/160309