この投稿は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にする
- Bodyを読む都合で、
で、HTTPの一つ下のレイヤがTCPで、ちょうど.NETには TcpListener という便利なクラスがあるため、今回はこれを使って実装することにしました。
処理の流れ
今回はメインの処理のバックグラウンドで動作させるため、起動とは別のスレッドでHTTP関連の処理を行うようにしました。
なので、大まかに次のような処理の流れになります。
-
HttpServer
のコンストラクタ呼び出し -
HttpServer
型インスタンスのStart
メソッドを実行
ここで、別スレッドにてHTTPリクエスト待機タスクを開始します。そして、タスク起動後にStart
メソッドが返り、元の処理を続行します- クライアントからの接続を待機
- リクエスト解析とレスポンス送信処理を別スレッドで開始
(以降、1と2の繰り返し)
また、リクエスト解析とレスポンス送信の処理は次のような流れになります。
- リクエスト行を読み取り・メソッド等を解析
- HTTPヘッダのヘッダーフィールドを読み取り、
NameValueCollection
に格納 -
NameValueCollection
型インスタンスからContenet-Length
を読み取り、その分だけリクエストボディも読み取る - リクエストの解析結果を
HttpRequest
型インスタンスに格納し、それを元にレスポンス生成用ハンドラを実行する - ハンドラから返ってきたレスポンス情報をもとに、レスポンスを送信する
- (リソースを解放する)
それぞれの処理の実装はそこまで難しいものではないので省略します。見たい方はリポジトリの方でご確認ください
リクエストを受けて解析クラスを実行するまでのクラス
リクエスト解析を行うクラス
詰まったところ
1. 書き込みにStreamWriterを使えなかった
理由はわかりませんでしたが、なぜか StreamWriter
でレスポンスを書き込もうとするとHTTP/0.9として認識されてしまいました。
文字列を書き込むのに 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
でヘッダ文字列を作成していたので、そのときの考えが抜けずに書いていました…
今回は、ヘッダ文字列の最後の要素として空文字列を指定することで、最後の要素 + 改行 + 空文字列
という状態にし、ヘッダ末尾の改行を実現しました。
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 でWeb Serverの実装を行う課題を始めるということもあり、C言語でも今回のような簡易HTTP Serverを実装してみようと思いまして。(その課題はCじゃなくてC++で実装するんですが、そこはまぁまぁ)
C言語版の簡易HTTP Serverの記事は 42 Tokyo Advent Calendar 2023 の19日目の記事として投稿予定です。お楽しみに?