ブラウザを PowerShell の UI にする - 3
これまで
前々回、前回で、なぜブラウザに PowerShell スクリプトの UI をやらせるか、その場合の通知処理を見てきました。
ブラウザ側の js コードはまだありますが、ボタンが押されたらコマンド発行とか UI そのものにかかわる話なので省略。いいよね?
今回から pwsh 側を見ていきましょう。
WebSocketServer.psm1
サーバーと名前を付けていますが1クライアントからの要求を処理するだけの超シンプル版です。UI 処理するクライアントを相手にするだけなのでこれで十分。
#
# 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()
はサーバーとしてのメインループを構成するもので、
- 初期化 - Prep()
- クライアントとの接続 - Connect()
- コマンド受信 - ReadMessage()
- 切断指示(!!TERMINATE!!) であればループ終了
- それ以外の場合はコマンド実行処理 - ProcessMessage()
- 結果を返信 - SendMessage
- 3へ戻って繰り返し
を行います。はい簡単。
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!
を送信して接続完了としています。
[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 仕様に合わせて文字列⇔バイト列の変換と送受信そのもの。特に言うことも無く。
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 側でもそうでしたが、一番下のレイヤーは通信データを文字列・バイト列として扱うので、その上に構造化された「コマンドのやりとり」を実現するレイヤーを入れます。
RegisterCommand([string]$cmd, $handler) {
$this.DispatchTable.$cmd = $handler
}
Init() {
$this.DispatchTable = @{}
}
RegisterCommand()
はコマンドの処理を実装するモジュールから呼ばれ、コマンド名と対応する処理関数の登録を行います。
# 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 がデータを受信したときに呼び出され、コマンドの処理ルーチンを呼び出します。
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