💎

それ、F#でやってみました - System.Net.HttpListenerによるHTTPサーバ

2024/04/12に公開

dotnet fsi ソースファイル名で起動しCtrl + Cで停止するHTTPサーバを作成してみました。動作はアクセスをカウントするHTMLコンテンツを返すのみです。System.Net.HttpListenerクラス1つでHTTPサーバを構築、起動できます。

System.Net.HttpListenerによる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()
結果(アクセスされるごとにカウントが1ずつ増えていく)
$ 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がRouteuriに含まれているかを探し、それに対応するhandlerが存在すれば、それを実行します。存在しない場合はURIをカレントディレクトリからの相対パスとするファイルが存在するかを確認します。ただし、ルーティング設定のuri/helloのとき、/hellogreetなどに反応しないように、uriに区切り記号(?, #, /のいずれか)を付け足して判定しています。

アクセスされた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

ルーティング設定を追加したソースコード

HttpListenerによるHTTPサーバ+ルーティング設定
//
// 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