Code for final

ふぁいなる向けのコード置き場です。すでにコードじゃないこともいっぱい。

Electron with TypeScriptアプリで開発に耐えうるログの出力をする

Electronのログの出力はlog4jsを使えばできることがわかりましたが、サンプル程度に出力する記事しかなかったので実際にアプリを開発して アプリ開発に耐えうるログ出力についてまとめます。

log4jsをいれる

javaを使ったことある方ならおなじみのlog4jそのNode.jsバージョンが"log4js"です。

log4js-node.github.io

今回はTypeScriptで使うので型定義もインストールします。
vscodeのターミナルで以下のコマンドをたたきます。

npm install log4js
npm install @types/log4js

設定ファイルをおく

log4js用の設定ファイル(log4js.config.json)をルート(.gitignoreと同じ場所)に配置します。
一般的なアプリ開発でよく使うレベル別にインフォログ(app.log)とデバッグログ(debug.log)とエラーログ(error.log)の3つのファイルに分けて出力する設定になっています。
ログはルートの"logs"フォルダに出力されます。

ロガーの宣言をいれる

コード中にログ出力処理をいれます。
クラスごとに以下の宣言をいれればメインプロセス、レンダラープロセス問わずどこでも出力できます。
以下の例ではロガー設定ファイルのパスは内部設定から取得しています。

import { configure, getLogger } from "log4js";
import AppConfig from "../models/AppConfig";

configure(AppConfig.LoggerConfigFile); // configure("./log4js.config.json");でもOK
const logger = getLogger();

ロガーを使ってログを出力する

宣言したロガーを使ってソースコード中でログを出力します。
例えば検索処理で検索パラメタをデバッグログで出力する場合は以下のような感じになります。

