PowerShellのHttpListnerによる簡易サーバーで複数リクエスト処理

# このページの対象者
* PowerShellでHttpListenerを使った簡易サーバーを立てたい  
例
    * ローカルでブラウザからのファイルアクセスを中継する
    * Headless Chromeなどを経由して、別ドメインの情報を取得する
* 同時アクセスに対して非同期で並列処理を実行したい
* PowerShellはある程度わかる

# 概略
PowerShellを用いて、`http://localhost:XXXX/`からアクセス可能な簡易なローカルサーバーを立てる方法を紹介する。
PowerShellはほとんどのWindowsで使用可能で、.NET Frameworkのクラスを利用できる利点がある。それを利用し、HttpListenerを用いた簡易サーバーの起動を立てることを試みた。しかし、PowerShellは並列実行が苦手である。普通にプログラムすると、同時にリクエストがあった場合、最初のリクエストの処理が終わるまで2番目以降のリクエストが保留されてしまう。本ページではPowerShellで並列処理の実行が可能なローカルサーバーを作成する手法を紹介する。

# 内容
## 同期版

まず、先により簡単な同期版を示す。同期処理の場合は前述の通り、複数のリクエストがあった場合に、後から来たリクエストは前のリクエストが終了するまで実行されない。

サーバー本体のps1ファイルと、ps1ファイルを呼び出すためのbatファイルがあり、2つが同じディレクトリに置かれている前提。もちろん、batの中身をコマンドプロンプトに直打ちしても良い。

```StartSyncServer.bat
PowerShell -ExecutionPolicy RemoteSigned .\SyncServer.ps1
```

```SyncServer.ps1
# localhostへアクセスできない環境では$falseを指定
$localhost_enable = $true

# .NetでローカルHTTPサーバーを作成
$listener = New-Object Net.HttpListener

# セキュリティ的にlocalhostに接続できない場合用に場合分け
if($localhost_enable){
    $listener.Prefixes.Add("http://localhost:8000/")
}else{
    $listener.Prefixes.Add("http://+:80/Temporary_Listen_Addresses/")
}

try {
    # サーバースタート
    "Sync Server is running..."
    $listener.Start()

    # 2回目以降のリクエストも受け付けられるようにループする
    while ($true) {
        # GetContextはリクエストが来るまで停止する
        $context = $listener.GetContext()
        
        # --これ以降の処理はリクエストが来ないと実行されない--

        # この$responseを使うことで、リクエスト元に結果を返せる
        $response = $context.Response

        # URLの?より後ろを取得(文字列が?を含まないように、encodeURIComponentで処理して渡す)
        $text = $context.Request.RawUrl.Split("?")[1]
        # 複数の変数を渡したい場合などは、下記のように&などで区切ること
        # $params = $context.Request.RawUrl.Split("?")[1].Split("&")

        # ここで、$textの中身をいじる重い処理など(以下は単に10秒待ち)
        Start-Sleep -s 10
        $result = "Hello, " + $text + "!, " + (Get-Date -format "HH:mm:ss")
        
        # byteに変換(出力するために必要)
        # リクエスト元の文字コードにあわせて変える必要がある(『～Encoding]::Default.Get～』など)。
        $content = [System.Text.Encoding]::UTF8.GetBytes($result)
        # リクエスト元に結果を出力
        $response.OutputStream.Write($content, 0, $content.Length)
        $response.Close()
    }
} catch {
    Write-Error($_.Exception)
}finally{
    # 処理を終了(途中でwhileを抜ける処理が入っていないので、実質エラーでしか呼ばれない)
    $listener.Stop()
}
```

アクセスするためにはブラウザから、
http://localhost:8000?「encodeURIComponentした文字列」
にアクセスすれば良い。
localhostへのアクセスに制限がある環境では、`$localhost_enable`を`$false`にした上で
http://127.0.0.1/Temporary_Listen_Addresses/?「encodeURIComponentした文字列」
にアクセスすること。

## 非同期版

同期版と同様にサーバーを起動するbatファイルと、本体のps1ファイルを示した。

