Open9
Connect RPC (connect-go / connect-web)
概要
ConnectはブラウザーとgRPC互換のHTTP APIを構築するライブラリです。
ストリーミングを含むgRPC、およびgRPC-Webプロトコルをサポートしており
ブラウザでのgRPC通信でEnvoyなどのプロキシを必要としないgRPC-Webリクエストが可能です。
Protocol Buffersのスキーマ定義からコードの自動生成や様々な言語に対応しているため
構築のしやすさや相互運用性、拡張性に優れています。
簡易アプリの作成
WebブラウザからのクライアントとしてNext.js
サーバーサイドをFastifyで構成
最終的なディレクトリ構成
├── .devcontainer // BufなどCLI実行用Workspace
│ ├── Dockerfile
│ └── devcontainer.json
├── README.md
├── apps
│ ├── server // Fastify サーバーアプリケーション
│ └── web // Next.js クライアントアプリケーション
├── buf.gen.yaml
├── buf.work.yaml
├── docker
│ ├── server
│ │ └── Dockerfile
│ └── web
│ └── Dockerfile
├── compose.yml
└── packages
└── protobuf // Protocol Buffers APIスキーマ
├── buf.yaml
└── eliza
└── v1
└── eliza.proto
Docker環境
DevContainer
各種言語に合わせて公式ガイドのツールをインストール
Dockerfile
FROM node:21-bookworm as base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
WORKDIR /workspace
RUN corepack enable
RUN corepack prepare pnpm@8.15.4 --activate
RUN pnpm install -g @bufbuild/buf@1.30 @bufbuild/protoc-gen-es@1.8 @bufbuild/protobuf@1.8 @connectrpc/protoc-gen-connect-es@1.4 @connectrpc/connect@1.4
devcontainer.json
{
"name": "devcontainer",
"build": {
"dockerfile": "./Dockerfile"
},
"workspaceFolder": "/workspace",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
"mounts": [
"source=volume-web-node-module,target=${containerWorkspaceFolder}/apps/web/node_modules,type=volume",
"source=volume-server-node-module,target=${containerWorkspaceFolder}/apps/server/node_modules,type=volume"
],
"init": true,
"customizations": {
"vscode": {
"extensions": [
"zxh404.vscode-proto3"
],
}
}
}
App Docker
docker/web/Dockerfile
FROM node:21-alpine as base
# -----------------------------------------------------------------------------
# Develop Application Run
# -----------------------------------------------------------------------------
FROM base AS develop
WORKDIR /usr/src/app
COPY ../../apps/web .
RUN corepack enable pnpm && corepack prepare pnpm@8.15.4 --activate \
&& pnpm i
CMD ["pnpm", "run", "dev"]
docker/server/Dockerfile
ARG ALPINE_VERSION=3.18
FROM node:21-alpine${ALPINE_VERSION} as base
FROM alpine:${ALPINE_VERSION} as runtime
# -----------------------------------------------------------------------------
# Develop Application Run
# -----------------------------------------------------------------------------
FROM base AS develop
WORKDIR /usr/src/app
COPY ../../apps/server .
RUN corepack enable pnpm && corepack prepare pnpm@8.15.4 --activate \
&& pnpm i
CMD ["pnpm", "run", "start:dev"]
App Docker Compose
docker compose watchを利用
compose.yml
version: '3.7'
services:
### Web App #########################################
web:
build:
context: .
dockerfile: ./docker/web/Dockerfile
target: develop
ports:
- "3000:3000"
init: true
stdin_open: true
develop:
watch:
- action: sync
path: ./apps/web
target: /usr/src/app
ignore:
- node_modules/
- action: rebuild
path: ./apps/web/package.json
### Server App #########################################
server:
build:
context: .
dockerfile: ./docker/server/Dockerfile
target: develop
ports:
- "8080:8080"
init: true
stdin_open: true
develop:
watch:
- action: sync
path: ./apps/server
target: /usr/src/app
ignore:
- node_modules/
- action: rebuild
path: ./apps/server/package.json
アプリケーションインストール
Next.js
導入
pnpm create next-app
設定
Connectで自動生成されたコードをエイリアスで読み込むため
apps/web/next.config.js
// @ts-check
/** @type {import('next').NextConfig} */
module.exports = {
output: 'standalone',
webpack: (
webpackConfig,
{ webpack },
) => {
webpackConfig.resolve.extensionAlias = {
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
'.cjs': ['.cts', '.cjs'],
};
return webpackConfig;
},
}
Fastify
導入
dev環境ではnodeでtypescirpt実行するためにtsxを導入
pnpm i fastify @fastify/cors
pnpm i -D typescript @types/node tsx
設定
apps/server/package.json
{
"name": "server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"start:dev": "tsx src/server.ts"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"fastify": "^4.26.2"
},
"devDependencies": {
"@types/node": "^20.12.2",
"tsx": "^4.7.2",
"typescript": "^5.4.3"
},
"engines": {
"pnpm": "8.15.4"
},
"packageManager": "pnpm@8.15.4"
}
apps/server/tsconfig.json
{
"compilerOptions": {
"lib": [
"es2023"
],
"module": "commonjs",
"target": "es2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node",
"outDir": "dist",
"baseUrl": "src",
"paths": {
"~/*": ["src/*"],
"@gen/*": ["gen/*"]
}
},
"include": ["src/**/*.ts", "gen/**/*.ts"]
}
apps/server/src/server.ts
import fastify from 'fastify'
const server = fastify()
server.get('/ping', async (request, reply) => {
return 'pong\n'
})
server.listen({ port: 8080, host: '0.0.0.0' }, (err, address) => {
if (err) {
console.error(err)
process.exit(1)
}
console.log(`Server listening at ${address}`)
})
起動確認
docker compose watch
Protocol Buffers スキーマ作成
サービス定義 Protobufファイル作成
メッセージを送信しレスポンスを返却するだけの実装
packages/protobuf/eliza/v1/eliza.proto
syntax = "proto3";
package eliza.v1;
message SayRequest {
string sentence = 1;
}
message SayResponse {
string sentence = 1;
}
service ElizaService {
rpc Say(SayRequest) returns (SayResponse) {}
}
コード生成のためのbufを定義
共通ディレクトリからの生成ためworkspaceを利用
optが複数指定(配列指定)ができないのでwebとserverそれぞれ定義
packages/protobuf/buf.yaml
version: v1
breaking:
use:
- FILE
lint:
use:
- DEFAULT
buf.work.yaml
version: v1
directories:
- packages/protobuf
buf.gen.yaml
version: v1
plugins:
- plugin: es
opt: target=ts
out: apps/server/gen
- plugin: connect-es
opt: target=ts
out: apps/server/gen
- plugin: es
opt: target=ts
out: apps/web/gen
- plugin: connect-es
opt: target=ts
out: apps/web/gen
コードの生成
DevContainerでコマンド実行
buf generate
└── apps
├── server
│ └── gen
│ └── eliza
│ └── v1
│ ├── eliza_connect.ts
│ └── eliza_pb.ts
└── web
└── gen
└── eliza
└── v1
├── eliza_connect.ts
└── eliza_pb.ts
サービスの実装 Fastify
生成したコードを利用して各種実装を行う
パッケージをインストール
pnpm i @connectrpc/connect-node @connectrpc/connect-fastify @bufbuild/protobuf @connectrpc/connect
ConnectRouter登録
apps/server/src/connect.ts
import type { ConnectRouter } from '@connectrpc/connect'
import { ElizaService } from '@gen/eliza/v1/eliza_connect'
import { SayRequest, SayResponse } from '@gen/eliza/v1/eliza_pb'
export default (router: ConnectRouter) =>
// registers eliza.v1.ElizaService
router.service(ElizaService, {
// implements rpc Say
async say(req: SayRequest) {
return new SayResponse({
sentence: `You said ${req.sentence}`,
})
},
})
エンドポイント作成
apps/server/src/server.ts
import fastify from 'fastify'
import { fastifyConnectPlugin } from '@connectrpc/connect-fastify'
import routes from '~/connect'
const main = async () => {
const server = fastify()
await server.register(fastifyConnectPlugin, {
routes,
})
await server.listen({ port: 8080, host: '0.0.0.0' })
console.log("server is listening at", server.addresses())
}
void main()
サービス実装 Next.js
パッケージインストール
pnpm i @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf
クライアントのセットアップ
Hooksにて作成
apps/web/hooks/use-connect-client.ts
import { useMemo } from 'react'
import { ServiceType } from '@bufbuild/protobuf'
import { createConnectTransport } from '@connectrpc/connect-web'
import { createPromiseClient, PromiseClient } from '@connectrpc/connect'
const transport = createConnectTransport({
baseUrl: "http://localhost:8080",
});
export const useConnectClient = <T extends ServiceType>(service: T): PromiseClient<T> => {
return useMemo(() => createPromiseClient(service, transport), [service])
}
実装
apps/web/app/page.tsx
'use client'
import { useState } from 'react';
import { ElizaService } from '~/gen/eliza/v1/eliza_connect'
import { useConnectClient } from '~/hooks/use-connect-client'
export default function Home() {
const client = useConnectClient(ElizaService)
const [sentence, setSentence] = useState('')
const handleSubmit = async () => {
const res = await client.say({
sentence: "I feel happy.",
})
setSentence(res.sentence)
}
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="w-1/2">
<div>
<p>Hello</p>
<button
type="button"
className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
onClick={handleSubmit}>
Submit
</button>
</div>
<div className='mt-5'>
<p>Eliza</p>
{!!sentence && (
<p>{sentence}</p>
)}
</div>
</div>
</main>
);
}
Server-Streaming 実装
Nodeでのシングルスレッド環境のため
簡易的に複数回レスポンスを返却する形で実装
Protobuf 更新
packages/protobuf/eliza/v1/eliza.proto
syntax = "proto3";
package eliza.v1;
message SayRequest {
string sentence = 1;
}
message SayResponse {
string sentence = 1;
}
+message IntroduceRequest {
+ string name = 1;
+}
+
+message IntroduceResponse {
+ string sentence = 1;
+}
+
service ElizaService {
rpc Say(SayRequest) returns (SayResponse) {}
+ rpc Introduce(IntroduceRequest) returns (stream IntroduceResponse) {}
コード生成
buf generate
Fastify ConnectRouter更新
apps/server/src/connect.ts
import type { ConnectRouter } from '@connectrpc/connect'
import { ElizaService } from '@gen/eliza/v1/eliza_connect'
-import { SayRequest, SayResponse } from '@gen/eliza/v1/eliza_pb'
+import { IntroduceRequest, SayRequest, SayResponse } from '@gen/eliza/v1/eliza_pb'
+const delay = (ms: number) => {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
export default (router: ConnectRouter) =>
// registers eliza.v1.ElizaService
router.service(ElizaService, {
// implements rpc Say
async say(req: SayRequest) {
return new SayResponse({
sentence: `You said ${req.sentence}`,
})
},
+ // implements server streaming rpc Introduce
+ async *introduce(req: IntroduceRequest) {
+ yield { sentence: `Hi ${req.name}, I'm Eliza` }
+ await delay(250)
+ yield {
+ sentence: `Before we begin, ${req.name}, let me tell you something about myself.`,
+ }
+ await delay(250)
+ yield { sentence: `I'm a Rogerian psychotherapist.` }
+ await delay(250)
+ yield { sentence: `How are you feeling today?` }
+ }
})
Next.js ページ追加
apps/web/app/introduce/page.tsx
'use client'
import { useCallback, useState } from 'react';
import { ElizaService } from '~/gen/eliza/v1/eliza_connect'
import { IntroduceRequest } from '~/gen/eliza/v1/eliza_pb';
import { useConnectClient } from '~/hooks/use-connect-client'
export default function Home() {
const client = useConnectClient(ElizaService)
const [userName, setUserName] = useState('')
const [responses, setResponses] = useState<string[]>([])
const handleSubmit = useCallback(
async () => {
setUserName("")
const req = new IntroduceRequest({name: userName})
for await (const response of client.introduce(req)) {
setResponses((resp) => [...resp, response.sentence]);
}
},
[userName]
)
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="w-1/2">
{responses.map((resp, i) => {
return (
<div key={i}>
<p>{resp}</p>
</div>
);
})}
<div className='mt-5'>
<input
type="text"
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
value={userName}
onChange={(e) => setUserName(e.target.value)}
/>
<button
type="button"
className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
onClick={handleSubmit}>
Submit
</button>
</div>
</div>
</main>
);
}