それ、F#でやってみました - System.Net.HttpListenerによるHTTPサーバ
dotnet fsi ソースファイル名
で起動しCtrl + C
で停止するHTTPサーバを作成してみました。動作はアクセスをカウントするHTMLコンテンツを返すのみです。System.Net.HttpListenerクラス1つでHTTPサーバを構築、起動できます。
//
// dotnet fsi ソースファイル名.fsx (Windowsは管理者ターミナルで実行)
//
open System.Net
let mutable c = 0 // カウンタ
let port = 8088 // ポート番号
let listener = new HttpListener() // HTTPサーバ
listener.Prefixes.Add(sprintf "http://+:%A/" port) // http://localhost:8088/
listener.Start() // サーバ起動
printfn "Server started(%A). Stop Ctrl + C..." port // Ctrl + Cで終了
while listener.IsListening do
let context = listener.GetContext() // リクエストを受信するまで待つ
c <- c + 1;
let buff = System.Text.Encoding.UTF8.GetBytes(sprintf "<html><body>%A: Hello F#!</body></html>" c) // レスポンスとなるコンテンツ
let response = context.Response // レスポンスの生成開始
response.ContentLength64 <- buff.Length // コンテンツの長さ
response.ContentType <- "text/html" // コンテンツの種別
let output = response.OutputStream // コンテンツの出力先
output.Write(buff, 0, buff.Length) // コンテンツの出力
output.Close() // 出力終了
// listener.Abort()
// listener.Stop()
$ curl http://localhost:8088/
<html><body>1: Hello F#!</body></html>
$ curl http://localhost:8088/
<html><body>2: Hello F#!</body></html>
$ curl http://localhost:8088/
<html><body>3: Hello F#!</body></html>
.....
このままではレスポンスのコンテンツがほぼ固定になってしまいますので、ルーティング設定をできるようにして、URIに対応したコンテンツを出力できるようにします。
ルーティング設定を追加
ルーティング設定というのは、URIとそれに対応した処理をサーバに登録することをいいます。ここでの方針として、ルーティング設定にないURIでアクセスされたときは、URIのドメインとポート番号より後ろをカレントディレクトリからの相対パスとみなし、そこにファイルが存在すればレスポンスでそれを出力することにします。
まずルーティング設定の型を以下のようにしました。Route
はURIと処理を対応付けるレコードで、handler
が処理を行う関数です。レスポンスにボディ部があるとき、その型をResponseBody
という判別共用体で定義しました。これはレスポンスのContent-Type
を設定するのに利用します。リダイレクトなどボディ部がない場合もあるため、handler
の戻り値はResponseBody option
としています。
// レスポンスの型(判別共用体)
type ResponseBody =
| Html of string
| Json of string
// ルーティングの型(レコード)
type Route = { uri: string; handler: HttpListenerContext -> ResponseBody option }
ルーティング設定はRoute
の配列としました。レスポンスのボディ部はUTF-8
とします。
// ルーティングの設定
let routes = [
{ uri = "/";
handler = fun ctx ->
Some <| Html "<html><body>Hello HttpListener</body></html>" }
{ uri = "/html/hello";
handler = fun ctx ->
Some <| Html "<html><body>こんにちはF#!</body></html>" }
{ uri = "/json/hello";
handler = fun ctx ->
Some <| Json "{\"message\":\"こんにちはF#!\"}" }
{ uri = "/redirect/hello";
handler = fun ctx ->
let response = ctx.Response
response.StatusCode <- int HttpStatusCode.Moved
response.RedirectLocation <- "/html/hello"
None }
]
これにより以下のようにアクセスできるようになります。
$ curl http://localhost:8088/
<html><body>Hello HttpListener</body></html>
$ curl http://localhost:8088/html/hello
<html><body>こんにちはF#!</body></html>
$ curl http://localhost:8088/json/hello
{"message":"こんにちはF#!"}
$ curl -L http://localhost:8088/redirect/hello # /html/helloにリダイレクト
<html><body>こんにちはF#!</body></html>
ファイルにアクセスされた時のために、ファイルの拡張子からtext/html
などContent-Type
に設定するメディアタイプの文字列に変換する関数も定義しました。変換する拡張子がほかにも必要ならここに追加していきます。ここで変換できなかったファイルはダウンロードされるようにします。
// 拡張子からメディアタイプに変換
let file2mediaType (fileName: string) =
match [
{| ext = "txt"; mediaType = MediaTypeNames.Text.Plain |}
{| ext = "html"; mediaType = MediaTypeNames.Text.Html |}
{| ext = "json"; mediaType = MediaTypeNames.Application.Json |}
{| ext = "jpg"; mediaType = MediaTypeNames.Image.Jpeg |}
{| ext = "mp3"; mediaType = "audio/mpeg" |}
{| ext = "mp4"; mediaType = "video/mp4" |}
] |> List.tryFind (fun record ->
'.'
|> fileName.Split
|> Array.last
|> (=) record.ext) with
| Some record -> Some record.mediaType
| None -> None
サーバにアクセスされたら、URIがRoute
のuri
に含まれているかを探し、それに対応するhandler
が存在すれば、それを実行します。存在しない場合はURIをカレントディレクトリからの相対パスとするファイルが存在するかを確認します。ただし、ルーティング設定のuri
が/hello
のとき、/hellogreet
などに反応しないように、uri
に区切り記号(?, #, /のいずれか)を付け足して判定しています。
// uriがルーティング設定に含まれているか
match routes |> List.tryFind (fun route ->
Regex("^" + route.uri + "(:?[\?#/]|$)").IsMatch(request.RawUrl))
with
| Some route -> // uriがルーティング設定されているとき
// 対応するhandlerの処理を実行
| None -> // uriがルーティング設定に含まれていないとき
// URIをパスとするファイルが存在するかを確認
handler
を実行した結果、レスポンスボディが返されたときはそれをUTF-8に変換し出力します。Content-Type
の設定もここで行います。ResponseBody
に項目が追加されたらここにも同様に処理を追加します。
let bytes = // レスポンスボディをUTF-8に変換
match body with
| Html str ->
response.ContentType <- (file2mediaType "html").Value + ";charset=UTF-8"
str
| Json str ->
response.ContentType <- (file2mediaType "json").Value + ";charset=UTF-8"
str
|> System.Text.Encoding.UTF8.GetBytes
let output = response.OutputStream
try
response.ContentLength64 <- bytes.Length
output.Write(bytes, 0, bytes.Length) // レスポンスボディの出力
with
| _ -> ()
output.Close()
URIをカレントディレクトリからの相対パスとみなしてファイルが存在するときは、それを読み込みつつレスポンスとして出力します。ファイルが存在しなければ、レスポンスをNot Found
とします。
// uriをカレントディレクトリからの相対パスとするファイルの読み込み
let fileName = System.Environment.CurrentDirectory
+ request.RawUrl.Replace('/', Path.DirectorySeparatorChar)
if File.Exists(fileName) then
// ファイルが存在するとき
match file2mediaType fileName with
| Some mediatype -> // メディアタイプを設定
response.ContentType <- mediatype
| None -> // メディアタイプが設定されていないファイルはダウンロード
response.AddHeader("Content-Disposition",
Path.DirectorySeparatorChar
|> fileName.Split
|> Array.last
|> sprintf "attachment; filename=\"%A\""
)
response.ContentLength64 <- (new FileInfo(fileName)).Length
// ファイルの出力
let file = File.Open(fileName, FileMode.Open)
file.CopyTo(response.OutputStream)
file.Close()
else
// ファイルが存在しないとき
response.StatusCode <- int HttpStatusCode.NotFound
ルーティング設定を追加したソースコード
//
// HttpListerによるHTTPサーバ + ルーティング設定
// dotnet fsi ソースファイル名 (Windowsは管理者ターミナルで実行)
//
open System.IO
open System.Net
open System.Net.Mime
open System.Text.RegularExpressions
// レスポンスの型(判別共用体)
type ResponseBody =
| Html of string
| Json of string
// ルーティングの型(レコード)
type Route = { uri: string; handler: HttpListenerContext -> ResponseBody option }
// ルーティングの設定
let routes = [
{ uri = "/";
handler = fun ctx ->
Some <| Html "<html><body>Hello HttpListener</body></html>" }
{ uri = "/html/hello";
handler = fun ctx ->
Some <| Html "<html><body>こんにちはF#!</body></html>" }
{ uri = "/json/hello";
handler = fun ctx ->
Some <| Json "{\"message\":\"こんにちはF#!\"}" }
{ uri = "/redirect/hello";
handler = fun ctx ->
let response = ctx.Response
response.StatusCode <- int HttpStatusCode.Moved
response.RedirectLocation <- "/html/hello"
None }
]
// 拡張子からメディアタイプに変換
let file2mediaType (fileName: string) =
match [
{| ext = "txt"; mediaType = MediaTypeNames.Text.Plain |}
{| ext = "html"; mediaType = MediaTypeNames.Text.Html |}
{| ext = "json"; mediaType = MediaTypeNames.Application.Json |}
{| ext = "jpg"; mediaType = MediaTypeNames.Image.Jpeg |}
{| ext = "mp3"; mediaType = "audio/mpeg" |}
{| ext = "mp4"; mediaType = "video/mp4" |}
] |> List.tryFind (fun record ->
'.'
|> fileName.Split
|> Array.last
|> (=) record.ext) with
| Some record -> Some record.mediaType
| None -> None
let port = 8088
let listener = new HttpListener()
listener.Prefixes.Add(sprintf "http://+:%A/" port)
listener.Start()
printfn "Server started(%A). Stop Ctrl + C..." port
while listener.IsListening do
let context = listener.GetContext()
let request = context.Request
let response = context.Response
// uriがルーティング設定に含まれているか
match routes |> List.tryFind (fun route ->
Regex("^" + route.uri + "(:?[\?#/]|$)").IsMatch(request.RawUrl))
with
| Some route -> // uriがルーティング設定されているとき
match route.handler context with // ルーティング設定の関数を実行
| Some body -> // レスポンスボディが存在するとき
let bytes = // レスポンスボディをUTF-8に変換
match body with
| Html str ->
response.ContentType <- (file2mediaType "html").Value + ";charset=UTF-8"
str
| Json str ->
response.ContentType <- (file2mediaType "json").Value + ";charset=UTF-8"
str
|> System.Text.Encoding.UTF8.GetBytes
let output = response.OutputStream
try
response.ContentLength64 <- bytes.Length
output.Write(bytes, 0, bytes.Length) // レスポンスボディの出力
with
| _ -> ()
output.Close()
| None -> ()
| None -> // uriがルーティング設定に含まれていないとき
// uriをカレントディレクトリからの相対パスとするファイルの読み込み
let fileName = System.Environment.CurrentDirectory
+ request.RawUrl.Replace('/', Path.DirectorySeparatorChar)
if File.Exists(fileName) then
// ファイルが存在するとき
match file2mediaType fileName with
| Some mediatype -> // メディアタイプを設定
response.ContentType <- mediatype
| None -> // メディアタイプが設定されていないファイルはダウンロード
response.AddHeader("Content-Disposition",
Path.DirectorySeparatorChar
|> fileName.Split
|> Array.last
|> sprintf "attachment; filename=\"%A\""
)
response.ContentLength64 <- (new FileInfo(fileName)).Length
// ファイルの出力
let file = File.Open(fileName, FileMode.Open)
file.CopyTo(response.OutputStream)
file.Close()
else
// ファイルが存在しないとき
response.StatusCode <- int HttpStatusCode.NotFound
response.Close()
Discussion