にわかプラス

にわかが玄人になることを夢見るサイトです。社会や国際のトレンド、プログラミングや電子工作のことについて勉強していきたいです。

gRPC-connectのGetting startをNode(TypeScript)でやってみた

sponsor

動機

前から少し気になっていたのと、時雨堂の方のTwitterを見て、gRPC-connectがこれから来るのではないかと思ったので、使用感を確かめてみた。

(上記ツイートは、WebSocket, Long polling, server sent event, gRPC-connectの比較の際に出てきた発言なので、これらgRPC-connect以外のものについても今後理解を深めていきたい。
c.f https://qiita.com/Takagi_/items/a9f2ac0b2bfae309f735

それとたまたま本記事をかいてる3/1にnodeの対応がv1.0になったようだ。

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.tsnode上に作成し以下を記載。

// 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用サーバを立てても問題ない
  • tRPCMagicOnionといった、サーバ・クライアントが同じ言語であることを活かした便利な通信ライブラリを使わない

cf.) REST、gRPC、および GraphQL を使い分ける (パート 1) | エクセルソフト ブログ

最後になるが、gRPCがあるのになぜわざわざgRPC-conncetが作られたのか、詳しい解説をしてくださるサイト様。これを読むと使える環境であればgRPC-connect使っておけばいいのでは?と思わされる。
https://symthy.hatenablog.com/entry/2022/09/24/160309

【gRPC】Connect が作られた背景概要/これまでの gRPC-Web/Connect でできること - SYM's Tech Knowledge Index & Creation Records