🎚️

ブラウザを PowerShell の UI にする - 2

2024/08/04に公開

これまで

前回は、なぜでブラウザをUI担当にすることになったかと、全体の動きの設計を書きました。

今回はブラウザ側の js コードを見ていきましょう。
ちなみに JavaScript はまじめに勉強したことないです(おい)。

js

EventManager

ブラウザ側のコードは通信部分とユーザ操作への反応で、モジュール間をイベントが飛び交う構造になるのですが、その結びつきを疎にするために、簡易的なイベント管理モジュール EventManager を入れます。

この EventManager は

  • イベントの作成
  • 作成されたイベントのサブスクライブ
  • イベントの起動(サブスクライブした相手の呼びだし)

をやるだけのお手軽バージョンです。

EventManager.js
//
// Event Manager
//

class EventManager {
    eventHandlers = {};

    constructor() {
        this.eventHandlers = {};
    }

    NewEvent(e) {
        this.eventHandlers[e] = 0;
        console.log(`EventManager: New event added: ${e}`);
    }

    Subscribe(e, cb) {
        if (this.eventHandlers[e] == undefined) {
            console.error(`EventManager: Subscring undefined event: ${e}`);
        } else {
            if (this.eventHandlers[e] != 0) {
                console.warn(`EventManager: Subscribing override: ${e}`);
            }
            this.eventHandlers[e] = cb;
        }
    }

    TriggerEvent(e, param) {
        var cb = this.eventHandlers[e]
        if (cb == undefined) {
            console.warn(`EventManager: Event triggered but not listened: ${e}`);
        } else {
            console.log(`EventManager: Triggering callback: ${e}`);
            cb(param);
        }
    }
}

const eventManager = new EventManager();

連想配列にイベント名をキーとしたデータを持っておき、イベントを起動されたらサブスクライブしているコールバック関数を呼び出すだけですね。はい次。

WebSocketHelper

このクラスは実際に WebSocket をクライアントとして使う部分です。

SendMessage() は UI 側から文字列でコマンドを送るメソッドですが、準備ができる前に呼ばれても困るので IsReady で準備完了かどうかを見ます。

IsReady になるのは WebSocket がつながってサーバー側(pwsh) が 'Hello, client!' と送ってきた後。ここの部分の処理は WebSocket の onmessage イベントをそのまま使って処理します。
コマンド実行結果が返ってきた時も onmessage が来るので受信メッセージを処理するコールバックをイベント経由で起動して処理を渡します。

WebSocketHelper.js
/*
 * WebSocketHelper
*/
class ClientWebSocket {
    IsConnected;
    IsReady;
    Port;
    ws;
    serverHello;

    constructor(port) {
        this.Port = port;
        this.IsConnected = false;
        this.IsReady = false;
        this.serverHello = false;

        eventManager.NewEvent('ClientWebSocket.Ready');
        eventManager.NewEvent('ClientWebSocket.OnMessage');
    }

    Connect(subprotocol) {
        if (this.IsConnected) {
            console.error(`ClientWebSocet.Connect: already connected`);
            return;
        }
        console.log(`Connecting to port ${this.Port}...`);
        this.ws = new WebSocket(`ws://localhost:${this.Port}/`, subprotocol);
        this.IsConnected = true;

        this.ws.onopen = function(e) {
            console.log(`Connection opened!`);
            this.IsReady = true;
        }

        this.ws.onmessage = (e) => {
            var msg = e.data;
            console.log(`ClientWebSocket message=${msg}`);
            if (!this.serverHello && msg == 'Hello, client!') {
                this.serverHello = true;
                this.IsReady = true;
                console.log(`ClientWebSocket: Connected!`);
                eventManager.TriggerEvent('ClientWebSocket.Ready', '');
            } else {
                eventManager.TriggerEvent('ClientWebSocket.OnMessage', msg)
            }
        }
    }

    SendMessage(msg) {
        if (!this.IsReady) {
            console.error(`Socket is not ready`);
            return;
        }
        this.ws.send(msg);
        console.log(`SendMessage: sent messasge: ${msg}`)
    }
}

CommandScheduler

完全に名前負けしているクラスw。文字列をやり取りする WebSocketHelper の上位層としてアプリケーションデータでのやり取りをするプロトコル層として実装しています。

  • WebSocket は全二重で通信できるので、前に発行したコマンドの終了を待たずに次を送るとか、その場合に必ずしもコマンド発行順に結果が返らないかもしれないのでそのあたりをよろしく処理

  • データのやり取りではなく通信経路の制御をする必要があるのでその処理

    • といっても終了するときに特別なメッセージを送るだけですが
  • ほとんどのケースは、コマンド送信⇒結果受け取りという「行って帰って」で処理できるわけですが、コマンド処理に時間がかかるような場合は途中経過を受け取ったり、進捗表示したくなります。そうなると「行って帰って」では済まなくなるので、Out-of-Band でメッセージを受信して処理するフローを入れています。

CommandScheduler.js
/*
 * Command Scheduler
 */
