ElectronでMySQLへ接続するアプリを作る

個人利用のアプリであるならば、データの保管場所はsqliteなどで良いかと思います。しかし、複数名共有して使うデータベースの場合には、やはりMySQLなどのDBサーバが必要になります。中央集権なこのDBがあることで、様々な社内ツールをElectronで実現する事ができます。

相手のサーバにWeb Serverがなくとも、Electron側がそれを担うので、非常に単純な構成でちょっとしたウェブアプリケーション的なクラサバ構築できるのは魅力的です。VBA卒業して挑戦するならElectronは良い選択肢だと思います。ウェブアプリと同じUI表現が利用できますし。

今回は、MySQL接続に加えて、DB接続パスワードなどを安全に格納できるように、keytarモジュールを利用してOS標準のキーストアを使ったセキュリティ対策も実施しておこうと思います。

図:今回作るアプリはこんな感じです

目次

今回使用するモジュール他

Node.jsモジュール

  • keytarモジュール - OS標準のパスワード管理システムを利用して、安全にパスワード等のやり取りをする
  • electron-store - 各種設定情報を格納する為のモジュール
  • Promise-MySQLモジュール - Promiseが使えるMySQLへアクセスする為のモジュール
  • jQueryモジュール - ElectronでjQueryを使えるようにする為のモジュール
  • node-gyp - keytarモジュールをリビルドする為に使用する

HTML側で利用するライブラリ

使用するDBファイルとプロジェクトファイル

また、今回のサンプルアプリで用いたファイル類は以下からダウンロードできます。今回はローカル仮想環境内のMySQLを利用しましたが、Google Cloud SQLへ接続して利用も可能です。Cloud SQLの場合一旦、Cloud Storageにバケットを作成して、SQLファイルを配置しなければ、インポートが出来ないので注意(GB/月あたりの価格: $0.023)。なぜか1回目はインポート失敗するけれど、2回目は成功します。

図:Cloud StorageのバケットからSQLファイルインポート中

図:Cloud SQLにインポートしてみた

事前準備

モジュールを追加する

まずは、プロジェクトファイル作成、package.json作成、index.htmlおよびindex.jsを作成して置きます。ターミナルを起動して以下のコマンドで今回利用する予定のモジュールを入れておきましょう。

npm install promise-mysql --save
npm install keytar
npm install electron-store
npm install jquery

promise、promise-mysql、keytarの3つを追加しました。node-gypは最新版からは標準搭載されているので、別途導入不要です。

また、モジュールを追加時に以下のようなエラーに遭遇した場合には、いちどnpm cache clean --forceを実施してキャッシュをお掃除してから再度チャレンジすると良いでしょう。(Unexpected end of JSON input while parsing near)

C:\Users\hiropon>npm i -g electron@3.0.0
npm ERR! Unexpected end of JSON input while parsing near '...Wes1LfeY2u6ougn36sf9V'

npm ERR! A complete log of this run can be found in:
npm ERR!     C:\Users\hiropon\AppData\Roaming\npm-cache\_logs\-debug.log

プロキシーに阻まれて追加できない時

社内でnpmでモジュールを追加する時に、プロキシーに阻まれて(getaddrinfo ENOTFOUNDというエラーがでます)追加できない場合があります。その場合には以下の手順でnpmに対してProxyのセッティングを通しておくと追加が可能です。アドレスはお使いのプロキシーのURLを入力します。

  1. npm config set proxy http://proxy.example.com:8080 を実行する
  2. npm config set https-proxy http://proxy.example.com:8080 を実行する
  3. npm config set registry https://registry.npmjs.org/ を実行する

また、3.にてhttpで追加をしてしまうと、418 I'm a teapot:エラーが出てしまいますので注意が必要です。

keytarについて

最新の環境でのKeytar対応について

2021年3月現在、keytarがネイティブ対応して、以下のようなとっても面倒なkeytarのリビルドが不要になりました。普通にnpm i keytarでインストールすることでリビルドする事なく、そのまま利用可能になっています。

最新のNode.jsとElectron環境でkeytarを動かしてみるテスト

keytarの概要と使い方

現代のOSには、OS標準のパスワード管理の為のセキュリティシステムが用意されています。macOSで言えば「keychain」、Linuxならば「libsecret」や「Gnome Keyring」、Windowsですと「Credential Vault(資格情報マネージャ)」がそれになります、keytarはElectronでセンシティブな情報を扱う場合に、ローカルストレージではなく、こうした管理システムへの情報の登録と呼び出しをサポートする為のモジュールです。

接続パスワードの保管や、OAuth2認証で使うClient_IDとSecretの格納、Access_TokenやRefresh_Tokenの格納などを安全に格納する事が可能で、この手のアプリケーションを作る上では必須とも言える機能ですね。

メソッドもえらくシンプルで

keytar.addPassword('サービス名', 'アカウント名', 'パスワード')
keytar.getPassword('サービス名', 'アカウント名')

が基本。他に削除、検索のコマンドがあります。但しパスワードの取得だけは少しだけコードが異なります。非同期処理なので、注意してください。

const secret = keytar.getPassword(servicename,account);
secret.then((result) => {
    pass = result;
});

もしくはIPC通信を利用して

//メインプロセス側
ipcMain.on('get-password', (event, user) => {
    event.returnValue = keytar.getPassword('ServiceName', user);
});

//レンダラプロセス側
const password = ipcRenderer.sendSync('get-password', user);

といった取り出し方をすると良いでしょう。

Linuxに於けるKeytarの問題点

まだ、Linux上でElectronのビルドを試してはいないのですが、少なくともNode.jsアプリとして、Ubuntu 18.04上でkeytarモジュールを動かす為には、以下のライブラリをインストールしておかなければなりません。

//libsecretと依存ライブラリのインストール
sudo apt install libssl-dev libsodium-dev libsecret-1-dev

macOSに於けるkeytarの問題点

また、環境によってはそのままだと動かないことがあるので、keytarモジュールをリビルドする必要があります。表示されるエラーは以下のような感じ。electron v4.0.3, keytar 4.3.1でテストしています。

dyld: lazy symbol binding failed: Symbol not found: __ZN2v816FunctionTemplate3NewEPNS_7IsolateEPFvRKNS_20FunctionCallbackInfoINS_5ValueEEEENS_5LocalIS4_EENSA_INS_9SignatureEEEiNS_19ConstructorBehaviorENS_14SideEffectTypeE
  Referenced from: /[... route to project folder ...]/node_modules/keytar/build/Release/keytar.node
  Expected in: flat namespace

dyld: Symbol not found: __ZN2v816FunctionTemplate3NewEPNS_7IsolateEPFvRKNS_20FunctionCallbackInfoINS_5ValueEEEENS_5LocalIS4_EENSA_INS_9SignatureEEEiNS_19ConstructorBehaviorENS_14SideEffectTypeE
  Referenced from: /[... route to project folder ...]/node_modules/keytar/build/Release/keytar.node
  Expected in: flat namespace

この問題は、素で入れたkeytarがelectronのバージョンに合っていない為に起こる問題で以下のような形でリビルドをすると、osxであれば無事にキーチェーンに資格情報が格納されます。

  1. electronのバージョンを、electron -vで調べておく
  2. 使用している環境が32bit環境下?64bit環境かしらべておく。通常今どきは64bitだと思うので、x64だと思います。
  3. ターミナルを立ち上げて、プロジェクトフォルダの中のnode-modulesフォルダ、さらに其の中のkeytarフォルダに入ります。
  4. 以下のコマンドを実行する(electronのバージョンは4.0.2で、アーキテクチャはx64で指定しています。)
//keytarをリビルドするコマンド
HOME=~/.electron-gyp node-gyp rebuild --target=4.0.2 --arch=x64 --dist-url=https://atom.io/download/atom-shell

electronで実際にリファレンスに従い、setPasswordをしてみたところキーチェーンに無事に資格情報が格納されました。getPasswordで取り出す事が可能です。

図:keytarをリビルド中

図:zasekiというサービス名にアカウント名とパスワードが格納される

Windowsでkeytarを使う場合

macOSの場合は前項のようにkeytarモジュールをリビルドするだけで利用する事ができました。しかし、問題はWindows。そもそもリビルドするにもビルド環境が必要だったり、リビルドしても動かずに停止してしまったりと、問題解決が非常に大変でした。すごく良いモジュールなのに、この問題が残念ですね。。。ネイティブモジュール特有の問題です。

2020年3月、electron 5.0.0とkeytar4.6.0でも成功したので、Windowsでビルドする場合には、以下の記事を参照してください。

electron@5.0.0でkeytar@4.6.0をWindowsで使う2020年版

以下の記事は古いバージョンでビルドした記録として残しておきます。

