雑なHTTPサーバーをC#で書いてみる
Created at:
人生のうち一度はHTTPサーバーを書くという話をしたら意外とそうでもなくてあれー?ってなったのですが、それはそれとして。
「HTTPサーバーには人生の大切な事が全てつまっている」と言った人がいるかどうかは定かではないですが、
雑なサーバーを書いてみるのはまあまあ簡単で理解も進んで面白いのでおすすめですよ、というわけで書いてみましょうという話。
アプリにWebサーバーを組み込みたくなったりして雑なHTTPサーバーを書いてみることというのがたまにあります。
そんな時、現代ではどの言語やプラットフォームでもパッケージやライブラリであっという間にサーバーを組み込めるので自分で書くことはないかもしれません。
ですが今時だとArduinoのような省メモリであることを必要とされる環境でもHTTPサーバーが必要になったとき、
ほぼ固定機能のHTTPサーバーを実装してAPIやWebページからコントロールできるように…ということもよくあると思いますので経験や知識は無駄ではないと思います。多分。
この記事ではC#で書きますが、お好みのプラットフォームなどで書いてみるとよいかと思います。
対象読者
- C#チョットデキル
- HTTPはなんとなく知っている
- サーバーとクライアントがあって、リクエストを投げる云々、GETとかHEADとかPOSTとか知ってる
- Socketはなんとなく知っているかもしれないし知らないかもしれない
雑なHTTPサーバーとは
おおよそこんな感じ。
- HTTP/1.0 っぽい
- パースも雑
- 長すぎるデータを考慮するとかもしない
- エラー処理も雑
- Keep-Alive サポートしない
- 他いろいろサポートしない
要するにお遊びなのでRFCとかも読まないし実際適当です。ほとんどのブラウザーなどからおおよそアクセスして何か表示できればよい程度を目指します。
ただし外部に出ると死ぬ(セキュリティーとか)ような感じもあるのでまじめに使う前提ではありません。
流れとHTTPについて
流れについて一応雑に説明すると…
- HTTPサーバー: クライアントからの接続を待ち受ける
- HTTPクライアント: サーバーへ接続する
- HTTPサーバー: クライアントからの接続を受け入れる
- HTTPクライアント: サーバーへリクエストを送信する
- HTTPサーバー: クライアントからのリクエストを解釈する
- HTTPサーバー: レスポンスをクライアントへ送信する
- HTTPクライアント: レスポンスを解釈する
となっています。ですのでHTTPサーバー部分を作るので以下の部分を作ればよいことになります。
- HTTPサーバー: クライアントからの接続を待ち受ける
- HTTPサーバー: クライアントからの接続を受け入れる
- HTTPサーバー: クライアントからのリクエストを解釈する
- HTTPサーバー: レスポンスをクライアントへ送信する
HTTPについてですが、ご存知の通りリクエストとレスポンスというメッセージを送受信するプロトコルです。
リクエストメッセージはこんな感じです。
POST / HTTP/1.0[CR][LF] User-Agent: Nantoka/1.0[CR][LF] X-Nantoka: Kantoka[CR][LF] [CR][LF] Body
CR+LFで区切られていて、CR+LFだけの行が来るまでがヘッダー部分、それ以降がボディ部分となっていてあったりなかったりする部分です。
レスポンスメッセージはこんな感じ。おおよそ同じです。一行目(スタート行)がちょっと違いますがそれ以外の形は同じです。
HTTP/1.0 200 OK[CR][LF] Server: Nantoka/1.0[CR][LF] X-Hauhau: Maumau[CR][LF] [CR][LF] Body
詳しくはRFC 7230をご覧ください(HTTP 1.1ですが)。実は折り返せたりして(obsoleteだけど)、本当にまじめに実装しようとすると結構面倒だったりします。
サーバーを書いてみる
と、雑な説明をしたところでサーバーを書いてみます。
クライアントからの接続を待ち受ける
まずは「クライアントからの接続を待ち受ける」部分を作ってみます。
クライアントからの接続というのはTCP接続を待ち受けるいわゆるLISTENするということで一般的にはSocketを使ったりします。
とはいえSocket直接というのはプリミティブすぎるので.NET FrameworkにはTcpListenerクラスという便利クラスが用意されているのでそれを使うと簡単です。
var tcpListener = new TcpListener(IPAddress.Loopback, 12345);
tcpListener.Start();
// Startメソッドはブロッキングではなくて、そのまま終わってしまうので適当に止めておく
Console.ReadLine();
接続待ち受けを開始するにはTcpListenerをどのIPアドレスとポートで待ち受けるか指定して作って、Startメソッドを呼び出すだけです。
ここではLoopback(127.0.0.1と::1、いわゆるlocalhost)のポート12345で待ち受ける指定にしています。
Loopbackにしておくと自身のコンピューターからしか接続できない状態となりますが、Loopback以外にはAny(何でも)や特定のIPアドレス(イーサネットアダプタのアドレス)などを指定できます。
そして実行してブラウザーから http://localhost:12345/ にアクセスすると……接続できませんとも言わずに何も起きないはずです。
クライアントからの接続を受け入れる
TcpListenerで待ち受ける状態になっても、「クライアントからの接続を受け入れる」処理がないのでクライアントはまだ待たされている状態です。
というわけで受け入れる処理を書きます。AcceptTcpClientAsyncメソッドを呼び出すことで受け入れが行われ、クライアントとの接続であるTcpClientクラスのインスタンスが返ってきます。
返ってきたTcpClientはIDisposableを実装しているのでusingしておきます。Disposeすればクライアントとの接続を切断できます。
var tcpListener = new TcpListener(IPAddress.Loopback, 12345);
tcpListener.Start();
using (var tcpClient = await tcpListener.AcceptTcpClientAsync())
{
// 接続元を出力しておく
Console.WriteLine(tcpClient.Client.RemoteEndPoint);
}
// Startメソッドはブロッキングではなくて、そのまま終わってしまうので適当に止めておく
Console.ReadLine();
このコードを実行してブラウザーから http://localhost:12345/ にアクセスすると……多分すぐにアクセスできませんでしたというメッセージが表示されるようになるはずです。
これでデータの流れはありませんが接続の「待ち受け」→「受け入れ」→「切断」の一連ができました。
しかし、この実装では1回しか受け入れられないのでもう一度アクセスすると突き刺さります。ということで何度も受け入れられるように無限ループにしておきます。
var tcpListener = new TcpListener(IPAddress.Loopback, 12345);
tcpListener.Start();
while (true)
{
using (var tcpClient = await tcpListener.AcceptTcpClientAsync())
{
// 接続元を出力しておく
Console.WriteLine(tcpClient.Client.RemoteEndPoint);
}
}
クライアントからのリクエストを解釈する(フリをする)
次はリクエストを読み出します。が、とりあえずいったん読んで解釈したフリをすることにします。
ほとんどのブラウザーやユーザーエージェントのリクエストはGETリクエストであって、返したものをそのまま表示したり解釈したりするので最悪読み捨てても大丈夫だったりします。
クライアントとのデータの送受信はTcpClientのGetStreamメソッドで得られるStreamのインスタンスを利用します。このStreamを読めばクライアントから受信、書き込めばクライアントへ送信ということです。もちろんStreamなのでStreamReader/StreamWriterを通すと簡単に読み書きできます。
StreamReaderを作ったら、ReadLineAsyncメソッドでヘッダー部分の終わり、つまりCR+LFのみの行(=空行)まで読み込みます。
その際、読み込んだ行をコンソールなどにデバッグ目的で適当に書き出しておきます。
var tcpListener = new TcpListener(IPAddress.Loopback, 12345);
tcpListener.Start();
while (true)
{
using (var tcpClient = await tcpListener.AcceptTcpClientAsync())
using (var stream = tcpClient.GetStream())
using (var reader = new StreamReader(stream))
using (var writer = new StreamWriter(stream))
{
// 接続元を出力しておく
Console.WriteLine(tcpClient.Client.RemoteEndPoint);
// ヘッダー部分を全部読む
string line;
do
{
line = await reader.ReadLineAsync();
// 読んだ行を出力しておく
Console.WriteLine(line);
} while (!String.IsNullOrWhiteSpace(line));
}
}
このコードを実行してブラウザーから http://localhost:12345/ にアクセスしてみると、コンソールなどに以下のようなブラウザーのリクエストが出力されるはずです。
GET / HTTP/1.1 Accept: text/html, application/xhtml+xml, image/jxr, */* Accept-Language: en-US,en;q=0.7,ja;q=0.3 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.14251 Accept-Encoding: gzip, deflate Host: localhost:12345 Connection: Keep-Alive
とりあえず今のところはこれでよいということにします。
レスポンスをクライアントへ送信する
いよいよクライアントに対してレスポンスを返します。
レスポンスは1行目がステータスライン(ステータスコードとかを含む行)、その後ヘッダー部分、ボディ部分を送信すれば出来上がりです。つまりStreamWriterに対して書き込んでいくだけの簡単なお仕事です。
200なステータスで、Content-Typeが指定されていて、ボディがあれば大体受け入れてもらえます。というわけでこんな感じのレスポンスを返すということにします。
HTTP/1.0 200 OK Content-Type: text/plain; charset=UTF-8 Hello! Konnichiwa!
これを今までのコードに組み込むとこうなります。
var tcpListener = new TcpListener(IPAddress.Loopback, 12345);
tcpListener.Start();
while (true)
{
using (var tcpClient = await tcpListener.AcceptTcpClientAsync())
using (var stream = tcpClient.GetStream())
using (var reader = new StreamReader(stream))
using (var writer = new StreamWriter(stream))
{
// 接続元を出力しておく
Console.WriteLine(tcpClient.Client.RemoteEndPoint);
// ヘッダー部分を全部読んで捨てる
string line;
do
{
line = await reader.ReadLineAsync();
// 読んだ行を出力しておく
Console.WriteLine(line);
} while (!String.IsNullOrWhiteSpace(line));
// レスポンスを返す
// ステータスライン
await writer.WriteLineAsync("HTTP/1.0 200 OK");
// ヘッダー部分
await writer.WriteLineAsync("Content-Type: text/plain; charset=UTF-8");
await writer.WriteLineAsync(); // 終わり
// これ以降ボディ
await writer.WriteLineAsync("Hello! Konnichiwa! @ " + DateTime.Now); // 動的感を出す
}
}
そしてこのコードを実行してブラウザーから http://localhost:12345/ にアクセスしてみます。”Hello! Konnichiwa!”と表示されたら成功です。
というわけで超雑なHTTPサーバーができました。なんと32行。この通り、データを返すだけならばとても簡単ですね。あとはこれをベースにいじって遊んでみるとよいのではないでしょうか。
まとめ
HTTPサーバーを書いてみると、クライアントとサーバー、接続やリクエスト、レスポンスといったものを少し身近に感じることができるようになると思います。
一度書いていじってみるのは個人的にはとてもおすすめなのです。Extraとしてちょっといじったものを載せておきます。
Extra: リクエストを解釈する
ヘッダー部分を読み捨てましたが、ヘッダー部分(とリクエストライン)を解釈するともっとそれっぽくなります。
リクエストラインやヘッダー部分にはリクエストパスやリクエストに関する情報が含まれています。
var tcpListener = new TcpListener(IPAddress.Loopback, 12345);
tcpListener.Start();
while (true)
{
using (var tcpClient = await tcpListener.AcceptTcpClientAsync())
using (var stream = tcpClient.GetStream())
using (var reader = new StreamReader(stream))
using (var writer = new StreamWriter(stream))
{
// ヘッダー部分を全部読んでおく
var requestHeaders = new List<string>();
while (true)
{
var line = await reader.ReadLineAsync();
if (String.IsNullOrWhiteSpace(line))
{
break;
}
requestHeaders.Add(line);
}
// 一行目(リクエストライン)は [Method] [Path] HTTP/[HTTP Version] となっている
var requestLine = requestHeaders.FirstOrDefault();
var requestParts = requestLine?.Split(new[] { ' ' }, 3);
if (!requestHeaders.Any() || requestParts.Length != 3)
{
await writer.WriteLineAsync("HTTP/1.0 400 Bad Request");
await writer.WriteLineAsync("Content-Type: text/plain; charset=UTF-8");
await writer.WriteLineAsync();
await writer.WriteLineAsync("Bad Request");
return;
}
// 接続元を出力しておく
Console.WriteLine("{0} {1}", tcpClient.Client.RemoteEndPoint, requestLine);
// パス
var path = requestParts[1];
if (path == "/")
{
// / のレスポンスを返す
await writer.WriteLineAsync("HTTP/1.0 200 OK");
await writer.WriteLineAsync("Content-Type: text/plain; charset=UTF-8");
await writer.WriteLineAsync();
await writer.WriteLineAsync("Hello! Konnichiwa! @ " + DateTime.Now); // 動的感を出す
}
else if (path == "/hauhau")
{
// /hauhau のレスポンスを返す
await writer.WriteLineAsync("HTTP/1.0 200 OK");
await writer.WriteLineAsync("Content-Type: text/plain; charset=UTF-8");
await writer.WriteLineAsync();
await writer.WriteLineAsync("Hauhau!!");
}
}
}
パスが取れれば、ルーティングのテーブルを作って振り分けて…とかもやってみたくなりますね。
Extra: 複数リクエストをさばく
今までの実装は async/await を使ってはあったもののリクエストを受けて流す部分は1つなので並列で処理を実行することができません。
例えばある一つのリクエストの処理に時間がかかっている間はほかのリクエストを受け入れることができなくなるということです。
そこで.NET FrameworkにはTaskという便利なものがあるので空いているスレッドに処理を回すことができます。
var tcpListener = new TcpListener(IPAddress.Loopback, 12345);
tcpListener.Start();
while (true)
{
var tcpClient = await tcpListener.AcceptTcpClientAsync();
// 接続を受け入れたら後続の処理をTaskに放り込む
Task.Run(async () =>
{
using (var stream = tcpClient.GetStream())
using (var reader = new StreamReader(stream))
using (var writer = new StreamWriter(stream))
{
// ヘッダー部分を全部読んでおく
var requestHeaders = new List<string>();
while (true)
{
var line = await reader.ReadLineAsync();
if (String.IsNullOrWhiteSpace(line))
{
break;
}
requestHeaders.Add(line);
}
// 一行目(リクエストライン)は [Method] [Path] HTTP/[HTTP Version] となっている
var requestLine = requestHeaders.FirstOrDefault();
var requestParts = requestLine?.Split(new[] { ' ' }, 3);
if (!requestHeaders.Any() || requestParts.Length != 3)
{
await writer.WriteLineAsync("HTTP/1.0 400 Bad Request");
await writer.WriteLineAsync("Content-Type: text/plain; charset=UTF-8");
await writer.WriteLineAsync();
await writer.WriteLineAsync("Bad Request");
return;
}
// 接続元を出力しておく
Console.WriteLine("{0} {1}", tcpClient.Client.RemoteEndPoint, requestLine);
// パス
var path = requestParts[1];
if (path == "/")
{
// / のレスポンスを返す
await writer.WriteLineAsync("HTTP/1.0 200 OK");
await writer.WriteLineAsync("Content-Type: text/plain; charset=UTF-8");
await writer.WriteLineAsync();
await writer.WriteLineAsync("Hello! Konnichiwa! @ " + DateTime.Now); // 動的感を出す
}
else if (path == "/hauhau")
{
// 遅い処理をシミュレートするマン
await Task.Delay(5000); // 5秒
// /hauhau のレスポンスを返す
await writer.WriteLineAsync("HTTP/1.0 200 OK");
await writer.WriteLineAsync("Content-Type: text/plain; charset=UTF-8");
await writer.WriteLineAsync();
await writer.WriteLineAsync("Hauhau!!");
}
}
});
}
まじめにやるときにはTaskと接続の管理とかが必要ですが、こんな感じで並列化ができます。
ちなみに~Async(非同期メソッド)の場合、IO待ちの際にスレッドを手放すことが多くなるので効率よく処理できるようになります。