class CommandScheduler {
    cws = null;
    IsReady = false;
    IsOpened = false;
    SeqNo = 0;
    Responders = [];
    InMsgResponder = null;

    constructor(port) {
        this.IsOpened = false;
        this.IsReady = false;
        this.SeqNo = 0;
        this.cws = new ClientWebSocket(port)
        this.Responders = [];
    }

    Start(subprotocol) {
        if (this.IsOpened || this.IsReady) {
            console.error(`CommandScheduler.Start: already started! isO=${this.IsOpened} IsR=${this.IsReady}`);
            return;
        }
        eventManager.Subscribe('ClientWebSocket.Ready', (m) => { this.SocketConnected(m); });
        eventManager.Subscribe('ClientWebSocket.OnMessage', (m) => { this.DispatchMessage(m); });
        this.cws.Connect(subprotocol);
        this.IsOpened = true;
    }

    SocketConnected(msg) {
        console.log(`SocketConnected: ready`);
        console.log(`IsReady=${this.IsReady}`);
        this.IsReady = true;
    }

Start() は WebSocket にクライアントとして接続する部分。WebSocket のイベントをサブスクライブして処理を廻していきます。

CommandScheduler.js

    Close() {
        this.cws.SendMessage('!!TERMINATE!!');
    }

    SendCommand(c, p) { this.SendCommand(c, p, "none"); }
    SendCommand(c, p, cb) {
        var cmd = {
            id: this.SeqNo,
            cmd: c,
            param: p
        };
        this.SeqNo++;

        this.Responders[cmd.id] = cb;
        const str = JSON.stringify(cmd);
        console.log(`SendCommand: ${str}`);
        this.cws.SendMessage(str);
    }

SendCommand() はコマンド、パラメータ、コールバックを受け取って通信用のJSONデータを作り WebSocket で送りつけます。

CommandScheduler.js

    DispatchMessage(m) {
        const resp = JSON.parse(m);
        if (resp.id == -1) {
            // this is special case; OOB/incomming message with no command associated
            this.ProcessIncommingMessage(resp);
            return;
        }
        switch (this.Responders[resp.id]) {
            case undefined:
                console.error(`DispathMessage: Found reponse with no responder: ${m}`);
                break;
            case "none":
                // simply no responder defined/used
                break;
            default:
                const cb = this.Responders[resp.id];
                cb(resp.result);
                break;
        }
    }

    ProcessIncommingMessage(r) {
        console.log(`Incomming Message: ${r.msg}`);
        if (this.InMsgResponder) {
            const cb = this.InMsgResponder;
            cb(r.msg);
        } else {
            console.log(`No responder registered for incomming message`)
        }
    }

    RegisterIncommingMessageHandler(cb) {
        this.InMsgResponder = cb;
    }
}

DispatchMessage() はサーバからデータを受信したときの処理で、コマンド実行結果の場合、コマンドのシーケンス番号に対応するコールバックを呼び出します。このコールバックはコマンド送信時に指定されていたもの。

シーケンス番号 -1 は out-of-band のもので、これは専用のコールバックが登録されていればそれを起動します。

あー、こうしてみると out-of-band の処理はあとからやっつけで作ったのが丸見え~。コマンド毎のコールバックを配列で覚えていて -1 で表現している out-of-band に対応できなかったので無理やりなんですわ。連想配列に変えればよかったんですけどね……。なぜそうしなかったのか。

あとここの管理領域が肥大化することは無視してます。UI処理が終われば消滅するので。あ~やっぱり連想配列にしてコマンド実行が終わったら消して回るべきだなー。

UIDemoAPI

最後は実際のコマンドを発行処理する部分です。APIと名付けてますが、実際には View。昔の自分よ、なぜこの名前にしたのか。

Start() で CommandScheduler を Start() するほか、実行ボタンが押されたときにコマンド送信その他一連の処理をしています。

Out-of-band でサーバー側(pwsh)からの通信があった場合は、ステータスメッセージの更新なので、RegisterIncommingMessageHandeler() でメッセージ領域を更新するコードを登録しています。

UIDemoAPI.js
//
// Pwsh UI Demo API
//

class UIDemoAPI {
    cs;

    constructor(port) {
        this.cs = new CommandScheduler(port);
    }

    Start() {
        this.cs.RegisterIncommingMessageHandler((m) => {
            console.log(`incomming message: ${m}`);
            ma1.innerText = m;
        });

        this.cs.Start('UIDemo1');
    }

    Close() { this.cs.Close(); }

    RunCommand() {
        ma1.innerText = '処理を実行しています。このままお待ちください';
        btnOK.disabled = 1;

        this.cs.SendCommand(
            "Run",
            { Id: textParamId.value, action: selAction.value }, (p) => {
                console.log(`Command:Run result ${p}`);
                ma1.innerText = p.msg;
                if (p.success != 1) {
                    m1.style.color = 'red';
                }

                btnOK.disabled = 1;
                btnCancel.value = '終了';
                btnCancel.disabled = 0;
                waitForClose = True;
            }
        );
    }
}

Discussion