🎛️

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

2024/08/05に公開

これまで

前々回前回で、なぜブラウザに PowerShell スクリプトの UI をやらせるか、その場合の通知処理を見てきました。

ブラウザ側の js コードはまだありますが、ボタンが押されたらコマンド発行とか UI そのものにかかわる話なので省略。いいよね?

今回から pwsh 側を見ていきましょう。

WebSocketServer.psm1

サーバーと名前を付けていますが1クライアントからの要求を処理するだけの超シンプル版です。UI 処理するクライアントを相手にするだけなのでこれで十分。

WebSocketServer.psm1
#
# WebSocket - Server
#

Set-StrictMode -Version latest

class WebSocketServer {
    [Byte[]] $Buffer;
    [ArraySegment[byte]] $Buffseg;
    $CancellationToken;
    $Listener;
    $HttpContext;
    $WebSocket;
    $SubProtocol = "Generic";

    Run($port, $handler, $sp) {
        $this.SubProtocol = $sp
        $this.Run($port, $handler)
    }

    Run($port, $handler) {
        $this.Prep($port)
        $this.Connect()

        while ($true) {
            $msg = $this.ReadMessage()
            if (-not $msg -or $msg -eq "!!TERMINATE!!") {
                log "TERMINATE command received. Terminating..."
                break
            }
            if (-not ($rc = $handler.ProcessMessage($msg))) {
                break
            }
            $this.SendMessage($rc)
        }
    }

Run() はサーバーとしてのメインループを構成するもので、

  1. 初期化 - Prep()
  2. クライアントとの接続 - Connect()
  3. コマンド受信 - ReadMessage()
  4. 切断指示(!!TERMINATE!!) であればループ終了
  5. それ以外の場合はコマンド実行処理 - ProcessMessage()
  6. 結果を返信 - SendMessage
  7. 3へ戻って繰り返し

を行います。はい簡単。

WebSocketServer.psm1
    Connect() {
        log "Waiting for connection..."
        $this.HttpContext = $this.Listener.GetContext()
        log "Connection request: IsWebSocketRequest=$($this.HttpContext.Request.IsWebSocketRequest)"
        if (-not $this.HttpContext.Request.IsWebSocketRequest) {
            log "Not a Websocket request. Disconnecting..."
            $this.HttpContext.Response.StatusCode = 400
            $this.HttpContext.Response.Close()
            throw "Not a WebSocet connection"
        } else {
            $task = $this.HttpContext.AcceptWebSocketAsync($this.SubProtocol)
            $this.WaitTask($task, "AcceptWebSocketAsync")
            $wctx = $task.Result
            $this.WebSocket = $wctx.WebSocket
            $this.SendMessage("Hello, client!")
        }
    }

Connect() は .Net の HttpListener の仕様に従って WebSocket を開通させます。WebSocket 以外は間違った接続なので終了させます。WebSocket が開通したら確認を兼ね、プロンプト代わりに Hello, client! を送信して接続完了としています。

WebSocketServer.psm1

    [string] ReadMessage() {
        log "Receiving..."
        $task = $this.WebSocket.ReceiveAsync($this.Buffseg, $this.CancellationToken)
        #log "ReadMessage: ReceiveAsync returns $($task|ConvertTo-Json -Depth 2)"
        if ($task.IsFaulted) {
            return $null
        }

        $this.WaitTask($task, "ReadMessage")
        $r = $task.Result
        #log "ReadMessage: Count=$($r.Count) EoM=$($r.EndOfMessage)"
        $msg = [System.Text.Encoding]::UTF8.GetString($this.Buffer[0 .. ($r.Count - 1)])
        log "ReadMessage: $msg"
        return $msg
    }

    SendMessage($msg) {
        log "SendMessage: sending {$msg}"
        [ArraySegment[byte]]$asb = [System.Text.Encoding]::UTF8.GetBytes($msg)
        $task = $this.WebSocket.SendAsync($asb, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $this.CancellationToken)
        $this.WaitTask($task, "SendMessage")
        #log "SendMessage: Completed."
    }

