[Node.js] Electron、それは果てしなく「今日は世界!」が続く世界


Electron、それは果てしなく「今日は世界!」が続く世界

0. 作ったもの

1.やりたかったのはWebアプリをWindowsのデスクトップアプリへ移植すること

 Node.jsを使い、MyDNSのユーザ情報を管理するWebアプリケーションを作成しました。しかし他人のユーザ情報を管理するのはさすがに怖いので、サービスとしては公開していませんでした。いっそデスクトップアプリにしてしまえば問題ないと思い立ち、Electronを使いパッケージ化することにしました。

2.Electronの利用方法を調べると、みんな挨拶しかしていない

 Node.jsをパッケージ化するための方法はいくつかありますが、一番まともそうな方法はElectronを使うことでした。ということで情報を調べていくと、ことごとくが「今日は世界」を表示するところで終わっています。「おまえら、どれだけ挨拶すれば気が済むんだ?」と心の中で叫ぶしかありませんでした。しかも、挨拶ラッシュが起こってからけっこう時代が進んでいるらしく、パッケージ化の情報が新旧入り交じっていました。

3.とにかく簡単に移植したい

 Electronをがっつり使いたいわけではありません。実質、ローカルWebServerと専用ブラウザとしての機能が使えれば良いのです。Electronでメインととレンダラを繋ぐためのプロセス間通信モジュールとかありますが、それを使った時点でWebアプリの移植に余計な改変を加えなければなりません。一切使わない方針を決めました。普通にAjaxが使えるんだから、移植だったらそのままいけば良いだけです。欠点があるとすればTCPのポートを一個、起動時に用意しなければならないぐらいです。

4.SQLite3の恐怖

 Electronはネイティブのライブラリが含まれているモジュールをそのまま動かしてくれないようです。大半の人が引っかかるであろうモジュールは、SQLite3だと思われます。ということで専用ビルドをかけなければなりません。コンパイルに必要な設定は以下の通りです。

  • Python2.7系統にpathを通す(先頭に記述しないとStoreが起動)
  • VisualStudioのインストール
    • VS2019の場合
    • C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin にパスを通す
    • インストーラを起動してVS2015のビルドツールの追加
  • npm install sqlite3 --build-from-source --save --runtime=electron --target=5.0.1 --dist-url=https://atom.io/download/electron

5. メインプロセスのconsole.logの文字化け

 Electronの動作をPowerShellから実行するとそのままではconsole.logで文字化けします。対処法はElectron実行前に、

chcp 65001

で、文字コードをUTF8にすることです。Electron利用時のconsole.logはデバッグ出力をするためなので、実際のところ日本語を出さなければ話は簡単です。

6. パッケージの作成

 以下の設定をpackage.jsonに追加し、electron-builderを実行します。electron-packagerの方は設定が不便なので使いません。

package.json
{

  "build": {
    "productName": "出力アプリケーション名",
    "appId": "hogehoge-hoge",
    "directories": {
      "output": "build"
    },
    "files": [
      "dist/**/*"
    ],
    "win": {
      "target": "nsis"
    }
  },
  
}

7. Webアプリ移植時に追加するコード

 余計な改変を最小限にするためバックエンド起動処理のindex.tsに、Electronのウインドウ表示処理を入れています。本来のWebアプリはバックエンドの処理のみを記述していました。この部分以外、Electron専用のソースは書いていません。

index.ts
import * as amf from 'active-module-framework'
import * as electron from 'electron'
import * as path from 'path'

const listenPort = 58621

/**
 * Electron用起動設定
 */
const app = electron.app
let window: electron.BrowserWindow|null = null
//Electronで実行されているかとUNIXドメインソケットを使用していないか確認
if (app) {
    //ウインドウが閉じられた場合の処理
    app.on("window-all-closed", async () => {
        if (process.platform != "darwin") {
            await manager.destory() //バックエンド処理を終了させる
            app.quit()              //アプリケーション終了
        }
    })
    //ウインドウ表示処理
    const init = () => {
        window = new electron.BrowserWindow({ width: 1280, height: 720, autoHideMenuBar: true })
        //起動メッセージの表示
        window.loadURL(`file://${path.resolve(__dirname,'../template/electron.html')}`)
    }
    //準備完了時に初期化
    if (app.isReady()) {
        init()
    } else {
        app.once("ready", () => {
            init()
        })
    }
}