```StartServer.bat
PowerShell -ExecutionPolicy RemoteSigned .\AsyncServer.ps1
```

こちらがサーバー本体。

```AsyncServer.ps1

# localhostへアクセスできない環境では$falseを指定
$localhost_enable = $true

# 非同期での同時実行数を指定
$maxJob = 5

# .NetでローカルHTTPサーバーを作成
$listener = New-Object Net.HttpListener

# セキュリティ的にlocalhostに接続できない場合用に場合分け
if($localhost_enable){
    $listener.Prefixes.Add("http://localhost:8000/")
}else{
    $listener.Prefixes.Add("http://+:80/Temporary_Listen_Addresses/")
}

try {
    # サーバースタート
    "Async Server is running..."
    $listener.Start()
    # 実行している処理と、その結果を保存する配列を作成
    $jobArray = New-Object System.Collections.ArrayList
    $responseArray = New-Object System.Collections.ArrayList
    # リクエストを受けたときに、その内容を一時保管するための変数を宣言
    $task = $null

    # 「リクエストが来ているか」「来ていた場合に処理を実行」
    # 「処理が終わっていたらcallbackを実行」の3つをこのループの中で全て行う。
    while ($listener.IsListening) {
        # リクエストが来ない場合に、リクエスト処理が空回りしないようにするため
        if($null -eq $task){
            # Asyncをつけることで、停止しない。リクエストが来ると、$taskにリクエスト内容が反映される
            $task = $listener.GetContextAsync()
        }

        # リクエストが来ると、$task.IsCompletedがtrueになり、if内が実行される。
        # 但し、並列での処理数が上で決めた値を越している場合は、次のループまで実行しない。
        if(($task.IsCompleted) -And ($jobArray.Count -lt $maxJob)){
            # リクエストの中身を取得。URLなどが入っている
            $context = $task.GetAwaiter().GetResult()
            # 次のリクエスト受付をスタートするために、$taskを初期化
            $task = $null
            # この$responseを使うことで、リクエスト元に結果を返せる
            $response = $context.Response

            # URLの?より後ろを取得(文字列が?を含まないように、encodeURIComponentで処理して渡す)
            $text = $context.Request.RawUrl.Split("?")[1]
            # 複数の変数を渡したい場合などは、下記のように&などで区切ること
            # $params = $context.Request.RawUrl.Split("?")[1].Split("&")

            # バックグラウンドで実行するジョブを開始
            # Start-Jobにcontextを直接は渡せないみたいなので、?以降の文字列を渡す
            $job = Start-Job -ScriptBlock {
                # 引数の受け渡し。複数の場合はカンマ区切りでparam($text1, $text2, $text3)
                param(
                    $text
                )
                # ここで、$textの中身をいじる重い処理など(以下は単に10秒待ち)
                Start-Sleep -s 10
                
                $result = "Hello, " + $text + "!, " + (Get-Date -format "HH:mm:ss")
                
                # 処理結果のStringを$resultに入れて返す。
                Write-Output $result
            } -ArgumentList $text
            # 複数の引数を渡したい場合は下記のようにも書ける
            # -ArgumentList $params[0], $params[1]
            
            # 後で参照できるようにジョブと結果返却用の変数を配列に格納。
            # 「 | Out-Null」は標準出力の抑制
            $jobArray.Add($job) | Out-Null
            $responseArray.add($response) | Out-Null
        }
        
        # ジョブの終了を確認し、終了していた場合はリクエストに対して結果を返す。
        # 配列から要素をremoveするので、大きい方から実行。
        for($i=$jobArray.Count-1; $i -ge 0; $i--){
            # とりあえず、一時変数にジョブを取り出す
            $job = $jobArray[$i]
            # そのjobが終了していたらレスポンス処理
            if($job.State -eq "Completed"){
                # responseも取り出す(ジョブとindexが一致しているはず)
                $response = $responseArray[$i]
                # 対応するジョブの結果をstringとして取り出す。
                $result = Receive-Job -Job $job
                # byteに変換(出力するために必要)
                # リクエスト元の文字コードにあわせて変える必要がある(『～Encoding]::Default.Get～』など)。
                $content = [System.Text.Encoding]::UTF8.GetBytes($result)
                # リクエスト元に結果を出力
                $response.OutputStream.Write($content, 0, $content.Length)
                $response.Close()
                
                # 終了したジョブを配列から除く
                # 後ろから逆向きにforしてるから、途中でremoveしても大丈夫
                $jobArray.removeAt($i)
                $responseArray.removeAt($i)
            }
        }
        # 処理が連続で繰り返されすぎないように少し待つ
        Start-Sleep -Milliseconds 100
    }
} catch {
    Write-Error($_.Exception)
}finally{
    # 処理を終了(途中でwhileを抜ける処理が入っていないので、実質エラーでしか呼ばれない)
    $listener.Stop()
}
```

