42tokyo の課題に、HTTPサーバープログラムを作成するものがある。今回はそれをクリアした中で、概要は他の方の記事で書かれているのでざっくりと省きつつ、印象に残った点を解説する。
対象読者:
これからHTTPサーバーを作成したい人
全体で10000行は超えないくらいの小規模なプログラムを作成するときの話が気になる人(一番下の反省へ)
書いたときのTips的な内容が多いので、すぐやらない人にはあまり参考にならないかもしれません。
(あとがき)
これを気にするべき。べき。で草むらをかき分けるように進めるのが得意で楽しい人もいると思います。それは別として、HttpServer として満たさなければいけない要件は明確に決まっています。
https://datatracker.ietf.org/doc/html/rfc2119
MUST and MUST NOT を満たしているかが、絶対条件です。
https://tex2e.github.io/rfc-translater/html/rfc9110.html
自分はRFC でMUST を単語検索しながらコードを書いていました。
-
HTTPサーバとは何か
HTTPリクエストを受け取り、HTTPレスポンスを返すプログラムである。 -
多重化IO ノンブロッキングIO 非同期IO ...
ここらへんが、言葉が堅苦しくて分かりづらかったので自分なりに整理する(間違ってるかも)。 大前提、これらの単語の定義はお互いの意味を含まない。別々の話である。
多重化IO とは、一つのスレッドが複数のIO(クライアント)をさばくことである。
ノンブロッキングIOとは、IO処理をカーネルに発行した後、ブロッキングされる処理が生じた時点でカーネルは仕事を辞めてプロセスに返すこと。
非同期IOとは、1つのスレッドがカーネルに対して同時に沢山のIO処理を発行できること。つまり、多重化IOという目的を果たすために、ノンブロッキングIOとか非同期IOが必要になる。そして、今回の課題では"同期"ノンブロッキングIOを用いて多重化IOを実現した。(つまり、1プロセスが複数のIOシステムコールを同時にカーネルに投げることは無い。しかも1プロセスで処理を進めるので、非同期な処理は含まれない)
(自分はIOの状態監視にselect() を用いたが、他の関数を用いると非同期に出来るのかもしれない?)https://qiita.com/legokichi/items/1f3b1bd51e206ffdd2a6
この方を参考にさせていただいたが、非同期ブロッキングの定義が自分とは異なっていると思う。 -
プログラムの流れ
- 設定ファイルを読み込む
- リスニングソケットを立ち上げ、接続待ち状態にする。fdの監視リストに登録する。
- fd のリストを監視する
- リスニングソケットが利用可能であればクライアントと接続を開始し、リストに読み取り対象でリストに登録する
- 読み取り対象ソケットが利用可能であればリクエストを読み取り、読み込みが完了したら書き込み対象でリストに登録する
- 書き込み対象ソケットが利用可能であればレスポンスを書き込み、書き込みが完了したらリストから削除し接続を切る
- 2に戻る
- 1~2 でエラーがあるか 3~7 で致命的なエラーがあればプログラムを終了する。
こんな感じ。
-
設定ファイルについて
私たちは設定ファイルでパースした情報をシングルトンパターンで管理した。
https://ja.wikipedia.org/wiki/Singleton_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3#:~:text=Singleton%20%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3%E3%81%A8%E3%81%AF%E3%80%81%E3%81%9D%E3%81%AE,%E5%AE%9F%E8%A3%85%E3%81%AB%E4%BD%BF%E7%94%A8%E3%81%95%E3%82%8C%E3%82%8B%E3%80%82
使ってみての感想は、まあまあ便利。作成してから不可変な情報に関しては利用機会が多いと思う。
Config クラスをどこからでも参照できるので、引数で渡す必要は無くなる。感覚的にはグローバル変数。
書き込みを許すと、どこからでも変更できてしまうので追えなくなってしまう。setter も用意しなかった。 -
CGI 周りについて
プログラムの概要は3の通りでイイのだが、CGIが絡むとややこしくなってくる。ややこしいのは以下の点。- CGIに送るべきメッセージや、CGIから返ってくるレスポンスはHTTPMessage(HttpRequest とHttpResponseのこと)とは異なっている。ので、メッセージ読み込み時のパースで、Cgiからなのか、Clientからなのかで分岐する必要がある。
- LocalRedirectResponse (LRR) というやつ
CGIResponseには4つの種類がある。3つは、CGIからの返答を受け取った時点で、Client へ返事を作成することが出来る。つまり
fromClientRequest -> toCgiRequest -> fromCgiResponse -> toClientResponse
というフローで処理できる。ここで、LRRというのは、Cgiがもう一度自分のプログラムに対してRequest を行うよう要求するレスポンスである。つまり
fromClientRequest ->
toCgiRequest -> fromCgiResponse (Loop)
(LRR 以外のResponse が来たら) -> toClientResponse
というフローになる。
自分は、
input:受信メッセージ
output:送信メッセージ
の関数 router() と
input:受信ソケット 受信メッセージ 送信メッセージ
output:送信ソケット
の関数 getSendSock() で処理を分けてまとめました。
Socket *getHandleSock(Socket *sock, HttpMessage *recvdMessage, HttpMessage *toSendMessage)
{
ClSocket *clSock = NULL;
if (CgiSocket *cgiSock = dynamic_cast<CgiSocket *>(sock)) // from cgi
{
clSock = cgiSock->moveClSocket();
socketDeleter(cgiSock);
}
else
clSock = dynamic_cast<ClSocket *>(sock); // from client
if (dynamic_cast<Request *>(toSendMessage))
{
CgiSocket *newCgiSock;
if ((newCgiSock = CgiSocketFactory::create(*recvdMessage, clSock))) // to cgi
_socketCount++;
return (newCgiSock);
}
else
return (clSock); // to client
}
CGIを実行する際、Request や Client の情報を環境変数を通して作成する必要があります。
そのために、LRRで生成したリクエストを、本当にクライアントから生成したリクエストかのように、3.5の処理に入れることも考えたのですが、select のイベントをこちらから発火させる方法が分からなかったので辞めました。
でも、select のラッパー関数を作って、勝手にfd_set をいじれば出来たなと今思いつきました。いや、でも、select()だと、状態を1bit で管理してるので、ソケットの状態が複数もたせられないんですよね。読み取り対象として登録して勝手に発火させても、recvするフローに入ってしまって、返り値が-1 になると思うので、エラーフローに巻き込まれてしまいます。poll とかだと、確かもう少し状態にビットが割り当てられてたと思うので、poll で使用していない範囲のbit を使って、独自にイベントを追加することができそうです。(poll に更新が会った時の互換性が悪そうですが)
色々アイデアはありますが、ソケットがどういう処理のフローにあるかは、保存先を変更することでわけるのではなくソケットに状態を持たせれば良かったな。。
https://jun-networks.hatenablog.com/entry/2022/12/05/234522#%E8%AA%B2%E9%A1%8C%E6%A6%82%E8%A6%81
こちらのブログがわかりやすかったです。
CGIについて↓
https://tex2e.github.io/rfc-translater/html/rfc3875.html#3-1--Server-Responsibilities
あとまあ、自分最初気づかなかったのですが、根本的な意味でCGIサーバーも作る必要があります。CGIサーバーとは、CGIプログラムを起動するための引数がinput で、CGIResponse がoutput のサーバーです。基本的に、CGIスクリプトを形式通りinputを用いて実行すればoutputを出力できるのですが、引数にあるスクリプトやランタイムが実行可能あるいは読み取り可能でなかった時、エラーを生成しなければなりません。そこらへんは、HTTPサーバーとも似ています。42のレビューで気づきました。ありがとうございます。
かといって、CGIサーバーが信用できるものだとは限らないので、CGIサーバーがおかしい出力をしてきた時、(例えばexecve() のエラーとか)もハンドリングするべきです。
- 反省
自分は、大局観と今何やってるか両方意識せず気分が乗ったところから書いてしまうことが多かったのですが、そのままだと自分でHttp に準拠したところを自分で破壊しかねないので、今回はコミットメッセージやブランチを細かく分けて自分を管理しました。次はissue を目的意識持って作成しようと思います。
HttpServerみたいな、要件定義が外部で明確に(細かく)行われているときは、ほんと自分で管理することは絶対出来ないので、issue や commit に準拠した(準拠する) 要件のリンクを章を指定して残したほうがいいです。content-length なかったら、読み込まなくてよかったよね。。? あれ、次回の読み込みでBody だけ残ってたらどうするんだっけ。。? とりあえず今実装するところじゃないから後でいいか。。。。。 ってやってると、頭にタスクが蓄積していって、病むかやる気しなくなってきます。 明確な要件を持つ issue を建てるということは、(その時点で把握しきれていないため実質的に)無限の"やるべきこと"へ注意を 有限で扱えるものに限定させることです。紙で管理してもいいけど、リンクとか書けないし紙はコピー機能無いのでMUST準拠も大変だと思います。やる気が湧かなくなったら、そのこと自体をタスクだと思って処理しましょう