async search() {
    try {
      logger.debug("Search Directory Path:[ %s ]", this.mainData.searchDirectory);
// ...

メッセージのフォーマットは以下のutil.format()に準拠しているみたいです。
ちなみにメッセージにフォーマットを指定せずに引数に渡した場合は末尾に追加されます。

nodejs.org

出力されるログは以下です。

[2020-06-21 15:59:07.604] [DEBUG] Search Directory Path:[ E:\test ]

スタックトレースを出力する

log4jsではログ出力設定でenableCallStackをtrueにしたらスタックトレースが出力されます。
上記の設定ではerrorレベルのみログ出力フォーマットにスタックトレースの出力情報を追加しているため、 errorレベルでのみスタックトレースが出力されます。

以下のようにディレクトリ削除処理でエラーとなった場合、エラーログを出力するとします。

ipcMain.handle("deleteDirectory", async (event, dirPath) => {
  const succesDirectories: string[] = [];

  await trash(dirPath)
    .then(() => {
      succesDirectories.push(dirPath);
    })
    .catch(() => {
      logger.error("error trash directory.", dirPath);
  });

  return succesDirectories;
});

出力されるログは以下になります。

[2020-06-21 16:19:32.357] [ERROR] error trash directory. E:\test\aaa
d:\finalstream\newdev\edc\dist_electron\index.js 28738 20
    at d:\finalstream\newdev\edc\dist_electron\index.js:28738:20
    at async d:\finalstream\newdev\edc\dist_electron\index.js:28733:9
    at async electron/js2c/browser_init.js:6152:30

・・・???。 そうです。Electronはjavascriptで動いているため、実際動作しているjsファイルの行数が出力されます。
あまり見ても役に立ちそうはないので、スタックトレースは出力しなくてもいいかもですね。

パッケージにログ出力設定ファイルを含める

このままパッケージングしてできたものを実行すると"log4js.config.json"が見つからないというエラーになってしまいます。
なので、electron-builderの設定で"log4js.config.json"をパッケージに含める設定を追加します。

module.exports = {
  // debug
  configureWebpack: {
    devtool: "source-map",
  },
  pluginOptions: {
    electronBuilder: {
      nodeIntegration: true,
      builderOptions: {
        extraFiles: ["log4js.config.json"],  // この設定を追加
      },
    },
  },
};

これだけ知っていればアプリでのログ出力は十分だと思います。
ただ、Electronアプリを開発始めたばかりなのでほかにも必要なことがあれば追記していきたいと思います。

Electron with Vue.js でのデバッグのやり方をまとめる

Electronでアプリを開発しててデバッグのやり方につまずいたのでまとめておきます。

デバッグはメインプロセスとレンダラープロセスでやり方が違うので注意してください。
メインプロセスとレンダラープロセスがわからない方は以下の記事を確認いただければなんとなく理解できると思います。

final.hateblo.jp

デバッグする前の設定(vue.config.jsをおく)

ルート(.gitignoreと同じ場所)に以下のvue.config.jsを配置します。これはメインプロセス、レンダラープロセスどちらでのデバッグでも必要な設定です。

メインプロセスのデバッグ

メインプロセスのデバッグVisual Studio Codeで行います。
手順は以下に書いてあるとおりにやればできましたが、英語なので簡単に必要なことだけ書きます。

nklayman.github.io

1. デバッグ構成ファイルをおく

ルートにある.vscodeフォルダ(なければ作成)に以下のlaunch.jsonとtasks.jsonを配置する。

2. ブレークポイントをはる

background.tsの止めたいところにブレークポイントをはります。

3. vscodeからデバッグ実行する

構成は"Electron:Main"を選択して実行ボタン(緑の▷)をクリックします。
ちなみに"Electron:All"を選択するとvueファイルでもブレークできますが、切り替える必要があるのでレンダラープロセスは後述する方法がおすすめです。

4. とまる

変数の中身も確認できますし、値も変更できます。
f:id:finalstream:20200613183236p:plain

レンダラープロセスのデバッグ(ロジック)

レンダラープロセスでのロジックのデバッグは通常のchromeと同様でデベロッパーツールで行います。
このデバッグ手順は通常のVue.jsでの開発も同じです。

1. デベロッパーツールのSourcesを開く

アプリを起動するとデベロッパーツールが右側に表示されているのでSourcesタブに変更します。

2. ツリーからvueファイルを選択する

ツリーにある"webpack://"を開いて"."フォルダを開いて"src"フォルダを開くとvueファイルがあるので選択します。
ファイル名の後ろに"?"がついているものもありますが、何もついていないものを選択します。

3. ブレークポイントをはる

vueファイルを選択するとソースが表示されるので止めたいところにブレークポイントをはります。

4. とまる

変数の中身も確認できますし、値も変更できます。

f:id:finalstream:20200613191133p:plain

レンダラープロセスのデバッグ(Vue.js)

通常のVue.jsの開発と同様にChrome拡張を入れることでvueオブジェクトのデータがリアルタイムに確認できます。

1. Chromeのアドオンをインストール

Chromeを開いて"Vue.js devtools"をインストールします。
chrome.google.com

2. background.tsのコメントアウトを解除する

background.tsのapp.on("ready")にinstallVueDevtools()の処理がコメントアウトされているので解除します。

app.on("ready", async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    // Devtools extensions are broken in Electron 6.0.0 and greater
    // See https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/378 for more info
    // Electron will not launch with Devtools extensions installed on Windows 10 with dark mode
    // If you are not using Windows 10 dark mode, you may uncomment these lines
    // In addition, if the linked issue is closed, you can upgrade electron and uncomment these lines
    try {
      await installVueDevtools();
    } catch (e) {
      console.error("Vue Devtools failed to install:", e.toString());
    }
  }
  createWindow();
});

3. デベロッパーツールでVueタブを選択

アプリを起動してVueタブを選択し、Vueオブジェクトを選択するとデータが確認できます。変更もできます。
f:id:finalstream:20200613191300p:plain

※ Vueタブが表示されない場合

Electron 9.0だとVueタブが表示されないという事象が発生します。
その場合、vue uiからアプリを起動するとコンソールに以下の表示がされていると思います。

(electron) 'BrowserWindow.addDevToolsExtension' is deprecated and will be removed. Please use 'session.loadExtension' instead.

