Electronアプリの画面側をWebに配置する

こんにちは。弥生の内山です。
この記事はMisoca+弥生 Advent Calendar 2019 22日目の記事です。

はじめに

Electronでアプリを作っています。
弊社のイベントでも、なんどかそのお話をしています。
(本記事は、直近の12/19の回で発表した内容をベースに加筆したものになります)

Electronのアプリは、大きく分けて以下の2つのパートで構成されています。

  • Main側:Node.jsのプロセス。OSの機能を呼び出したり、Renderer側を起動したりできる
  • Renderer側:Chromiumによる画面描画のプロセス。要するにブラウザなので、HTML+JS+CSSを用いて画面を描画することができる

Renderer側は、ブラウザと同様に、HTMLファイル(とJS, CSSファイル)を読み込んで画面描画を開始するのですが、このHTMLは、アプリ内に同梱したものだけでなく、Webに配置したものを読み込むことが可能です。

WebにHTML, JS, CSSを配置すると何がうれしいかと言うと、アプリに何か修正を行いたい場合、Renderer側の修正だけであれば、修正したバージョンをユーザーにインストールしてもらうことなく、修正が全ユーザーに反映されるということです。
要するに、インストール型アプリでありながら、部分的にWebアプリのようなリリースの利点が得られるということです。

我々のアプリは、当初はRenderer側のファイルもローカルに保持する構成で開発していたのですが、途中から上記のようなメリットを得るために、Renderer側をWebに配置する構成への変更を行いました。
この変更が、思ったよりも大変だったため、具体的に何を考え、どのような変更を行ったのかをまとめたのが、本記事の内容となります。

やったこと

Same Origin Policyを回避する

Same Origin Policyは、ブラウザで読み込んだページが属するオリジンのみからしかリソース取得を行えない、という制約です。
詳細は、以下のサイトをご覧ください。

developer.mozilla.org

要するに、ブラウザ上からは、ドメインが違うサイトへのHTTPリクエストは発生させられない、ということです。

Renderer側のファイルをアプリ内に同梱していたときは、ブラウザ的にはファイルリソースへのアクセスなので、Same Origin Policyが発動することはありませんでした。
しかし、Webからファイルを読み込む場合は、この制約が発生してしまいます。

これにより何が困るかと言うと、Renderer側からWebAPI等へのアクセスができなくなるということです。
本アプリでは、認証などのためにWebAPIを叩く必要があったため、これは困ります。

そこで、本アプリでは、HTTPリクエストを発生させる元を全てMain側に移しました。
Main側では、ElectronのnetモジュールNode.jsのhttpモジュール、あるいはaxiosに代表されるようなHTTPクライアントライブラリを使うことができます。

Renderer側からは、ElectronのIPCを通じて、Main側のHTTP呼び出し機能にアクセスします。

例えば、Main側はIPCで"http"というメッセージを受けたら、HTTPリクエストを行うようにしておきます(httpRequestはHTTPリクエストを行う関数です)。

ipcMain.on("http", async (event, message) => {
  event.returnValue = await httpRequest(message);
});

そして、preloadスクリプトに上記のIPC呼び出しを行う関数を定義しておき、Renderer側ではこの関数を呼ぶことで、HTTPリクエストを行うようにします。

global.httpRequest = (message) =>
  ipcRenderer.sendSync("http", message);

この変更により、Same Origin Policyは回避できました。
これによる副次的な効果として、Webに公開しているコード内に、アクセス先のURLや、WebAPIに渡すトークン等のパラメータを埋め込む必要がなくなったので、セキュリティ的にもよかったと思います。

ローカルでの動作確認用にWebサーバーを立てる

開発中のローカルでの動作確認時に、Renderer側はどこにあるファイルを読みに行くべきか、は少し悩みました。
Node.jsでのアプリ開発ではNODE_ENV環境変数で動作モードを切り替える、という手法がよく使われるので、developmentモードを指定した場合はローカルにあるファイルを、productionモードを指定した場合はWebに配置したファイルを読む、というようなことは少し考えました。
しかし、これは以下の理由により避けました。

  • Rendererはローカルファイルを読む場合とWebから読む場合とでは動作が異なる(前述のSame Origin Policyなど)ため、日常的にWebから読む状態で開発しないと、いざリリースするときに想定していなかった問題が発生する恐れがある
  • developmentモードかproductionモードかは、ファイルをどこから読むかとは別問題

というわけで、開発中でもWebサーバーを立ててファイルをホストさせ、Renderer側はそれを読み込みに行くようにしました。

Webサーバーを立てるツールは、以下のようにしました。

  • developmentモードの場合:webpack-dev-server
  • productionモードの場合:http-server

webpack-dev-serverは一般的なフロントエンド開発でもよく使われるツールです。hot-reloadを有効にすれば、Electronであってもコードの変更時に自動的にリロードされるため、便利でした。

http-serverは、Node.js製の、手軽にWebサーバーを起動できるCLIツールです。

www.npmjs.com

productionモードの場合、実際のリリース物と同じビルドとなるため、webpack-dev-serverを使うのは適切ではありませんでした。
代わりに、webpack-dev-serverと同様にコマンドで起動して手軽に使える、http-serverを採用しました。