    WaitTask($t, $m) {
        $t.Wait()
        #log "WaitTask: Task completed ($m) Completed?=$($t.IsCompleted) Faulted?=$($t.IsFaulted)"
    }

受信ルーチンの ReadMessage() も送信ルーチンの SendMessage() も .Net 仕様に合わせて文字列⇔バイト列の変換と送受信そのもの。特に言うことも無く。

WebSocketServer.psm1
    Prep($port) {
        $this.CancellationToken = New-Object System.Threading.CancellationToken
        $this.Buffer = [byte[]]::New(4 * 1024)
        $this.Buffseg = New-Object ArraySegment[byte] -ArgumentList @(,$this.Buffer)

        $this.Listener = [System.Net.HttpListener]::New()
        $this.Listener.Prefixes.Add("http://localhost:$($port)/")
        $this.Listener.Start()
    }
}

Prep() も各種変数初期化と指定された TCPポート番号で Listener をセットアップするだけの簡単なお仕事。はい次行きましょう。

WSCommandDispatcher.psm1

js 側でもそうでしたが、一番下のレイヤーは通信データを文字列・バイト列として扱うので、その上に構造化された「コマンドのやりとり」を実現するレイヤーを入れます。

WSCommandDispatcher.psm1
    RegisterCommand([string]$cmd, $handler) {
        $this.DispatchTable.$cmd = $handler
    }

    Init() {
        $this.DispatchTable = @{}
    }

RegisterCommand() はコマンドの処理を実装するモジュールから呼ばれ、コマンド名と対応する処理関数の登録を行います。

WSCommandDispatcher.psm1
    # ProcessMessage is called by SocketServer for msg recieved from client
    # and expected to return result as string.
    # Incoming message is json format of command and parameters.
    [string] ProcessMessage([string]$msg) {
        $req = $msg |ConvertFrom-Json -AsHashtable
        # here req will look like:
        # @{id=<int>; cmd=[string]; params=null or anything}

        $cmd = $req.cmd;
        if ($this.DispatchTable.Contains($cmd)) {
            $cb = $this.DispatchTable.$cmd
            $rc = $cb.Invoke($req)
            $req.result = $rc
        } else {
            # no handler registered -> log it, return something as client is waiting for response
            log "$($this.GetType().Name).ProcessMessage: No hanlder resigtered for command: $cmd"
            $req.result = @{ msg="ERROR! Command not found: $cmd" }
        }
        return $req |ConvertTo-Json -Depth 10 -Compress
    }

ProcessMessage() は WebSocket がデータを受信したときに呼び出され、コマンドの処理ルーチンを呼び出します。

WSCommandDispatcher.psm1
    RegisterOOBSender($cb) {
        $this.OOBSender = $cb;
    }

    SendOOBMessage([string]$msg) {
        $pac = @{}
        $pac.id = -1
        $pac.msg = $msg
        $txt = $pac |ConvertTo-Json -Depth 10 -Compress
        $this.OOBSender.Invoke($txt)
    }

最後のこの2つは out-of-band のメッセージ、ここではサーバー側からクライアント側にステータス更新などを送る時に使うことを想定しています。時間のかかる処理を pwsh 側でしている時の状況を適宜 UI 側に教えるケースですね。

サーバーのメイン処理は 1) コマンド受信、2) コマンド実行して完了待ち、3) コマンド実行結果をクライアントに返信、というシンプルなループを回るので out-of-band を送ることができません。

ループ中にポーリングするとか、コマンド実行結果が複数あることを想定してループの回り方を変えることもできなくはないですが、ポーリングはマルチスレッド/プロセス化にほかならず、複数回のコマンド実行結果を受け取るのは複数回コマンド実行ルーチンを呼ぶということで実行ルーチン側も複雑な制御をすることなります。

通信自体は全二重で上りも下りも好きに通信できるので、コマンド実行ルーチンから送信ルーチンを呼び出すことができればそれでいいのですが、モジュール分割の構成上直接できないので、コールバック関数を貰っておいてそれを使う方法でやっています。

モジュール分割の仕方が悪い? 正論っすね。

Discussion