LoginSignup
5
0

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

Last updated at Posted at 2023-12-06

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

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


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

C#では HttpListener というクラスを使うことで、簡単にHTTP Serverの機能を実装することができます。

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

(たぶんこの辺?↓)

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

成果物

方針

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

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

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

で、HTTPの一つ下のレイヤがTCPで、ちょうど.NETには TcpListener という便利なクラスがあるため、今回はこれを使って実装することにしました。

処理の流れ

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

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

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

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

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

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

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

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

詰まったところ

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

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

image.png

文字列を書き込むのに StreamWriter は便利なんですが、今回は文字列を一旦UTF-8な byte[] に変換したうえで、NetworkStream.WriteAsync メソッド にて送信を行うようにすることで解決しました。

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

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

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 でヘッダ文字列を作成していたので、そのときの考えが抜けずに書いていました…

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

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

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

image.png

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

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

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

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

あとがき

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

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


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

C言語版の簡易HTTP Serverの記事は 42 Tokyo Advent Calendar 2023 の19日目の記事として投稿予定です。お楽しみに?

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0