Open9

Connect RPC (connect-go / connect-web)

RyoRyo

概要

ConnectはブラウザーとgRPC互換のHTTP APIを構築するライブラリです。
ストリーミングを含むgRPC、およびgRPC-Webプロトコルをサポートしており
ブラウザでのgRPC通信でEnvoyなどのプロキシを必要としないgRPC-Webリクエストが可能です。

Protocol Buffersのスキーマ定義からコードの自動生成や様々な言語に対応しているため
構築のしやすさや相互運用性、拡張性に優れています。

https://connectrpc.com/docs/introduction/

RyoRyo

最終的なディレクトリ構成

├── .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
RyoRyo

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
RyoRyo

アプリケーションインストール

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
RyoRyo

Protocol Buffers スキーマ作成

https://buf.build/docs/introduction

サービス定義 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
RyoRyo

サービスの実装 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()
RyoRyo

サービス実装 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>
  );
}
RyoRyo

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>
  );
}