こんにちは。42Tokyo Advent Calendar 19日目を担当する、在校生のkfujitaです。
18日目の記事は、 @konaito さんによる とりあえず動くアプリを作る方法 でした。
20日目の記事は、@nemukyoku さんによる 電子工作初心者が謎の中華製サーマルプリンタをしばくまで です。
遅くなり申し訳ありません
42には webserv
という、Webサーバを実装する課題があります。
あまり具体的な話を書くわけにはいかないのですが、簡単に言うとnginxライクなプログラムを作ろう、みたいな課題です。もちろん実装するのはほんとに一部の機能だけですが、Common Core後半のチーム課題なだけあり、かなり重いものになっています。
今回、別件ですが C#で簡単なHTTPサーバ を作ったので、どうせだしということで同じようなものをCでも作ってみよう、ということで記事のネタにしました。
webservの課題要件には従っていないので、その点ご了承ください。
ご安心ください? webservはC++で開発することになっています。
今回のリポジトリ
処理の流れ
C#版の方に書いた内容と同じ感じになりますが、簡単に書くと以下のような流れで処理を行います。
- TCP接続を待ち受け
- クライアントから接続を受ける
- リクエストの最初の行を読み取り、「メソッド」「ターゲット」「HTTPバージョン」の3点を取得する
- 空行が現れるまで、リクエストヘッダを読み取る
- 1行ごとに、
:
(コロン) 区切りでヘッダの名前と値が送られてくるので、それを解析する - ヘッダの名前は大文字小文字を識別しない (case-insensitive)
- 1行ごとに、
- (Request Bodyがある場合) 空行を挟んで、リクエストボディを読み取る
- リクエストの解析結果をもとに、適切な処理を行い、レスポンスを生成する
- レスポンスを返す
- 接続を閉じる
- (1に戻る)
なお、諸事情でRequest Bodyの解析だったりHeaderの名前のcase-insensitive実装だったり「リクエストに応じた適切な処理」だったりを実装できていません。
1. TCP接続を待ち受け
待ち受け自体は本当に簡単で、
- ソケット用のFile Descriptorを生成
- ソケットにアドレスを結びつける
- ソケットで接続の待ち受けを開始する
これだけです。
__attribute__((nonnull))
bool ft_httpserv_init(
t_httpserv *serv,
uint16_t port
)
{
serv->port = port;
serv->serv_addr.sin_family = AF_INET;
serv->serv_addr.sin_addr.s_addr = INADDR_ANY;
serv->serv_addr.sin_port = htons(port);
serv->sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (serv->sockfd < 0)
return (perror_retint("socket init", false));
if (bind(serv->sockfd, (struct sockaddr *)&(serv->serv_addr),
sizeof(serv->serv_addr)) < 0)
return (perror_retint("bind", false));
if (listen(serv->sockfd, 8) < 0)
return (perror_retint("listen", false));
return (true);
}
listen
では 接続の待ち受けキューの長さ (backlog
) として 8
を指定していますが、これは適当に決めただけです。
2. クライアントから接続を受ける
listen
してると、接続を受ける度にキューへと接続要求が溜まっていきます。キューから接続要求を取り出して処理するには、accept
を使用します。
ft_bzero(&addr, sizeof(addr));
addrlen = sizeof(addr);
sockfd = accept(serv->sockfd, &addr, &addrlen);
if (sockfd < 0)
return (perror_retint("accept", false));
戻り値のsockfdを使用することで、acceptしたセッションでの通信を行うことができます。
3. リクエストの最初の行を読み取り、「メソッド」「ターゲット」「HTTPバージョン」の3点を取得する
sockfd
はFile Descriptorなので、read(2)を使用することができます。
sockfd
から read
を使ってリクエストを読み取り、そのうちの最初の1行を使用します。
例えば、curlで -v
を付けて実行すると、リクエスト・レスポンスをstderrに出力してくれます。
% curl -v --HEAD 't0r.dev/abc/def?ghi=jkl&mn=op#qr-s'
* Trying 118.27.125.168:80...
* Connected to t0r.dev (118.27.125.168) port 80
> HEAD /abc/def?ghi=jkl&mn=op HTTP/1.1
> Host: t0r.dev
> User-Agent: curl/8.4.0
...
1行目がコマンドを入力した部分なんですが、この4行目に HEAD /abc/def?ghi=jkl&mn=op HTTP/1.1
のように出力されています。
この場合、以下のような構造になっています。
- Method:
HEAD
- Target:
/abc/def?ghi=jkl&mn=op
- Version:
HTTP/1.1
これらがスペース区切りで書かれているわけなので、単純にスペースで分割すればOK…なはずです。
とはいえちょっと怖かったので、「前後trimして、最初のSPCまでがMethod、最後のSPCから後ろがVersion、それ以外がTarget」という実装をしました。
4. 空行が現れるまで、リクエストヘッダを読み取る
これは本当に簡単で、:
の前後でNameとValueに分けるだけです。
本当はcase-insensitive実装だったり Valueの ,
での分割とかすべきだったんですが、ちょっと時間がなかった…
static void _str_trim(char **str)
{
size_t i;
size_t len;
i = 0;
while (ft_isspace((*str)[i]))
(*str)[i++] = '\0';
*str += i;
len = ft_strlen(*str);
i = len - 1;
while (0 < i && ft_isspace((*str)[i]))
(*str)[i--] = '\0';
while (ft_isspace((*str)[len - 1]))
--len;
}
static t_http_header_list *_read_parse_header(int sockfd, t_gnl_state *state)
{
t_http_header_list *headers;
char *line;
char *value;
headers = NULL;
while (true)
{
line = get_next_line(state);
if (line == NULL)
break ;
if (ft_isspace_str(line))
return (headers);
value = ft_strchr(line, ':');
if (value == NULL)
break ;
*value++ = '\0';
_str_trim(&value);
if (!http_header_list_add(&headers, line, value))
break ;
free(line);
}
free(line);
http_header_list_free(&headers);
_res_err_500(sockfd, "error @ " __FILE__);
return (NULL);
}
5, 6 (省略)
実装できていないので、省略します…
7. レスポンスを返す
これも簡単で、レスポンスのStatus Lineを出力後、レスポンスヘッダを出力し、空行を挟んだあとResponse Bodyを出力します。
bool ft_httpserv_send_status(
int sockfd,
t_http_status_code status_code
)
{
char buf[BUF_SIZE];
size_t len;
buf[0] = '\0';
len = ft_strlcat(buf, HTTP_VER_STR, BUF_SIZE);
buf[len++] = ' ';
ft_itoa(status_code, buf + len);
ft_strlcat(buf, " ", BUF_SIZE);
ft_strlcat(buf, get_http_status_msg(status_code), BUF_SIZE);
ft_strlcat(buf, STR_CRLF, BUF_SIZE);
len = ft_strlen(buf);
if (send(sockfd, buf, len, 0) != (ssize_t)len)
return (perror_retint("send(HTTP Response - status line)", false));
return (true);
}
__attribute__((nonnull))
bool ft_httpserv_send_header(
int sockfd,
const char *key,
const char *value
)
{
size_t len;
ssize_t ret;
len = ft_strlen(key);
ret = send(sockfd, key, len, 0);
if (ret != (ssize_t)len)
return (perror_retint("send(HTTP Header - key)", false));
len = sizeof(HTTP_HEADER_SEP) - 1;
ret = send(sockfd, HTTP_HEADER_SEP, len, 0);
if (ret != (ssize_t)len)
return (perror_retint("send(HTTP Header - sep)", false));
len = ft_strlen(value);
ret = send(sockfd, value, len, 0);
if (ret != (ssize_t)len)
return (perror_retint("send(HTTP Header - val)", false));
len = sizeof(STR_CRLF) - 1;
ret = send(sockfd, STR_CRLF, len, 0);
if (ret != (ssize_t)len)
return (perror_retint("send(HTTP Header - sep)", false));
return (true);
}
バッと載せちゃいましたが、こんな感じでひたすらに出力してきます。
ちなみに、sockfd
はFile Descriptorなので write
でも送信できます。特に今回は引数 flags
を使用しなかったので、write関数でも十分でした。
8. 接続を閉じる
接続を閉じる場合は close
を呼ぶだけ… ではあるんですが、
タイミングによっては「connection reset by peer」みたく、全て送信しきる前に接続が閉じられてします場合があります。
これは、Nagleアルゴリズムによって送信が遅延される場合があるためです。
これを無効化してしまってもいいのですが、shutdown
を使うことで送信しきってからの終了ができるとのことだったので、今回はそれを使うことにしました。
↓参考
shutdown(sockfd, SHUT_RDWR);
close(sockfd);
最後に
TCP接続周りは本当に簡単でしたが、リクエストの解析が無茶苦茶面倒でした…
この辺り、C#とかだと本当に楽できますね。
遊びで作ったプログラムなのでこれをメンテナンスするつもりはありませんが、気が向いたら不足部分を実装したいと思います。
まぁwebservで不足部分全部実装することになると思うので、それで代わりになりそうですが…