ElectronのIPCをWeb APIっぽく使えるようなTypeScript実装の試み
背景
- ElectronアプリをTypeScriptを使って書いている
- やりたいこと:(1) IPC (Inter-Process Communication)をWeb APIっぽく使えるようにしたい、(2) 同時に、型情報を活かしつつ綺麗に実装したい
+----------+ +----------+ | |--- Request --->| | | Renderer | | Main | | |<-- Response ---| | +----------+ +----------+
こんな風にしてみた
- もっと良い実装があるかもしれないけど、現時点ではこれが良いのでは、と思っている方針
- 一言で言えば、リクエスト/レスポンスの型定義をうまく書いてGenericsやIndex 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"を追加するには
以下の通り。本質的なことを書くだけで済む。
考察
満足している点
不満/疑問が残る点
IpcRequest
とIpcResponse
の定義が別々なので、同じ"API"の情報がコード上並ばないので可読性がよくない- 内部的には型無しで扱っている箇所があり、それは良いのかどうか?
- Channelを1本開きっぱなしで使い回す、というのは良いのか?
その他
そもそもElectronのIPCをAPIっぽく扱いたい、というような需要はあるのか(あるいは筋が良いのか)どうか、よくわかっていません。
↓のようなレポジトリは見つかって、結構モチベーションは近そうなのですが、特に流行ってはいない様子。 github.com
APIっぽい、という話ではないですが、IPCをPromise的にシンプルに扱いたいというライブラリもあるようです↓ github.com github.com
結び
長々とお付き合いありがとうございました。 自分はElectron・TypeScriptのコードリーディングをあまりしてきていないので、典型的なオレオレ実装になっているのでは、という不安もありつつ書いてみました。 より良い実装が見つかれば、追記ないし別エントリを書こうと思います。