(node:5400) ExtensionLoadWarning: Warnings loading extension at C:\Users\final\AppData\Roaming\emptydirectorycleaner\extensions\nhdogjmejiglipccpnnnanhbledajbpd: Unrecognized manifest key 'browser_action'. Unrecognized manifest key 'update_url'. Permission 'contextMenus' is unknown or URL pattern is malformed. Cannot load extension with file or directory name _metadata. Filenames starting with "_" are reserved for use by the system. 
(node:5400) ExtensionLoadWarning: Warnings loading extension at C:\Users\final\AppData\Roaming\emptydirectorycleaner\extensions\nhdogjmejiglipccpnnnanhbledajbpd: Unrecognized manifest key 'browser_action'. Unrecognized manifest key 'update_url'. Permission 'contextMenus' is unknown or URL pattern is malformed. Cannot load extension with file or directory name _metadata. Filenames starting with "_" are reserved for use by the system. 

これについて調べたところ、electron-builder公式のissueにありました。

github.com

background.tsのfunction createWindow()のところを以下のようにasyncとawaitを追加します。

// ①↓asyncを追加
async function createWindow() {
// ...
  if (process.env.WEBPACK_DEV_SERVER_URL) {
    // Load the url of the dev server if in development mode
    // ②↓awaitを追加
    await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string);
    if (!process.env.IS_TEST) win.webContents.openDevTools();
// ...

これでデベロッパーツールにVueタブが表示されるようになりました。
ちなみにこの対処方法は6/13に判明したのですが、それまではCtrl + Shift + iでデベロッパーツールを開き直せばいけてました。

最後に

vscodeでアプリをデバッグ起動してbackgroud.tsにブレークポイントをはって、デベロッパーツールでvueファイルにブレークポイントをはればメインプロセスとレンダラープロセスの連携した動作もデバッグできるようになり、Vue.js devtoolsで変更検知されているか確認できるのでElectronのアプリ開発が捗りますね。

最低限抑えておくレベルでElectronを使ったアプリ開発のプロセス間通信を簡単に理解する

Electronを使ってアプリ開発をしていましたが、Electronの仕組みを理解できていなかったので開発に必要なレベルで簡単にまとめました。
コードの説明はvue.js + typescriptを使った例になりますが、ほかの場合も考え方はほぼ同じだと思います。

メインプロセスとレンダラープロセスはサーバーとクライアントのような関係

Electronはメインプロセス(1つ)とレンダラープロセス(複数)で動作します。
メインから画面を立ち上げるごとにレンダラープロセスが生成されます。
ここでいうプロセスはexeファイルのことだと思ってください。
メインプロセスでOSとのやり取りを行い、レンダラープロセスではユーザーとやり取りを行います。
メインプロセスをサーバーと考えれば、レンダラープロセスはクライアント(ブラウザ)というイメージですかね。

クライアント・サーバー間の通信はHTTP、メイン・レンダラープロセス間の通信はプロセス間通信

クライアントとサーバー間はHTTPを使って通信するようにメインとレンダラー間はプロセス間通信(IPC)で通信します。
プロセス間通信というと難しく聞こえるかもですが、HTTPと同じでリクエストを送って、レスポンスを受けるのと変わりません。

プロセス間通信のイメージ

私が理解したプロセス間通信をイメージを絵にしてみました。個人的なイメージなので間違ってたらすみません。

f:id:finalstream:20200609144100j:plain

OSとやり取り(ファイル操作とか)するのはメインプロセスで、レンダラープロセスからは依頼(リクエスト)して結果(レスポンス)を受け取るイメージです。

レンダラープロセスの実装(リクエスト送信)

Node.jsにプロセス間通信を行うためのapiが用意されておりそれを使うことで簡単に実装できます。
レンダラープロセス内(vueファイル)でリクエストしたいタイミングでipcRenderer.invoke()を実行します。
以下の例はディレクトリパスを渡して配下のディレクトリを返却する検索処理だと思ってください。
"searchDirectory"が呼び出すAPIの名前で、this.directoryPathはAPIに渡すパラメタです。

import electron from "electron";

export default class App extends Vue {

  private ipcRenderer = electron.ipcRenderer;

  async search() {
    const response: IpcResponse<string[]> = await this.ipcRenderer.invoke(
      "searchDirectory",
      this.directoryPath
    );
    if (response.error) {
      // エラーのときの処理
      this.showMessage(MessageLevel.Warning, "ディレクトリーが存在しません");
      return;
    }
    // 正常のときの処理
    this.updateDirectories(response.data);
  }

ipcRendererにはsend()というメソッドがありますが、async/awaitで書けるinvoke()を使ったほうがシンプルに書けるのでおすすめです。
TypeScriptで書いているのでIpcResponseというレスポンスオブジェクトを定義しています。これは自作クラスです。標準で用意されているかと思い、探してなかったので作りました。

メインプロセスの実装(レスポンス送信)

レンダラープロセス同様にこちらもAPIが用意されています。
メインプロセス(background.ts)にipcMain.handle()の処理を実装しておきます。
以下の例は指定パスの配下のディレクトリを取得して返却する処理になります。
"searchDirectory"は待ち受けるAPI名で、directoryPathは受信したパラメタです。

import { ipcMain } from "electron";

ipcMain.handle("searchDirectory", async (event, directoryPath) => {
  try {
    const dirents = await fs.promises.readdir(dirpath, { withFileTypes: true });
    return new IpcResponse(directoryPath);
  } catch (e) {
    return new IpcResponse(e);
  }
});

ipcMainにもon()というメソッドがありますが、async/awaitで書けるhandle()を使ったほうがシンプルに書けるのでおすすめです。

プロセス間通信を行うための設定

デフォルトの設定でプロセス間通信を行おうとすると、アプリを起動しただけで以下のようなエラーに苛まれます。

index.js:4 Uncaught ReferenceError: __dirname is not defined
    at Object../node_modules/electron/index.js (index.js:4)
    at __webpack_require__ (bootstrap:853)
    at fn (bootstrap:150)
    at Module../node_modules/cache-loader/dist/cjs.js?!./node_modules/babel-loader/lib/index.js!./node_modules/ts-loader/index.js?!./node_modules/cache-loader/dist/cjs.js?!./node_modules/vue-loader/lib/index.js?!./src/views/AppMain.vue?vue&type=script&lang=ts& (app.js:1056)
    at __webpack_require__ (bootstrap:853)
    at fn (bootstrap:150)
    at Module../src/views/AppMain.vue?vue&type=script&lang=ts& (AppMain.vue?679e:1)
    at __webpack_require__ (bootstrap:853)
    at fn (bootstrap:150)
    at Module../src/views/AppMain.vue (AppMain.vue?8622:1)

これはだいぶハマったのですが、どうやらデフォルトではセキュリティを高めるため、プロセス間通信できないようになっています。
background.tsの以下の箇所にある"nodeIntegration"をtrueにすればOKです。

// Create the browser window.
  win = new BrowserWindow({
    x: AppStore.instance.get("window.x"),
    y: AppStore.instance.get("window.y"),
    width: 800,
    height: 600,
    webPreferences: {
      // Use pluginOptions.nodeIntegration, leave this alone
      // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info
      nodeIntegration: true,  //  ★ここをtrueにする
    },
    title: "Empty Directory Cleaner",
  });

もしくはvue.config.jsでも指定できます。どちらかお好きな方で指定してください。

module.exports = {
  pluginOptions: {
    electronBuilder: {
      nodeIntegration: true,
    },
  },
};

ただ、このnodeIntegrationをtrueにするのは推奨されない行為らしいです。
これをfalseのままプロセス間通信できるか調べて見ました。
以下でも議論されていますが、やり方はあるっぽいですが、Node.jsの便利なAPIは使えないっぽいです。

github.com

まとめ

Electronのプロセス間通信はwebと同じで、サーバー(メインプロセス)とクライアント(レンダラープロセス)間で行われるHTTP通信のようなもの。