ElectronのIPCをWeb APIっぽく使えるようなTypeScript実装の試み

背景

  • ElectronアプリをTypeScriptを使って書いている
  • やりたいこと:(1) IPC (Inter-Process Communication)をWeb APIっぽく使えるようにしたい、(2) 同時に、型情報を活かしつつ綺麗に実装したい
    • 一般的なWebアプリをイメージしている。Rendererプロセスがブラウザ、Mainプロセスがサーバーという対応
    • 色んな処理(CRUDとか)をRenderer側からリクエストして、非同期でレスポンスが返ってきたらRenderer側にメッセージ表示などしたい
      • 処理の種類に対応する複数の種類の"API"を実装(例:AddUserGetUserInfo、etc.)
    • IPCのChannelは1つ開きっぱなしにして、全ての"API"で共有する
      • "API"ごとにChannelを分けると、管理が煩雑になったため
+----------+                +----------+
|          |--- Request --->|          |
| Renderer |                |   Main   |
|          |<-- Response ---|          |
+----------+                +----------+

こんな風にしてみた

  • もっと良い実装があるかもしれないけど、現時点ではこれが良いのでは、と思っている方針
  • 一言で言えば、リクエスト/レスポンスの型定義をうまく書いてGenericsIndex typesの機能を活用することで、型情報の活用とボイラープレートの最小化を実現している、ということに尽きます。

"API"の種類

"API"名のunionとして定義

type IpcType = 'AddUser' | 'GetUserInfo' | ...

リクエスト・レスポンスの型

それぞれ別々のinterfaceとして実装する。 各"API"名がプロパティで、値がリクエスト/レスポンスの型になっているという形式. この形式にしている理由はすぐ次の節へ。

interface IpcRequest {
  AddUser: {
    name: string;
    age: number;
    ...
  };
  GetUserInfo: {
    id: number;
  };
  ...
}

interface IpcResponse {
  AddUser: number; // id
  GetUserInfo: {
    name: string;
    age: number;
    ...
  };
  ...
}

"API"のリクエスト側(≒クライアント)

ここが主に試行錯誤したところ。 "API"ごとにメソッドを生やさないようにしたかったけど、最初Genericsがうまく扱えなかった。

最終的に、IpcRequest[T]IpcResponse[T]と書くことで、"API"の種類に応じたリクエスト・レスポンスの型を引っ張ってこれることに気づいた。

class IpcClient {
  private readonly map: ResponseHandlerMap;
  private isOpen: boolean;

  constructor() {
    this.isOpen = false;
    this.map = new ResponseHandlerMap();
  }

  // どの"API"もこの共通メソッドでリクエストできる
  // GenericsとIndex Typesを使って工夫している
  public send<T extends IpcType>(
    type: T,
    req: IpcRequest[T]
  ): Promise<IpcResponse[T]> {
    return new Promise(resolve => {
      const id = uuidv4(); // ユニークなIDを付与しておき、どのリクエストに対するレスポンスなのか区別できるようにする
      this.map.set(id, type, (res: IpcResponse[T]) => resolve(res));
      ipcRenderer.send('MAIN_CHANNEL', type, req, id);
    });
  }

  // IPC Channelを開くために一度だけ叩いておく必要があるメソッド
  // ここは型が付いていない
  public open() {
    ipcRenderer.on(
      'MAIN_CHANNEL',
      (event: any, res: any, err: any, id: string) => {
        const { type, handler } = this.map.get(id);
        handler(res);
        this.map.delete(id);
      }
    );
    this.isOpen = true;
  }

  public close() {
    ipcRenderer.removeAllListeners('MAIN_CHANNEL');
    this.isOpen = false;
  }
}

// Mainプロセスから返ってきたレスポンスのハンドラ関数の型定義
type ResponseHandler<T extends IpcType> = (res: IpcResponse[T]) => void;

// レスポンスハンドラをidごとに保持しておくMap。
// JSのMapの薄いラッパで、こいつの中身は型をちゃんと扱っていない。
// IpcClientはできるだけ型のある世界にしたかったので、汚いものをこいつに押し付けている形。
class ResponseHandlerMap {
  private readonly map: Map<string, { type: IpcType; handler: (req: any) => void }>;

