🗂

gRPC - connect - Render でwebサービスを作ってみる:web service with connect

に公開

背景

gRPCもRemixも触ったことがないので、触ってみたいと思います。今回はクライアントサイドの開発をtypescriptでやってみます。
動作するコードは以下にあります。

参照情報

フロントエンドからの通信方法を学びます。

このガイドではELIZAに接続するwebインターフェースを作ります。ELIZAは1960年代に作られた心理療法士サービスです。サービスはすでに動作済みで、プロトコルバッファーのスキーマが定義されています。

前提条件

  • Node.js - TLSバージョンをお勧めします
  • npmを使う説明をしますが、yarnでもpnpmでもよいです
  • コード生成にbufを使います。インストールはこちらを見てください

準備

Viteを使ってフロントエンドの設定を進めます。viteはこの後の工程で必要になる機能を全て持っています。

npm create vite@latest -- connect-example --template react-ts
cd connect-example
npm install

クライアントを生成する

Protocol Buffer schema for ELIZAからコードを生成しましょう。今回はBuf Schema Registry の機能である生成済みのSDKを使います。最初のコマンドでnpmに@bufのレジストリを見るように指示します。installで必要な型をその場で生成します。

$ npm install @buf/connectrpc_eliza.bufbuild_es @connectrpc/connect @connectrpc/connect-web

これでパッケージからサービスをインポートして使うことができます。src/App.tsxを編集し、以下の実装をしましょう。

// src/App.tsx
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";

// 接続したいサービスをインポート
import { ElizaService } from "@buf/connectrpc_eliza.bufbuild_es/connectrpc/eliza/v1/eliza_pb";

// transportではどのタイプのエンドポイントを使うか定義します
// 今回はConnect endpointを使います。
// エンドポイントが`g-RPC-web`しか対応していない場合は`createGrpcWebTransport`を使ってください
const transport = createConnectTransport({
    baseUrl: "https://demo.connectrpc.com"
})

// サービス定義とtransportを組み合わせてクライアントを作ります
const client = createClient(ElizaService, transport)

アプリをビルドする

これでクライアントはすべての設定を完了し動作するため、ライブアプリケーションと接続できます。今回はインタフェースを作るためにReactを使いますが、Connectはフレームワークを選ばないので vanilla Javascriptなどでも動作します。

以下のコマンドでWebサーバが動きます。ソースコードの変更は自動的にブラウザで更新されます。

npm run dev

初期化時に生成されたApp()のコードを削除し以下のようにしましょう。

// src/App.tsx
...
function App() {
  return <>Hello world</>;
}
...

ELIZAの目的はユーザとサーバの間のチャットを表現することです。そのためコミュニケーションを容易にするためのフォームを作成します。これで入力と提出用のボタンが作られます。

// src/App.tsx
...
function App() {
    return <>
        <form>
            <input />
            <button type="submit">Send</button>
        </form>
    </>
}
...

これでテキストを提出することができるようになりましたが、Sendをクリックしてもページがリロードされるだけで何も起きません。もう少し変更する必要があります。まず、入力をstateとして保持して、管理しやすくしましょう。

// src/App.tsx
...
function App() {
    const[inputValue, setInputValue] = useState("")
    return <>
        <form >
            <input value={inputValue} onChange={e =>setInputValue(e.target.value)}/>
            <button type="submit">Send</button>
        </form>
    </>
}
...

クライアントを接続する

formのsubmitをキャプチャしてページがリフレッシュされてしまうことを避けましょう。前のステップで作ったクライアントを使って実際にサーバーとやり取りできます。

// src/App.tsx
...
function App() {
    const[inputValue, setInputValue] = useState("")
    return <>
        <form onSubmit={async (e) => {
            e.preventDefault(); // ページリロードを避ける
            await client.say({
                sentence: inputValue
            })
        }}>
            <input value={inputValue} onChange={e =>setInputValue(e.target.value)}/>
            <button type="submit">Send</button>
        </form>
    </>
}
...

これでサーバーと最小限のコミュニケーションが取れるようになりましたが、何も表示しません。developer toolsでみるとELIZAが "Please tell me more" と言っているのがわかります!

次に送信メッセージと受信メッセージの管理を追加します。

// src/App.tsx
function App() {
    const [inputValue, setInputValue] = useState("")
    const [messages, setMessages] = useState<
        {
            fromMe: boolean;
            message: String;
        }[]
    >([]);
    return <>
        <form onSubmit={async (e) => {
            e.preventDefault(); // ページリロードを避ける
            // メッセージを送信したらフォームをクリアします
            setInputValue("");
            // inputValueをmessageに登録し、fromMeのフラグをtrueとします
            setMessages((prev) => [
                ...prev,
                {
                    fromMe:true,
                    message: inputValue
                }
            ]);
            // レスポンス取得
            const response = await client.say({
                sentence: inputValue
            });
            // レスポンスをELIZAからのものとして登録する
            setMessages((prev) => [
                ...prev,
                {
                    fromMe:false,
                    message:response.sentence
                },
            ]);
        }}>
        <input value={inputValue} onChange={e =>setInputValue(e.target.value)}/>
            <button type="submit">Send</button>
        </form>
    </>
}

以上でメッセージが管理されますが、何も表示はされないので、表示を追加します。

// src/App.tsx
function App() {
    const [inputValue, setInputValue] = useState("")
    const [messages, setMessages] = useState<
        {
            fromMe: boolean;
            message: String;
        }[]
    >([]);
    return <>
        <form onSubmit={async (e) => {
            e.preventDefault(); // ページリロードを避ける
            // メッセージを送信したらフォームをクリアします
            setInputValue("");
            // inputValueをmessageに登録し、fromMeのフラグをtrueとします
            setMessages((prev) => [
                ...prev,
                {
                    fromMe:true,
                    message: inputValue
                }
            ]);
            // レスポンス取得
            const response = await client.say({
                sentence: inputValue
            });
            // レスポンスをELIZAからのものとして登録する
            setMessages((prev) => [
                ...prev,
                {
                    fromMe:false,
                    message:response.sentence
                },
            ]);
        }}>
        <input value={inputValue} onChange={e =>setInputValue(e.target.value)}/>
            <button type="submit">Send</button>
        </form>

        <ol>
            {messages.map((msg, index) => (
                <li key={index}>
                    {`${msg.fromMe ? "ME:" : "ELIZA"} ${msg.message}`}
                </li>
            ))}
        </ol>
    </>
}

以上で以下のようにコミュニケーションできるようになります!

まとめ

ViteReactを使ってELIZAとコミュニケーションできました!
アプリケーションが成長してきたときには型安全なプロトコルバッファのスキーマがめっちゃ便利になるよ!とのことです。

次は、生成済みのSDKではなく自分でスキーマを作ってELIZAとコミュニケーションしてみたいと思います。

Discussion