ローカルでの動作確認用にアプリとWebサーバーを同時に起動する

前述のように、開発中であってもWebサーバーを立てる構成としたので、動作確認の際には、ElectronアプリとWebサーバーの両方を起動させる必要があります。 ElectronもWebサーバーも、プロセスが端末を占有してしまうので、それぞれを起動させておくために端末を2つ立ち上げておく必要があります。
しかし、このような操作が必要な場合、以下のようなミスが発生しやすくなります。

  • なぜか動かないと思ったら、Webサーバーが起動していなかった
  • なぜか動かないと思ったら、Webサーバー側でエラーがでているのに気づかなかった
  • ElectronとWebサーバー、両方の再起動が必要なのに、それを忘れ、うまく動かなかった

いずれも些細な問題ではありますが、発生頻度がそこそこ高く、開発速度の低下につながるため、早めに対処しておきたいところです。

どのように対処するかというと、要するに以下のようなことが実現できていればよいと考えました。

  • コマンド一発でアプリとWebサーバーが両方起動すること
  • アプリを終了したら、Webサーバーも終了すること

上記を実現するため、今回はConcurrentlyというCLIツールを利用しました。

www.npmjs.com

このツールは、複数のコマンドを並行して実行できるため、この問題の解決にピッタリです。
このConcurrentlyを使い、アプリの起動コマンドを以下のように作成しました。

concurrently -k “run_server” “run_app” || exit 0 

上記のコマンドでのポイントは以下です。

  • -kオプションを付与すると、起動したいずれかのプロセスが終了すると、他のプロセスも終了するため、アプリを終了したらWebサーバーも終了させることができる
  • -kオプションによる終了はエラー終了扱いになり、エラーが出力されて気持ち悪いので、exit 0を直後に実行して、終了コードを強制的に正常終了にする

このようにすることで、Webサーバーの存在をほとんど意識せず、アプリの動作確認を行えるようになりました。

アプリからの参照先Web環境を切り替える

一般的なWebアプリを開発する場合、開発/ステージング/本番のように、用途ごとに環境を使い分けることはよく行われます。
Webアプリの場合は、ブラウザに入力するURLをそれぞれの環境のものに切り替えれば済むのですが、Electronアプリはアプリ内に参照先のURLを持たせなければならないため、Webアプリほど簡単に環境を切り替えることができません。

Electronアプリが参照するURLを切り替える方法を考えたとき、真っ先に思いつくストレートな方法としては、設定ファイルにURLを記載し、アプリはそれを参照してURLを取得する、というものがあります。
しかし、これは以下のような理由で避けることにしました。

  • ユーザーによって値を改変されたくない:もしユーザーがファイルの内容を書き換えたりしてしまった場合、アプリが動作しなくなる恐れがあります
  • ユーザーが普通に見える場所に値を置きたくない:URLや各種パラメーターが見えることで、アプリへの攻撃を行うヒントになってしまうかもしれません

ただ、個人的にはそもそも環境の値が可変値なのがしっくりこなかったのが理由としてあります。アプリ本体とWeb側は一体のものであり、Web側はパラメータとして気軽に差し替えするようなものではないと考えました。

結果として、アプリをビルドする際に、Web側の環境を埋め込むことにしました。
以下に、環境を決定する仕組みを示します。

まず、ビルドを行うコマンドを実行する際に、環境変数で参照先の環境を指定します。
ここでは、DEPLOY_ENVという環境変数で、環境指定を行っています。

DEPLOY_ENV=development npm run build

アプリのコード内では、上記の環境変数で指定された値に対応する環境設定ファイルをrequireするようにします。
環境変数は、WebpackのDefinePluginを用いてビルド時に埋め込みを行っています。

require(`./${DEPLOY_ENV || "development"}`)

以上の仕組みにより、ビルド時に例えば"development.js"のようなファイルが結合され、アプリ内に環境の値が埋め込まれるようになります。

参照先Web環境を上書きする

前述の仕組みにより、参照先の環境の問題は解決したと思ったのですが、開発工程の中で「本番環境向けのビルドを開発/ステージング環境へ向けるようにしてテストしたい」という要望があることがわかりました。
要するに、本番環境向けビルドと開発環境向けビルドは、同じソースからビルドしていても別物なので、開発環境向けビルドをテストしても、本番環境向けビルドをテストしたことにはならない、ただし本番環境でテストすることは避けたい、という理屈です…。

これを解決するため、特定の位置に設定ファイルがあれば、そこに記載された値をアプリに埋め込まれた設定値よりも優先する、という機構を追加しました。
先ほど「設定ファイルを避けた」と述べましたが、これは開発中のリリース前テスト等の工程で利用するものであり、ユーザーが使う機構ではないため、設定ファイルを回避した理由は維持しているつもりです(;・∀・)
なお、弥生の他の製品でも、実際にこのような設定の上書きを行うような機構を使ってテストを行っています。

おわりに

ElectronアプリのRenderer側をWebに配置するようにする際、どのような変更を行ったかについてご紹介しました。
同様のことでお困りの方の助けになれば幸いです。

明日は@t_haraさんの「Swift製ファミコンエミュレータ開発の進捗について書く」です。楽しみですね!