  constructor() {
    this.map = new Map();
  }

  public set<T extends IpcType>(
    id: string,
    type: T,
    handler: ResponseHandler<T>
  ) {
    this.map.set(id, { type, handler });
  }
  public get<T extends IpcType>(
    id: string
  ): { type: T; handler: ResponseHandler<T> } | undefined {
    const got = this.map.get(id);
    if (!got) {
      return undefined;
    }
    return { type: got.type as T, handler: got.handler };
  }

  public delete(id: string) {
    this.map.delete(id);
  }

  public get size() {
    return this.map.size;
  }
}

// 全般、エラーハンドリング系は煩雑なので省略しました

"サーバー"(Mainプロセス)側

工夫した点や実装はリクエスト側と同様なので読み飛ばしてもよいです。

// なんとなくclassとして書いている、classにする必要性は特にない。
// IpcController.initialize()を一度呼べばOK
class IpcController {
  public static initialize() {
    const handlerMap = new RequestHandlerMap();

    ipcMain.on(
      'MAIN_CHANNEL',
      async <T extends IpcType>(
        event: any,
        type: T,
        req: IpcRequest[T],
        id: string
      ) => {
        try {
          const handler = handlerMap.get(type);
          const res = await handler(req);
          event.sender.send('MAIN_CHANNEL', res, null, id);
        } catch (error) {
          event.sender.send('MAIN_CHANNEL', null, error.message, id);
        }
      }
    );

    // ここからずらずらと"API"の実処理を書き並べる
    // reqにはちゃんとIpcRequestの定義に基づいた型情報が付く
    handlerMap.set('AddUser', async req => { ... });
    handlerMap.set('GetUserInfo', async req => { ... });
    ...
}

// "API"のコントローラ関数の型定義にあたる。
// ResponseHandlerと同様
export type RequestHandler<T extends IpcType> = (
  req: IpcRequest[T]
) => Promise<IpcResponse[T]>;

// ResponseHandlerMapと同様、この中は型が付いてない
class RequestHandlerMap {
  private map: Map<IpcType, (req: any) => Promise<any>>;

  constructor() {
    this.map = new Map();
  }

  public set<T extends IpcType>(type: T, handler: RequestHandler<T>) {
    this.map.set(type, handler);
  }

  public get<T extends IpcType>(type: T): RequestHandler<T> | undefined {
    const got = this.map.get(type);
    if (!got) {
      return undefined;
    }
    return got as RequestHandler<T>;
  }
}

"API"を叩くには

Rendererのコードで以下のように書くだけ。型も勝手についてくるし、シンプルに書ける。

ipcClient.send('GetUserInfo', id)
  .then(userInfo => {
    // データを使った処理を書く。
    // userInfoには型が付いている
  });

"API"を追加するには

以下の通り。本質的なことを書くだけで済む。

  • 新しい"API"名をIpcTypeに追加
  • IpcRequestにリクエストの型情報を追加
  • IpcResponseにレスポンスの型情報を追加
  • IpcControllerの中に"API"の実処理を追加

考察

満足している点

  • API追加時にボイラープレートコードを書かなくて済む
  • 各種関数にちゃんとリクエスト・レスポンスの型情報が乗ってくれるので安心

不満/疑問が残る点

  • IpcRequestIpcResponseの定義が別々なので、同じ"API"の情報がコード上並ばないので可読性がよくない
  • 内部的には型無しで扱っている箇所があり、それは良いのかどうか?
  • Channelを1本開きっぱなしで使い回す、というのは良いのか?

その他

そもそもElectronのIPCをAPIっぽく扱いたい、というような需要はあるのか(あるいは筋が良いのか)どうか、よくわかっていません。

↓のようなレポジトリは見つかって、結構モチベーションは近そうなのですが、特に流行ってはいない様子。 github.com

APIっぽい、という話ではないですが、IPCをPromise的にシンプルに扱いたいというライブラリもあるようです↓ github.com github.com

結び

長々とお付き合いありがとうございました。 自分はElectron・TypeScriptのコードリーディングをあまりしてきていないので、典型的なオレオレ実装になっているのでは、という不安もありつつ書いてみました。 より良い実装が見つかれば、追記ないし別エントリを書こうと思います。