🚂

TypeScriptを使ってNode.jsとブラウザ間のRPC(情報求む)

2021/08/17に公開約6,900字2件のコメント

前提

昔から、個人が思いつくようなことは

  1. 既に方法が確立されている。自分が発見できていないだけ。
  2. 既に試してみた先人たちがいるが、上手くいかないことが分かり頓挫したプロジェクトである。

の2択だとよく言われます。
私が今探しているのは超シンプルなWebサーバーとクライアント間のRPCです。
特に、バックエンドはNodeJS限定とします。
これにより言語が統一されるのでバックエンドとフロントエンドでコード共有ができます。
C#のMagicOnionのように、APIのインターフェースとその実装、および少々のコンフィギュレーションを定義するだけで済むはずです。
超お手軽です。

現実

既存の実装を探してみました。

どちらも多言語対応しているので、謎の第3スキーマ言語が出てきます。
私はフロントエンドもバックエンドもTypeScriptで書きたいので、共通のインターフェースを定義するだけで良いはずなのです。
少なくともこれらを直接生で扱うというよりはラッパーが欲しいところです。

もし良いライブラリを御存じの方がいればコメントにてご教示願います……

暫定的な解決案

自作。
良さげなライブラリが見つからないので貧者のRPCを自作する以外の解決策が思い浮かびませんでした。

※本記事のサンプルコードはMITライセンスです。

フォルダ構成イメージ

/src/*common, backend, frontendの3つのプロジェクトを作成します。
それぞれの依存関係はワークスペースで管理します。

/
├ src
│ ├ common
│ │ └ package.json
│ ├ backend
│ │ └ package.json
│ └ frontend
│   └ package.json
└ package.json

本記事における貧者のRPC

使用するHTTPメソッドはGETPOSTの2種類のみとします。

名前name: stringがついた、n引数を取る非同期関数の集まりを考えます。

{
    [name: string]: (...args: any[]): Promise<any>
}

例えば、テキストの追加と一覧参照のみができる超シンプルなToDoアプリケーションもどきを考えます。
このアプリケーションのAPIの契約は次のようになるでしょう。

type Todo = { id: number; text: string };

interface ITodosApi {
  getTodos(): Promise<Todo[]>;
  addTodo(todo: Omit<Todo, "id">): Promise<Todo>;
}

バックエンドはITodosApiの契約内容を提供しなければなりません。
フロントエンド側ではITodosApiの契約内容からクライアントを自動生成します。

ここで、引数args: any[]を次のようなJSONで表してGETのパラメータやPOSTのボディに使用することにします。

{
    d: args
}

先ほどの例で言うと、addTodoメソッドへのPOSTの際のボディは例えば

{
    d: [ { text: "add me" } ]
}

となります。

これにより、色々頑張らなくてもバックエンドとフロントエンドを相互通信できるはずです。

RPCエンジンのコード

今回はバックエンドにExpressを採用し、フロントエンドはfetch APIを使うことにしました。
規則自体は単純なのでfastifyでもXHRでも自由な組み合わせで実装できるはずです。
以下のコード群は流し読みで結構ですので、そのまま「使い方」に飛んでください。

/common/rpc.ts

export type HttpMethods = "GET" | "POST";

export type ApiRoutes<TApi> = Record<keyof TApi, [HttpMethods, string]>;

export type ApiEntry<TApi> = {
  name: keyof TApi;
  method: HttpMethods;
  path: string;
};

export function getApiEntries<TApi>(routes: ApiRoutes<TApi>): ApiEntry<TApi>[] {
  return Object.entries(routes).map(([key, value]: any) => ({
    name: key,
    method: value[0],
    path: value[1],
  }));
}

export type Api<TApi> = {
  routes: ApiRoutes<TApi>;
  actions: {
    [K in keyof TApi]: (...args: any[]) => Promise<any>;
  };
};

/backend/rpc.ts

import { Router } from "express";
import { RPC } from "common";

// Poor man's RPC engine for Express
export function createRPCRouter<TApi>({ routes, actions }: RPC.Api<TApi>) {
  const router = Router();

  for (const { name, method, path } of RPC.getApiEntries(routes)) {
    if (method === "GET") {
      router.get(path, async (req, res, next) => {
        try {
          const args = JSON.parse(req.query?.d as any)
          const action = actions[name];
          const data = await action(...args);
          res.json(data);
        } catch (err) {
          next(err);
        }
      });
    } else if (method === "POST") {
      router.post(path, async (req, res, next) => {
        try {
          const action = actions[name];
          const data = await action(...req.body?.d);
          res.json(data);
        } catch (err) {
          next(err);
        }
      });
    }
  }

  return router;
}

/frontend/rpc.ts

import { RPC } from "common";

// Poor man's RPC engine for Browser
export function getApiClient<TApi>(routes: RPC.ApiRoutes<TApi>): TApi {
  const api = {} as any;

  for (const { name, method, path } of RPC.getApiEntries(routes)) {
    if (method === "GET") {
      api[name] = async (...args: any[]) => {
        const param = { d: JSON.stringify(args) };
        const qs = new URLSearchParams(param);
        const res = await fetch(`${path}?${qs.toString()}`);
        const data = await res.json();
        return data;
      };
    } else if (method === "POST") {
      api[name] = async (...args: any[]) => {
        const res = await fetch(path, {
          method,
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ d: args }),
        });
        const data = await res.json();
        return data;
      };
    }
  }

  return api;
}

使い方

ここからは先ほど挙げたRPCエンジンの使い方と、私が冒頭に述べた理想のRPCライブラリがどのように使えるべきかを示します。

テキストの追加と一覧参照のみができる超シンプルなToDoアプリケーションもどきを考えます。
ToDoデータを格納するオブジェクトおよびToDoデータの検証isValidをバックエンド・フロントエンドでコード共有します。

/common/Todo.ts

export class Todo {
  protected constructor(readonly id: number, readonly text: string) {}

  static isValid(text: string) {
    return text !== "";
  }

  static create(id: number, text: string) {
    return new Todo(id, text);
  }
}

次にToDoまわりのAPIの契約を定義し、バックエンド・フロントエンドでコード共有します。

/common/api.ts

export interface ITodosApi {
  getTodos(): Promise<Todo[]>;
  addTodo(todo: Omit<Todo, "id">): Promise<Todo>;
}

export const todosApiRoutes: ApiRoutes<ITodosApi> = {
  getTodos: ["GET", "/api/todos"],
  addTodo: ["POST", "/api/todos/create"],
};

todosApiRoutesでは使用するHTTPメソッドの種類と、そのエンドポイントを指定しておきます。
ここまではバックエンド・フロントエンドでコード共有します。

バックエンド

バックエンド側ではITodosApiインターフェースを実装したクラスを作成します。

class TodosApi implements ITodosApi {
  async getTodos(): Promise<Todo[]> {
    return /* データベースとの通信等の実装 */;
  }
  async addTodo(todoWithoutId: Omit<Todo, "id">): Promise<Todo> {
    return /* データベースとの通信等の実装 */;
  }
}