かなり長く見えるが、一応これで最小と思われる。

## 実際にアクセスしてみる
コマンドラインからサーバーを起動後、ブラウザから下記の2つのURLに(ほぼ)同時にアクセスした結果を示す。
http://localhost:8000/?world
http://localhost:8000/?Kitty

まず、以下は先に示した同期版の結果である。

```SyncServer.ps1の結果
Hello, world!, 00:45:18
Hello, Kitty!, 00:45:28
```

見ての通りちょうど10秒ずれて実行結果が得られていることがわかり、同時にアクセスしているにも関わらず、前の処理が終わってから、次の処理が実行されている。
※一応、Firefoxで開いた時のブラウザへの出力結果のスクリーンショット
![SimpleServerFirefox.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/396838/d9f2ad90-a4e2-943b-5e7c-2b780a871809.png)

対して、以下は非同期版の結果である。

```AsyncServer.ps1の結果
Hello, world!, 00:49:50
Hello, Kitty!, 00:49:50
```

このように、同時にリクエストを送れば、同時に結果が返ってくるのが分かる。

## 内容解説
PowerShellでは基本的に「リクエストが来たら実行」「ジョブが終わったら実行」といった形の非同期処理のcallbackを受けることができない。そのため、一時的にどこかにジョブを保存しておき、ジョブが終了したかどうかを定期的に確認する必要がある。
AsyncServer.ps1ではwhileループ内で「リクエストの受信確認」「ジョブの終了確認」を同時に行うような設計にしている。
ジョブの確認終了では、配列に一時保存しておいたジョブをforループ内で一つずつ確認して、ジョブが終了したものから、リクエスト元に結果を返すようにしている。

# 注意点
## ユーザーにダウンロードさせて使う場合
Windows10などにAsyncServer.ps1を配布した場合、利用者がオンラインからダウンロードすると、ネットから取得したフラグ(Zone.Identifier)が付与されてしまって、実行できないことがある。その場合は、下記の方法で解除可能。

* 「AsyncServer.ps1」を右クリックして、「プロパティ(R)」を開く
* 「全般」タブの下部の「セキュリティ: ～～」の「許可する」をチェックして、「OK」。

また、コマンドプロンプトからも下記のコマンドで削除可能。

```deleteZoneIdentifier.bat
echo.>ファイル名:Zone.Identifier
```

## 外部ページから呼び出す場合

ブラウザで直接表示するだけなら良いが、別のドメインから読み込む場合はJSONP形式で出力するなどの工夫が必要。
例えば、

```foo.html
<script type="text/javascript" src="http://localhost:8000/?mycallback&text"></script>
```

もしくは

```bar.js
let script = document.createElement("script");
script.type = "text/javascript";
script.src = "http://localhost:8000/?mycallback&text";
document.body.appendChild(script);
```

のようにscriptで呼び出し、サーバー側では下記のようにJSONPで返す。

```response.OutputStream.Writeで書き出す内容
mycallback({result: "text"})
```

この場合は、JSONP injectionなどの攻撃に備えること。

# 参考
[管理者権限のないプレーンなWindowsでWebサーバを立てる戦い](https://qiita.com/koyoru1214/items/721e528c86ee2baff871)
[ダウンロードしたファイルの「ブロック解除」をコマンドで](https://qiita.com/gentaro/items/3beb65a8f2f89089a042)