はじめに
タイトルにあるとおり、PowerShellでREST APIサーバーを立ててみたのでその紹介です。
レベル別に段階を踏んで解説してみたので、パパっと作りたい人やしっかり作りこみたい人など、さまざまな目的の人の参考になれば幸いです。
Level1: リクエストを受け取る
まずはミニマムに「リクエストを受け取ったら後続の処理を実行して通信を終了させる」という内容のプログラムです。
$URI_PREFIX = "http://localhost:8931/"
# リスナーの作成とURLの登録
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add($URI_PREFIX)
$listener.Start()
# リクエスト待機
Write-Host "リクエストを待機中です..."
# リクエストを検知するまでここで止まり、検知したらコンテキストに代入されて次に進む
$context = $listener.GetContext()
# リクエスト内容の取得
$request = $context.Request
# リクエスト内容の変換
$url = $request.Url
$method = $request.HttpMethod
$rawUrl = $request.RawUrl
$path = $rawUrl -replace '[/]?\?.*'
# 中身の確認
Write-Host "url is ${url}"
Write-Host "method is ${method}"
Write-Host "rawUrl is ${rawUrl}"
Write-Host "path is ${path}"
# パラメータの取得
$param = [ordered] @{}
# Getの場合: クエリパラメータからパラメータを取得
if ($method -eq "GET") {
Add-Type -AssemblyName System.Web
$nvc = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query)
$nvc.Keys | % {
$key = $_
$value = $nvc.Get($key)
$param.Add($key, $value)
}
}
# Post/Put/Deleteの場合: bodyからパラメータを取得
if ($method -in @("POST", "PUT", "DELETE") -and $request.ContentLength64 -gt 0) {
$reader = [System.IO.StreamReader]::new($request.InputStream)
$body = $reader.ReadToEnd()
$reader.Close()
$json = $body | ConvertFrom-Json
$json.psobject.Properties.Name | % {
$key = $_
$value = $json.$key
$param.Add($key, $value)
}
}
# 中身の確認
Write-Host "param is $($param | ConvertTo-Json -Compress -Depth 10)"
# 正常応答で終了 (デフォルトは200)
$context.Response.Close()
# リスナーの終了 (レスポンスが渡る前に切断にならないよう適当にwait)
Start-Sleep 2
$listener.Stop()
$listener.Close()
解説
リスナーの作成と要求待ち
まず最初にリスナーを作成します。
$URI_PREFIX = "http://localhost:8931/"
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add($URI_PREFIX)
$listener.Start()
$context = $listener.GetContext()
.NETの System.Net.HttpListener
クラスを使ってリスナーを作成しています。
リスナーにはローカルホストのアドレスとポート番号を設定して、そのアドレスへのリクエストをキャッチするよう指定しています。
そして GetContext
メソッドの実行でリクエスト待ち状態になります。この状態でリクエストを受け取ると $context
にリクエストの内容が入って後続の処理に進みます。
リクエスト内容の解析
まずメソッドやURLを取得しておきます。
$url = $request.Url
$method = $request.HttpMethod
$rawUrl = $request.RawUrl
$path = $rawUrl -replace '[/]?\?.*'
例えば GET http://localhost:8931/foo/bar/sample?x=123&y=hello
という要求を受け取った時の各変数は以下のようになります。
$url = "http://localhost/foo/bar/sample?x=123&y=hello"
$method = "GET"
$rawUrl = "/foo/bar/sample?x=123&y=hello"
$path = "/foo/bar/sample"
次にパラメータを取得します。ここはクエリパラメータの場合とリクエストボディの場合で取り方が異なります。
まずクエリパラメータの場合です。
$param = [ordered] @{}
Add-Type -AssemblyName System.Web
$nvc = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query)
$nvc.Keys | % {
$key = $_
$value = $nvc.Get($key)
$param.Add($key, $value)
}
System.Web.HttpUtility
クラスの ParseQueryString
メソッドを使うことでNameValueCollection型でクエリパラメータを取得できます。
次にリクエストボディの場合です。
$param = [ordered] @{}
$reader = [System.IO.StreamReader]::new($request.InputStream)
$body = $reader.ReadToEnd()
$reader.Close()
$json = $body | ConvertFrom-Json
$json.psobject.Properties.Name | % {
$key = $_
$value = $json.$key
$param.Add($key, $value)
}
System.IO.StreamReader
クラスでストリームから文字列を取得でき、JSON文字列であれば ConvertFrom-Json でカスタムオブジェクト型でリクエストボディを取得できます。
ちなみに今回のようにハッシュテーブルに変換しておくと後続の処理でクエリパラメータなのかリクエストボディなのか意識せず汎用的にパラメータにアクセスできるようになるのでオススメです。
通信とリスナーの終了
レスポンスについてはLevel2で扱うのでここではシンプルに通信を閉じるだけにします。
$context.Response.Close()
Start-Sleep 2
$listener.Stop()
$listener.Close()
System.Net.HttpListenerResponse
クラスの Close
メソッドでクライアントにレスポンスを返せます。
リスナーはStopとCloseで閉じることができます。ただスクリプトが終われば破棄されるのでわざわざ閉じなくても問題ないとは思います。(お行儀の問題?)
ちなみに $context.Response.Close()
は非同期処理のようですぐに完了します。なのでリスナーを明示的に閉じる際はレスポンスを返し終わる前に閉じてしまわないか気を付けたほうがよいかと。(今回は雑にsleepで対処)
実行
上記サーバースクリプトを実行してサーバーを立てた状態で別のコンソールからローカルホストにリクエストを送ってみます。
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/sample?x=10&y=20' -Method Get | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {}
中身が空の正常応答が返ってきましたね。サーバ側は
リクエストを待機中です...
url is http://localhost:8931/foo/bar/sample?x=10&y=20
method is GET
rawUrl is /foo/bar/sample?x=10&y=20
path is /foo/bar/sample
param is {"x":"10","y":"20"}
といった感じです。
Level2: レスポンスを返す
つづいて「レスポンスとしてJSONを返す」という内容のプログラムです。
なおここからは大部分がLevel1と同じなのでコードは必要があれば適宜開いてみてください。
コード
$URI_PREFIX = "http://localhost:8931/"
# リスナーの作成とURLの登録
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add($URI_PREFIX)
$listener.Start()
# リクエスト待機
Write-Host "リクエストを待機中です..."
# リクエストを検知するまでここで止まり、検知したらコンテキストに代入されて次に進む
$context = $listener.GetContext()
# リクエスト内容の取得
$request = $context.Request
# リクエスト内容の変換
$url = $request.Url
$method = $request.HttpMethod
$rawUrl = $request.RawUrl
$path = $rawUrl -replace '[/]?\?.*'
# 中身の確認
Write-Host "url is ${url}"
Write-Host "method is ${method}"
Write-Host "rawUrl is ${rawUrl}"
Write-Host "path is ${path}"
# パラメータの準備
$param = [ordered] @{}
# Getの場合: クエリパラメータからパラメータ取得
if ($method -eq "GET") {
Add-Type -AssemblyName System.Web
$nvc = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query)
$nvc.Keys | % {
$key = $_
$value = $nvc.Get($key)
$param.Add($key, $value)
}
}
# Post/Put/Deleteの場合: bodyからパラメータ取得
if ($method -in @("POST", "PUT", "DELETE") -and $request.ContentLength64 -gt 0) {
$reader = [System.IO.StreamReader]::new($request.InputStream)
$body = $reader.ReadToEnd()
$reader.Close()
$json = $body | ConvertFrom-Json
$json.psobject.Properties.Name | % {
$key = $_
$value = $json.$key
$param.Add($key, $value)
}
}
# 中身の確認
Write-Host "param is $($param | ConvertTo-Json -Compress -Depth 10)"
# レスポンスの作成
$response = $context.Response
$response.StatusCode = 200
$response.ContentType = "application/json"
$jsonData = [ordered] @{
"message" = "Hello, World!"
"number" = Get-Random -Minimum 0 -Maximum 100
"param" = $param
}
$jsonString = $jsonData | ConvertTo-Json -Compress -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($jsonString)
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.Close()
# リスナーの終了 (レスポンスが渡る前に切断にならないよう適当にwait)
Start-Sleep 2
$listener.Stop()
$listener.Close()
解説
レスポンスの作成
Level1では何も返さず正常終了させましたが、ここではJSONを返してみます。
$response = $context.Response
$response.StatusCode = 200
$response.ContentType = "application/json"
$jsonData = [ordered] @{
"message" = "Hello, World!"
"number" = Get-Random -Minimum 0 -Maximum 100
"param" = $param
}
$jsonString = $jsonData | ConvertTo-Json -Compress -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($jsonString)
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.Close()
レスポンスをCloseする前にStatusCodeやContentTypeに設定した値がレスポンスとなります。
例えば以下のようにすればHTMLのホスティングも可能です。
$response = $context.Response
$response.StatusCode = 200
$response.ContentType = "text/html"
$contents = Get-Content "${PSScriptRoot}\index.html" -Raw
$bytes = [System.Text.Encoding]::UTF8.GetBytes($contents)
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.Close()
実行
GETリクエストを送ってみるとレスポンスとしてJSONが返ってくることが確認できます。
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/sample?x=10&y=20' -Method Get | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {"message":"Hello, World!","number":13,"param":{"x":"10","y":"20"}}
POSTだとこんな感じです。
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/sample' -Method Post -Body '{"x":10,"y":20}' | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {"message":"Hello, World!","number":56,"param":{"x":10,"y":20}}
Level3: サーバーを起動しつづける
ここまでは1回のリクエストでサーバー処理が終了するプログラムだったので、今度はリクエストをずっと受け続けられるようにしてみます。
コード
$URI_PREFIX = "http://localhost:8931/"
# リスナーの作成とURLの登録
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add($URI_PREFIX)
$listener.Start()
while ($listener.IsListening) {
# リクエスト待機
Write-Host "リクエストを待機中です..."
# リクエストを検知するまでここで止まり、検知したらコンテキストに代入されて次に進む
$context = $listener.GetContext()
# リクエスト内容の取得
$request = $context.Request
# リクエスト内容の変換
$url = $request.Url
$method = $request.HttpMethod
$rawUrl = $request.RawUrl
$path = $rawUrl -replace '[/]?\?.*'
# 中身の確認
Write-Host "url is ${url}"
Write-Host "method is ${method}"
Write-Host "rawUrl is ${rawUrl}"
Write-Host "path is ${path}"
# パラメータの準備
$param = [ordered] @{}
# Getの場合: クエリパラメータからパラメータ取得
if ($method -eq "GET") {
Add-Type -AssemblyName System.Web
$nvc = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query)
$nvc.Keys | % {
$key = $_
$value = $nvc.Get($key)
$param.Add($key, $value)
}
}
# Post/Put/Deleteの場合: bodyからパラメータ取得
if ($method -in @("POST", "PUT", "DELETE") -and $request.ContentLength64 -gt 0) {
$reader = [System.IO.StreamReader]::new($request.InputStream)
$body = $reader.ReadToEnd()
$reader.Close()
$json = $body | ConvertFrom-Json
$json.psobject.Properties.Name | % {
$key = $_
$value = $json.$key
$param.Add($key, $value)
}
}
# 中身の確認
Write-Host "param is $($param | ConvertTo-Json -Compress -Depth 10)"
# レスポンスの作成
$response = $context.Response
$response.StatusCode = 200
$response.ContentType = "application/json"
$jsonData = [ordered] @{
"message" = "Hello, World!"
"number" = Get-Random -Minimum 0 -Maximum 100
"param" = $param
}
$jsonString = $jsonData | ConvertTo-Json -Compress -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($jsonString)
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.Close()
# パラメータに停止信号 (`stop=true` または `"stop":true`) があれば停止させる
if ("stop" -cin $param.Keys -and $param.stop -eq $true) {
Write-Host "サーバーを停止します。"
break
}
}
# リスナーの終了 (レスポンスが渡る前に切断にならないよう適当にwait)
Start-Sleep 2
$listener.Stop()
$listener.Close()
解説
無限ループ
解説といっても特別なことはなく while ($listener.IsListening)
でリスナーが生き続けてる間はリクエストを受け続けるだけです。
少し工夫が必要なのは終了処理で、どうも GetContext
メソッドでリクエストを待っている間は Ctrl + C でプロセスを終了することができないようです。
なので自分はターミナルごと閉じてサーバーを再起動していたのですが、さすがに野蛮すぎるかなと思ったので、試しに特定のリクエストが飛んで来たらループを抜け出すようにしてみました。
if ("stop" -cin $param.Keys -and $param.stop -eq $true) {
Write-Host "サーバーを停止します。"
break
}
この要領でサーバー再起動なんかもリクエストをトリガーに実行したりできそうですね。
実行
リクエストをなんども受け付けられるようになりました。
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/sample?x=10&y=20' -Method Get | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {"message":"Hello, World!","number":21,"param":{"x":"10","y":"20"}}
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/sample' -Method Post -Body '{"stop":true}' | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {"message":"Hello, World!","number":77,"param":{"stop":true}}
ログはこんな感じです。
リクエストを待機中です...
url is http://localhost:8931/foo/bar/sample?x=10&y=20
method is GET
rawUrl is /foo/bar/sample?x=10&y=20
path is /foo/bar/sample
param is {"x":"10","y":"20"}
リクエストを待機中です...
url is http://localhost:8931/foo/bar/sample
method is POST
rawUrl is /foo/bar/sample
path is /foo/bar/sample
param is {"stop":true}
サーバーを停止します。
Level4: 別PCからアクセスする
ここまではサーバーを立てたPCと同一PCからリクエストを投げていたので、今度は別のPCからもリクエストを投げられるようにしてみます。
※ここはPC設定の変更なのでコードはないです。
解説
まず以下の手順でFireWallを穴あけしておきます。なおポート番号やプロファイルは用途や環境に応じて適宜読み替えてください。
- コントロールパネルから「Windows Defender ファイアウォール」を開く。
- 左側のメニューから「詳細設定」を開く。
- 左側のメニューから「受信の規則」を右クリックし「新しい規則」を選択する。
- 以下内容で規則を作成する。
- 規則の種類 : ポート
- プロトコルおよびポート : TCP + 8931
- 操作 : 接続を許可する
- プロファイル : プライベート
つぎに netsh
コマンドでHttp.sysにアドレスを予約します。
- 管理者権限でPowerShellを立ち上げる。
-
ipconfig | Select-String 'ipv4'
を実行してサーバー側のIPアドレスを確認する。 -
netsh http add urlacl url=http://192.168.0.100:8931/ user=everyone
を実行する。(アドレス部分は2で確認した値)
これで設定は完了で、あとはスクリプトの方もリスナーにローカルホストを指定していたところをサーバーのアドレスに変更しておきます。
# $URI_PREFIX = "http://localhost:8931/"
$URI_PREFIX = "http://192.168.0.100:8931/"
これでクライアントPCからサーバPCへのアドレス指定でのリクエストに対してもレスポンスを返せるようになります。
> Invoke-WebRequest -Uri 'http://192.168.0.100:8931/foo/bar/sample?x=10&y=20' -Method Get | Out-Null
ちなみに netsh
で予約するアドレスとリスナーに渡すアドレスはワイルドカードを使って http://+:8931/
のようにも指定できます。可変IPの場合はこっちの方が便利かと。(詳細は System.Net.HttpListener を参照)
と、ここまで解説を書いてきましたが、実はほとんど以下サイトの受け売りです。
HttpListenerを使うために必要なネットワークまわりの設定についてより詳しく書かれています。
また以下の記事もHttp.sysやnetshコマンド、HttpListenerについて詳しく書かれていてわかりやすかったです。
Level5: パスごとにロジックを分割する
さいごにサーバーの拡張性をすこしあげてみたいと思います。
これまでは1つのスクリプトにすべての処理を書いていましたが、リクエストURLごとの処理を別スクリプトに分割してみます。
コード
$URI_PREFIX = "http://localhost:8931/"
# リスナーの作成とURLの登録
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add($URI_PREFIX)
$listener.Start()
while ($listener.IsListening) {
# リクエスト待機
Write-Host "リクエストを待機中です..."
# リクエストを検知するまでここで止まり、検知したらコンテキストに代入されて次に進む
$context = $listener.GetContext()
# リクエスト内容の取得
$request = $context.Request
# リクエスト内容の変換
$url = $request.Url
$method = $request.HttpMethod
$rawUrl = $request.RawUrl
$path = $rawUrl -replace '[/]?\?.*'
# 中身の確認
Write-Host "url is ${url}"
Write-Host "method is ${method}"
Write-Host "rawUrl is ${rawUrl}"
Write-Host "path is ${path}"
# パラメータの準備
$param = [ordered] @{}
# Getの場合: クエリパラメータからパラメータ取得
if ($method -eq "GET") {
Add-Type -AssemblyName System.Web
$nvc = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query)
$nvc.Keys | % {
$key = $_
$value = $nvc.Get($key)
$param.Add($key, $value)
}
}
# Post/Put/Deleteの場合: bodyからパラメータ取得
if ($method -in @("POST", "PUT", "DELETE") -and $request.ContentLength64 -gt 0) {
$reader = [System.IO.StreamReader]::new($request.InputStream)
$body = $reader.ReadToEnd()
$reader.Close()
$json = $body | ConvertFrom-Json
$json.psobject.Properties.Name | % {
$key = $_
$value = $json.$key
$param.Add($key, $value)
}
}
# 中身の確認
Write-Host "param is $($param | ConvertTo-Json -Compress -Depth 10)"
# 処理のふりわけ
switch -Regex -CaseSensitive ($path) {
"^/foo/bar/sample$" {
$respData = . "${PSScriptRoot}\logic\sample.ps1" $method $param
}
"^/foo/bar/user/([0-9]+)$" {
$userId = $Matches[1]
$respData = . "${PSScriptRoot}\logic\user.ps1" $method $param $userId
}
default {
$respData = [ordered] @{
"status" = 200
"body" = [ordered] @{
"message" = "script for ${path} is not defined"
}
}
}
}
# レスポンスの作成
$response = $context.Response
$response.StatusCode = $respData.status
$response.ContentType = "application/json"
$jsonData = $respData.body
$jsonString = $jsonData | ConvertTo-Json -Compress -Depth 10
$bytes = [System.Text.Encoding]::UTF8.GetBytes($jsonString)
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$response.Close()
# パラメータに停止信号 (`stop=true` または `"stop":true`) があれば停止させる
if ("stop" -cin $param.Keys -and $param.stop -eq $true) {
Write-Host "サーバーを停止します。"
break
}
}
# リスナーの終了 (レスポンスが渡る前に切断にならないよう適当にwait)
Start-Sleep 2
$listener.Stop()
$listener.Close()
解説
今回はこんな構成にしてみました。
root/
├─ server.ps1
└─ logic/
├─ sample.ps1
└─ user.ps1
server.ps1
がメインのサーバー処理で logic
ディレクトリ内のスクリプトがリクエストに対応した処理です。
そしてサーバー処理の中でSwitch文でリクエストURLに応じてロジックスクリプトを呼び分けています。
switch -Regex -CaseSensitive ($path) {
"^/foo/bar/sample$" {
$respData = . "${PSScriptRoot}\logic\sample.ps1" $method $param
}
"^/foo/bar/user/([0-9]+)$" {
$userId = $Matches[1]
$respData = . "${PSScriptRoot}\logic\user.ps1" $method $param $userId
}
default {
$respData = [ordered] @{
"status" = 200
"body" = [ordered] @{
"message" = "script for ${path} is not defined"
}
}
}
}
PowerShellのSwitch文は正規表現とキャプチャが使えるので、この仕組みでパスパラメータにも対応させています。(Switch文についてはこちらの記事が詳しいです。)
上記の例でいうと /foo/bar/user/1234
というパスならユーザーID 1234
をパスパラメータからキャプチャして user.ps1
に渡しています。
ロジックスクリプトは決まった返り値を返すように統一しておくことでサーバー処理側で統一的に扱えます。
以下は sample.ps1
の中身で、今回はステータスコードとJSONレスポンスの中身を返す仕様にしています。
Param(
[string] $method,
[object] $param
)
return [ordered] @{
"status" = 200
"body" = [ordered] @{
"message" = "sample is called as ${method} method"
"param" = $param
}
}
この実装のメリットとして、リクエストが飛んでくるたびにスクリプトを読みに行くのでサーバーを落とさなくてもロジックスクリプトの更新が反映されるという点があります。
自分はテスト用のモックサーバーを作ったのですが、ファイルを保存した瞬間にテストデータを変えられるのでかなりストレスフリーでした。
実行
パスごとに対応するロジックスクリプトを呼べていることが確認できます。
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/sample?x=10' -Method Get | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {"message":"sample is called as GET method","param":{"x":"10"}}
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/dummy?x=10' -Method Get | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {"message":"script for /foo/bar/dummy is not defined"}
> Invoke-WebRequest -Uri 'http://localhost:8931/foo/bar/user/0123' -Method Post -Body '{"name":"Alice"}' | Select-Object -Property StatusCode, Content
StatusCode Content
---------- -------
200 {"message":"user is called as POST method and userId is 0123","param":{"name":"Alice"}}
おわりに
いかがでしたか?
Level5にも書きましたが、修正が即時反映されるのがかなり気持ちよかったです。やっぱりPowerShellは最高ですね。
ただまあ当然ではあるんですけど、複数ユーザーからの同時大量アクセスは想定していないので、あくまでテスト用のモックサーバーや自分用の個人サーバーとして使うのがよさそうです。(もしかしたら同時アクセスにも意外と耐えられたり?)
最後まで読んでいただきありがとうございました。