APIの契約の実装TodosApiと、エンドポイントの情報todosApiRoutesの2つのデータからExpressルータを作成します。
これをそのままミドルウェアとして使います。

import express from "express";

const todosApi: RPC.Api<ITodosApi> = {
  routes: todosApiRoutes,
  actions: new TodosApi(),
};

const app = express();
app.use(express.json());
app.use(createRPCRouter(todosApi));

const PORT = 8080;
app.listen(PORT, () => {
  console.log(`listening at http://localhost:${PORT}`);
});

この時点で、GET /api/todos?d=[]に対してTodoApi#getTodos()を呼び出してその結果を返し、
POST /api/todos/createbody: { d: [ hoge ] }に対してTodoApi#addTodo(hoge)を呼び出してその結果を返します。

フロントエンド

フロントエンド側ではRPCエンジンgetApiClientを使用して、todosApiRoutesから動的にクライアントを作成します。
次のようなオブジェクトが生成されます。

// getApiClient(todosApiRoutes)の返り値(疑似コード)
{
  async getTodos() {
    const res = await fetch(`/api/todos?d=[]`);
    const data = await res.json();
    return data;
  },
  async addTodo(todo: { text: string }) {
    const res = await fetch(`/api/todos/create`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ d: [todo] }),
    });
    const data = await res.json();
    return data;
  },
}

まとめ

超シンプルなTypeScript専用のRPCライブラリが見つからないという嘆きからRPCを自作してみました。
もっと洗練されたRPCライブラリが既に存在するような気がするので御存じの方がいらっしゃれば是非情報提供をお願いします……

GitHubで編集を提案

Discussion

RPC 便利ですよね。

自分は JSON-RPC 2.0 の規格に乗っ取っている json-rpc-2.0 という NPM ライブラリを管理しています。プロトコルに縛られない(HTTP でも WebSocket でも in-memory でも何でもいい)、他のライブラリに依存していない、ブラウザでも Node.js でも動く、TypeScript で開発しているなどの特徴があります。

JSON-RPC も規格なので多言語に対応していますが、JSON ベースでスキーマを定義する必要もありません。

もしよかったら見ていってください!

ご紹介ありがとうございます。

試験的に本記事で行っていることをjson-rpc-2.0ライブラリで実装してみました。

https://github.com/eagle-k/json-rpc-todo-experiment

実装の詳細はライブラリに任せて、その上に薄いラッパーを書けば本記事で行いたいことができそうです。
今後は認証やエラーハンドリングの実装もしてみたいと思います。ありがとうございました!

ログインするとコメントできます