前置き
Electronは、Web技術を用いてクロスプラットフォームアプリを開発できる便利なフレームワークです。自分は主に複雑なUIが必要な中規模アプリの開発にElectronを使っています。
さて、自分はElectronを使い始めて1年が経とうとしたのですが、Electronの実装において最も面倒くさいと思っている部分がありました。
それが「API実装(プロセス間通信)」です。
ElectronにおけるAPI実装の基本
いくつか方法がありますが、今回はcontextBridge・preload機能を使った実装パターンを簡略化してみたいと思います。
まず、contextBridgeを用いてプロセス間通信を行う場合は、そのまま実装するとなんと4か所以上に変更を加える必要があります。
実装方法について
以下が私が以前まで行っていた実装方法です。既に知っている方は読み飛ばしてください。
実装方法
開発環境はElectron React Boilerplateに手を加えたものを採用しています。
そのためフォルダ構成はこんな感じです。
1.api本体を用意
今回は単純に「Hello ~」と返すAPIを用意してみます。
このapi.tsファイルはmainプロセス上で動くのでmainフォルダに置きます。
export const getHello = async (name: string) => `Hello ${name}`
2.mainプロセス(ipcMain)に登録
APIを作ったら、ipcMain.handle() を使ってmainプロセス上に登録します。
handle時の注意点として、第一引数はElectorn.IpcInvokeMainEvent型の変数が必ず入るので、ユーザー定義の引数は第二引数から指定しなければなりません。
import { getHello } from './api'
//...色々と記述する
mainWindow = new BrowserWindow({
show: false,
width: 800,
height: 800,
webPreferences: {
// preload.jsというファイルを指定し、
// その中でrendererプロセスに関数を公開する処理を書く
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
},
});
//...createWindowとか色々と記述する
ipcMain.handle('getHello', (event, name: string) => getHello(name))
3.preloadファイルで呼び出し処理を記述する
mainプロセスにAPIを登録したら、次にipcRenderer.invoker()をして、
rendererプロセス側でAPIを呼び出せるように設定します。
import { contextBridge, ipcRenderer } from 'electron'
//renderer側では、window.第一引数(ここではapi) とすることで呼び出しが可能に
contextBridge.exposeInMainWorld('api', {
getHello: (name: string) => ipcRenderer.invoke('getHello', name)
})
4.型定義ファイルを用意する(TypeScript限定)
TypeScriptを使っている場合は、どのようなAPIが、どこに公開されているのかを型定義ファイルを使って直接定義する事が出来ます。
export interface IUserAPI {
getHello: (name: string) => Promise<string>;
}
declare global {
//contextBridge.exposeInMainWorldの第一引数として指定した値 = プロパティ値とする
interface Window {
api: IUserAPI
}
}
5.rendererプロセスで呼び出す
ここまで来たら後は呼び出すだけです。設定したAPIはwindow.api.getHello()とすると呼び出せます。返り値はPromiseなのでthen()又はasync/awaitを忘れないように。
本人の都合上Reactで書いてますが、呼び出し構文はバニラJSでも同様です。
const App = () => {
const [message, setMessage] = useState('')
const [name, setName] = useState('')
return (
<div>
<h1>{message}</h1>
<input type={'text'} onChange={(e) => { setName(e.target.value) }} />
<button onClick={async () => {
setMessage(await window.api.getHello(name)) //async awaitで呼び出し
}}>Say Hello</button>
</div>
)
}
これで、名前を入力して「Say Hello」ボタンを押すと「Hello 名前」と返すAPIを呼び出すことが出来ました。
問題点
しばらくこの方法でAPIを用意してきましたが、正直しんどいです。
どうしんどいかというと、
- APIを追加する際に似たような処理を何回も書かないといけず時間がかかる
- しかも一ヶ所でも記述をミスると動かない
- 型定義が実装と独立しているので定義ミスによるバグが頻繁に発生
- APIを修正する際に、変更箇所や影響範囲が多くて大変
- 案の定変更忘れが起きてバグが発生する
とにかく、API関連の実装部分が色々なファイルに散らばっているお陰で保守性が悪いです。 あとAPIの追加に手間がかかりすぎるので機能追加も億劫になり開発体験も良くなかったので、私は考えました。
「散らばってるなら1つのファイルに纏めればいいじゃない」 と。
簡単な話です。
STEP1 API実装部の集約
先程のパターンだと、変更が必要なファイルが多すぎるのが問題だったので、API関連の実装を1つのファイルに纏める事にしました。具体的にはこんな感じに。
import { ipcMain, ipcRenderer } from "electron";
export const getHello = async (name: string) => `Hello ${name}`
// mainプロセスにハンドリングする。mainプロセス上で呼び出す。
export const setAPI = () => {
ipcMain.handle('getHello', (event, name: string) => getHello(name))
}
// rendererプロセスに公開するAPI。preload上で呼び出す。
export const APIInvoker = {
getHello: (name: string) => ipcRenderer.invoke('getHello', name)
}
// rendererプロセスに公開するAPIの型定義。renderer.d.tsで参照する。
export type API = typeof APIInvoker
以前までmain.tsやpreload.ts等に点在していた処理を、api専用のファイルを作ってそこに纏める事で可読性/保守性を上げる狙いです。ついでに型定義が独立していた問題はtypeofを使って解決しています。
main.ts,preload.ts,rendere.ts内での記述は以下の様に変更します。(インポート文は省略)
//main.ts
- ipcMain.handle('getHello', (event, name: string) => getHello(name))
+ setAPI()
// preload.ts
- contextBridge.exposeInMainWorld('api', {
- getHello: (name: string) => ipcRenderer.invoke('getHello', name)
- })
+ contextBridge.exposeInMainWorld('api', APIInvoker)
//renderer.d.ts
- export interface IUserAPI {
- getHello: (name: string) => Promise<string>;
- }
declare global {
interface Window {
- api: IUserAPI
+ api: API // APIInvokerのオブジェクト型をそのまま参照。
}
}
こうする事で、以前の様に様々なファイルを行き来しながらAPIの実装をする必要が無くなり、実装部がこのファイル1つに集約化されました。
勿論、大規模になれば機能ごとにAPIを分割する事になるでしょうが以前よりはマシです。
必要であれば/main/api ディレクトリを作成して、そこに分割したapiを入れれば大した苦労も無く管理できるようになるでしょう。
さらなる楽を求めて
ただ、同じ処理を何回も書かないといけない問題は、型定義ファイル関係の処理を簡略化したおかげで大分減りましたが、完全には解決していません。
具体的にはipcRenderer.invoke()とipcMain.handle() の部分です。
ここは基本的に1対1で記述する事が多いため、欲を言えば自動で設定して欲しいものです。
名前定義をミスると上手く呼び出しがされない事もあるのでなおさらです。
とはいえ何も方法が思いつかなかったので助っ人(ChatGPT)を呼びます。
STEP2 handleとinvoke設定の自動化
先程のコードを提示して、ChatGPTに「オブジェクトを元にipcMainとipcRendererに自動ハンドリングする方法」 を(割とダメ元で)聞いてみました。
するとChatGPT君が見事に要望通りのコードを書いてくれました。しかもちゃんと動きます。
それに色々と手を加えた結果、以下の様なコードが完成しました。(STEP1と若干関数名や変数名が変わっているのでご了承下さい)
import { ipcMain, ipcRenderer } from "electron";
/**
* ハンドリングするAPIを定義するオブジェクト。
*
* このオブジェクトをregisterAPIHandlersやcreateAPIInvokerの引数に指定する事で、
* 自動でipcMain.handle(),ipcRenderer.invoke()の処理を実行させることが出来る。
*
* 「export API = typeof apiHandlers」とすることで、contextBridgeで公開されているAPIとその型を参照できる。
*/
export const apiHandlers = {
getHello: async (name: string) => `Hello ${name}`,
getTime: async () => {
const now = new Date()
return `${now.toLocaleString('ja-JP')}`
},
}
/** mainプロセスにAPIをハンドリングする。mainプロセス上で呼び出す。*/
export const registerAPIHandlers = (apiHandlersObj: Record<string, (...args: any[]) => any>) => {
//invoke-apiというイベントを用意する。APIを使う際はまずこのイベントを通り、各種APIにはapiName引数を指定する事でアクセスする。
ipcMain.handle('invoke-api', async (event, apiName: string, ...args: any[]) => {
// handlers引数にはAPI定義オブジェクト(apiHandlers等)を渡す。「handlers[apiName]」でAPI定義オブジェクトに登録したプロパティ(API)を呼び出す。
if (apiHandlersObj[apiName]) {
try {
return await apiHandlersObj[apiName](...args);
}
catch (error) {
console.error(`Error in '${apiName}':`, error);
throw error;
}
}
else {
console.error(`API '${apiName}' is not defined.`);
throw new Error(`API '${apiName}' is not defined.`);
}
});
}
/** rendererプロセスでAPIを呼び出すためのオブジェクトを生成する。preloadファイルで呼び出し、rendererプロセスに作成したオブジェクトを公開する。*/
export const createAPIInvoker = (apiHandlersObj: Record<string, (...args: any[]) => any>) => {
const apiRenderer: Record<string, (...args: any[]) => Promise<any>> = {};
//API定義オブジェクト(apiHandlerObj)のプロパティを、1つずつipcMainの「invoke-api」イベントと接続する。
for (const apiName in apiHandlersObj) {
apiRenderer[apiName] = async (...args: any[]) => {
return await ipcRenderer.invoke('invoke-api', apiName, ...args);//プロパティ名をapiName引数として渡し、各種APIにアクセスできるようにする。
};
}
return apiRenderer; //for文で生成された、APIアクセス用のオブジェクトを返す
}
/** APIの型定義。renderer.d.tsファイルで参照する。*/
export type API = typeof apiHandlers
ぱっと見だと良く分からないので順に解説します。
1. api定義オブジェクトを作成
まず、公開したいapiを定義した「apiHandlers」というオブジェクトを作成します。
※説明時に分かりやすいように新たにgetTimeというAPIを追加しています。
export const apiHandlers = {
getHello: async (name: string) => `Hello ${name}`,
getTime: async () => {
const now = new Date()
return `${now.toLocaleString('ja-JP')}`
},
}
2. オブジェクトを元に自動でipcMain.handle()
main.ts用にregisterAPIHandlers
という関数を実装します。この関数はapi定義オブジェクトを引数に渡すと、自動でipcMainにAPIを設定してくれます。mainプロセスからは以下の様に呼び出します。
import {apiHandlers, registerAPIHandlers} from './api'
//...mainプロセスの色々な処理
registerAPIHandlers(apiHandlers)// apiをハンドリングする
先程上げたコードでは、APIの受け口として'invoke-api'というイベントをmainプロセスに設定しており、その後各種APIへのルーティングは「apiName」という引数を元に分岐するようになっています。
先程の「apiHandlers」と次に説明するcreateAPIInvokerを使用すると、このように呼び出されます。(※イメージ)
//■ 呼び出しの例
// 【rendererプロセス】
// window.api.getHello('ForestMountain')
// -> 【contextBridge】
// ipcRenderer.invoke('invoke-api', 'getHello', 'ForestMountain')
// -> 【mainプロセス】
// ipcMainの'invoke-api'イベントを
// (apiName:'getHello', args:['ForestMountain'])で呼び出し
// -> 【apiHandlers】
// .getHello('ForestMountain)を実行し、値を返す
// -> 【戻り値の中身】
// 'Hello ForestMountain' ※実際はPromiseが返る
//■ ipcMain側に渡る実際の引数
//asyncで定義した関数の第二引数は「API名(プロパティ名)」が渡される。これで処理を分岐させる
ipcMain.handle('invoke-api', async (event, 'getHello', 'ForestMountain') => {
// apiHandlersオブジェクトのメソッドを名前指定で呼び出す
if (apiHandlers['getHello']) {
try {
// apiHandlers.getHello('ForestMountain')と同義
return await apiHandlers['getHello']('ForestMountain');
}
catch (error) {/*省略...*/}
}
else {/*省略...*/}
}
3. オブジェクトを元に自動でipcRenderer.invoke()
preload.ts用にcreateAPIInvoker
という関数を実装します。この関数はapi定義オブジェクトを引数に渡すと、そのプロパティ1つ1つを.invoke()処理に変換してくれます。
preload.tsからは以下の様に呼び出します。
import { contextBridge } from "electron"
import { createAPIInvoker, apiHandlers } from "./api/apiHandler"
const APIRenderer = createAPIInvoker(apiHandlers);// apiHandlersを.invoke()に変換
contextBridge.exposeInMainWorld("api", APIRenderer)// rendererプロセスに公開
ちなみに、先程も出たRecord<string, (...args: any[]) => any>
は、TypeScriptの型アノテーションの一つです。
この型は、文字列をキーとして持ち、値として関数型((...args: any[]) => any)を持つオブジェクトを表現します。具体的には、オブジェクトの各プロパティのキーは文字列であり、対応する値は任意の引数を受け取り、任意の型を返す関数です。
Recordの使用例(ChatGPT作)
const myObject: Record<string, (...args: any[]) => any> = {
method1: (arg1: string) => {
// 何らかの処理
return 'result';
},
method2: (arg1: number, arg2: boolean) => {
// 何らかの処理
return 123;
},
};
これを用いる事で、プロパティを上手く変換しています。変換後のオブジェクトはこの記述と同義です。
export const APIInvoker = {
getHello: (name: string) => ipcRenderer.invoke('invoke-api', 'getHello', name),
getTime: () => ipcRenderer.invoke('invoke-api', 'getTime')
}
また、呼び出し側(rendererプロセス)で予測変換をしたい場合は、まずapi定義オブジェクト(apiHandlers)の型を使ってrenderer.d.tsで参照すれば問題なく動作&予測変換させることができます。
import { API } from '../main/api'
declare global {
interface Window {
api: API
}
}
APIの追加/変更時はどうするのか?
APIの追加や変更があった場合は、
apiHandlersに以下のように関数プロパティを追加するだけで大丈夫です。
export const apiHandlers = {
getHello: async (name: string) => `Hello ${name}`,
getTime: async () => {
const now = new Date()
return `${now.toLocaleString('ja-JP')}`
},
+ getHoge: async () => 'hogehoge',
}
たったこれだけで、
- ipcMainへの登録処理の実装(main.ts)
- ipcRendererからの呼び出し処理の実装(preload.ts)
- 型定義の修正(renderer.d.ts)
の全てが自動で行われるようになります。
今までの苦労は何だったんだ…と思う程劇的に改善してしまったのでした。
ありがとうChatGPT。
まとめ
今回はElectronのcontextBridgeを使ったAPI実装が一気に楽になる方法を紹介しました。まだ実際の現場で検証した訳ではありませんので、胸を張って推奨できる方法かどうかは今後実際に開発に使ってみて考えてみたいと思います。
尚、私は業務アプリ開発が専門業で、外部にリリースするアプリは作らないので、セキュリティ関連の話はそこまで意識してません。
詳しくないので良く分からないのですが、受け口を「'invoke-api'」1つに絞ることで、contextBridgeのセキュアさが多少失われるかもしれません。(とはいえipcRenderer直接公開よりは遥かに安全、かつ実装速度と保守性も兼ね備えたコスパの良い方法と今のところは思っています)
何かご指摘ございましたら遠慮なくご指摘いただけると幸いです。