/**
 * バックエンドが準備完了になった場合の処理
 */
const listened = (port: number | string|null)=> {
    if (window){
        if (port === null){
            //ポート使用中のエラー表示
            window.loadURL(`file://${path.resolve(__dirname, '../template/electron.html')}?cmd=error&port=${listenPort}`)
        }
        else if(typeof port === 'number'){
            window.loadURL(`http://localhost:${port}`)
        }
    }
}


/**
 * バックエンドの設定
 */
const manager = new amf.Manager({
    remotePath: '/',                                        //一般コンテンツのリモートパス
    execPath: '/',                                          //コマンド実行用リモートパス
    indexPath: path.resolve(__dirname, '../template/index.html'),//index.thmlテンプレート
    rootPath: path.resolve(__dirname, '../public'),         //一般コンテンツのローカルパス
    cssPath: ['css'],                                       //自動ロード用CSSパス
    jsPath: ['js'],                                         //一般コンテンツのローカルパス
    localDBPath: path.resolve(__dirname,'../db/app.db'),    //ローカルDBパス
    //localDBPath: path.resolve('app.db'),  //ローカルDBパス(カレントパスに設定)
    modulePath: path.resolve(__dirname, './modules'),       //モジュール配置パス
    jsPriority: [],                                         //優先JSファイル設定
    debug: false,                                           //デバッグ用メッセージ出力
    listened,                                               //初期化完了後コールバック
    //listen: listenPort                                    //受付ポート/UNIXドメインソケット
    listen: path.resolve(__dirname, '../sock/app.sock')     //UNIXドメインソケットを使用する場合
})

 ウインドウを表示後にバックエンド処理がlisten可能になったらページを切り替えています。Electronから起動していない場合はappにインスタンスが入らないので、Electronがらみの処理を行いません。こうやって書いておけば、通常のNodeプロセスとして起動しても動きます。その場合、通常のWebアプリモードとなります。

8. ビルド手順

 package.jsonのscriptが以下のようになっています。フロントエンドはWebPack、バックエンドはtsc、パッケージ化はelectron-builderを利用します。さらにSQLite3のインストールを忘れないように書いておきました。

package.json
{
・・・
  "scripts": {
    "start": "node dist/app/index.js ",
    "watch": "tsc -b -w",
    "build-app": "tsc -b",
    "build-front": "npx webpack",
    "build-electron": "npx electron-builder --win",
    "install-sqlite": "npm install sqlite3 --build-from-source --save --runtime=electron --target=5.0.1 --dist-url=https://atom.io/download/electron",
    "run": "electron ."
  },
・・・
}

 手数が多いというか、使っているものがどんどん増えていく感じがなんとも言えません。

9. 使っているもの

 Webアプリを素早く作成するため、オレオレフレームワークで固めました。フロントエンドは有名どころのVue.jsやReactが、私としては使いにくかったので全部自作です。

 Webアプリを作るときの理想は、フロントエンド側はJavaScriptを呼び出すための初期ページ以外はHTMLを書かないことです。バックエンド側の理想は、初期ページの吐き出し以外に一切のHTMLを出力せず、データのやりとりのみを記述することです。

10. まとめ

 最近のWebプログラムは、エコシステムが発達したおかげで、コマンド一発で便利なライブラリやフレームワークが手に入ります。入門記事を見ながら進めれば、なんとなく雰囲気をつかむところまでは到達できることでしょう。しかし「今日は世界」を卒業しようとすると、途端に情報量が少なくなり、その先は茨の道が待っています。何をやろうにも必要とする技術が多岐に渡り、初学者が始めようとすると、そこそこの地獄を見ているのではないでしょうか? 

 結局のところ試行錯誤して、自分なりの正解を見つけるしかありません。用意されているものを敢えて使わないという選択肢もあるのです。プログラムはやりたいことを書いていけば、思った通りには動きませんが書いた通りには動きます。「今日は世界」を突破して、新たな世界を開拓しましょう。そこはそもそも挨拶の必要が無い、誰もいない不毛の土地かもしれませんが。