簡易HTTP Serverを作ってみた (C#編)

この投稿はC#アドベントカレンダー2023の7日目の記事です。

明日は @annulusgames さんによる「【C#】unsafeコードを書いてみよう」です。

---

さて、皆さん、HTTPを使用したことはありますか?
いやまぁこの投稿を見ている時点でHTTPは使っているはずですが。

C#では [HttpListener](https://learn.microsoft.com/ja-jp/dotnet/api/system.net.httplistener?view=net-8.0) というクラスを使うことで、簡単にHTTP Serverの機能を実装することができます。

**が**、残念ながらWindows環境のHttpListenerでは外部からの接続を受けるためにかなり面倒な設定が必要になってきます。

(たぶんこの辺？↓)

https://stackoverflow.com/questions/50517010/httplistener-wont-work-over-network-even-with-firewall-allowed

今回、ゲームのMODで外部からHTTPリクエストを受ける必要があり、一般公開するMODでそんな特殊な操作を要求するのはなぁと考え、`HttpListener` に依存しないHTTP Serverを実装してみることにしました。

## 成果物

https://github.com/TetsuOtter/SimpleHttpServerDotNet

## 方針

大前提として、HttpListenerは使用できません。
なので、HttpListenerが実現している機能を別の方法で実装していくことになります。

まぁ流石に全部は実装しきれないので、今回は以下の方針を定めました。

- HTTPのみをサポートする (HTTPSはサポートしない)
- `cURL` でリクエスト飛ばして、正常にレスポンスが表示される状態を目指す
- ルーティングはサポートしない (呼び出し元 = MOD側 で実装してもらう)
- リクエストヘッダは `NameValueCollection` に放り込むだけ
  - Bodyを読む都合で、`Content-Length` をlongに変換できない場合はBad Requestにする

で、HTTPの一つ下のレイヤがTCPで、ちょうど.NETには [TcpListener](https://learn.microsoft.com/ja-jp/dotnet/api/system.net.sockets.tcplistener?view=net-8.0) という便利なクラスがあるため、今回はこれを使って実装することにしました。

## 処理の流れ

今回はメインの処理のバックグラウンドで動作させるため、起動とは別のスレッドでHTTP関連の処理を行うようにしました。

なので、大まかに次のような処理の流れになります。

1. `HttpServer` のコンストラクタ呼び出し
2. `HttpServer` 型インスタンスの `Start` メソッドを実行  
   ここで、別スレッドにてHTTPリクエスト待機タスクを開始します。そして、タスク起動後に `Start` メソッドが返り、元の処理を続行します
    1. クライアントからの接続を待機
    2. リクエスト解析とレスポンス送信処理を別スレッドで開始  
       (以降、1と2の繰り返し)

また、リクエスト解析とレスポンス送信の処理は次のような流れになります。

1. リクエスト行を読み取り・メソッド等を解析
2. HTTPヘッダのヘッダーフィールドを読み取り、`NameValueCollection` に格納
3. `NameValueCollection` 型インスタンスから　`Contenet-Length` を読み取り、その分だけリクエストボディも読み取る
4. リクエストの解析結果を `HttpRequest` 型インスタンスに格納し、それを元にレスポンス生成用ハンドラを実行する
5. ハンドラから返ってきたレスポンス情報をもとに、レスポンスを送信する
6. (リソースを解放する)

それぞれの処理の実装はそこまで難しいものではないので省略します。見たい方はリポジトリの方でご確認ください

https://github.com/TetsuOtter/SimpleHttpServerDotNet

### リクエストを受けて解析クラスを実行するまでのクラス

https://github.com/TetsuOtter/SimpleHttpServerDotNet/blob/main/TR.SimpleHttpServer/HttpServer.cs

### リクエスト解析を行うクラス

https://github.com/TetsuOtter/SimpleHttpServerDotNet/blob/main/TR.SimpleHttpServer/ProcessOneConnectionWorker.cs

## 詰まったところ

### 1. 書き込みにStreamWriterを使えなかった

理由はわかりませんでしたが、なぜか `StreamWriter` でレスポンスを書き込もうとするとHTTP/0.9として認識されてしまいました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687506/a9e07a37-ed45-591f-388d-b0da1d388398.png)

文字列を書き込むのに `StreamWriter` は便利なんですが、今回は文字列を一旦UTF-8な `byte[]` に変換したうえで、[`NetworkStream.WriteAsync` メソッド](https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.writeasync?view=net-8.0) にて送信を行うようにすることで解決しました。

### 2. (凡ミス) ResponseにてHeaderとBodyの間の空行を入れ忘れた

最初、↓のようなコードを書いていました。

```cs
string headerStr = string.join("\r\n", [
    $"HTTP/1.0 {status}",
	$"Server: {typeof(HttpServer).FullName}",
	$"Content-Type: {contentType}; charset=UTF-8",
	$"Content-Length: {content.Length}",
	$"Date: {DateTime.UtcNow:R}",
	$"Connection: close"
]);

string bodyStr = "***";

// 面倒なのでstringを直接渡しているように書いていますが、実際はbyte[]に直してから渡しています
await stream.WriteAsync(headerStr);
await stream.WriteAsync("\r\n");
await stream.WriteAsync(bodyStr);
```

すごい凡ミスでした。
当たり前ながら、`string.join` って要素間に第一引数の文字列を挿入して合体させるだけなんですよね。なので、ヘッダ文字列の末尾に改行が挿入されていない状態でした。
元々 `StringBuilder.AppendLine` でヘッダ文字列を作成していたので、そのときの考えが抜けずに書いていました…

今回は、ヘッダ文字列の最後の要素として空文字列を指定することで、`最後の要素 + 改行 + 空文字列` という状態にし、ヘッダ末尾の改行を実現しました。

:::note info
ちなみに、HEADメソッド等、Response Bodyが存在しない (≒ GETメソッド等のレスポンスで `Content-Length` が `0`) 場合は、最初のコードでも問題なく動作します。これは、「HeaderとBodyを区切ろうとしていたCRLF」が、ヘッダ末尾の改行と化しているためです。

Bodyが付くと、HeaderとBodyの間の空行が無いせいでBodyもHeaderとして認識されてしまい、結果「`Content-Length` でBodyのサイズが指定されているはずなのに、それだけのBodyを読み取ることができなかった」と怒られます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687506/1e9a98fe-4a02-27a8-69c4-2b587585eda8.png)
:::

