はじめに
GET, POST メソッドに対応した HTTP サーバをつくってみよう、という記事です。
HTTP の勉強という意味もありますが、自作してみると意外と考えることが多くて楽しめるのでいろんな人にオススメしたくて記事を書いています。
想定読者
- WEB開発に携わる人
- ブラウザの開発者ツール等でリクエスト、レスポンスメッセージをなんとなく眺めたことがある人
- HTTP サーバって作れるんだ!?と思った人
ソースコード
記事内ではだいぶ端折っているので全体が見たい方はこちらからどうぞ
https://github.com/ksugimori/SimpleHttpServer
Java 版よりちょっと機能が少ないですが Ruby 版も作ってます
https://github.com/ksugimori/simple_http_server
仕様
実装する機能
あくまで HTTP の勉強用に作成するので、静的なページの表示ができる程度。
- GET メソッドに対応する
- 静的なページを表示することができる
- 存在しないリソースへのアクセスは レスポンスコード 404 を返し、エラーページに遷移する
- POST メソッドに対応する
- とりあえずリクエストを受け取るところまで
- 何もしないのもつまらないので POST された内容を標準出力に出力する
何ができれば良いか?
WEBページを表示する際、非常に単純化して言えば次のような処理が行われています。
- クライアントから HTTP リクエストメッセージを受け取る
- リクエストを解析する
- リクエストされたリソースを読み取る
- HTTP レスポンスメッセージを作成する
- レスポンスをクライアントに送信する
以下ではこの5ステップに沿って実装していきます。
実装
ステップ0:HTTP メッセージクラス
「5ステップに沿って…」と言いましたが、その前に HTTP のベースになるリクエスト・レスポンスメッセージを表すクラスを作成しておきます。
まずは仕様を確認→ RFC7230
HTTP-message = start-line
*( header-field CRLF )
CRLF
[ message-body ]
start-line = request-line / status-line
- 一行目は Start Line。リクエストの場合 Request Line、レスポンスの場合 Status Line と呼ばれ、それぞれフォーマットが異なる
- 2行目から CRLF 区切りでヘッダーが続く
- 空行を挟んでボディ
request-line = Method SP Request-URI SP HTTP-Version CRLF
status-line = HTTP-version SP status-code SP reason-phrase CRLF
GET /path/to/something HTTP/1.1
HTTP/1.1 200 OK
Start Line 以外の構造は同じなので、抽象化した基底クラスを作りそれを継承することにしてみます。
public abstract class AbstractHttpMessage {
protected Map<String, String> headers;
protected byte[] body;
public AbstractHttpMessage() {
this.headers = new HashMap<>();
this.body = new byte[0];
}
public void addHeaderField(String name, String value) {
this.headers.put(name, value);
}
public Map<String, String> getHeaders() {
return headers;
}
public void setBody(byte[] body) {
this.body = body;
}
public byte[] getBody() {
return body;
}
protected abstract String getStartLine();
@Override
public String toString() {
return getStartLine() + " headers: " + headers + " body: " + new String(body, StandardCharsets.UTF_8);
}
}
public class Request extends AbstractHttpMessage {
Method method;
String target;
String version;
public Request(Method method, String target, String version) {
super();
this.method = method;
this.target = target;
this.version = version;
}
public Method getMethod() {
return method;
}
public String getTarget() {
return target;
}
public String getVersion() {
return version;
}
@Override
public String getStartLine() {
return method.toString() + " " + target + " " + version;
}
}
public class Response extends AbstractHttpMessage {
String version;
Status status;
public Response(String version, Status status) {
super();
this.version = version;
this.status = status;
}
public String getVersion() {
return version;
}
public int getStatusCode() {
return status.getCode();
}
public String getReasonPhrase() {
return status.getReasonPhrase();
}
@Override
public String getStartLine() {
return version + " " + getStatusCode() + " " + getReasonPhrase();
}
}
ステップ1:リクエストを受け取る
HTTP サーバとはいえ、基本的には普通のソケット通信を行うだけ。
main メソッドではひたすら接続を待ち、接続が確立したら別スレッドでそれを処理します。
public static void main(String[] args) {
ServerSocket server = new ServerSocket(8080);
ExecutorService executor = Executors.newCachedThreadPool();
while (true) {
Socket socket = server.accept();
// socket オブジェクトを渡して各リクエストの処理は別スレッドで
executor.submit( new WorkerThread(socket) );
}
}
ステップ2:リクエストを解析する
Request-Line
request-line = Method SP Request-URI SP HTTP-Version CRLF
スペース区切りの文字列なので正規表現でパターンマッチさせて、HTTP メソッド、URI、HTTP バージョンを抜き出す。
InputStream in = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(in));
String requestLine = br.readLine();
Pattern requestLinePattern
= Pattern.compile("^(?<method>\\S+) (?<target>\\S+) (?<version>\\S+)$");
Matcher matcher = requestLinePattern.matcher(requestLine);
Method method = Method.valueOf(matcher.group("method"));
String target = matcher.group("target");
String version = matcher.group("version");
Request request = new Request(method, target, version);
ヘッダ
header-field = field-name ":" OWS field-value OWS
ヘッダは :
で区切ってフィールド名と値の組なのでこれも正規表現で抽出。
ボディが 0 バイトだったとしてもヘッダとボディの区切りになる空行は必ず存在するので空行に出会うまでヘッダとして読み取る。1
※ OWD
は "optional white space" 。0個または1個の半角スペース・タブ文字が入るようだ → 3.2.3. Whitespace
Pattern headerPattern = Pattern.compile("^(?<name>\\S+):[ \\t]?(?<value>.+)[ \\t]?$");
while ( true ) {
String headerField = br.readLine();
if ( EMPTY.equals(headerField.trim()) ) break; // header と body の区切りまで読む
Matcher matcher = headerPattern.matcher(headerField);
if (matcher.matches()) {
request.addHeaderField(matcher.group("name"), matcher.group("value"));
} else {
throw new ParseException(headerField);
}
}
ボディ
ボディが存在する場合は Content-Length
または Transfer-Encoding
ヘッダが必ず指定される。つまり、リクエストヘッダをもとに条件分岐して以下3パターンに対応できれば良いはず。
- Content-Length あり
- Transfer-Encoding あり
- どちらも存在しない
1. Content-Length あり
message-body = *OCTET
というシンプルなフォーマットなので、単純に送られた内容を byte
型の配列に格納するだけでOK.
読み取るバイト数は Content-Length
をもとに決定。
Integer contentLength = Integer.valueOf(request.getHeaders().get("Content-Length"));
char[] body = new char[contentLength];
bufferedReader.read(body, 0, contentLength);
request.setBody((new String(body)).getBytes());
2. Transfer-Encoding あり
Content-Length
を必須にしてしまうと、クライアントはリクエストボディの作成が終わるまでリクエストが送信できず効率が悪い。そのため HTTP/1.1 では以下のように小分けにされた(chunked)リクエストボディの送信が可能になっている。2
POST /hoge HTTP/1.1
Host: example.jp
Transfer-Encoding: chunked
Connection: Keep-Alive
a
This is a
a
test messa
3
ge.
0
チャンクのサイズ(バイト数。16進数) CRLF
チャンクされたデータ CRLF
ボディ全体のバイト数がわからなくても、用意できた部分から順次バイト数を付けて送信してしまえ、という作戦ですね。Content-Length
が無くボディ全体の終わりがサーバ側で判断できないので、クライアントは最後に 0 バイトのチャンクを送信する必要があります。
String transferEncoding = request.getHeaders().get("Transfer-Encoding");
// "Transfer-Encoding: gzip, chunked" のように複数指定することも可能ですが今回は chunked のみ対応します
if (transferEncoding.equals("chunked")) {
int length = 0;
ByteArrayOutputStream body = new ByteArrayOutputStream();
String chunkSizeHex = br.readLine().replaceFirst(" .*$", ""); // ignore chunk-ext
int chunkSize = Integer.parseInt(chunkSizeHex, 16);
while (chunkSize > 0) {
char[] chunk = new char[chunkSize];
br.read(chunk, 0, chunkSize);
br.skip(2); // CRLF の分
body.write((new String(chunk)).getBytes());
length += chunkSize;
chunkSizeHex = br.readLine().replaceFirst(" .*$", "");
chunkSize = Integer.parseInt(chunkSizeHex, 16);
}
request.addHeaderField("Content-Length", Integer.toString(length));
request.getHeaders().remove("Transfer-Encoding");
request.setBody(body.toByteArray());
}
3. Transfer-Encoding も Content-Length も存在しない
この場合はリクエストボディが存在しないはずなので何もしない
ステップ3:リクエストされたリソースを読み取る
GET メソッドの場合
単純にリクエストされたファイルを Files#readAllBytes
で byte 配列として読み取ってやるだけでOK。
// ドキュメントルートと繋げて実ファイルパスに
Path target = Paths.get(SimpleHttpServer.getDocumentRoot(), request.getTarget()).normalize();
// ドキュメントルート以下のみアクセス可能にする
if (!target.startsWith(SimpleHttpServer.getDocumentRoot())) {
return new Response(protocolVersion, Status.BAD_REQUEST);
}
if (Files.isDirectory(target)) {
target = target.resolve("index.html");
}
try {
response = new Response("HTTP/1.1", Status.OK);
response.setBody(Files.readAllBytes(target)); // ファイルは byte の配列として格納
} catch (IOException e) {
// 存在しない場合はエラーコードを設定して、エラーページ用の HTML ファイルを読み取る
response = new Response("HTTP/1.1", Status.NOT_FOUND);
response.setBody(SimpleHttpServer.readErrorPage(Status.NOT_FOUND));
}
POST メソッドの場合
POST の場合はとりあえずボディがちゃんと読み取れているか確認するために標準出力に出力する。
正常終了を知らせるためにレスポンスコードは 204: No Content にしておきます。
System.out.println("POST body: " + new String(request.getBody(), StandardCharsets.UTF_8));
Response response = new Response(protocolVersion, Status.NO_CONTENT);
ステップ4:HTTP レスポンスメッセージを作成する
ファイル形式に合わせた Content-Type
をヘッダに指定してやれば、あとはクライアント側がよしなに取り計らってくれます。3
response = new Response(protocolVersion, Status.OK);
response.setBody(Files.readAllBytes(target));
Map<String, String> mimeTypes = new HashMap<>();
mimeTypes.put("html", "text/html");
mimeTypes.put("css", "text/css");
mimeTypes.put("js", "application/js");
mimeTypes.put("png", "image/png");
String ext = StringUtils.getFileExtension(target.getFileName().toString());
String contentType = mimeTypes.getOrDefault(ext, "");
response.addHeaderField("Content-Type", contentType);
ステップ5:レスポンスをクライアントに送信する
レスポンスメッセージのフォーマットは
HTTP-version SP status-code SP reason-phrase CRLF
*( header-field CRLF )
CRLF
[ message-body ]
なので、愚直にフォーマット通りにソケットに書き出していく。
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out));
String statusLine
= resp.getVersion() + SP + resp.getStatusCode() + SP + resp.getReasonPhrase() + CRLF;
writer.write(statusLine);
for (Map.Entry<String, String> field : response.getHeaders().entrySet()) {
writer.write(field.getKey() + ":" + SP + field.getValue() + CRLF);
}
writer.write(CRLF); // ヘッダーとボディの区切りに空行が必要
writer.flush();
out.write(response.getBody()); // ボディはファイルから読み取った byte 配列をそのまま書き込む
レスポンスをチャンク形式にしてみる
このプログラムではレスポンスメッセージが完成してからクライアント側に送信するのでチャンク形式にする価値があまり無いのですが、せっかくなので作ってみます。
チャンクのサイズ(バイト数。16進数) CRLF
チャンクされたデータ CRLF
ボディ全体を表す byte 型の配列を CHUNK_SIZE
ずつ分割し、上記のフォーマットに整形していきます。
byte[] CRLF = new byte[] {0x0D, 0x0A};
byte[] body = response.getBody();
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (int offset = 0; offset < body.length; offset += CHUNK_SIZE) {
byte[] chunk = Arrays.copyOfRange(body, offset, offset + CHUNK_SIZE);
String lengthHex = Integer.toHexString(chunk.length);
out.write(lengthHex.getBytes());
out.write(CRLF);
out.write(chunk);
out.write(CRLF);
}
out.write("0".getBytes());
out.write(CRLF); // チャンクサイズ行の行末
out.write(CRLF); // サイズ 0 のチャンクデータ
動かしてみる
Request
オブジェクトと、Response
オブジェクトを使って、クライアントにレスポンスを返す直前に Apache っぽいアクセスログを出力するようにしています。
トップページ
他のページ
フォームの送信
特に何もせずに標準出力に書き出しているので半角スペースがパーセントエンコーディングされて +
になってますが、フォームの内容が受けて取れていることがわかります。
存在しないパス
ステータスコードは 404 ですが、ボディにはエラーページ用の HTML ファイルを入れているのでエラー画面に遷移できています。
チャンクされたレスポンス
ボディが 20(16進数で14)バイトずつに分割されていることが確認できました。
$ curl localhost:8080/chunked/sample.txt --trace-ascii /dev/stdout
== Info: Trying ::1...
== Info: TCP_NODELAY set
== Info: Connected to localhost (::1) port 8080 (#0)
=> Send header, 96 bytes (0x60)
0000: GET /chunked/sample.txt HTTP/1.1
0022: Host: localhost:8080
0038: User-Agent: curl/7.54.0
0051: Accept: */*
005e:
<= Recv header, 17 bytes (0x11)
0000: HTTP/1.1 200 OK
<= Recv header, 28 bytes (0x1c)
0000: Transfer-Encoding: chunked
<= Recv header, 26 bytes (0x1a)
0000: Content-Type: text/plain
<= Recv header, 2 bytes (0x2)
0000:
<= Recv data, 109 bytes (0x6d)
0000: 14
0004: This is a sample tex
001a: 14
001e: t..Response is chunk
0034: 14
0038: ed in every 20 bytes
004e: 14
0052: ....................
0068: 0
006b:
This is a sample text.
Response is chunked in every 20 bytes.
※これ試しているときに知ったんですが Transfer-Encoding: chunked
かつ Content-Type: text/plain
だと、ブラウザで開いたとき自動的にファイルダウンロードになるんですね。application/octet-stream
だけかと思ってました。
参考
RFC 7230
新しいプログラミング言語の学び方 HTTPサーバーを作って学ぶ Java, Scala, Clojure
このスライドがきっかけで HTTP サーバを作ってみようと思いました。
-
これだとクライアントが変なリクエストを投げると無限ループにハマってしまうのでイケてない気がする。。 ↩
-
若干簡略化しています。詳しくはこちら → 4.1. Chunked Transfer Coding ↩
-
特に理由がなければ付けるべきですが、一応 "MUST" ではなく "SHOULD" なので付けなくても良いみたいです → 3.1.1.5. Content-Type ↩