7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

42 TokyoAdvent Calendar 2023

Day 19

簡易HTTP ServerをCで作ってみた

Posted at

こんにちは。42Tokyo Advent Calendar 19日目を担当する、在校生のkfujitaです。

18日目の記事は、 @konaito さんによる とりあえず動くアプリを作る方法 でした。
20日目の記事は、@nemukyoku さんによる 電子工作初心者が謎の中華製サーマルプリンタをしばくまで です。

遅くなり申し訳ありません :bow: :bow: :bow:


42には webserv という、Webサーバを実装する課題があります。
あまり具体的な話を書くわけにはいかないのですが、簡単に言うとnginxライクなプログラムを作ろう、みたいな課題です。もちろん実装するのはほんとに一部の機能だけですが、Common Core後半のチーム課題なだけあり、かなり重いものになっています。

今回、別件ですが C#で簡単なHTTPサーバ を作ったので、どうせだしということで同じようなものをCでも作ってみよう、ということで記事のネタにしました。
webservの課題要件には従っていないので、その点ご了承ください。

ご安心ください? webservはC++で開発することになっています。

今回のリポジトリ

処理の流れ

C#版の方に書いた内容と同じ感じになりますが、簡単に書くと以下のような流れで処理を行います。

  1. TCP接続を待ち受け
  2. クライアントから接続を受ける
  3. リクエストの最初の行を読み取り、「メソッド」「ターゲット」「HTTPバージョン」の3点を取得する
  4. 空行が現れるまで、リクエストヘッダを読み取る
    • 1行ごとに、: (コロン) 区切りでヘッダの名前と値が送られてくるので、それを解析する
    • ヘッダの名前は大文字小文字を識別しない (case-insensitive)
  5. (Request Bodyがある場合) 空行を挟んで、リクエストボディを読み取る
  6. リクエストの解析結果をもとに、適切な処理を行い、レスポンスを生成する
  7. レスポンスを返す
  8. 接続を閉じる
  9. (1に戻る)

なお、諸事情でRequest Bodyの解析だったりHeaderの名前のcase-insensitive実装だったり「リクエストに応じた適切な処理」だったりを実装できていません。

1. TCP接続を待ち受け

待ち受け自体は本当に簡単で、

  1. ソケット用のFile Descriptorを生成
  2. ソケットにアドレスを結びつける
  3. ソケットで接続の待ち受けを開始する

これだけです。

ft_httpserv.c#L27-L46
__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_httpserv.c#L64-L68
		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の , での分割とかすべきだったんですが、ちょっと時間がなかった…

parse_req.c#L21-L65
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を出力します。

send_response.c#L28-L76
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 を使うことで送信しきってからの終了ができるとのことだったので、今回はそれを使うことにしました。

↓参考

ft_httpserv.c#L73-L74
		shutdown(sockfd, SHUT_RDWR);
		close(sockfd);

最後に

TCP接続周りは本当に簡単でしたが、リクエストの解析が無茶苦茶面倒でした…
この辺り、C#とかだと本当に楽できますね。

遊びで作ったプログラムなのでこれをメンテナンスするつもりはありませんが、気が向いたら不足部分を実装したいと思います。
まぁwebservで不足部分全部実装することになると思うので、それで代わりになりそうですが…

7
4
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
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?