- 投稿日:2021-01-03T23:37:56+09:00
Raspberry Pi用node.jsでゲームパッド入力をキーボード/マウス入力に変換する
概要
Raspberry Piでゲームパッドの入力をキーボードやマウスの入力に変換するソフトは今の所無いようなので、node.jsでさくっと作ってみた。
(Intel/AMD製CPU用の)Linux・Windows・MacにはGUIで設定できる同様のソフトが存在するので素直にそっちを使ったほうがいいです。
インストール
node.js及び、joystick・robotjsモジュールをインストールする必要があります。
また、robotjsをインストールするためにlibx11-devとlibxtst-devもインストールする必要があるようです(参考)sudo apt install npm libx11-dev libxtst-dev npm install joystick robotjs --save-devスクリプト
joystickモジュールでゲームパッドの入力を受け取ってその結果を元にrobotjsモジュールを利用してキー・マウス操作する感じになります。
接続されるまでポーリングで待機したり、キー・マウス入力への変換をオブジェクトで定義したりできるようにしてみました。
joykey.jsconst fs = require('fs'); const joystick = require('joystick'); const robot = require('robotjs'); var joys = new joystick(jid); //コンソールにジョイスティックイベントの内容を表示 //var consolelog = true; var consolelog = false; //ジョイスティックのID(通常は0) var jid = 0; //キーやスティックが押されたときの動作を定義 /* ジョイスティックのボタン押下(button) "0", "1", "2".... ジョイスティックのスティック操作(axis) "a0", "a1", "a2"... type: "enable" キー変換の有効・無効の切り替え(無効でもenableだけは有効) type: "key" キークリック key: クリックするキー文字もしくはキー文字列 type: "text" 文字列送信 str: 送信する文字列 type: "mouse" マウスボタン操作 mtype: マウスボタンの操作タイプ("ckick", "toggle", "double") button: クリックするマウスボタン("left", "middle", "right") type: "move" マウス移動 mx: x軸移動量 my: y軸移動量 type: "scroll" スクロール mx: x軸スクロール量 my: y軸スクロール量 type: "updown" 上下カーソルキー mv: 0以上:そのまま 0未満:反転 type: "leftright" 左右カーソルキー mv: 0以上:そのまま 0未満:反転 */ var joytable = { "0": { type: "mouse", mtype: "click", button: "right" }, "1": { type: "mouse", mtype: "click", button: "left" }, "2": { type: "toggle", key: "left" }, "a0": { type: "move", mx: 10, my: 0 }, "a1": { type: "move", mx: 0, my: 10 }, "a3": { type: "scroll", mx: 0, my: 5 }, "a4": { type: "leftright", mv: 0}, "a5": { type: "updown", mv: 0}, }; //toggle用にマウスボタンの状態を記憶(trueが押されている状態) var mousestate = { "left": false, "right": false, "middle": false }; //キーマウス動作が有効かどうか var enable = true; //定義を元にキーやマウスの動作を実行 var sendkey = function (dat, mul) { if (dat == null || dat.type == null) return; mul = mul < 0 ? -1 : 1; if (dat.type === "enable") { //キー変換有効無効の切り替え enable = enable ? false : true; } else if (enable && dat.type === "key") { //キー送信 if (dat.key == null) return; robot.keyTap(dat.key); } else if (enable && dat.type === "text") { //文字列送信 if (dat.str == null) return; robot.typeString(dat.str); } else if (enable && dat.type === "mouse") { if (dat.mtype === "click") { //マウスボタンクリック if (dat.button == null) return; robot.mouseClick(dat.button); } else if (dat.mtype === "double") { //マウスボタンダブルクリック if (dat.button == null) return; robot.mouseClick(dat.button, true); } else if (dat.mtype === "toggle") { //マウスボタン押下状態切り替え if (dat.button == null || mousestate[dat.button] == null) return; robot.mouseToggle(mousestate[dat.button] ? "up" : "down", dat.button); mousestate[dat.button] = mousestate[dat.button] ? false : true; } } else if (enable && dat.type === "move") { //マウスポインタ移動 if (dat.mx == null || dat.my == null) return; var pos = robot.getMousePos(); robot.moveMouseSmooth(pos.x + (dat.mx * mul), pos.y + (dat.my * mul)); } else if (enable && dat.type === "scroll") { //スクロール if (dat.mx == null || dat.my == null) return; robot.scrollMouse(dat.mx * mul, dat.my * mul); } else if (enable && dat.type === "updown") { //上下カーソルキー送信 if (dat.mv == null) return; dat.mv = dat.mv < 0 ? -1 : 1; robot.keyTap(dat.mv * mul < 0 ? "up" : "down"); } else if (enable && dat.type === "leftright") { //左右カーソルキー送信 if (dat.mv == null) return; dat.mv = dat.mv < 0 ? -1 : 1; robot.keyTap(dat.mv * mul < 0 ? "left" : "right"); } }; //ジョイスティック接続待ち用のsleep var sleep = function (waitSec) { return new Promise(function (resolve) { setTimeout(function () { resolve() }, waitSec); }); }; //joystickイベントの定義 var init = function (joy) { //ボタンクリックイベント joy.on('button', button => { if (consolelog) console.log({ button }); if (button.value != 0) { sendkey(joytable[button.number], 0); } }); //axis移動イベント joy.on('axis', axis => { if (consolelog) console.log({ axis }); if (axis.value != 0) { sendkey(joytable["a" + axis.number], axis.value); } }); //エラーイベント joy.on('error', async (err) => { if (consolelog) console.log({ err }); //エラーが発生したら/dev/input/js?の存在を1秒ごとに確認して存在すれば再度joystickオブジェクトを作成 while (1) { if (fs.existsSync('/dev/input/js' + jid)) break; await sleep(1000); } joys = new joystick(jid); init(joys) }); }; //開始 init(joys);キーリピート対応版
このまんまだとキーリピートができないのでちょっと使いにくいが、
対応しようと思うと根本的に作り直す感じになるっぽいので保留・・・
こっちの方もさくっと作ってみた。joykeyrepeat.jsconst fs = require('fs'); const joystick = require('joystick'); const robot = require('robotjs'); var joys = new joystick(jid); //コンソールにジョイスティックイベントの内容を表示 //var consolelog = true; var consolelog = false; //ジョイスティックのID(通常は0) var jid = 0; //キーやスティックが押されたときの動作を定義 /* ジョイスティックのボタン押下(button) "0", "1", "2".... ジョイスティックのスティック操作(axis) "a0", "a1", "a2"... type: "enable" キー変換の有効・無効の切り替え(無効でもenableだけは有効) repeat: キーリピート(true:有効, false:無効) type: "key" キークリック key: クリックするキー文字もしくはキー文字列 repeat: キーリピート(true:有効, false:無効) type: "text" 文字列送信 str: 送信する文字列 repeat: キーリピート(true:有効, false:無効) type: "mouse" マウスボタン操作 mtype: マウスボタンの操作タイプ("ckick", "toggle", "double") button: クリックするマウスボタン("left", "middle", "right") repeat: キーリピート(true:有効, false:無効) type: "move" マウス移動 mx: x軸移動量 my: y軸移動量 repeat: キーリピート(true:有効, false:無効) type: "scroll" スクロール mx: x軸スクロール量 my: y軸スクロール量 repeat: キーリピート(true:有効, false:無効) type: "updown" 上下カーソルキー mv: 0以上:そのまま 0未満:反転 repeat: キーリピート(true:有効, false:無効) type: "leftright" 左右カーソルキー mv: 0以上:そのまま 0未満:反転 repeat: キーリピート(true:有効, false:無効) */ var joytable = { "0": { type: "mouse", mtype: "click", button: "right", repeat: false}, "1": { type: "mouse", mtype: "click", button: "left", repeat: false}, "2": { type: "toggle", key: "left", repeat: false}, "a0": { type: "move", mx: 10, my: 0, repeat: true}, "a1": { type: "move", mx: 0, my: 10, repeat: true}, "a3": { type: "scroll", mx: 0, my: 5, repeat: true}, "a4": { type: "leftright", mv: 0, repeat: true}, "a5": { type: "updown", mv: 0, repeat: true}, }; //ジョイスティックの状態を記憶 var joystate = { "0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "7": 0, "8": 0, "9": 0, "10": 0, "11": 0, "12": 0, "13": 0, "14": 0, "15": 0, "16": 0, "17": 0, "18": 0, "19": 0, "a0": 0, "a1": 0, "a2": 0, "a3": 0, "a4": 0, "a5": 0, "a6": 0, "a7": 0, "a8": 0, "a9": 0, }; var joyenable = false; //toggle用にマウスボタンの状態を記憶(trueが押されている状態) var mousestate = { "left": false, "right": false, "middle": false }; //キーマウス動作が有効かどうか var enable = true; //定義を元にキーやマウスの動作を実行 var sendkey = function (dat, mul) { if (dat == null || dat.type == null) return; mul = mul < 0 ? -1 : 1; if (dat.type === "enable") { //キー変換有効無効の切り替え enable = enable ? false : true; } else if (enable && dat.type === "key") { //キー送信 if (dat.key == null) return; robot.keyTap(dat.key); } else if (enable && dat.type === "text") { //文字列送信 if (dat.str == null) return; robot.typeString(dat.str); } else if (enable && dat.type === "mouse") { if (dat.mtype === "click") { //マウスボタンクリック if (dat.button == null) return; robot.mouseClick(dat.button); } else if (dat.mtype === "double") { //マウスボタンダブルクリック if (dat.button == null) return; robot.mouseClick(dat.button, true); } else if (dat.mtype === "toggle") { //マウスボタン押下状態切り替え if (dat.button == null || mousestate[dat.button] == null) return; robot.mouseToggle(mousestate[dat.button] ? "up" : "down", dat.button); mousestate[dat.button] = mousestate[dat.button] ? false : true; } } else if (enable && dat.type === "move") { //マウスポインタ移動 if (dat.mx == null || dat.my == null) return; var pos = robot.getMousePos(); robot.moveMouseSmooth(pos.x + (dat.mx * mul), pos.y + (dat.my * mul)); } else if (enable && dat.type === "scroll") { //スクロール if (dat.mx == null || dat.my == null) return; robot.scrollMouse(dat.mx * mul, dat.my * mul); } else if (enable && dat.type === "updown") { //上下カーソルキー送信 if (dat.mv == null) return; dat.mv = dat.mv < 0 ? -1 : 1; robot.keyTap(dat.mv * mul < 0 ? "up" : "down"); } else if (enable && dat.type === "leftright") { //左右カーソルキー送信 if (dat.mv == null) return; dat.mv = dat.mv < 0 ? -1 : 1; robot.keyTap(dat.mv * mul < 0 ? "left" : "right"); } }; //ジョイスティック接続待ち、ループ用のsleep var sleep = function (waitSec) { return new Promise(function (resolve) { setTimeout(function () { resolve() }, waitSec); }); }; //joystickイベントの定義 var init = function (joy) { //ボタンクリックイベント joy.on('button', button => { if (consolelog) console.log({ button }); joystate[button.number] = button.value; }); //axis移動イベント joy.on('axis', axis => { if (consolelog) console.log({ axis }); joystate['a' + axis.number] = axis.value; }); //エラーイベント joy.on('error', async (err) => { if (consolelog) console.log({ err }); //エラーが発生したら/dev/input/js?の存在を1秒ごとに確認して存在すれば再度joystickオブジェクトを作成 joyenable = false; while (1) { if (fs.existsSync('/dev/input/js' + jid)) break; await sleep(1000); } joys = new joystick(jid); init(joys) }); joyenable = true; }; //ループ var start = async function () { while(1) { if(joyenable) { for(var key in joystate) { if(joystate[key] != 0){ sendkey(joytable[key], joystate[key]); if(!joytable[key].repeat) joystate[key] = 0; } } } await sleep(16); } } //開始 init(joys); start();
- 投稿日:2021-01-03T23:03:39+09:00
[Node.js] 新規アプリケーションの製作方法
環境構築をする
- nodebrewをインストールする
- Node.jsをインストールする
詳しくは以下でまとめています。
https://qiita.com/momo1010/items/dab9c70bfe84a78f23e2好きな場所にアプリケーション用のディレクトリを作成する
アプリケーション用のディレクトリの作成と移動のコマンドを実行します。
$ mkdir sample $ cd sampleエディタを起動して先程作成したフォルダを開く
今回エディタは
Visual Studio Code
を使用します。
Open Folder...
から先程作成したフォルダを選択します。パッケージをインストールする
以下のコマンドを実行すると、
npm
の設定ファイルであるpackage.json
が生成されます。package.json
には、 npmパッケージの設定情報などが書き込まれています。$ npm init --yes次に、npmパッケージのインストールをします。今回は、
express
とejs
をインストールします。$ npm install express ejsnodemonをインストールする
jsファイルの変更を反映するには、毎回サーバーを再起動する必要があります。その作業を省くために
nodemon
という、ファイル更新時に自動でサーバーが再起動するようになるnpmパッケージをインストールします。$ npm install -g nodemonサーバーを起動してページを表示する
Visual Studio Code
で必要なファイルを作成します。
node_modules
フォルダとpackage-lock.json
package.json
ファイルがnpmパッケージのインストールをしたときに既に生成されています。
app.js
ファイルを作成する追加で
app.js
ファイルを作成します。app.js
ファイルに以下のコードをコピーして、貼り付けてください。const express = require('express'); const app = express(); //CSSや画像ファイルを置くフォルダ(public)を指定するコード app.use(express.static('public')); app.get('/', (req, res) => { res.render('hello.ejs'); }); app.listen(3000);
views
フォルダを作成する
views
フォルダを作成し、hello.ejs
ファイルに以下のコードをコピーして、貼り付けてください。<h1>Hello World</h1>
public
フォルダを作成する
public
フォルダの中にcss
フォルダやimages
フォルダを作成します。サーバーを起動する
nodemonを使ってサーバーを起動していきます。
$ nodemon app.jsブラウザを開いて
localhost:3000
というURLにアクセスしてHello World
と表示されれば成功です。
- 投稿日:2021-01-03T19:25:14+09:00
【Robocode】JavaScriptを勉強しながら戦車ゲームを攻略していく その1
はじめに
前回記事で構築したRobocodeを遊びながら攻略していきます。
https://qiita.com/abemaki/items/54712e50e4a4a25c229bゲームを起動すると以下のような画面が出てきます。
初期段階でもステージ0であれば、五分五分の戦いです。
今回は軽く触りながら、ステージ0での勝率を高めていきたいと思います。反響があればGithubに改良版をアップしたり続編を書いてみようかな。
ソースコードを理解する
公式の説明を読む前にちょっとだけソースコードを見ていきます
ソースコードを見ていくと
boss-.jsといたファイルとmyrobot.jsといったファイルがあるのがわかります。
myrobot.jsは自機で、boss-.jsは敵機を表しているものであることがファイル名からわかると思います。
ファイルを開いてみると、何やらファンクションがずらりと並んでいるのがわかりますがこのままだと詳細がよくわかりません。
この理解のまま先には進めません。
公式の説明を読んでみる
https://github.com/youchenlee/robocode-js
公式のロボットHOWTOを見てみると以下のことがわかります
入手可能な情報:
自己情報
・ me.id
・ me.x
・ me.y
・ me.hp
・ me.angle-現在の角度(タンク角度+タレット角度)
・ me.tank_angle
・ me.turret_angle敵情報
・ enemy-spot [N] .id
・ enemy-spot [N] .hp
・ enemy-spot [N] .angle-敵に対する角度(方向)シーケンシャルアクション:
・turn_left(角度)
・turn_right(角度)
・move_forwards(距離)
・move_backwards(距離)
・move_opposide(distance)-このアクションはOnWallCollide()でのみ使用できます並列アクション:
・turn_turret_left(角度)
・turn_turret_right(角度)
・シュート()
・yell(メッセージ)
イベント:
OnIdle()-アイドル時にトリガーされます(実装する必要があります)
OnWallCollide()-タンクが壁に衝突したとき
OnHit()-弾丸が当たったとき
OnEnemySpot()-砲塔が敵に直接面している場合(発砲しない理由はないようです!)
試しに自機と敵機のセリフを変えてみます。
セリフはyell(メッセージ)で定義します。
ファイルを変更したら保存して、ブラウザを更新しましょう
ファイルに変更を加えるとしっかりと結果に反映されるのがわかります。
ソースコードを改良して、勝率100%を目指してみる。
現状の勝率が何故5割程度なのか整理してみる
両者同じような動きをしていて
クルクル回って、相手を見つけたら弾を発射するだけなので、そのあと発見場所から近い場所を探す等の動作をせずに
またひたすらクルクル相手を探しはじめます。 その為、五分五分の戦いをしていることがわかります。・自分の動き
右に動き回りながら相手を探して見つけたら弾を発射するだけ
上記を繰り返す・相手の動き
クルクル砲台を回転させながら相手を探して見つけたら弾を発射するだけ
上記を繰り返す
敵に対面した後の動作を変更してみる
そこで小刻みに右に動き回って
相手を見つけたら、できるだけ近い場所で相手を左利回りに探して
攻撃をしかけるようにソースを修正してみようかと思います。
小刻みに敵を探して、敵を発見したらできるだけ近い場所を探して敵を攻撃するように修正してみます。
対面フラグを追加してアイドル時の処理を以下のように分岐させます。■アイドル時
対面フラグ=敵を未発見(false)の際の処理:
小刻みに右回りで敵を探す対面フラグ=敵を発見(true)の際の処理:
小刻みに左回りで敵を探す
この際に敵を発見できなかった場合に対面フラグを対面フラグを敵を未発見(false)に変更します■敵発見時
攻撃をしかけて
対面フラグを敵を発見(true)に変更します修正前後の自機のソースコード(myrobot.js)
修正前のmyrobot.js(クリックして展開)
// Generated by LiveScript 1.2.0 (function(){ var MyRobot, tr; importScripts('../base-robot.js'); MyRobot = (function(superclass){ var prototype = extend$((import$(MyRobot, superclass).displayName = 'MyRobot', MyRobot), superclass).prototype, constructor = MyRobot; prototype.onIdle = function(){ this.move_forwards(50); this.turn_turret_left(10); this.turn_right(90); }; prototype.onWallCollide = function(){ this.move_opposide(10); this.turn_left(90); }; prototype.onHit = function(){ this.yell("Oops!"); }; prototype.onEnemySpot = function(){ this.yell("Fire!"); this.shoot(); }; function MyRobot(){ MyRobot.superclass.apply(this, arguments); } return MyRobot; }(BaseRobot)); tr = new MyRobot("MyRobot"); function extend$(sub, sup){ function fun(){} fun.prototype = (sub.superclass = sup).prototype; (sub.prototype = new fun).constructor = sub; if (typeof sup.extended == 'function') sup.extended(sub); return sub; } function import$(obj, src){ var own = {}.hasOwnProperty; for (var key in src) if (own.call(src, key)) obj[key] = src[key]; return obj; } }).call(this);
修正後のmyrobot.js(クリックして展開)
// Generated by LiveScript 1.2.0 (function(){ var MyRobot, tr; importScripts('../base-robot.js'); MyRobot = (function(superclass){ var discoveryFired,prototype = extend$((import$(MyRobot, superclass).displayName = 'MyRobot', MyRobot), superclass).prototype, constructor = MyRobot; // 対面フラグ discoveryFired = false; // アイドル時にトリガーされます(実装する必要があります) prototype.onIdle = function(){ if(this.discoveryFired){ // 対面後の動作 小刻みに左に動いで相手を探す // 前進(距離) this.move_forwards(10); // 砲塔を左に向ける(角度) this.turn_turret_left(10); // 左に曲がる(角度) this.turn_left(10); // 対面フラグリセット this.discoveryFired = false; } else { // 対面前の動作 小刻みに右に動いで相手を探す // 前進(距離) this.move_forwards(10); // 砲塔を右に向ける(角度) this.turn_turret_right(10); // 右に曲がる(角度) this.turn_right(10); } }; // タンクが壁に衝突したとき prototype.onWallCollide = function(){ // 反対に移動(距離) this.move_opposide(10); // 左に曲がる(角度) this.turn_left(90); }; // 自機に弾丸が当たったとき prototype.onHit = function(){ // セリフ this.yell("痛いっ"); }; // 砲塔が敵に直接面している場合(発砲しない理由はないようです!) prototype.onEnemySpot = function(){ // セリフ this.yell("当たれ~っ"); // 発射 this.shoot(); // 対面フラグを立てる this.discoveryFired = true; }; function MyRobot(){ MyRobot.superclass.apply(this, arguments); } return MyRobot; }(BaseRobot)); tr = new MyRobot("MyRobot"); function extend$(sub, sup){ function fun(){} fun.prototype = (sub.superclass = sup).prototype; (sub.prototype = new fun).constructor = sub; if (typeof sup.extended == 'function') sup.extended(sub); return sub; } function import$(obj, src){ var own = {}.hasOwnProperty; for (var key in src) if (own.call(src, key)) obj[key] = src[key]; return obj; } }).call(this);
改良した戦車で闘ってみる
圧勝です。 このステージに関しては勝率100%です。遊びながらちゃんとエンジニアの基礎中の基礎が経験できるような仕組みになっているのは面白いですね。
・既存ソースコードを見て把握する
・公式ドキュメントを読んで理解を深める
・既存ソースコードに公式ドキュメントで得た理解をコメント文に落とし込んでみる
・現状の動きから課題点を洗い出す
・課題点を改善する方法を考える
・課題点を改善する
・動作確認して結果に超自己満足(←超大事)ちなみに今回の改良でステージ0は余裕ですが以降はステージが進むにつれて勝率が下がっていきます。
- 投稿日:2021-01-03T18:30:06+09:00
javascriptによる並列処理
概要
普段javascriptを書いているとよく使う非同期処理(Promise)だが、言語仕様上(
シングルスレッドのため)非同期処理は処理の順番を変えているだけで厳密には同期処理のようだった。ざっくり図
同期処理 実行→結果→実行→結果... 非同期処理 実行→実行→結果→結果... 並列処理 実行→結果... 実行→結果...よくよく調べていると
Worker
オブジェクトを使用すれば並列処理ができるみたいなので試してみたDocument
- Docs Workerブラウサでの使用
※メインスレッド以外では、domの更新などができない
index.jsvar worker = new Worker("./greeting-worker.js"); // データが送られてきたら発火 worker.onmessage = function (message) { console.log(message) // workerの停止 this.terminate(); }; // Worker作成時かWorker実行中でエラーとなった場合に発火 worker.onerror = function (err) { console.error("onerror", err.message); }; // データ送信 worker.postMessage("Hi");greeting-worker.js// worker.postMessageが呼ばれたら発火 self.onmessage = function (message) { // 送られてきたデータ console.log(message); // workerにデータを送る self.postMessage("I'm good."); };バックエンド(Node.js)での使用
index.jsvar { Worker } = require("worker_threads"); var worker = new Worker("./greeting-worker.js"); // データが送られてきたら発火 worker.on("message", function (message) { console.log(message); // workerの停止 worker.terminate(); }); // Worker作成時かWorker実行中でエラーとなった場合に発火 worker.on("error", function (err) { console.error("onerror", err.message); // workerの停止 worker.terminate(); }); // workerが停止したら発火 worker.on("exit", function () { console.log("Bye!!"); }); // データ送信 worker.postMessage("Hi, how are you?");greeting-worker.js// worker.postMessageが呼ばれたら発火 self.onmessage = function (message) { // 送られてきたデータ console.log(message); // workerにデータを送る self.postMessage("I'm good."); };// スレッドを増やしたい場合 var worker1 = new Worker("./worker1.js") var worker2 = new Worker("./worker2.js") var worker3 = new Worker("./worker3.js") ...感想
あまり並列にすることもない処理でしたが、意外と簡単に扱うことができました。
使い所としては、処理が重い計算処理などやindexedDB(ブラウザ)があるそうです。
またワーカースレッドでデータをやりとりできるMessageChannel
というのもありました。(他ArrayBufferやFileHandleというものもあった)まとめ
- javascriptは非同期処理の他に並列処理もできる
- ブラウザ/Node.jsともにAPIが用意されている.(使用感もほぼ同じ)
- メインスレッド以外では、domの更新などができない
- ワーカースレッドを増やしたい場合は複数Workerを呼び出す
- ワーカースレッド間でのデータのやりとりは
MessageChannel
を使用して行うことができる。
- 投稿日:2021-01-03T17:25:24+09:00
Node.jsのプロファイリングを試してみる
PM2のプロファイリング周りの調査結果とNode.jsでプロファイリングを行ってみた結果です。(PM2のinstancesの値の変更時の確認方法 - Qiitaの続き)
PM2 Pricingについて
PM2 PM2 PM2 Plus PM2 Enteprise ゼロダウンタイムリロード ✅ ✅ ✅ ターミナルベースのモニタリング(pm2 monit) ✅ ✅ ✅ ... ... ... ... CPUプロファイリング ✅ PM2 PlusでもCPUプロファイリング可能?
PM2 PlusでもCPUプロファイリングができるみたいなドキュメントが存在しますが、Enterprise限定のようです。
Memory & CPU Profiling | Guide | PM2 Plus Documentation · Issue #212 · keymetrics/doc-pm2 · GitHub
Node.jsでプロファイリングを行う
PlusでCPUプロファイリングできるなら課金しようと思いましたが、Enterpriseの契約はめんどくさそうなので今回はPM2を使わずにプロファイリングを行います。
今回使用したコードはこちら
app.jsconst express = require("express"); const crypto = require("crypto"); const app = express(); server = app.listen(3000, function(){ console.log("Node.js is listening to PORT:" + server.address().port); }); let users = []; app.get('/newUser', (req, res) => { let username = req.query.username || ''; const password = req.query.password || ''; username = username.replace(/[!@#$%^&*]/g, ''); if (!username || !password || users.username) { return res.sendStatus(400); } const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512'); users[username] = { salt, hash }; res.sendStatus(200); }); app.get('/auth', (req, res) => { let username = req.query.username || ''; const password = req.query.password || ''; username = username.replace(/[!@#$%^&*]/g, ''); if (!username || !password || !users[username]) { return res.sendStatus(400); } const { salt, hash } = users[username]; const encryptHash = crypto.pbkdf2Sync(password, salt, 10000, 512, 'sha512'); if (crypto.timingSafeEqual(hash, encryptHash)) { res.sendStatus(200); } else { res.sendStatus(401); } }); app.get('/auth2', (req, res) => { let username = req.query.username || ''; const password = req.query.password || ''; username = username.replace(/[!@#$%^&*]/g, ''); if (!username || !password || !users[username]) { return res.sendStatus(400); } crypto.pbkdf2(password, users[username].salt, 10000, 512, 'sha512', (err, hash) => { if (users[username].hash.toString() === hash.toString()) { res.sendStatus(200); } else { res.sendStatus(401); } }); });参考:Node.js アプリケーションの簡単なプロファイリング | Node.js
# ApacheBenchを利用するためhttpdをインストール yum install httpd -y # 今回PM2は使わないことにしたのでstopしておく pm2 stop test-app node --prof app.js # ユーザー作成 curl -X GET "http://localhost:3000/newUser?username=matt&password=password" # ベンチマークテスト ab -k -c 20 -n 250 "http://localhost:3000/auth?username=matt&password=password"
ベンチマーク結果
This is ApacheBench, Version 2.3 <$Revision: 1879490 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking localhost (be patient) Completed 100 requests Completed 200 requests Finished 250 requests Server Software: Server Hostname: localhost Server Port: 3000 Document Path: /auth?username=matt&password=password Document Length: 2 bytes Concurrency Level: 20 Time taken for tests: 20.130 seconds Complete requests: 250 Failed requests: 0 Keep-Alive requests: 250 Total transferred: 57250 bytes HTML transferred: 500 bytes Requests per second: 12.42 [#/sec] (mean) Time per request: 1610.378 [ms] (mean) Time per request: 80.519 [ms] (mean, across all concurrent requests) Transfer rate: 2.78 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.1 0 1 Processing: 81 1543 263.8 1610 1697 Waiting: 78 1543 263.9 1610 1697 Total: 81 1543 263.7 1610 1697 Percentage of the requests served within a certain time (ms) 50% 1610 66% 1612 75% 1612 80% 1613 90% 1614 95% 1614 98% 1615 99% 1615 100% 1697 (longest request)# 人が読みやすいファイルに変換 node --prof-process isolate-0x3d49da0-16098-v8.log > processed.txt cat processed.txt同期バージョンのpbkdf2関数を使用した後のプロファイリング結果。
processed.txt... [Bottom up (heavy) profile]: Note: percentage shows a share of a particular caller in the total amount of its parent calls. Callers occupying less than 1.0% are not shown. ticks parent name 56011 75.3% __GI_epoll_pwait 18023 24.2% node::crypto::PBKDF2(v8::FunctionCallbackInfo<v8::Value> const&) 18023 100.0% v8::internal::Builtin_HandleApiCall(int, unsigned long*, v8::internal::Isolate*) 18023 100.0% LazyCompile: ~pbkdf2Sync internal/crypto/pbkdf2.js:45:20 17951 99.6% LazyCompile: ~<anonymous> /var/www/test/app.js:29:18 17951 100.0% LazyCompile: ~handle /var/www/test/node_modules/express/lib/router/layer.js:86:49 17951 100.0% LazyCompile: ~next /var/www/test/node_modules/express/lib/router/route.js:114:16コード改善後のプロファイリング結果
ベンチマーク結果は差がありませんでした。(Nodeのバージョンが
v12.20.0
)
変更後のベンチマーク結果
This is ApacheBench, Version 2.3 <$Revision: 1879490 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking localhost (be patient) Completed 100 requests Completed 200 requests Finished 250 requests Server Software: Server Hostname: localhost Server Port: 3000 Document Path: /auth?username=matt&password=password Document Length: 2 bytes Concurrency Level: 20 Time taken for tests: 20.287 seconds Complete requests: 250 Failed requests: 0 Keep-Alive requests: 250 Total transferred: 57250 bytes HTML transferred: 500 bytes Requests per second: 12.32 [#/sec] (mean) Time per request: 1622.975 [ms] (mean) Time per request: 81.149 [ms] (mean, across all concurrent requests) Transfer rate: 2.76 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.1 0 0 Processing: 86 1564 237.9 1619 1657 Waiting: 82 1564 238.0 1619 1657 Total: 86 1564 237.8 1619 1657 Percentage of the requests served within a certain time (ms) 50% 1619 66% 1629 75% 1634 80% 1635 90% 1643 95% 1647 98% 1656 99% 1657 100% 1657 (longest request)
processed.txt... [Bottom up (heavy) profile]: Note: percentage shows a share of a particular caller in the total amount of its parent calls. Callers occupying less than 1.0% are not shown. ticks parent name 94151 98.9% __GI_epoll_pwaitApache Benchの引数を変更
# 改善前のコード、同時接続200、合計250リクエスト ab -k -c 200 -n 250 "http://localhost:3000/auth?username=matt&password=password" > apr_socket_recv: Connection reset by peer (104)# 改善後のコード、同時接続200、合計250リクエスト ab -k -c 200 -n 250 "http://localhost:3000/auth2?username=matt&password=password"同時接続20であればパフォーマンスに影響はありませんでしたが、200にしたところ改善前のコードでは
apr_socket_recv: Connection reset by peer (104)
が返ってきました。また、同条件でpm2で起動した場合は改善前のコードでも正常に実行されました。(PM2のプロセス数を見直す際にはApache Benchでテストしてみる)
- 投稿日:2021-01-03T16:27:36+09:00
Node.jsを勉強する② - テキストファイルの作成方法
はじめに
前回は、Node.jsの環境構築についてまとめました。今回はテキストファイルの作成方法を記事にします。
教材
Udemy
The Complete Node.js Developer Course (3rd Edition)
https://www.udemy.com/course/the-complete-nodejs-developer-course-2/Jsファイルの作成とファイルモジュールの導入
まずは、コードを書くファイルを作成します。今回はnodeapp.jsと名付けます。
次に、app.jsの中にrequireを用いファイルを操作するモジュールを導入します。
constを用いて変数fsを定義し、モジュールの中身を代入します。nodeapp.jsconst fs = require('fs')テキストファイルを作成する
次に、"writeFileSync"メソッドを用いて、テキストファイルを作成し、文字も入れます。
カッコの中の一つ目の要素はファイル名、2つ目の要素は、書く文字になります。nodeapp.js//ファイルモジュールを導入 const fs = require('fs') //ファイルを作成し、文字を書く fs.writeFileSync('notes.txt', 'Hello, this is the first message!')実行するにはターミナルにnode ファイル名と入力します。
ターミナルnode nodeapp.js
文字を追加する
文字を追加するには、appendFileSyncメソッドを使います。
カッコの中の一つ目がnodeapp.js//ファイルを作成し、文字を追加する fs.appendFileSync('notes.txt', 'Hello, this is the first message!')再度実行して、動作を確認しましょう
ターミナルnode nodeapp.js
テキストファイルを確認して、文字が追加されていれば、完成です。
- 投稿日:2021-01-03T16:20:37+09:00
Node.jsを勉強する① - 開発環境構築
はじめに
ずっと勉強したいと思っていたNode.js
Udemyの講座で勉強してみました。備忘録として学んだことを自分なりにまとめて記しておきます。
なお、今回は英語の講座でしたが、英語も勉強できて一石二鳥ですね。今回は開発環境構築を記事にします。
教材
Udemy
The Complete Node.js Developer Course (3rd Edition)
https://www.udemy.com/course/the-complete-nodejs-developer-course-2/Node.jsのインストール
公式ホームページ(nodejs.org)に行き、最新のNode.jsをダウンロードします
カスタマイズする必要はないので、インストーラーは"Next"をclickして進め、インストールを完了します。
ダウンロードされているかチェック
コマンドプロンプトを起動して、node -vでインストールされたバージョンを確認
コードのエディタがない場合は、ローカルにインストール
講座では、Visual Studio Code を使用しています。
個人的にatomの方が馴染みがありますが、Visual Studio Codeも使いやすかったです。ダウンロードはこちらから。(https://code.visualstudio.com)
コードを書いてみる
まずは任意のフォルダーをデスクトップにつくり、それをVisual Studio Codeで開きます。
左上にフォルダーが表示されます。
さらに一番左のNew Fileボタンを押して、nodeapp.jsという名前のファイルを作って下さい。
作ったファイルの中に、console. logを用いてメッセージが表示されるコードを書きます。
nodeapp.jsconsole.log("Hello World!")Terminal >> New Terminalをクリックし、ターミナルを開いて、コードを実行してみます。
ターミナルnode nodeapp.js Hello World!
nodeapp.jsでconsole.logの中に設定したメッセージが表示されれば、完成です。
- 投稿日:2021-01-03T13:49:02+09:00
ElectronアプリでKeycloakと連携(3. ログイン機能導入編)
背景
今回は、前回作成したelectronのアプリに認証機能を追加してみる。
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc今回も前回に引き続きAuth0のブログを参考にしてみる。
https://auth0.com/blog/securing-electron-applications-with-openid-connect-and-oauth-2/※(注意)自学習を目的として書いています。この記事に記載の内容は、あくまで自分(素人)の解釈となります。
electronアプリへの認証機能追加
前回作成したシンプルなelectronアプリにログイン機能、ログアウト機能を追加してみる。
ログイン機能の実装
まずは、単純にkeycloakのログイン画面にリダイレクトして、認証するところまでを実装してみる。
前回作ったmainフォルダの下に今度はauth-process.jsというファイルを置く。
main/auth-process.jsconst {BrowserWindow} = require('electron'); const createAppWindow = require('../main/app-process'); let win = null; function createAuthWindow() { destroyAuthWin(); win = new BrowserWindow({ width: 1000, height: 600, webPreferences: { nodeIntegration: false, enableRemoteModule: false } }); win.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/auth?' + 'scope=openid profile offline_access&' + 'response_type=code&' + 'client_id=test&' + 'redirect_uri=http://localhost/callback'); const {session: {webRequest}} = win.webContents; const filter = { urls: [ 'http://localhost/callback*' ] }; webRequest.onBeforeRequest(filter, async () => { // TODO トークン取得 createAppWindow(); return destroyAuthWin(); }); win.on('closed', () => { win = null; }); } function destroyAuthWin() { if (!win) return; win.close(); win = null; } module.exports = { createAuthWindow, };createAuthWindow()は、ログインウインドウを生成するfunctionで、最後にexportすることで公開している。
最初にウインドウを破棄するfunctionであるdestroyAuthWin()を一旦呼び出してから生成を開始している。
ホームページ用のウインドウとは異なり、ログインウインドウはセキュリティリスクを軽減するため以下のように設定している。
nodeIntegration: falseは、Node.jsの組み込みを実施しないための設定。
enableRemoteModule: falseは、レンダラープロセスがメインプロセスと通信しないための設定。その後、win.loadURL()で呼び出すログインページを指定している。
ここは、前々回にkeycloakで設定したレルムにおける認証用のURLを指定しておく。webRequest.onBeforeRequest()で特定のURLへのリクエストの実行前に実施する処理を書いている。
filterに'http://localhost/callback*'を指定しているので、keycloakでログインした後のリダイレクトのURLにリクエストを送る際に発動する。処理としては、最初にトークンを取得して、ホームページ用のウインドウを開き、ログイン用のウインドウを閉じる。
ただ、トークンの取得はまだ実装していないので、現時点ではTODOとしておく。続いて、main.jsを以下のように修正。
main.js... const createAppWindow = require('./main/app-process'); const {createAuthWindow} = require('./main/auth-process'); // 追加 async function showWindow() { // return createAppWindow(); 一旦コメントアウト return createAuthWindow(); } ...createAppWindow()に代えて、createAuthWindow() を呼び出すようにしてみる。
ちなみに、createAuthWindow() の処理の中でcreateAppWindow()が呼び出される。
一旦ここで実行してみる。
> yarn start無事にkeycloakのログイン画面が表示された。
ちなみに、今回は組み込みのBrowserWindowにログイン画面を表示している(Auth0のブログも同様)。この場合、セキュリティ的にはあまりよろしくないという意見もあるらしい。
大きなところでは下記の2つの問題点が挙げられる。
正規のサイトかどうかアプリの利用者には判定できないので、利用者に不安を与える。
表示したログイン画面からいろんなところに遷移できるようになっていると、組み込みのBrowserWindow内で、悪さをされる可能性が出てくる。
一方で、標準ブラウザを利用してログインさせるやり方もあるけど、いきなり別のブラウザが開く挙動は、ユーザービリティ的には微妙な気がする(ログイン後に標準ブラウザのタブを閉じる方法も考える必要がある)。
まぁ、今回は認証サービスも自前で作っていて(そもそもテスト用だけど。。)、googleやtwitterのアカウントと連携しているわけではないので、ユーザービリティ優先ということで。keycloakのログイン画面から他のページに遷移することもできなさそうだし。
ということで、用意しておいたユーザーでログインしてみる。
ちゃんと前回のホームページが表示された。
ログアウト機能の追加
トークン取得処理を先に実装すべきかもしれないが、「logout」ボタンを押しても何も起こらないのは寂しいので、先にログアウトを実装してみる。
auth-process.jsを以下のように編集。
main/auth-process.js... function createLogoutWindow() { const logoutWindow = new BrowserWindow({ show: false, }); logoutWindow.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/logout'); logoutWindow.on('ready-to-show', async () => { logoutWindow.close(); // TODO クライアント側のログアウト処理 }); } module.exports = { createAuthWindow, createLogoutWindow, // 追加 };createLogoutWindow()というfunctionを追加してexportしている。
createLogoutWindow()では、非表示のウインドウを開いて、keycloakのlogout用エンドポイントを呼び出しているだけ。
続いて、renderersフォルダの下に、以下のようなhome.jsを追加する。
renderers/home.jsconst { remote } = require("electron"); const authProcess = remote.require("./main/auth-process"); document.getElementById("logout").onclick = () => { authProcess.createLogoutWindow(); remote.getCurrentWindow().close(); };ログアウトボタンが押されたときにログアウト処理が実行されるようにしている。
home.htmlでこのスクリプトファイルを読み込むようにする。
renderers/home.html<html lang="en"> ... <script src="home.js"></script> </html>実行して確認してみる。
> yarn startログアウトボタンを押すと、Electronアプリが閉じることは確認できた。
ただ、本当にログアウトできたか怪しいので、keycloak側で確認してみる。
ログアウトボタン押下前と押下後でセッションが減っていることが確認できた。
トークンの取得
ここからが本番。
先に「axios」と「jwt-decode」をインストールしておく。
> yarn add axios > yarn add jwt-decode今度はservicesというフォルダを作成し、その下にauth-service.jsというファイルを置いて以下のように実装した。
services/auth-service.jsconst jwtDecode = require("jwt-decode"); const axios = require('axios'); const crypto = require('crypto'); const url = require('url'); let codeVerifier = null; let accessToken = null; let profile = null; function getAccessToken() { return accessToken; } function getProfile() { return profile; } async function loadTokens(callbackURL) { const urlParts = url.parse(callbackURL, true); const query = urlParts.query; var params = new URLSearchParams(); params.append('grant_type', 'authorization_code'); params.append('client_id', 'test'); params.append('code', query.code); params.append('redirect_uri', 'http://localhost/callback'); params.append('code_verifier', codeVerifier); try { const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params); accessToken = response.data.access_token; profile = jwtDecode(response.data.id_token); } catch (error) { // TODO ログアウト throw error; } } function getChallenge() { codeVerifier = base64URLEncode(crypto.randomBytes(32)); return base64URLEncode(sha256(codeVerifier)); } function sha256(buffer) { return crypto.createHash('sha256').update(buffer).digest(); } function base64URLEncode(str) { return str.toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } module.exports = { getChallenge, getAccessToken, getProfile, loadTokens, };loadTokens()がトークン取得のメインロジック。認証後のリダイレクト時に呼び出せるようにするので、このfunctionはexportしておく。
loadTokens()の中で、トークン取得用のエンドポイントにPOSTリクエストを投げる。
リクエストパラメータにはURLSearchParamsを利用する必要があるので注意。また、PKCEを利用するので、code_verifierも指定している。
取得したaccess_token(accessToken)とid_token(profile)は、メモリ上にキャッシュしておき、getAccessToken()とgetProfile()で外から取得できるようにしておく。
getChallenge()では、最初にcode_verifierで利用する値を生成する。この値を利用して、code_challengeを生成する。
先に生成したcode_verifierの値はloadTokens()の中で、リクエストパラメータに指定するのでメモリ上にキャッシュしておく(codeVerifier)。
次に生成したcode_challengeの値は、keycloakのログイン画面呼び出し時に利用するため、getChallenge()の返り値として取得できるようにし、getChallenge()は、exportしてauth-process.jsから呼び出せるようにしておく。
PKCEの流れは下記のような感じ。
- ログイン画面のリクエスト時に指定されたcode_challengeの値を、keycloak側が認可コードに紐づけて保存しておく。
- 後のトークン取得リクエスト時に送られてきたcode_verifierを利用して、keycloak側でクライアントと同じロジックでチャレンジを生成。
- 1と2のチャレンジコードを比較して、同じクライアントから送られたリクエストであることを検証する。
つまり、認証後のリダイレクトURLに含まれる認可コードを何らかの方法で盗んだとしても、正しいcode_verifierの値を知っていなければ、トークン取得に失敗することになるということらしい。
auth-process.js側の修正は以下のような感じ。
main/auth-process.jsconst {BrowserWindow} = require('electron'); const createAppWindow = require('../main/app-process'); const authService = require('../services/auth-service'); // 追加 ... function createAuthWindow() { ... win.loadURL('http://localhost:8080/auth/realms/electron/protocol/openid-connect/auth?' + 'scope=openid profile offline_access&' + 'response_type=code&' + 'client_id=test&' + 'code_challenge=' + authService.getChallenge() + '&' + // 追加 'code_challenge_method=S256&' + // 追加 'redirect_uri=http://localhost/callback'); ... webRequest.onBeforeRequest(filter, async ({url}) => { // パラメータにurlを追加 await authService.loadTokens(url); // 追加 createAppWindow(); return destroyAuthWin(); }); ... } ...まずは、keycloakのログイン画面呼び出し時のURLのパラメータに「code_challenge」と「code_challenge_method」を追加している。
「code_challenge」の値は、auth-service.jsのgetChallenge()で取得した値を指定する。
「code_challenge_method」はkeycloak側の設定に合わせて「S256」を指定する。
続いて、keycloakのログイン画面で認証した後のリダイレクト時の事前処理で、auth-service.jsのloadTokens()を呼び出し、keycloakから取得した認可コードを利用してアクセストークンを取得する。
その後の、ホームのウインドウを生成し、ログイン用のウインドウを破棄する流れは変更なし。
せっかくなので、取得した情報をホームの画面で表示できるようにしてみる。home.htmlを以下のように修正。
renderers/home.html... <body> <p>Home</p> <div> <!-- ここから追加--> <textarea id="token" rows="12" cols="120"></textarea> <textarea id="profile" rows="8" cols="120"></textarea> </div> <!-- ここまで--> <button id="logout">Logout</button> </body> ... </html>tokenとprofileを表示するテキストエリアを追加しただけ。
続いて、home.jsを編集して、auth-service.jsからアクセストークンとプロファイルの情報を取得して、テキストエリアにセットするようにする。
renderers/home.jsconst { remote } = require("electron"); const authProcess = remote.require("./main/auth-process"); const authService = remote.require("./services/auth-service"); // 追加 const webContents = remote.getCurrentWebContents(); // 追加 // 追加 webContents.on("dom-ready", () => { const token = authService. getAccessToken(); const profile = authService.getProfile(); document.getElementById("token").innerText = token; document.getElementById("profile").innerText = JSON.stringify(profile); }); ...実行して確認してみる。
> yarn start用意したユーザーでログインすると、下記の画面が表示された。
無事にアクセストークンとプロファイルが取得できていることが確認できた。
リフレッシュトークンの利用
続いて、リフレッシュトークンを利用する実装を導入してみようと思う。
先に「keytar」をインストールしておく。
> yarn add keytar「keytar」は「システムのキーチェーンでパスワードを取得、追加、置換、削除するためのネイティブモジュール」とのこと。
アクセストークンはメモリ上に保持しているが、リフレッシュトークンは、一度クライアントを落とした後に再起動しても利用できるように、何らかの形で永続化する必要がある。
ということで、比較的安全に保存するために、システムのキーチェーンを利用する。
「keytar」をインストールしたら、auth-service.jsに下記のようにリフレッシュトークンの取得処理を追加する。
services/auth-service.jsconst jwtDecode = require("jwt-decode"); const axios = require('axios'); const keytar = require("keytar"); // 追加 const os = require("os"); // 追加 const crypto = require('crypto'); const url = require('url'); let codeVerifier = null; let accessToken = null; let profile = null; let refreshToken = null; // 追加 ... // function追加 async function refreshTokens() { const refreshToken = await keytar.getPassword('electron-openid-test', os.userInfo().username); if (refreshToken) { var params = new URLSearchParams(); params.append('grant_type', 'refresh_token'); params.append('client_id', 'test'); params.append('refresh_token', refreshToken); try { const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params); accessToken = response.data.access_token; profile = jwtDecode(response.data.id_token); } catch (error) { // TODO ログアウト throw error; } } else { throw new Error("No available refresh token."); } } ... async function loadTokens(callbackURL) { ... try { const response = ... ... // 追加 refreshToken = response.data.refresh_token; if (refreshToken) { await keytar.setPassword('electron-openid-test', os.userInfo().username, refreshToken); } } catch (error) { ... } } ... module.exports = { ... refreshTokens, // 追加 };refreshTokens()のfunctionを追加して、トークンエンドポイントにトークンのリフレッシュを要求する。
refreshTokenが有効であれば、アクセストークンとプロファイル情報を返してくれる。refreshTokenを取得していない場合や、refreshTokenが無効な場合はエラーをあげる。
refreshTokenは、アクセストークンの取得時に一緒に返してくれているので、loadTokens()の中でメモリ上にキャッシュし、システムのキーチェーンにも保存しておく。
続いて、main.jsの最初でリフレッシュトークンの処理を実行するように、showWindow()の中身を変更する。
main.js... const authService = require('./services/auth-service'); // 追加 async function showWindow() { try { await authService.refreshTokens(); return createAppWindow(); } catch (err) { createAuthWindow(); } } ...最初にrefreshTokens()を呼び出して、アクセストークンをリフレッシュしてから、ホームのウインドウを開く。
リフレッシュトークンが無効になっている場合などには、エラーがあがってくるので、catchしてログイン処理にまわす。
実行して確認してみると、とりあえず以下の動作を確認できた。
初回起動
→ ログイン画面が表示されて、ログインするとホームの画面が表示される一旦アプリを落として再起動
→ ログイン画面が表示されずにホームの画面が表示されるただ、ログアウト時にリフレッシュトークンを破棄する処理を実装していないので、このままでは再度ログイン画面を表示させるためには、30日間放置するか、keycloakの管理画面で強制的にログアウトさせる必要がある。
※認証時のリクエストで「scope」に「offline_access」を指定しているため、keycloakの「Offline Session Idle」がタイムアウト時間(デフォルト30日)となる。
ログアウト時のトークン破棄
ということで、ログアウト時にトークンを破棄する実装を入れる。
services/auth-service.js... async function refreshTokens() { ... if (refreshToken) { ... try { ... } catch (error) { await logout(); // 追加 throw error; } } else { ... } } async function loadTokens(callbackURL) { ... try { ... } catch (error) { await logout(); // 追加 throw error; } } ... // 追加 async function logout() { await keytar.deletePassword('electron-openid-test', os.userInfo().username); accessToken = null; profile = null; refreshToken = null; } module.exports = { ... logout, // 追加 };logout()のfunctionを追加して、各種トークンを削除する処理を入れる。また、各種トークン取得処理の失敗時にこれを呼び出すようにする。
明示的にログアウトが指示された場合にもlogout()が呼び出せるように、exortして公開しておく。
続いて、明示的にログアウトが指示された場合の、呼び出し側のコード修正。
auth-process.jsを修正する。
main/auth-process.js... function createLogoutWindow() { ... logoutWindow.on('ready-to-show', async () => { logoutWindow.close(); await authService.logout(); // 追加 }); } ...ログアウトが指示されたときに、auth-service.jsのlogout()を呼び出すようにするだけ。
実行して確認してみると、以下のよう感じで意図した動作となった。
おまけ(Signed JWTを利用したクライアント認証の導入)
ここまでで一通りの実装は出来たはず(取得したアクセストークンは利用してないけど。。)なので、ここからはおまけ。
実は、最初は署名付きのJWTを利用して、クライアント認証を実施しようと考えていた。
keycloakはクライアントの設定で「Access Type」を「Confidential」にすることで、クライアント認証の設定ができるようになる。
で、「Credentials」のタブで「Signed JWT」を選択すると、署名付きのJWTを利用したクライアント認証を実施できるようになっている。
「Generate new keys and certificate」を押すと、キーペアの生成画面が表示されるので、「PKCS12」を選択し、適当にパスワードを設定して、「Generate and Download」ボタンを押下する。
「credentials」の画面に戻ると同時に「keystore.p12」がダウンロードされる。
ここまででkeycloak側の設定は完了なんだけれども、この時点で行き詰まってしまった。
クライアント認証するためには、この「keystore.p12」の秘密鍵を使って、クライアントで生成したJWTに署名を付ける必要があるんだけれども、そのためには、クライアント側で「keystore.p12」を保持する必要がある。
electronアプリのようなクライアントはエンドユーザーの端末にインストールされる感じかと思うので、どう足掻いても秘密鍵を盗まれるリスクは排除できないし、electronアプリに秘密鍵を同梱して配布したら、秘密鍵が悪用された場合などに、簡単に差し替え出来なくなる。
ということで、そもそもelectronアプリのようなクライアントの「Access Type」は「Public」を選ぶべきなんだろうと考え、クライアント認証は諦めた。(いろんな記事を見ても、「public」にして、PKCEで認可コード横取りに対処するのが一般的なんだろうと考えた。)
で今に至る。
一応、署名付きJWTのクライアント実装も試してみたので、載せておく。
services/auth-service.js... async function loadTokens(callbackURL) { const urlParts = url.parse(callbackURL, true); const query = urlParts.query; var params = new URLSearchParams(); params.append('grant_type', 'authorization_code'); params.append('client_id', 'test'); params.append('code', query.code); params.append('redirect_uri', 'http://localhost/callback'); params.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); params.append('client_assertion', generateClientAssertion()); try { const response = await axios.post('http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', params); accessToken = response.data.access_token; profile = jwt.decode(response.data.id_token); console.log(profile); } catch (error) { throw error; } } function generateClientAssertion() { const now = new Date(); const iatValue = now.getTime(); now.setMinutes(now.getMinutes() + 1); const expValue = now.getTime(); const payload = { aud: 'http://localhost:8080/auth/realms/electron/protocol/openid-connect/token', // トークンエンドポイントのURL exp: expValue, // トークンの有効期限 jti: Math.random().toString(32).substring(2), // ユニークな値(今回はランダム文字列を簡易生成) iat: iatValue, // トークンを署名した時刻 iss: 'http://localhost:3000/', // JWT を署名したクライアントの識別子 sub: 'test' // keycloakに登録したクライアントID }; return sign(payload); } function sign(payload) { const keyFile = fs.readFileSync('keys/keystore.p12'); // ファイル読み込み const keyBase64 = keyFile.toString('base64'); // Stringで取得 const p12Der = forge.util.decode64(keyBase64); // base64からデコード const p12Asn1 = forge.asn1.fromDer(p12Der); // ASN.1オブジェクトを取得 const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, 'test'); // p12として読み込み const privateKey = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[forge.pki.oids.pkcs8ShroudedKeyBag][0].key; // 秘密鍵取得 const rsaPrivateKey = forge.pki.privateKeyToAsn1(privateKey); // RSA秘密鍵に変換 const privateKeyInfo = forge.pki.wrapRsaPrivateKey(rsaPrivateKey); // PrivateKeyInfoでラップ const pemPrivate = forge.pki.privateKeyInfoToPem(privateKeyInfo); // PEM形式に変換 const signedJwt = jwt.sign(payload, pemPrivate, { algorithm: 'RS256'}); // JWTに署名 return signedJwt; } ...依存として「jsonwebtoken」と「node-forge」をインストールし、auth-service.jsのloadTokens()を上記のように書き換える。さらに「keys」というフォルダの下に「keystore.p12」を置く必要がある。
上記実装で、クライアント認証したうえでトークンが取得できた。
今回は確認のために、electronアプリの実装に埋め込んで試してみたが、もしクライアント認証を実施したいなら、別のやり方を考える必要がある。
例えば、トークンに署名するための専用サーバーを立ち上げて、認可コードを受け取って、署名したJWTを返すようなサービスを作るとか、認可コードを受け取って、署名したJWTを生成しつつトークンエンドポイントへのリクエストを中継するようなサービスを作る感じか。
秘密鍵はサーバー側にあるので、盗まれるリスクが少なく、後から差し替えも可能になる。
ただ、盗まれた認可コードでリクエストが来る可能性があるので、結局、PKCEのような仕組みを導入して、認証時のユーザーと同一かどうかを検証する必要がありそう。
非常にめんどくさいし、やらかしそう。
(2021/01/04 追記:別の記事で無理やり試してみました。)
ということで、特別な事情でもない限りは、「Access Type」は「Public」として実装する方がよいと思われる。
さいごに
3つの記事でに分けて、keycloakと連携してelectronのアプリに認証機能を追加するところまで実施してみました。
基本的にはAuth0のブログを参考にして実装しましたが、一気に理解するのは難しそうだったので、少しずつ実装を追加していく形にしました。おかげで、個人的には理解が深まった(気がする)けど、長編記事になってしまいました。なので、もともと詳しい人は、Auth0のブログを直接見た方がきっと分かりやすいんじゃないかと思います。
ただ、Keycloakの場合はトークン取得時のリクエストの形式(content-type)が違うなど、いくつかAuth0とは違う実装が必要だったので、その辺りの実装が参考になれば幸いです。
過去2回の記事は以下です。
ElectronアプリでKeycloakと連携(1. keycloakの設定編)
https://qiita.com/yusuke-ka/items/69d4146f344a95aa4662ElectronアプリでKeycloakと連携(2. Electronアプリの作成編)
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc
- 投稿日:2021-01-03T13:48:44+09:00
ElectronアプリでKeycloakと連携(2. Electronアプリの作成編)
背景
前回、keycloakの設定まで実施した。今回は認証機能を導入するElectronアプリを実装してみる。
https://qiita.com/yusuke-ka/items/69d4146f344a95aa4662Auth0のブログで公開されているやり方が安全そうな気がするので、ここを参考にしてやってみようと思う。
https://auth0.com/blog/securing-electron-applications-with-openid-connect-and-oauth-2/といっても、今回は単純なElectronアプリを作るだけで、認証回りは次回。
準備
まずは、プロジェクトの作成から。
> mkdir electron > cd electron > yarn initinitはすべてデフォルトのまま。
続いて依存のインストールを実施する。
> yarn add electronElectronのインストールだけ。少し時間がかかった。
Electronアプリの作成
最初に、アプリのホームページを作ってみる。
renderersというフォルダを作り、その下に以下のようなファイル(home.html)を作成する。
renderers/home.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Electron App</title> </head> <body> <p>Home</p> <button id="logout" class="btn">Logout</button> </body> </html>直接ブラウザでhome.htmlを読み込んだ際の見た目はこんな感じ。
テスト用なので、Homeという文字とログアウトボタンだけを配置したシンプルなホームページ。
もちろん、ログアウトボタンを押しても何も起こらない。続いて、このページをElectronで表示するようにしてみる。
今度はmainというフォルダを作成し、その下にapp-process.jsというファイルを置く。
main/app-process.jsconst { BrowserWindow } = require("electron"); function createAppWindow() { let win = new BrowserWindow({ width: 1000, height: 600, webPreferences: { nodeIntegration: true, enableRemoteModule: true, }, }); win.loadFile("./renderers/home.html"); win.on("closed", () => { win = null; }); } module.exports = createAppWindow;nodeIntegration: trueは、Node.jsの組み込みを実施するための設定。
enableRemoteModule: trueは、レンダラープロセスがメインプロセスと通信するための設定。createAppWindowというfunctionをexportして公開している。
続いて、ルートフォルダの下に、最初に呼び出されるファイル(main.js)を作成する。
main.jsconst {app} = require('electron'); const createAppWindow = require('./main/app-process'); async function showWindow() { try { return createAppWindow(); } catch (err) { // TODO 認証 } } app.on('ready', showWindow); app.on('window-all-closed', () => { app.quit(); });今はまだ、先ほどのapp-process.jsが公開しているcreateAppWindowを呼び出すだけの実装。
とりあえず、この状態で動かしてみる。
package.jsonに以下を追記。
package.json"scripts": { "start": "electron ./main.js" }electronコマンドでmain.jsを呼び出しているだけ。
実行。
> yarn startElectronアプリとして、ホームページが表示された。
次回予告
ElectronアプリでKeycloakと連携したログイン機能を実装するために、今回は、確認用のシンプルなElectronアプリの実装をおこなった。
次は、いよいよElectronアプリに認証機能を導入する。
https://qiita.com/yusuke-ka/items/17a5b8fbd544c4c211a3
- 投稿日:2021-01-03T13:48:23+09:00
ElectronアプリでKeycloakと連携(1. keycloakの設定編)
背景
以前、Nuxt.jsでKeycloakと連携したログイン機能を実装してみた。
https://qiita.com/yusuke-ka/items/1beef8d9e0bbeb052e5a今度はelectronアプリでKeycloakと連携したログイン機能を実装してみようと思う。
その準備として、今回は、まずkeycloakの設定をやってみる。
keycloakのインストール等は以下で実施したので、これを使う。
keycloakの設定
keycloakの管理UIにアクセスして、keycloakの設定を実施していく。
レルム追加
最初にレルムを追加する。
「Name」を入力して、「Create」ボタンを押すだけ。
クライアントの追加
続いて、作成したレルムの「Clients」でclientを新規作成。
右のほうにある「create」ボタンを押す。
Clientの追加画面が表示されるので、「Client ID」だけ入力して「Save」ボタンを押すと、詳細設定画面に遷移する。
「Client Protocol」の設定は「openid-connect」のままにする。
electronアプリの場合、クライアント認証は難しそうなので、「Access Type」もとりあえず「public」のままにしておく。
(「confidencial」にすると、Client Secretや秘密鍵をクライアント側で保持する必要がでてくる。electronアプリのように、エンドユーザーのPCにインストールするようなアプリだと、これらを安全に保持するのはとても難しいと思われる。)「Standard Flow Enabled」が有効になっていることを確認。
electronアプリから利用することを想定しているので、「Valid Redirect URIs」には、とりあえず、「 http://localhost/callback 」を設定しておく。
publicなクライアントの場合、コードの横取りに対処する必要があるとのことなので、PKCE(Proof Key for Code Exchange by OAuth Public Clients)の設定を使うようにしてみる。
下の方にある「Advanced Settings」を展開すると、「Proof Key for Code Exchange Code Challenge Method」という項目が出てくる。「Plain」と「S256」が選択できるので、今回は「S256」を選択してみた。
「save」ボタンで保存する。
ユーザーの追加
確認用のユーザーも作っておく。やり方は下記を参照(「Keycloakの設定」という項目の最後のほう)。
https://qiita.com/yusuke-ka/items/1beef8d9e0bbeb052e5a#keycloak%E3%81%AE%E8%A8%AD%E5%AE%9A次回予告
electronアプリでKeycloakと連携したログイン機能を実装するために、今回は、Keycloak側の設定を実施した。
次は、electronアプリを作成する。
https://qiita.com/yusuke-ka/items/a4767c511f03b6083afc
- 投稿日:2021-01-03T13:30:17+09:00
node.js + express + nodemailer
node.jsでmail機能を実装するには…
node.jsでmail機能を実装するにあたってnpmのnodemailerというパッケージを使っていきます!
$ express app --view=ejs $ cd app $ npm installルートフォルダーにcontact.jsファイルを作って下記の記述をします
contact.js"use strict"; const nodemailer = require("nodemailer"); async function main() { let transporter = nodemailer.createTransport({ ignoreTLS:true, port: 1025, secure: false, // true for 465, false for other ports }); let info = await transporter.sendMail({ from: '"Fred Foo ?" <foo@example.com>', // sender address to: " s@gmail.com", // list of receivers subject: "成功!!!!", // Subject line text: "サンプルテキストです", // plain text body }); console.log("送られたメッセージ: %s", info.messageId); } main().catch(console.error);参考)https://nodemailer.com/about/
メールが送信されているかをチェックするためにmailcatcherを使います
mailcatcherを使うためのパッケージをインストールして起動してみます$ npm install -g maildev $ maildev$ node contact.jslocalhostの1080ポートが起動しているのでhttp://127.0.0.1:1080 にアクセスすると
ポート1025でメールを受け取ることができました!
あとがき
mail機能を実装するためにはamazon SESでもできるようです
今回nodemailerを使ってみましたが、圧倒的にメールサーバについての理解が乏しいと実感しました(´;ω;`)
mailについて学習し、また記事を書きます!
- 投稿日:2021-01-03T04:16:30+09:00
puppeteerでドットインストールの総学習時間を取得する
オンライン学習サイトのドットインストールには学習時間を確認できる機能がある。
……がこの時間がどうも正しくない。実際の学習時間よりだいぶ少ない。おそらく回線が不安定な環境で動画を視聴しても視聴時間に加算されない。→ 自動取得した合計と視聴時間が一致していたので、ドットインストールの視聴時間は正しかった。正しい学習時間が欲しいのでブラウザ自動操作ツールであるpuppeteerを利用して学習時間の合計を自動で求める。
注意
自動操作ツールでのアクセスは頻度が高すぎるとサービスへ負荷を与えてアクセスブロック等の対象となるので、適切にwaitを入れてあげると良い。
戦略
①puppeteerでヘッドレスブラウザ(目に見えないブラウザ)を起動する。
②ドットインストールのユーザ認証を行う。
③ドットインストールのプロフィールページへ遷移し、受講した講座のURL一覧を取得する。
④講座ページへ遷移し、受講完了した動画の視聴時間の合計を求める。(これを③で取得したURL分繰り返す)
⑤集めたデータを以下の形で出力する。[{ lessonName: 'C#入門', lessonUrl: 'https://dotinstall.com/lessons/basic_csharp', completeTime: '01:49:23', incompleteTime: '00:21:01' }, { ...... }]実装
①npmプロジェクトを作成し、puppeteerとログイン情報入力に使用するreadline-syncをインストールする。
$ npm init -y $ npm i -S puppeteer readline-sync②puppeteerの動作確認で、googleにアクセスしてみる。
main.jsconst puppeteer = require('puppeteer'); // awaitを使うためasync関数を定義する。 async function main() { // puppeteerのブラウザを起動する。 const browser = await puppeteer.launch(); // ブラウザの新しいタブを開く。 const page = await browser.newPage(); // googleにアクセスする。 await page.goto('https://google.com'); // スクショを撮る。 await page.screenshot({path: 'test.png'}); // ブラウザを閉じる。 await browser.close(); } main();上記を実行してpuppeteerからgoogleにアクセスできることを確認した。
②ログイン情報を入力させる。
ドットインストールにログインするためのログイン情報をユーザに入力させる。パスワード入力時は入力内容を出力させず履歴に残させないことがミソだ。
main.jsconst puppeteer = require('puppeteer'); const readlineSync = require('readline-sync'); async function main() { // メールアドレスを入力させる。 const mail = readlineSync.question('mail: '); // パスワードを入力させる。オプションで入力内容を出力させない。 const password = readlineSync.question('password: ', {hideEchoBack: true}); console.log(mail, password); } main();③ドットインストールにログインする。
ドットインストールへのログインは以下の流れで行う。
ログインページへの遷移(https://dotinstall.com/login)
↓
ユーザ名とパスワードのinput欄へ自動入力する。
↓
ログインボタンを押下させる。main.jsconst puppeteer = require('puppeteer'); const readlineSync = require('readline-sync'); async function main() { ...... // ログインページへの遷移 await page.goto('https://dotinstall.com/login'); // メールアドレスとパスワードの入力 await page.evaluate(text => document.querySelector('#mail').value = text, mail); await page.evaluate(text => document.querySelector('#password').value = text, password); // ログインボタン押下 await page.click('#login_button'); ......④受講した講座のURL一覧を求める。
ユーザ名をクリックしプロフィールページへ遷移する。
↓
各講座へのリンクのaタグのhref属性を取得する。
↓
講座のページへ遷移し時間を求める。async function main() { ...... await Promise.all([ page.waitForNavigation({waitUntil: ['load', 'networkidle2']}), page.click('#login_button') ]); await Promise.all([ page.waitForNavigation({waitUntil: ['load', 'networkidle2']}), page.click('a.user-name') ]); const urls = await page.evaluate(() => { const urls = []; const aElements = document.querySelectorAll('.cardBox > h3 > a'); for (const aElement of aElements) { urls.push(aElement.getAttribute('href')); } return urls; }); const result = []; for (const url of urls) { const lessonUrl = 'https://dotinstall.com' + url; await page.goto(lessonUrl, {waitUntil: ['load', 'networkidle2']}); // 負荷軽減のため3秒待機する await page.waitForTimeout(3000); const lessonName = await page.$eval('.package-info-title span', element => element.innerHTML); const [completeTime, incompleteTime] = await page.evaluate(() => { let completeTime = 0; let incompleteTime = 0; const sectionElements = document.querySelectorAll('#lessons_list > li'); for (const sectionElement of sectionElements) { const time = sectionElement.querySelector('.lessons-list-title > span').innerHTML; const [, min, sec] = time.match(/\((\d\d)\:(\d\d)\)/); const seconds = parseInt(min) * 60 + parseInt(sec); const isCompleted = sectionElement.querySelector('.lesson_complete_button > span').innerHTML === '完了済'; if (isCompleted) { completeTime += seconds; } else { incompleteTime += seconds; } } return [completeTime, incompleteTime]; }); function sec2time(sec) { return `${parseInt(sec / 3600)}:${parseInt((sec / 60) % 60)}:${sec % 60}`; } result.push({ lessonName, lessonUrl, completeTime: sec2time(completeTime), incompleteTime: sec2time(incompleteTime) }); } console.log(result); ......