結果的には、electronはv3.0.0keytarは4.2.1を利用し、electron-rebuildを使ってのリビルドで使えるようにはなりました。以下に使えるようにする為の手順を残しておきます。参考になったのは、こちらのサイトと、issue議論のサイトissue議論サイト2node-gypインストール方法サイトでした。また、node-gypについてはpython 2.7系に対応している為、3.x系が入っていると具合が悪いです。また、以下の作業は「管理者権限」で行うようにしましょう。

  1. electronは一旦、npm uninstall -g electronでアンインストールする
  2. keytarも一旦、プロジェクトフォルダに入ってから、npm uninstall keytarでアンインストールする(Windowsでは、keytar v4.3.0は問題有り
  3. npm install -g electron@3.0.0でバージョン指定でインストール
  4. keytarをインストールする前に、管理者権限のPowerShellにてnpm install -g windows-build-toolsを実行して、インストールする(結構時間が掛かる)
  5. Visual C++ Buildtoolsをインストールする(Visual Studio 2017相当)。2015だとエラーが出ます。
  6. npm config set msvs_version 2017コマンドを実行
  7. npm config listで現在の設定値を確認できます。
  8. エクスプローラの検索にて、python.exeがある場所を探しだし、npm config set python python.exeのフルパス コマンドを実行する(パスが通って入れば、python2.7でも可)なお、通常はc:¥Users¥ユーザ名¥.windows-build-tools¥python27以下にpython.exeがインストールされています。
  9. npm install -g node-gypで、node-gypをインストール(但し、Node.js最新のNode.jsは初めからバンドルしてるのでこの作業は不要。入れるとオカシナことになります)。
  10. 一旦再起動する
  11. 実はこの時点ではまだnode-gypはエラーになることがあります。正しいnode-gypへのパスが通っていない(間違ったパスが環境変数に登録されている)のです。コントロールパネルのユーザアカウントを開きます
  12. 左サイドの「環境変数の変更」をクリックします。
  13. ユーザ環境変数の中にある「path」をクリックし、編集ボタンをクリック。
  14. 中身をテキストエディタにでもコピーして、npm-lifecycleの文字がある変数の部分だけを削除する
  15. 続けて、同じ所に「;」で繋げて、「C:\Users\ユーザ名\AppData\Roaming\npm」を加える(node-gypを-gでインストールした場合)。いちばん手間のない方法は、electron-rebuildをインストールし、「C:\Users\ユーザ名\AppData\Roaming\npm\node_modules\electron-rebuild\node_modules\.bin」を指定する事。Windows10ではこれで問題なくリビルドできました。他にもしnode-gypらしきものを参照してる環境変数があったら削除しておく。
  16. 13.に15.の内容を上書きして保存する。これでnode-gypコマンドが使えるようになります。
  17. コマンドプロンプトよりnode-gypと打って、Usageなどの使い方に関する項目が表示されればオッケー
  18. npm install -g electron-rebuildで、electron-rebuildをインストール
  19. プロジェクトフォルダに入り、npm install keytar@4.2.1を実行して、ネイティブコンパイルインストールする
  20. この時、node-gypがKeyError '2017'というエラーを吐くことがあります。Windows10 64bitだと問題なかったのですが、Windows8.1で確認。こちらでもissue報告されています。
  21. keytarディレクトリに入り、node-gyp configureを実行する
  22. プロジェクトフォルダ直下に戻る
  23. electron-rebuild -w keytarでリビルドをする。rebuild complateが出れば完了。
  24. これで、Windowsでもkeytarが使えるようになり、アプリが落ちることがなくなりました。
  25. 試しにkeytarを使ってパスワードをセットしてみて、該当の設定がWindows資格情報に入っていれば成功!!

図:Windows-Build-Toolsがやけに時間が掛かる

図:node-gypのパスが間違って登録されているので修正が必要

C:\Users\googl>node-gyp

  Usage: node-gyp <command> [options]

  where <command> is one of:
    - build - Invokes `msbuild` and builds the module
    - clean - Removes any generated build files and the "out" dir
    - configure - Generates MSVC project files for the current module
    - rebuild - Runs "clean", "configure" and "build" all at once
    - install - Install node development files for the specified node version.
    - list - Prints a listing of the currently installed node development files
    - remove - Removes the node development files for the specified version

node-gyp@3.8.0  C:\Users\googl\AppData\Roaming\npm\node_modules\node-gyp
node@10.15.3

コマンド:node-gypを実行した結果

C:\Users\googl\Documents\ootebook\node_modules\keytar>node-gyp configure
gyp info it worked if it ends with ok
gyp info using node-gyp@3.8.0
gyp info using node@10.15.3 | win32 | x64
gyp info spawn C:\Users\googl\.windows-build-tools\python27\python.EXE
gyp info spawn args [ 'C:\\Users\\googl\\AppData\\Roaming\\npm\\node_modules\\node-gyp\\gyp\\gyp_main.py',
gyp info spawn args   'binding.gyp',
gyp info spawn args   '-f',
gyp info spawn args   'msvs',
gyp info spawn args   '-G',
gyp info spawn args   'msvs_version=2015',
gyp info spawn args   '-I',
gyp info spawn args   'C:\\Users\\googl\\Documents\\ootebook\\node_modules\\keytar\\build\\config.gypi',
gyp info spawn args   '-I',
gyp info spawn args   'C:\\Users\\googl\\AppData\\Roaming\\npm\\node_modules\\node-gyp\\addon.gypi',
gyp info spawn args   '-I',
gyp info spawn args   'C:\\Users\\googl\\.node-gyp\\10.15.3\\include\\node\\common.gypi',
gyp info spawn args   '-Dlibrary=shared_library',
gyp info spawn args   '-Dvisibility=default',
gyp info spawn args   '-Dnode_root_dir=C:\\Users\\googl\\.node-gyp\\10.15.3',
gyp info spawn args   '-Dnode_gyp_dir=C:\\Users\\googl\\AppData\\Roaming\\npm\\node_modules\\node-gyp',
gyp info spawn args   '-Dnode_lib_file=C:\\Users\\googl\\.node-gyp\\10.15.3\\<(target_arch)\\node.lib',
gyp info spawn args   '-Dmodule_root_dir=C:\\Users\\googl\\Documents\\ootebook\\node_modules\\keytar',
gyp info spawn args   '-Dnode_engine=v8',
gyp info spawn args   '--depth=.',
gyp info spawn args   '--no-parallel',
gyp info spawn args   '--generator-output',
gyp info spawn args   'C:\\Users\\googl\\Documents\\ootebook\\node_modules\\keytar\\build',
gyp info spawn args   '-Goutput_dir=.' ]
gyp info ok

コマンド:node-gyp configure実行して成功した様子

KeyError: '2017'
gyp ERR! configure error
gyp ERR! stack Error: `gyp` failed with exit code: 1
gyp ERR! stack     at ChildProcess.onCpExit (C:\Program Files\nodejs\node_module
s\npm\node_modules\node-gyp\lib\configure.js:345:16)
gyp ERR! stack     at ChildProcess.emit (events.js:189:13)
gyp ERR! stack     at Process.ChildProcess._handle.onexit (internal/child_proces
s.js:248:12)
gyp ERR! System Windows_NT 6.3.9600
gyp ERR! command "C:\\Program Files\\nodejs\\node.exe" "C:\\Program Files\\nodej
s\\node_modules\\npm\\node_modules\\node-gyp\\bin\\node-gyp.js" "rebuild"
gyp ERR! cwd C:\Users\Hiroyuki\Documents\ootebook\node_modules\keytar
gyp ERR! node -v v10.15.3
gyp ERR! node-gyp -v v3.8.0
gyp ERR! not ok
npm WARN ootebook@1.0.0 No description
npm WARN ootebook@1.0.0 No repository field.

npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! keytar@4.2.1 install: `prebuild-install || node-gyp rebuild`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the keytar@4.2.1 install script.
npm ERR! This is probably not a problem with npm. There is likely additional log
ging output above.

npm ERR! A complete log of this run can be found in:

npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! keytar@4.2.1 install: `prebuild-install || no
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the keytar@4.2.1 install script.
npm ERR! This is probably not a problem with npm. Ther
ging output above.

npm ERR! A complete log of this run can be found in:

コマンド:node-gypでリビルド中の厄介なエラー(Windows8.1で確認)

図:Windowsの資格情報マネージャに登録できた

  • ※keytar4.3.0やelectron4.0.3でもelectron-rebuildをすれば使えるようになるかもしれませんが、試していません。とりあえず、動く環境にたどりつけたので良かったです。
  • ※keytarを使ったアプリをmacOSとWindowsの両方でリリースする場合は、それぞれに環境を作ってビルドするほうが良いと思います。
  • ※64bit Windows上でrebuildした場合、64bit Windows上でしか動作しませんので注意。また、ia32でelectron-packagerでパッケージを作ろうとした場合にも同様のエラーが出ます。

社内で使うと接続が途切れる場合

小さな企業内の場合、せいぜいルーターが1個ある程度の環境なので、普通にサーバを立てれば普通に運用が可能だと思いますが、ある程度の組織の場合、途中に存在するルータ、スイッチその他いろいろなものが入っています。ネットワークの設計上早めにTCPのコネクションを解除するような設定が入っていると、立てたMySQL Serverに他の方が接続できなくなったり、しにくくなったりすることがあります。

そこで、OS側に定期的に生存している旨のパケットを送る設定がありますが、これを少し変更しておくと接続を維持できるようになります。もちろん、OS自体が勝手にスリープしてしまったり、サスペンドしないように設定しておくようにしましょう。今回はUbuntu Linuxをベースに記述します。これらの作業はターミナルとテキストエディタで行います。

logind.confでサスペンドをオフ

//geditでlogind.confを編集
sudo gedit /etc/systemd/logind.conf

geditが起動したら、以下の項目を新規に追記

HandleLidSwitch=ignore

追記したら再起動すればサスペンドしないようになります。

InterfacesにDNS設定

//geditでlogind.confを編集
sudo gedit /etc/network/interfaces

geditが起動したら以下の項目を新規に追記。すでにあるdns-nameserversはコメントアウト。DNSは社内にあるならそれを指定。

dns-nameservers 8.8.8.8

追記したら再起動するだけです。

nsswitch.confでhostsの設定

//geditでnsswitch.confを編集
sudo gedit /etc/nsswitch.conf

geditが起動したら以下の項目に編集しなおす。

hosts: files dns

追記したら再起動するだけです。

resolv.conf関係にDNSを設定

//geditで編集
sudo gedit /etc/resolvconf/resolv.conf.d/base

geditが起動したら、以下の項目を追記する

nameserver 8.8.8.8

さらに以下のコマンドで編集を行う

sudo resolvconf -u
sudo gedit /etc/resolv.conf

起動したら既存のdns-nameserversをコメントアウトして、以下の項目を追記

nameserver 8.8.8.8

追記したら再起動するだけです。

sysctl.confでkeepaliveの設定

最後に一番重要な設定。キープアライブの設定とIPv6の設定を行います。UbuntuはIPv6の設定をオフにしておきます。

//geditでsysctl.confを編集する
sudo gedit /etc/sysctl.conf

geditが起動したら以下の項目を追記する

#IPv6の設定を無効にする
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1

#キープアライブの設定
net.ipv4.tcp_keepalive_time = 10
net.ipv4.tcp_keepalive_probes = 2
net.ipv4.tcp_keepalive_intvl = 3

追記したら以下のコマンドで設定を反映する。sysctl -a | grep keepalive_timeは保存した設定を確認するコマンドです。

sudo sysctl -p
sysctl -a | grep keepalive_time

これで即時にキープアライブとIPv6の設定が反映されます。

仮想環境で構築した場合

仮想環境にMySQLサーバを構築した場合、通常であれば仮想マシン側に固定IPを割り当てて運用するところですが、ネットワークによってはこれが非常に遅く、またブツ切れを招くことがあります。つながったりつながらなかったり・・・

そこで、親機に固定IPを持たせて、仮想マシン側はNAT接続にすると具合がよかったりします。また、その場合親のポートを仮想マシン側へポートフォワーディングさせる設定が必要です。VirtualBoxの場合、NATで接続する場合には簡単に仮想マシン側へポートフォワーディングが可能です。親の3306にアクセスすると、仮想マシン側の3306につながります。

図:ポートフォワーディングで一気にパフォーマンス改善

MySQL側の準備

MySQL Serverのインストール

今回、仮想環境上のLinuxやmacOS上に、MySQL Serverをインストールして開発を行っております。今回はmacOSで以下の手順でインストールしております。以前、XAMPPを使っていた関係で、以前のバージョンのMySQLが残っていたため、トラブルになりつつも、無事にサーバを用意する事ができました。

  1. macOSでのMySQLはHome Brewを使ってのインストールが一般的ですね。まずはターミナルを起動します。
  2. brew updateでアップデートをまずは実行
  3. 続けて、brew install mysqlで本体をインストールします。
  4. 完了したら、mysql_secure_installationコマンドでセットアップを開始するのですが、今回エラーが出ました。「Can't connect to local MySQL server through socket '/tmp/mysql.sock'」
  5. sudo touch /tmp/mysql.sockでソケットファイルをつくってあげる
  6. また、作成したソケットの権限を、sudo chown ユーザ名 /tmp/mysql.sockで変更
  7. mysql_secure_installationコマンドで、セットアップ開始。rootのパスワード設定他、基本的にはYで回答しておけばオッケー。
  8. ERROR! The server quit without updating PID fileというエラーが出る場合には、以下のコマンドを実行して、mysqlを一度再インストールすると良いです。
rm -rf /usr/local/mysql
rm -rf /Library/StartupItems/MYSQL
rm -rf /Library/PreferencePanes/MySQL.prefPane
rm -rf /Library/Receipts/mysql-.pkg

brew uninstall mysqlでアンインストール可能です。macOSでHomeBrewで入るバージョンは、5.7ではなく8.0系なので注意が必要です。

テーブルの定義

今回は以前、Google Apps Scriptにて作成した座席表アプリを、MySQL + Electronで移植をしてみようと思います。MySQL側では、スプレッドシートで構成したシートレイアウトを元に、予めテーブルを作っておきましょう。自分が用意した構成は以下の通りです。

  1. MySQLに新たなschemeとして「zaseki」を作成
  2. zasekiスキーマに対して、「ユーザ表」と「シートデータ」のテーブルを準備
  3. MySQLにログインするユーザアカウントの作成とパスワードの設定を済ませておく
  4. ユーザ表はuseridとして定義、シートデータはseatとして定義しました。
  5. 2つのテーブルには3.で作ったアカウントでのアクセス権限は付与しておきましょう。

図:新しいテーブルスキーマを作成

図:ユーザ表のテーブル定義

図:シート表のテーブル定義

MySQLのTimeoutを設定等

MySQLを動かすUbuntu上のサーバでいろいろとKeepaliveの設定を行っていても、MySQLにはMySQLのコネクションの処理に関する設定があります。こちらも設定しておくべきでしょう。

skip-resolve-name

my.cnfもしくはmysqld.cnfに対して、skip-resolve-nameの設定を追記します。これは固定IPで運用する場合に設定するもので、DNSでの逆引きを利用しない設定です。少しだけ早くなると同時に余計なDNS参照をする事がなくなります。単純にskip-resolve-nameを追記するだけでオッケーです。

ただしこの設定を追加すると、localhostでの接続ができなくなります。127.0.0.1での接続は可能ですが、MySQL側で例えば、root@localhostでアカウントを作っている場合、接続出来ません。root@127.0.0.1で作成している場合には接続が可能です。

wait_timeout

MySQLに接続する時に用いられる、接続してからの維持時間がコレです。デフォルトでは8時間(28800秒)で設定されています。正直これは長すぎるので、短く設定してみたいと思います。

これを300秒とする場合には、my.cnfもしくはmysqld.cnfに対して、wait_timeout=300を追記するだけです。

ソースコード

ログイン画面の作成(setting.html)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/jquery.touch-punch.min.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">
    <link rel="stylesheet" href="css/setting.css">

    <script src="index.js"></script>
    <script>

      $(function() {
          $( "input[type=submit], a, button" )
            .button()
            .click(function() {
            });
      });

      // IPC通信を行う
      var ipcRenderer = require( 'electron' ).ipcRenderer;
      window.onload = function () {
        //受信レンダラーの準備
        testAsync();
      };

      //メインプロセス側からの非同期に通信を受信待機する(1回だけ実行)
      function testAsync() {
        //各種雑多なメッセージを受け取る
        ipcRenderer.on('msg', function(event,arg) {
          alert(arg);
        });

        //保存済みデータを受け取る
        ipcRenderer.on('init',function(event,arg){
          //受け取ったデータをHTMLのボックスに反映する
          var array = arg;

          //配列データを各項目に入れてあげる
          document.getElementById("server").value = array[0];
          document.getElementById("dbname").value = array[1];
          document.getElementById("user").value = array[2];
          document.getElementById("pass").value = array[3];
        })

        //DB設定初期値をもらう
        ipcRenderer.send('async', "dbname");
      }

      //キャンセル時に設定ウィンドウを閉じる
      function notsetting(){
        ipcRenderer.send('closeset', "setting");
      }

      //セッティング項目を保存する
      function savesetting(){
        //入力値のvalidation
        var validata = "";
        var array = [];

        //サーバーアドレス
        validata = document.getElementById("server").value;
        if(validata == ""){
          alert("サーバアドレスが入っていませんよ");
          document.getElementById("server").focus();
          return;
        }else{
          array.push(validata);
        }

        //使用DB名
        validata = document.getElementById("dbname").value;
        if(validata == ""){
          alert("使用するDB名が入っていませんよ");
          document.getElementById("dbname").focus();
          return;
        }else{
          array.push(validata);
        }

        //ユーザID
        validata = document.getElementById("user").value;
        if(validata == ""){
          alert("ユーザIDが入っていませんよ");
          document.getElementById("user").focus();
          return;
        }else{
          array.push(validata);
        }

        //パスワード
        validata = document.getElementById("pass").value;
        if(validata == ""){
          alert("パスワードが入っていませんよ");
          document.getElementById("pass").focus();
          return;
        }else{
          array.push(validata);
        }

        //メインプロセスに処理を送る
        ipcRenderer.send('keytar', array);

      }

    </script>

    <title>座席表 Plusのセッティング</title>
  </head>
  <body>
    <!-- セッティング項目を表示 -->
    <form class="contact_form" action="#" method="post" name="contact_form">
        <ul>
            <li>
                 <h2>DB接続設定</h2>
                 <span class="required_notification">*印は、必須入力項目です</span>
            </li>
            <li>
                <label for="name">サーバーアドレス</label>
                <input type="text"  placeholder="192.168.1.100" style="width:100px" id="server" required />
                <span class="form_hint">ポートは3306決め打ちなので入力不要</span>
            </li>
            <li>
                <label for="name">使用DB名:</label>
                <input type="text" name="dbname" placeholder="database名"  style="width:80px" id="dbname" required />
                <span class="form_hint">接続するデータベース名を入力</span>
            </li>
            <li>
                <label for="uid">ユーザID:</label>
                <input type="uid" name="uid" placeholder=""  style="width:80px" id="user" required />
                <span class="form_hint">DB接続用のユーザIDを入力します。"</span>
            </li>
            <li>
              <label for="name">パスワード:</label>
              <input type="password" name="password" placeholder="********"  style="width:100px" id="pass" required />
              <span class="form_hint">DB接続用のパスワードを入力します。"</span>
            </li>
        </ul>
    </form>

    <p>
      <center>
        <button onClick='savesetting()' id="saveman" class="action" title='設定を保存する'>設定保存</button>
        <button onClick='notsetting()' id="cancelman" class="action" title='キャンセル'>キャンセル</button>
      </center>
    </p>

  </body>
</html>
  • メイン画面の子Windowとして呼び出されます。macOSの場合は上からにゅっと出てくるようになるのですが、Windowsの場合はモーダルダイアログとして表示されます。
  • サーバアドレス、使用DB名、ユーザID、パスワードを入力し、メインプロセスのkeytarにて情報をOS管理の資格情報システムに登録します。また、起動時には資格情報システムから呼び出してロードします。
  • 今回の画面のCSSはこちらのサイトのスタイルシートを利用しています。

メイン画面の作成(index.html)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <script>
      var $ = jQuery = require("jquery")
    </script>

    <script type="text/javascript" src="js/jquery-ui.min.js"></script>
    <script src="js/jquery.touch-punch.min.js"></script>
    <script src="js/jquery.shapeshift.js"></script>
    <link rel="stylesheet" href="css/jquery-ui.css">

    <style>
      .container {
        border: 0px dashed #CCC;
        position: relative;
        line-height: 40px;
      }

      .container > div {
        background: #FFFFFF;
        position: absolute;
        height: 40px;
        width: 60px;
        border-style: solid;
        border-color: #5882FA;
        font-size: 10px;
        font-weight:bold;
        text-align:center;
        display: inline-block;
      }

      .container > div:hover{
        background-color: #f5ffaa;
        -webkit-transition: all 0.2s ease;
        -moz-transition: all 0.2s ease;
        -o-transition: all 0.2s ease;
        transition: all  0.2s ease;
      }

      .container > div:active{
        background-color: #ffc9d7;
        -webkit-transition: all 0.2s ease;
        -moz-transition: all 0.2s ease;
        -o-transition: all 0.2s ease;
        transition: all  0.2s ease;
      }

      .container > div[data-ss-colspan="2"] { width: 130px; background:#d3ffd7;}
      .container > div[nothing="1"] { visibility:hidden; }
      .container > div[cabinet="1"] { background:#A4A4A4; color:#fff;}

      .container > .ss-placeholder-child {
        background: transparent;
        border: 1px dashed blue;
      }

      /* ダイアログの背景画像変更用 */
      .ui-widget-overlay {
        background: #000 url(images/halloween.png) 10% 100% repeat-x;
        opacity: .50;
        background-size: 100% 100%;
        filter: Alpha(Opacity=70);
      }

      .box {
        float: left;
        width: 180px;
        text-align: center;
      }

      .box2 {
        float: left;
        width: 180px;
        text-align: center;
      }

      .boxContainer {
        overflow: hidden;
        width:100%;
      }

      /* clearfix */
      .boxContainer:before,
      .boxContainer:after {
        content: "";
        display: table;
      }

      .boxContainer:after {
        clear: both;
      }

      /* For IE 6/7 (trigger hasLayout) */
        .boxContainer {
        zoom: 1;
      }
    </style>
    <script src="index.js"></script>
    <script>
      //クリック時に確保する一時座席ID
      var tempseat = "";

      //ダイアログ表示用
      $(function() {
        $( "#dialog" ).dialog({
          autoOpen: false,
          width:400,
          height: 270,
          title: "ログイン",
          modal: true,
          show: {
            effect: "explode",
            duration: 500
          },
          hide: {
            effect: "explode",
            duration: 500
          }
        });
      });

      //登録用ダイアログ表示設定
      $(function() {
          $( "#dialog2" ).dialog({
            autoOpen: false,
            closeText: "保存せずに閉じます",
            width: 400,
            height: 300,
               title: "作業内容",
            modal: true,
            show: {
              effect: "clip",
              duration: 500
            },
            hide: {
              effect: "clip",
              duration: 500
            },
            position: {
              of : 'body',
              at: 'center',
              my: 'center'
            }
          });
      });

      $(function() {
          $( "input[type=submit], a, button" )
            .button()
            .click(function() {
            });
      });

      // IPC通信を行う
      var ipcRenderer = require( 'electron' ).ipcRenderer;
      window.onload = function () {
        //受信レンダラーの準備
        testAsync();
      };

      //メインプロセス側からの非同期に通信を受信待機させる(1回だけ)
      function testAsync() {
        //各種雑多なメッセージを受け取る
        ipcRenderer.on('message', function(event,arg) {
          alert(arg);
          return;
        });

        //シート確保時
        ipcRenderer.on('ongetsheet', function(event,arg) {
          //メッセージ表示
          alert(arg);

          //シートデータを更新
          connectSQL();
          return;
        });
        //seatデータを取得してHTMLへ反映する
        ipcRenderer.on('record',function(event,arg){
          //JSONデータを取得する
          var json = arg;

          //データを分解
          var dlength = json.count;
          var record = json.recman;

          //データ生成ルーチンへ渡す
          onSeat(dlength,record);
          return;
        })

        //シートの空き具合データを取得する
        ipcRenderer.on('seatok',function(event,arg){
          //JSONデータを取得する
          var json = arg;

          //データを分解
          var record = json.data;

          //データ生成ルーチンへ渡す
          onSuccess(record);
        })
      }

      //シート状況をチャートに反映するコード
      function onSeat(dlength,record){
        //データをパースする
        var json = record;
        var length = dlength;

        //ループを回してシート状況を反映する
        for(var i = 0;i<length;i++){
          try{
            //固定フラグがONのデータの場合スルーする
            if(json[i].koteiflg == true){
              //ユーザ名がからの場合
              if(json[i].username == null || json[i].username == ""){
                //スルーする
              }else{
                //既定値を反映する
                document.getElementById(json[i].seatname).innerHTML  = json[i].username;

                //既定値のみ背景色を変更
                document.getElementById(json[i].seatname).style.background = "#d3f1ff";

              }

            }else{
              //データが空の場合には、スルーし、ある場合には反映する
              if(json[i].username == ""  || json[i].username == null){
                //データが空なので空として反映する
                document.getElementById(json[i].seatname).innerHTML  = json[i].username;
                document.getElementById(json[i].seatname).style.background = "";
                continue;
              }else{
                //キープ済み
                document.getElementById(json[i].seatname).innerHTML  = json[i].username;

                //キープ済席の背景色を変更
                document.getElementById(json[i].seatname).style.background = "#f6d3ff";
              }
            }
          }catch(e){

          }
        }
      }

      //シートの空き状況を取得して処理をする関数
      function onSuccess(data){
        var flag = false;
        //ユーザ名が空ならばflagはtrueとする
        if(data[0].username == null || data[0].username == ""){
          flag = true;
        }

        //返り値を元に処理を分岐
        if(flag == true){
          //シート確保用のダイアログを表示する
          //ダイアログ表示
          $('#dialog').dialog({
                title: "席の確保",
                close : function(){
                }
            });
          $( "#dialog" ).dialog( "open" );
          $( "#dialog" ).dialog("moveToTop");
          document.getElementById("dialog").focus();

          //IEに保存されてるIDを呼び出せるなら呼び出す
          document.getElementById("wasabi").value = getData("mannum");

        }else{
          alert("シート取られてしまったようです・・・画面をリロードして、やり直してみてください。");
        }
      }

      //LocalStorageへのデータの挿入
      function setData(key, data){
        localStorage.setItem(key, data);
      }

      //LocalStorageからのデータの取得
      function getData(key){
        var ret = localStorage.getItem(key);

        //null値判定
        if(ret == null){
          return "";
        }else{
          return ret;
        }
      }

      //シートをキープする処理
      function seatkeep(){
        //ダイアログ内のIDを取得する
        var manid = document.getElementById("wasabi").value;

        if(manid == ""){
          alert("社員IDが入っていませんよ");
          document.getElementById("wasabi").focus();
          return;
        }else{
          //ダイアログ内のIDをLocalStorageへ記憶する
          setData("mannum",manid);
        }

        //GAS側へシート確保処理をなげる
        ipcRenderer.send('async', "keepsheet",[manid,tempseat]);

        //ダイアログを閉じる
        document.getElementById("wasabi").value = "";
        $( "#dialog" ).dialog( "close" );
      }

      //シート確保をキャンセルする関数
      function seatcancel(){
        //ダイアログを閉じる
        document.getElementById("wasabi").value = "";
        $( "#dialog" ).dialog( "close" );

        //メッセージ表示
        alert("キャンセルされますた。");
      }

      //座席の解放ボタンを押した時のダイアログ表示用処理
      function releaseseat(){
        //シート確保用のダイアログを表示する
        //ダイアログ表示
        $('#dialog2').dialog({
          title: "席の解放",
          close : function(){
          }
        });
        $( "#dialog2" ).dialog( "open" );
        $( "#dialog2" ).dialog("moveToTop");
        document.getElementById("dialog2").focus();

        //IEに保存されてるIDを呼び出せるなら呼び出す
        document.getElementById("wasabi2").value = getData("mannum");
      }

      //席の解放ボタンクリック時の処理
      function release(){
        //ダイアログ内のIDを取得する
        var manid = document.getElementById("wasabi2").value;

        if(manid == ""){
          alert("社員IDが入っていませんよ");
          document.getElementById("wasabi2").focus();
          return;
        }else{
          //ダイアログ内のIDをLocalStorageへ記憶する
          setData("mannum",manid);
        }

        //シートリリース処理をメインプロセスへ
        ipcRenderer.send('async', "releaseseat",[manid,tempseat]);

        //ダイアログを閉じる
        document.getElementById("wasabi2").value = "";
        $( "#dialog2" ).dialog( "close" );
      }

      //シート確保をキャンセルする関数
      function seatcancel2(){
        //ダイアログを閉じる
        document.getElementById("wasabi2").value = "";
        $( "#dialog2" ).dialog( "close" );

        //メッセージ表示
        alert("キャンセルされますた。");
      }

      //現在のシートデータをDBから取得する
      function connectSQL(){
        // メインプロセスに引数を送信
        ipcRenderer.send('async', "connect");
      }

      //接続設定ウィンドウを表示する
      function setman(){
        //メインプロセスに引数を送信
        ipcRenderer.send('closeset', "open");
      }

      //パネルを反映
      function shapeshifter(){
        $(".container").shapeshift({
          enableDrag: false,
          enableCrossDrop: false,
          enableResize: false,
          minColumns: 3,
          minHeight:50,
          gutterX:5,
          gutterY:5,
          paddingX:5,
          paddingY:5
        });

        //パネルのクリックアクションを設定
        $('.ss-active-child').on('click', function(){
          //クリックした要素のIDを取得
          var id =  $(this).attr("id");

          //一時変数に座席IDを確保
          tempseat = id;

          //DIVの値を取得
          var manname = $(this).html();

          //取得したID要素を表示
          if(id == undefined){
            alert("<img draggable="false" data-mce-resize="false" data-mce-placeholder="1" data-wp-emoji="1" class="emoji" alt="💁" src="https://s.w.org/images/core/emoji/11/svg/1f481.svg">この席は座る事が出来ません!!");
          }else{
            //名前が入ってるかどうかをチェック
            if(manname != ""){
              //確保されてるのでユーザ情報ダイアログを表示する
              alert("この席はすでに【" + manname + "】さんが座ってますよ。");
              return;
            }else{
              //スプレッドシートを参照して空いてるかチェック
              //google.script.run.withSuccessHandler(onSuccess).checkseat(id);
              ipcRenderer.send('async', "checkman",id);
            }
          }
        });
      }

      //座席データのHTML挿入
      $(function(){
          $("#zasekin").load("zaseki.html",
            function() {
              shapeshifter();
              connectSQL();
          });
      });

    </script>

    <title>座席表 Plus</title>
  </head>

  <body>
    <div>
      <button onClick='connectSQL()' id="releaseman" class="action" title='席をリリースします。'>再読込</button>
      <button onClick='setman()' id="setwindow" class="action" title='接続設定を表示'>接続設定</button>
      <button onClick='releaseseat()' id="releaseman" class="action" title='席をリリースします。'>座席の解放</button>
      <hr>
    </div>
    <!-- 座席用HTMLを呼び出す -->
    <div style="width:1000px">
      <div id="zasekin"></div>
    </div>

    <!-- 座席位置設定ダイアログ -->
    <div id="dialog" title="Basic dialog">
      <p>
      今日はこの席でお仕事しますか?座る場合は、IDを入れて、キープをクリックしてください。
      </p>
      <div align="center">
        <b>あなたのID:</b>&nbsp;&nbsp;<input type="text" id="wasabi" size="10" maxlength="9" placeholder="例:R91001">
      </div>

      <p>
      <div class='boxContainer'>
        <div class='box2'>
          <span><button onClick='seatkeep()' id="button1" style="font-size: 16px;vertical-align: middle" class="ponyo" title='席を確保します。'><img src='images/icon_check2.png' />&nbsp; キープ</button></span>
        </div>
        <div class='box'>
          <span><button onClick='seatcancel()' id="button2" style="font-size: 16px;vertical-align: middle" class="ponyo" title='キャンセルします。'><img src='images/cross.png' />&nbsp; キャンセル</button></span>
        </div>
      </div>
    </div>

    <!-- 座席位置解除送信ダイアログ -->
    <div id="dialog2" title="Basic dialog">
      <p>
      <img draggable="false" data-mce-resize="false" data-mce-placeholder="1" data-wp-emoji="1" class="emoji" alt="🍲" src="https://s.w.org/images/core/emoji/11/svg/1f372.svg">今日はもうお帰りですか?座席を解放する場合は、IDを入れて、リリースをクリックしてください。
      </p>
      <div align="center">
        <b>あなたのID:</b>&nbsp;&nbsp;<input type="text" id="wasabi2" size="10" maxlength="6" placeholder="例:R91001">
      </div>

      <p></p>
      <div class='boxContainer'>
        <div class='box2'>
          <span><button onClick='release()' id="button3" style="font-size: 16px;vertical-align: middle" class="ponyo" title='席を解放します。'><img src='images/icon_check2.png' />&nbsp; リリース</button></span>
        </div>
        <div class='box'>
          <span><button onClick='seatcancel2()' id="button4" style="font-size: 16px;vertical-align: middle" class="ponyo" title='キャンセルします。'><img src='images/cross.png' />&nbsp; キャンセル</button></span>
        </div>
      </div>
    </div>
  </body>
</html>
  • メインの座席表を表示するレンダラープロセスのコードです。
  • 座席確保やリリース時に入力するダイアログでの値はLocalstorageに保存されます。画面表示する際には呼び出されます
  • 座席表の基本データはzaseki.htmlに記述してあり、起動時にidがzasekinの場所にロードされます。ロードが完了してから、shapeshifterの適用を行い、またMySQLから現在のシート確保状況データをconnectSQLでロードさせています。
  • 座席のレイアウトや確保対象外とするパネルはzaseki.html側を編集します。nothingが1のものは、確保対象外のパネル(非表示になります)を意味します。cabinetはそもそも椅子ではない場所を意味します。data-ss-colspanは横長のパネルで役員席などに適用する為のものです(横に2パネル消費します)
  • testsync関数では、メインプロセスからの通信を受け取るipcrenderer.onを待機させる為の関数です。各関数はメインプロセスからプッシュされると自動的に応答して処理がなされます。
  • メインプロセス側にデータを送る役目をしてるのが、ipcRenderer.sendでメインプロセス側で受け取ると処理が開始されます。
  • 着席中の人をクリックして、例えばSkype for Businessを起動させて直で連絡させる、なんて機能を付けたい場合は、Skype for BusinessのURLスキーマを使って、単純に<a>タグでURLスキーマをリンクとして張ればOK(例:<A href="sip:yamato.takeru@hogehoge.com?call">Skypeで連絡</A>)

図:タグだけで挙動を制御できるのがElectronの利点ですね

Node.js側コード(index.js)

'use strict';

//標準モジュールの宣言
const electron = require('electron');
const { app } = require('electron');
const BrowserWindow = electron.BrowserWindow;
var fs = require('fs');
var readline = require('readline');

//Node.js側とHTML側で通信をするモジュール
const ipcMain = require('electron').ipcMain;

//追加モジュールの宣言
const keytar = require('keytar');
const mysql = require('promise-mysql');
const Store = require('electron-store');
const store = new Store();

// メインウィンドウはグローバル宣言
let mainWindow = null;
let setWindow = null;

app.on('ready', function() {
  //とりあえず、キャッシュをクリアしておく
  electron.session.defaultSession.clearCache(() => {})

  // メイン画面の表示。ウィンドウの幅、高さを指定できる
  mainWindow = new BrowserWindow({
  	'width': 1030,
  	'height': 750,
  	'autoHideMenuBar':true,
  	//nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止
  	webPreferences: {
        nodeIntegration: true
    },
    'resizable':false,
    'fullscreenable':false,
    'fullscreen':false
  });

  //セッティングウィンドウ
  setWindow = new BrowserWindow({
    'width': 520,
  	'height': 400,
  	'autoHideMenuBar':true,
  	//nodeIntegrationを有効にしないとrenderProcessでrequireを使えない。v5.0.0ではデフォルトで廃止
  	webPreferences: {
        nodeIntegration: true
    },
    'resizable':false,
    'fullscreenable':false,
    'fullscreen':false,
    'alwaysOnTop':true,
    'modal':true,
    'parent':mainWindow,
    'show':false
  })

  //初期ページの表示
  mainWindow.loadURL('file://' + __dirname + '/index.html');

  //キー管理情報が存在しているかどうかで判定
  setWindow.loadURL('file://' + __dirname + '/setting.html');
  if(store.get("id") == "undefined"){
    setWindow.show();  //idがないので最初から表示
  }

  //デベロッパーツールを有効化
  //mainWindow.webContents.openDevTools();
  mainWindow.on('closed', function() {
    mainWindow = null;
  });
  setWindow.on('closed', function() {
    //キャッシュを捨てる
    electron.session.defaultSession.clearCache(() => {})
    setWindow = null;
  });
});

// 全てのウィンドウが閉じたときの処理
app.on('window-all-closed', () => {
  // macOSの時以外はアプリケーションを終了させます(osxだとドックに残る)
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

//mysql関係をコントロールする
ipcMain.on('async', function( event, args, args2){

  //コマンド名によって処理を開始
  switch(args){
    case "connect":
      //接続テスト
      conncloudsql(function (ret){
        var json = ret;
        switch(json.status){
          case "NOSET":
            //接続設定がないため繋がなかった場合の処理
            mainWindow.webContents.send('message', "DB接続設定がありませんよ");
            break;
          case "ERR":
            //エラーが発生した場合の処理
            mainWindow.webContents.send('message', json.error);
            break;
          case "OK":
            //カウントデータを取得する
            event.sender.send('record', json);
            break;
        }
      });
      break;
    case "dbname":
      //DB接続設定をもらう
      //レンダラー側に送りつける設定を集める
      var array = [];
      array.push(store.get("server"));
      array.push(store.get("dbname"));
      array.push(store.get("id"));

      //idを元にkeyを探索する
      if(store.get("id") != "undefined"){
        var servicename = "zaseki_" + store.get("id");

        if(keytar.findPassword(servicename)){
          const secret = keytar.getPassword(servicename,store.get("id"));
          secret.then((result) => {
              array.push(result);
              event.sender.send('init', array);
              return;
          });
        }else{
          array.push("");
          event.sender.send('init', array);
        }

      }else{
        array.push("");
        event.sender.send('init', array);
      }
      break;
    case "checkman":
      //シートの空き状況を確認する
      var seatid = args2;

      //SQLで空き状況確認
      checkseatsql(seatid,function (ret){
        var json = ret;
        //ステータスで判定して処理
        switch(json.status){
          case "NOSET":
            //接続設定がないため繋がなかった場合の処理
            mainWindow.webContents.send('message', "DB接続設定がありませんよ");
            break;
          case "ERR":
            //エラーが発生した場合の処理
            mainWindow.webContents.send('message', json.error);
            break;
          case "OK":
            //カウントデータを取得する
            event.sender.send('seatok', json);
            break;
        }
      });
      break;

    case "keepsheet":
      //シートを確保する
      var uid = args2[0];
      var seat = args2[1];

      //ユーザの存在確認
      chkhumansql(uid,function (ret){
        var json = ret;
        //ステータスで判定して処理
        switch(json.status){
          case "NOSET":
            //接続設定がないため繋がなかった場合の処理
            mainWindow.webContents.send('message', "DB接続設定がありませんよ");
            break;
          case "ERR":
            //エラーが発生した場合の処理
            mainWindow.webContents.send('message', json.error);
            console.log("check");
            break;
          case "OK":
            //カウントデータを取得する
            if(json.count == 0){
              //ユーザがいませんでした。
              event.sender.send('message', "ユーザの登録情報がありませんでした。");
              return;
            }

            //ユーザ名を取得して席取得処理
            var human = json.data[0].name;

            //シートの重複確保チェックと実際の確保ルーチン
            keepsheetsql([uid,human,seat],function (ret){
              var json = ret;
              switch(json.status){
                case "NOSET":
                  //接続設定がないため繋がなかった場合の処理
                  mainWindow.webContents.send('message', "DB接続設定がありませんよ");
                  break;
                case "ERR":
                  //エラーが発生した場合の処理
                  mainWindow.webContents.send('message', json.msg);
                  console.log("keep");
                  break;
                case "OK":
                  //完了メッセージを送る
                  event.sender.send('ongetsheet', json.msg);
                  break;
              }
            });
             break;
        }
      });
      break;
    case "releaseseat":
      //確保した席をリリースします
      //シートを確保する
      var uid = args2[0];
      var seat = args2[1];

      //ユーザの存在確認
      chkhumansql(uid,function (ret){
        var json = ret;
        //ステータスで判定して処理
        switch(json.status){
          case "NOSET":
            //接続設定がないため繋がなかった場合の処理
            mainWindow.webContents.send('message', "DB接続設定がありませんよ");
            break;
          case "ERR":
            //エラーが発生した場合の処理
            mainWindow.webContents.send('message', json.error);
            console.log("check");
            break;
          case "OK":
            //カウントデータを取得する
            if(json.count == 0){
              //ユーザがいませんでした。
              event.sender.send('message', "ユーザの登録情報がありませんでした。");
              return;
            }

            //ユーザ名を取得して席取得処理
            var human = json.data[0].name;

            //シートの重複確保チェックと実際のリリースルーチン
            relsheetsql([uid,human,seat],function (ret){
              var json = ret;
              switch(json.status){
                case "NOSET":
                  //接続設定がないため繋がなかった場合の処理
                  mainWindow.webContents.send('message', "DB接続設定がありませんよ");
                  break;
                case "ERR":
                  //エラーが発生した場合の処理
                  mainWindow.webContents.send('message', json.msg);
                  console.log("keep");
                  break;
                case "OK":
                  //完了メッセージを送る
                  event.sender.send('ongetsheet', json.msg);
                  break;
              }
            });
            break;
        }
      });
      break;
    default:
      break;
  }
});

//ウィンドウをコントロールする
ipcMain.on('closeset', function( event, args ){

  //コマンド名によって処理を開始
  switch(args){
    case "setting":
      //セッティングウィンドウを非表示にする
      setWindow.hide();
      break;
    case "open":
      //セッティングウィンドウを表示する
      setWindow.show();
      break;
    default:
      break;
  }
});

//keytar関係をコントロールする
ipcMain.on('keytar', function( event, args ){
  //配列データを受け取る
  var array = args;

  //サービス名を構築する
  var servicename = "zaseki_" + array[2];

  //キーワードを保存する
  keytar.setPassword(servicename,array[2],array[3]);

  //他の情報はelectron-storeで保存する
  store.set("server",array[0]);
  store.set("dbname",array[1]);
  store.set("id",array[2]);

  //セッティングウィンドウを非表示にする
  setWindow.hide();
});

//シートをリリースする処理
function relsheetsql(args,callback){
  var uid = args[0];
  var uname = args[1];
  var seatnum = "";
  var connection;
  var retman = {};
  var pass = [];
  var result = "";
  var pafu = "";

  //サービス名を構築する
  var servicename = "zaseki_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: store.get("dbname")
      }).then(function(conn){
          //seat確保済みかどうかのチェック
          connection = conn;

          //該当のIDでの登録数が何件かしらべる
          var result = connection.query('select *  from seat where emp_id = "' + uid + '";');
          return result;

      }).then(function(rows){
          //データ件数を取得する
          var dlength = rows.length;
          seatnum = rows[0].seatname;
          //返り値を判定する
          if(dlength != 0){
           //0件じゃないのでクエリを実行
           //seatnumをRowsから取得

           console.log(seatnum);
           //シートを確保する
           var result = connection.query('update seat set username = ?, emp_id = ? where seatname = ?;',
             ["","",seatnum],
             (err,result)=> {
               //エラーが発生した場合
               if (err) {
                 console.log("接続エラー");
                 retman.status = "ERR";
                 retman.msg = error;
                 connection.end();
                 callback(retman);
                 return;
               }

               //取得データを返す
               retman.status = "OK";
               retman.msg = "該当のシートをリリースしました。";
               callback(retman);
               connection.end();
               return;
             }
           );
          }else{
           //確保はされていないのでそのまま返す
           //取得データを返す
           retman.status = "OK";
           retman.msg = "該当の席は既にリリースされているみたいですよ。";
           callback(retman);
           connection.end();
           return;
          }
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.msg = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });
}

//シートをキープする処理
function keepsheetsql(args,callback){
  var uid = args[0];
  var uname = args[1];
  var seatnum = args[2];
  var connection;
  var retman = {};
  var pass = [];
  var result = "";
  var pafu = "";

  //サービス名を構築する
  var servicename = "zaseki_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: store.get("dbname")
      }).then(function(conn){
          //seat確保済みかどうかのチェック
          connection = conn;

          //該当のIDでの登録数が何件かしらべる
          var result = connection.query('select *  from seat where emp_id = "' + uid + '";');
          return result;

      }).then(function(rows){
          //データ件数を取得する
          var dlength = rows.length;

          //返り値を判定する
          if(dlength == 0){
           //0件なのでクエリを実行
           //シートを確保する
           var result = connection.query('update seat set username = ?, emp_id = ? where seatname = ?;',
             [uname,uid,seatnum],
             (err,result)=> {
               //エラーが発生した場合
               if (err) {
                 console.log("接続エラー");
                 retman.status = "ERR";
                 retman.msg = error;
                 connection.end();
                 callback(retman);
                 return;
               }

               //取得データを返す
               retman.status = "OK";
               retman.msg = seatnum + "のシートをID:" + uid + "で確保しました。";
               callback(retman);
               connection.end();
               return;
             }
           );
          }else{
           //すでに登録済みなのでメッセージを返す
           //取得データを返す
           retman.status = "OK";
           retman.msg = "すでに別の席が" + uid + "にて確保されているみたいですよ。";
           callback(retman);
           connection.end();
           return;
          }
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.msg = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });
}

//ユーザがいるかどうかチェック
function chkhumansql(args,callback){
  var connection;
  var retman = {};
  var pass = [];
  var result = ""

  //サービス名を構築する
  var servicename = "zaseki_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: store.get("dbname")
      }).then(function(conn){
          //レコード用変数
          var hflg = false; //椅子の空き状況

          //クエリの実行
          connection = conn;
          var result = connection.query('select *  from userid where userid = "' + args + '";', function (err, rows, fields) {
            //エラーが発生した場合
            if (err) {
              console.log("接続エラー");
              retman.status = "ERR";
              retman.error = error;
              callback(retman);
              connection.end();
              return;
            }

            //取得データを返す
            retman.status = "OK";
            retman.data = rows;
            retman.count = rows.length;
            console.log("OK");
            callback(retman);
            connection.end();
            return;
          });
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.error = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });
}

//MySQL Serverに接続してシートの空き状況を確認する
function checkseatsql(seatnum,callback){
  var connection;
  var retman = {};
  var pass = [];
  var result = ""

  //サービス名を構築する
  var servicename = "zaseki_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: store.get("dbname")
      }).then(function(conn){
          //レコード用変数
          var checkseat = ""; //椅子の空き状況

          //クエリの実行
          connection = conn;
          var result = connection.query('select * from seat where seatname = "' + seatnum + '";', function (err, rows, fields) {
            //エラーが発生した場合
            if (err) {
              console.log("接続エラー");
              retman.status = "ERR";
              retman.error = error;
              callback(retman);
              connection.end();
              return;
            }

            //取得データを返す
            retman.status = "OK";
            retman.data = rows
            console.log("OK");
            callback(retman);
            connection.end();
            return;
          });
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.error = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });
}

//MySQL Serverに接続してシートデータを取得する関数
function conncloudsql(callback){
  var connection;
  var retman = {};
  var pass = [];
  var result = ""

  //サービス名を構築する
  var servicename = "zaseki_" + store.get("id");

  //接続設定があるかないか判定
  if(store.get("id") == "undefined" || store.get("id") == null){
    //エラーでコールバックさせる
    retman.status = "NOSET";
    callback(retman);
    return;
  }

  //パスワードを取得する
  var secret = keytar.getPassword(servicename,store.get("id"));
  secret.then((result) => {
      //パスワードを取得する
      pass = result;

      //MySQLに接続してデータを取得する
      //createConnectionでは接続が時々切れる
      mysql.createConnection({
          host: store.get("server"),
          port: 3306,
          user: store.get("id"),
          password: pass,
          database: store.get("dbname")
      }).then(function(conn){
          //レコード用変数
          var rlength = 0;    //レコードの数
          var record = ""     //レコードデータを格納する

          //クエリの実行
          connection = conn;
          var result = connection.query('select * from seat;', function (err, rows, fields) {
            //エラーが発生した場合
            if (err) {
              console.log("接続エラー");
              retman.status = "ERR";
              retman.error = error;
              callback(retman);
              connection.end();
              return;
            }

            //レコードデータを格納する
            rlength = rows.length;

            //取得データを返す
            retman.status = "OK";
            retman.count = rlength;
            retman.recman = rows;
            console.log("OK");
            callback(retman);
            connection.end();
            return;
          });
      }).catch(function(error){
          if (connection && connection.end) connection.end();
          //logs out the error
          retman.status = "ERR";
          retman.error = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?";
          callback(retman);
          return;
      });
  });

}
  • keytar自体はモジュールが動いてくれる状況であれば、OSに合わせてOSに装備されてる資格情報管理システムへパスワードを記録してくれます。
  • 今回、やや冗長なコードの書き方になっています。
  • 接続設定用のsetting.htmlおよびメイン表示用のindex.htmlの2つによるマルチウィンドウ仕様ですが、setting.html側には初期ではhideになるようにし、またindex.htmlの子ウィンドウになるようbrowserwindowにオプション設定を追加してあります。
  • ipcMain.onにてレンダラープロセス側からの通信を待機させてあります。
  • promise-mysqlを利用しているので、同期処理でMySQLへの接続を実現しています。
  • MySQL接続とクエリの処理では、通常のSQL文を使った処理と、where条件に?を使うプレースホルダーを使ったケースの2つで記述をしています。プレースホルダーを使ったほうが引数を渡しやすいですね。SQLインジェクション対策も考えたら、プレースホルダと変数のチェック機能をつけるべきでしょう。
  • keepsheetとrelsheetの2つでは確保状況の確認と実際に値をセットするSQLの2回を連続実行しています。promiseで順番に実行させられるので、コールバック地獄にならず綺麗に書けます。
  • relsheetsqlにてケースによって確保した座席がリリースされない場合があるので修正しました。

機能の追加

2019年8月30日現在、社内向けで作ってる座席表アプリは、更なる機能増強を図り、現在以下のような機能を搭載しています。目的は座ってる場所の把握と、顔と名前が一致しない覚えられないというフリーアドレスならではの問題を解決する為です。

  1. 座席に座ってる人を検索する為の機能(クリックすると座ってる部屋に切り替わり、対象者に★が付く)
  2. Canvas使用時の文字がにじむ現象の修正(高解像度対応
  3. 座席キープ者をクリック時に、詳細な情報をダイアログで表示する機能(写真表示あり)
  4. 人員増加によりメインルームも複雑化した為、全面クリッカブルマップ化を行った。

この増強した中で、1.および3.についての知見をここに残しておこうと思います。

他のウィンドウから他のウィンドウを操作する

Electronで生成したBrowserWindowによるウィンドウはメインプロセス側から操作する事は可能でも、他のBrowserWindowで生成したレンダラプロセス側から操作する事は通常できません。しかし、検索用ウィンドウからメインの座席表の検索と表示の切り替えをしたいと思った場合、「メインプロセスを中継して操作する」ようなコマンドを用意する必要があります。

命令を受けるレンダラプロセス側では、メインプロセスから飛んできた命令について処理をするコマンドをipc通信で待機させておき、飛んできたら処理を実行するよう仕組みを用意しておきます。

//遠隔からマップを切り替え実行するコマンド
ipcRenderer.on('changemap',function(event,arg){
    //argsからmapidを取得
    var mapid;
    
    //canvasをクリアする
    clearcanvas();
    
    //IDによって表示マップを切り替えてデータをリロード
    if(mapid == 0){
        //執務室をオープンする
        //背景画像をチェンジする
        document.getElementById("backimage").src = "images/mainroom.png";
        
        //イメージマップを設定する
        onImagemap();
        
        //フロアフラグを設定する
        floorflg = 0;

    }else{
        //パークエリアをオープンする
        //背景画像をチェンジする
        document.getElementById("backimage").src = "images/floormap.png";
        
        //イメージマップを設定する
        onImagemap2();	
        
        //フロアフラグを設定する
        floorflg = 1;	
    }
})

//遠隔からマップに★をつけるコマンド
ipcRenderer.on('setstar',function(event,arg){
    //argからxとyの座標を取得
    var xcoord = arg.xcoord - 10;
    var ycoord = arg.ycoord;

    // CanvasとContextを取得する。
    var canvas = document.getElementById("canvas4");
    var ctx    = canvas.getContext("2d");

    //テキストのフォントのオプション指定
    ctx.font= 'bold 20px sans-serif';

    //テキストデータを書き込む
    ctx.fillStyle = '#fff';
    ctx.textBaseline = 'middle';
    ctx.fillText("🌟",xcoord,ycoord);
})

//遠隔からマップの★だけクリアするコマンド
ipcRenderer.on('clearcanvas',function(event,arg){
    //canvas4をクリアする
    var ctx = document.getElementById('canvas4').getContext('2d');
    ctx.clearRect(0, 0, 1200, 700);
})

//エリアチェンジコマンド
ipcRenderer.on('areachange',function(event,arg){
    //areaフラグを取得
    var mapid = arg;
    
    //マップチェンジ
    mapChange(mapid);
});

//マップ切り替え
function mapChange(mapid){
    //canvasをクリアする
    clearcanvas();
    clearstar();
    
    //IDによって表示マップを切り替えてデータをリロード
    if(mapid == 0){
        //執務室をオープンする
        //背景画像をチェンジする
        document.getElementById("backimage").src = "images/mainroom.png";
        
        //イメージマップを設定する
        onImagemap();
        
        //フロアフラグを設定する
        floorflg = 0;

    }else{
        //パークエリアをオープンする
        //背景画像をチェンジする
        document.getElementById("backimage").src = "images/floormap.png";
        
        //イメージマップを設定する
        onImagemap2();	
        
        //フロアフラグを設定する
        floorflg = 1;	
    }
}
  • 対象者が今見ているマップではない場合、マップチェンジを行ってから🌟の描写を行ってる
  • 🌟描写後に、検索窓を閉じたりマップを変更した場合には星をクリアするようにしている。
  • 🌟の描写用に4枚目のcanvasを用いています。

図:ユーザ座標検索窓から座席表メインウィンドウを操作する

キープ者の詳細ダイアログ表示

キープされてる座席をクリックすると、座席のタグに書き込まれているフラグを読み取り、対象者の詳細情報をMySQLから取り出し、モーダルダイアログに表示するようにしています。今回利用させて頂いたモーダルのコード自体は、こちらのサイトを参照してみて下さい。非常に優れたモーダルウィンドウです。

このウィンドウの注意点は以下の通り。

  1. 写真の有無はMySQL側のpict列を参照させ、-1ならば指定のURLからロードする
  2. 写真は人によって縦横サイズが異なる。このような写真を400x400の写真枠内に収まるようにアスペクト比を考慮してリサイズする。
  3. CSSな処理として、人名については写真にオーバーレイさせた位置に表示。
  4. CSSな処理として、社員IDについても、左上にIDとして重ねて表示させる(こちらのCSSを利用させて頂きました)
HTML側コード
<!-- ここからモーダルウィンドウ -->
<div id="modal-content" style="z-index=21;width:800px;height:500px">
    <div id="modal-bar" style="width:100%;heifht:100px;background:#b7ffd5;border:0px solid #a7e;padding 2px">詳細情報</div>
    <div id="modal-content-innar">
        <table >
            <tr>
                <th rowspan="5" width="300">
                    <div id="overman">
                        <section>
                            <div class="img-container--table-cell">
                                <img id="usrpict" src=""  border="0" width="400px" height="400px"/>
                            </div>
                        </section>

                        <p id="userfullname">社員氏名</p>
                        <span id="idinfo">社員のIDはここ</span>
                    </div>
                </th>
                <td id="busyoman">所属部署名はここ</td>
            </tr>
            <tr>
                <td>
                    <div class='boxContainer'>
                    <div class='box3'>
                        <img id="telicon" src="images/tel.png" width="32" height="32" border="0"/>
                    </div>
                    <div class='box4'>
                        <div class="telman" id="telnum">ここに電話番号を記載</div>
                    </div>
                    </div>
                </td>
            </tr>
            <tr>
                <td>
                    <div class='boxContainer'>
                    <div class='box3'>
                        <img id="pcicon" src="images/pc.png" width="32" height="32" border="0"/>
                    </div>
                    <div class='box4'>
                        <div class="telman" id="pcnum">ここにパソコン名</div>
                    </div>
                    </div>					
                </td>
            </tr>
            <tr>
                <td>
                    <div class='boxContainer'>
                    <div class='box3'>
                        <img id="mailicon" src="images/letter.png" width="32" height="32" border="0"/>
                    </div>
                    <div class='box4'>
                        <div class="telman2"><a href="mailto:armin.arlert@hotmail.com" id="mailman">armin.arlert@hotmail.com</a></div>
                    </div>
                    </div>	
                </td>
            </tr>
            <tr>
                <td>
                    <div class='boxContainer'>
                    <div class='box3'>
                        <img id="teamsicon" src="images/teams.png" width="32" height="32" border="0"/>
                    </div>
                    <div class='box4'>
                        <div class="telman2" id="teams"><a href="msteams://recipient=armin.arlert@hotmail.com">Teamsで連絡</a></div>
                    </div>
                    </div>	
                </td>
            </tr>
        </table>
        
        <hr>
        <div align="right">
            <p><a id="modal-close" class="button-link">閉じる</a></p>
        </div>
    </div>
</div>
CSSコード
/* 氏名を画像の上にオーバーレイさせる */
#overman{
    position: relative;
}

#overman p {
    position: absolute;
    color: black;
    top: 300px;
    left: 0px;
    font-size: 30px;
    width:400px;
    text-shadow: 
        white 2px 0px,  white -2px 0px,
        white 0px -2px, white 0px 2px,
        white 2px 2px , white -2px 2px,
        white 2px -2px, white -2px -2px,
        white 1px 2px,  white -1px 2px,
        white 1px -2px, white -1px -2px,
        white 2px 1px,  white -2px 1px,
        white 2px -1px, white -2px -1px;
}

/* 社員IDを画像の上左端にオーバーレイさせる */
#overman span {
    position: absolute;
    top: 0;/*画像の左上に配置*/
    left: 0;
    margin: 0; /*余計な隙間を除く*/
    color: black;/*文字を白に*/
    background: skyblue;/*背景色*/
    font-size: 15px;
    line-height: 1;/*行高は1に*/
    padding: 5px 10px;/*文字周りの余白*/
}

/* 画像のアスペクト比を固定したまま表示 */
#usrpict{
    object-fit:contain;
    width:400px;
    height:400px;
    background-clor:#aaa;
}

.img-container--table-cell{
    width:400px;
    height:400px;
    display:table-cell;
    vertical-align:middle;
    text-align:center;
    border:0px solid darkgray;
}

図:写真データがある場合はその画像をロードします

外部アクセスでプロキシーを経由させる

自宅や特にプロキシーサーバを設定していないような会社の場合には、この設定は不要ですが、それ相応の企業になると外部へのアクセスはプロキシーサーバ経由でなければさせないようになっていると思います。この場合、メインプロセス、レンダラープロセス共に外部へアクセス出来ないので、例えばレンダラプロセス側でCDNなどのライブラリを読みに行っている場合、取ってくる事ができません。

そこで以下のようなコードを追加する事で、必ず外部アクセスはプロキシーサーバを経由するようにする事が可能です。

//プロキシーサーバ直の場合
app.commandLine.appendSwitch('proxy-server', 'http://192.168.1.2:8080');

//プロキシーのPACファイルの場合
app.commandLine.appendSwitch('proxy-pac-url', 'http://192.168.1.2:8080/proxy.pac');

図:プロキシー経由させずにDNSエラーが出る

JSONデータをまるっと列に入れ込みたい

MySQL 5.7より、Columnの型に「JSON型」というものが用意されました。予め、テーブルの対象の列の方をJSON型にしておき、入れ込むJSONをJSON.stringifyしておいたものを、普通に入れ込めば入ります。但し、MySQL側で入れることのできるJSONは、いい加減な書式だと弾かれてしまいます。入れることのできるJSONの例は以下の通り

{"col1":"test", "col2","tomato"}

列の名前がMySQLの予約語(tableやらbeforeやら)でなければ、JSON.stringify(jsondata)した値をそのまま、insert intoで流し込むことができるようになります。自分の場合、アプリケーションで編集前のデータを取っておき、編集保存時にログテーブルへとこの編集前データを記録。いざという時には、このログからそのまま復元できるようにアプリに作り込んでいます。

図:beforedata列にJSON値が入った

指定時刻に座席を強制リリース

今回のアプリケーションは、誰かが座席を確保したまま、リリースを忘れると永遠に席を確保された状態になってしまいます。それでは翌日皆が困ることになりますね。そこで、この座席についてkoteiflgが0の値(つまりフリーアドレス席全部)について、すべての座席データを強制リリースさせる為の仕組みが必要になります。

この作業もNode.jsでやらせる事が可能です。MySQL Serverの入ってるマシンにNode.jsをインストールして、座席を強制リリースするSQL文を書いたアプリをサービスとして常駐させて置きます。今回は、Ubuntu Linux 18.04をベースにお話を進めます。

事前準備

electronでもNode.jsを利用しましたが、今回のアプリでもNode.jsを利用します。今回追加で利用するモジュールは以下の通り。クライアントアプリも作れ、サーバサイドも同じ言語で作れるNode.jsの美味しいポイントですね。

これらをnpm installでプロジェクトフォルダ内で実行しインストールしておきましょう。foreverはプログラム中で使うのではなく、外部で利用するものなので、-gオプションでグローバルインストールしましょう。

ソースコード

'use strict';

//標準モジュールの宣言
var fs = require('fs');
var readline = require('readline');
var cronJob = require('cron').CronJob;

// 毎日21:00分に実行
var cronTime = "00 00 21 * * 1-7";

//CronJobを定義する
var job = new cronJob({
  //実行したい日時 or crontab書式
  cronTime: cronTime

  //指定時に実行したい関数
  , onTick: function() {
    cleardatabase();
  }

  //ジョブの完了または停止時に実行する関数
  , onComplete: function() {
    console.log('無事に実行されますた。')
  }

  // コンストラクタを終する前にジョブを開始するかどうか
  , start: false

  //タイムゾーン
  , timeZone: 'Asia/Tokyo'
})

//readlineインターフェース初期化
var rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

var Prompt = require('prompt-password');
var prompt = new Prompt({
  type: 'password',
  message: 'PASSWORD:',
  name: 'password'
});

//追加モジュールの宣言
const keytar = require('keytar');
const mysql = require('promise-mysql');
var Promise = require('promise');

//接続データベース情報
var user = "hiroyuki";
var dbname = "zaseki";
var dbserver = "127.0.0.1"
var port = "3306";
var pass = "";
var servicename = "service_clear";

//資格情報にパスワードがあるかどうか調べる
(function (){
  //userを元にkeychainの情報を探索
  var ret = keytar.findPassword(servicename);
  ret.then((result) => {
    if(result == null){
      //見つからなかった場合
      prompt.run()
        .then(function(answers) {
          //passにも格納する
          pass = answers;

          //パスワードをセットする
          keytar.setPassword(servicename,user,pass);

          //CronJobの実行
          job.start();

        });
    }else{
      //見つかった場合はpassが得られる
      pass = result;

      //CronJobの実行
      job.start();
    }
  }).catch(function (error) {
    // 呼ばれる
    console.log(error);  // => 'Error'
  });

})();

//MySQLで特定のテーブルの特定のデータをUPDATEする
function cleardatabase(){
  var connection;
  var result = ""

  //パスワードを取得する
  mysql.createConnection({
      host: dbserver,
      port: port,
      user: user,
      password: pass,
      database: dbname
  }).then(function(conn){
      //クエリの実行
      connection = conn;
      var result = connection.query("UPDATE zaseki.seat SET username ='', emp_id='' WHERE koteiflg = 0;", function (err) {
        //エラーが発生した場合
        if (err) {
          console.log("接続エラー");
          connection.end();
          return;
        }

        //実行結果を返す
        console.log("OK");
        connection.end();
        return;
      });
  }).catch(function(error){
      if (connection && connection.end) connection.end();
      console.log("接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?");
      return;
  });
}
  • keytarモジュールは今回とくにリビルドせずとも動作しました。electron上で動かす時とは違って素直でした。
  • zaseki_clearというサービス名を自分は使って、資格情報にパスワードを登録しています。初回だけこのパスワード格納の作業が必要です。次回以降はkeytarモジュールが自動で取得してくれます。
  • 座席のクリアは、UPDATE文の発行で処理します。koteiflgが0のレコードを対象にします。
  • node-cronにてCronのように指定時刻に特定のコマンドを実行します。このアプリは一度起動するとシャットダウンされるまで自動で、常駐し続けて動作します。

図:パスワードと鍵にて登録内容を確認できる

デーモン化

今回のこのアプリですが、このままでは、起動する為には手動でコマンド入力が必要です。そこで、このアプリをOS起動時に自動的に起動し常駐させておく仕組みも用意します。foreverモジュールを利用すると、作成したJSファイルをデーモン化して管理することが出来るようになります。複数のNode.jsアプリケーションを起動し常駐させることができ、またスクリプトが落ちた時に自動的に再起動もしてくれます。Node.jsを扱うなら是非入れておきたモジュールの一つです。デーモン化自体は以下のコマンドで可能です

//foreverでデーモン化
forever start index.js

この時、オプションなどを指定することが出来ます。ファイルの指定は拡張子まで含めて行わなければなりません。プロセスは、forever listコマンドで一覧が出てきます。通常はここから、systemdなどに登録してこのスクリプトを自動起動したいところなのですが、今回のスクリプトは、keytarを使ってパスワードを取得する必要があるため、最初の1回はforeverでstartする際にsudoでのログインパスワードを求める画面が出るので、systemdでの自動化が出来ません。

図:デーモン起動時にパス取得の問い合わせが出る

図:forever listで現在のデーモンを確認

そこで利用するのが、gnome-session-properties。これは、Ubuntuが起動後に自動的に起動するアプリケーションを登録しておく為のもので、systemdのような仕組みというより、Windowsのスタートアップフォルダみたいなものです。ここで以下のように登録しておきます。

  1. 右下の●の粒をクリックする
  2. アプリケーション一覧が出てくるので、検索画面でsessionと入れる。「自動起動するアプリケーションの設定」が出てくるので、クリックする
  3. 追加ボタンをクリック
  4. 適当な名前、実行するコマンドの入力(今回はシェルスクリプトを作ってそれを登録してあります)します。
  5. 保存を押して完了

これで、毎回ログイン後に、forever start index.jsの入ったシェルスクリプトが起動し、パスワード入力を求めてきます。パスワードを入れて実行すると、keytarがlibsecretからパスワードを拾ってきてforeverにてindex.jsのアプリが常駐化します。

図:アプリやシェルスクリプトをX起動後に実行します。

きちんとパーミッションやこのシェルスクリプトの内容が正しければ、再起動した直後にforever listコマンドを叩くと、Node.jsアプリのプロセスが出てくるはずです。また、Node.jsのアプリがこけて自動再起動が掛かると、PIDが変わります。

  • どうも、昔はNode.jsは起動しっぱなしだとメモリリークでどんどんメモリを食いつぶすとか、それは直ったといった話があったようです。node –expose_gc setseat.jsといったようなオプションを付けて起動すると良いとかなんとか。
  • 今回のスクリプトは1回手動でパスワード登録が必要になっているので、node index.jsにて起動し、パスワードが無事に登録出来ているかを確認しましょう。次回以降はパスワードを自動でロードされると思います。

指定時刻に自動バックアップ

前項にて指定時刻にて強制リリースをnodeのcronモジュールを利用して実現しました。今回のアプリケーションは基本登録データに対して読み書きはすれど、レコードを追加するアプリケーションではないので、初期バックアップだけできていれば、いざという時は、それを復元することで事足ります。

しかし、社内申請システムのようなデータを蓄積していくタイプのアプリケーションの場合には、これでは困ります。そこで、前回のスクリプトに加え、自動的に外付けHDDにMySQLのDBを自動的にバックアップするシェルスクリプトを実行するコードを追加して利用しています。

自動的にMySQLのデータをDump

このシェルスクリプトは次項のバックアップを実行するnodeのスクリプトから呼び出されるシェルスクリプトです。ファイルを作成したら、適度なアクセス権限を付与しましょう。スクリプト内にパスワードが記載されているので、誰でもアクセスできる状況は好ましくありません。

#!/bin/sh

#backup期間(世代数)
period=10

#バックアップ先(外付けHDD)
dirpath='/media/ユーザ名/volume/mysql'

#ファイル名の設定
filename=`date +%y%m%d`

#指定のデータベースを吐き出すメインコマンド
mysqldump --opt --all-databases --events --default-character-set=binary -u ユーザ名 --password=パスワード | gzip > $dirpath/$filename.sql.gz

#古いバックアップファイルを削除
oldfile=`date --date "$period days ago" +%y%m%d`
rm -f $dirpath/$oldfile.sql.gz
  • 外付けHDDにSQL形式のファイルがgz圧縮されて生成されます。
  • 今回のスクリプトは全データベースを指定しています。
  • ファイル名には日付が付与されるようにし、10世代管理を行っていますので、過去のファイルは削除されてゆきます。
  • 今回このシェルスクリプトにはdumpman.shというファイル名をつけてあります。

自動的に指定時刻にバックアップするスクリプト

前回作成した自動で強制リリースするスクリプトに追記する形で、以下のコードを付け加えています。シェルスクリプトを実行するだけのものなので、特に追加のモジュールは必要ありません。

標準モジュールの宣言に追加

const childProcess = require("child_process");

child_processモジュールは標準装備のモジュールで、Nodeスクリプトからシェルコマンドを実行するために必要なものですので、宣言に追加しておきましょう。

CronJobのOnTickに追加

//指定時に実行したい関数
  , onTick: function() {
    cleardatabase();
    mysqlexport();
  }

前回のコードの中にあるOnTickに次項で作成するmysqlexport()を実行するコードを追記します。

dumpman.shを実行する関数を追加

//mysqldumpするシェルスクリプトを実行する
function mysqlexport(){
    childProcess.exec('/home/ユーザ名/dumpman.sh',(error, stdout, stderr) => {
        if(error){
            console.log(stderr);
            return;
        }else{
            console.log(stdout);
        }
    });
}

前項で作成したdumpman.shを実行するシェルコマンドを実行するコードをchildProces.execで記述することができます。node.jsからシェルコマンドを実行する時のテクニックです。複数行に渡るコードをこれで記述するのは辛いので、シェルスクリプトにしてそれを単純実行する形にすると楽になります。

実行結果を受け取ることも可能です。

実行ファイルと結果

実行ファイルのダウンロード

今回のファイルをそれぞれの環境でelectron-packagerでビルドしてみました。無事にローカルのMySQLおよびGoogle Cloud SQL、Cloud SQL Proxy経由で接続が成功しました。

図:Windowsでも無事に動きました。

こんな感じに動きます

electronで座席表アプリを作る

関連リンク

ElectronでMySQLへ接続するアプリを作る” に対して2件のコメントがあります。

  1. 通りすがり より:

    Electronでkeytar導入を検討していて見に来たのですが、やっぱりネイティブの機能の特にセンシティブな部分なので修羅の道ですね……。
    OSサイドもセキュリティ周り弄ってくるし、Electronもバージョンアップ激しいので、保守コストなりでエグいことになりそう。
    お疲れ様です……。

  2. akanemaru2017 より:

    各OS用のキーチェーンとも言えるものの読み書きは可能ですが、keytarモジュール自身はNode.js用なので、モジュールのリビルドが上手くいくとは限らないのが・・・この部分だけは別のサーバでも立てて、Node.js経由でやり取りするといった方法ならば緩和できるかなぁとは思います。

    生のままAccess Tokenをローカルに置いておくわけいかないので、苦しいですね。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)