### 3. StreamReaderでRequest Headerを読むと、Bodyをbyte[]で読み取れない

当たり前と言えば当たり前なんですが、`StreamReader.ReadLineAsync` 等のメソッドは、ある程度まとめて`Stream` から読んで、それをクラス内部のバッファに溜めたうえで、そこから改行文字を探し出してそこまでを返す… という実装になっています。

ここで重要なのは、「まとめて読んで、バッファに溜める」という挙動です。
`NetworkStream` は `Seek` 等でposをずらすことができないため、一度読んだらそれっきり再度読むことができません。
つまり、「HeaderをReadLineして、Headerが終わったらBodyをbyte[]で取得する」処理において、「StreamReaderによってまとめて読まれた部分」を取得する手立てがなくなってしまいます。

そこで、今回は「1行を `string` で読み取る」機能と「残りのデータを `byte[]` で読み取る」機能を自分で実装しました。

## あとがき

色々雑に実装したので、危ないところがいっぱいありそうです…

今回の実装では、リクエストボディを読む際に、受け取ったContent-Lengthを使用しない実装になってしまっています。
修正すべきだよなぁとは思いつつ、面倒なので一旦このままでいきました。そのうち直そうと思います。

---

さて、今回はC#で実装しましたが、もちろん他の言語でも同じように実装できるはずです。
そこで、そろそろ [42Tokyo](https://42tokyo.jp/) でWeb Serverの実装を行う課題を始めるということもあり、C言語でも今回のような簡易HTTP Serverを実装してみようと思いまして。(その課題はCじゃなくてC++で実装するんですが、そこはまぁまぁ)


C言語版の簡易HTTP Serverの記事は [42 Tokyo Advent Calendar 2023](https://qiita.com/advent-calendar/2023/42tokyo) の19日目の記事として投稿予定です。お楽しみに?

https://42tokyo.jp/