簡単な HTTP サーバーを実装することで HTTP を学ぶ。
ローカルの HTML ファイルをブラウザから開けるようになるのを目指す。
免責事項
あくまで車輪の再発明による HTTP および HTTP サーバーのローレベルなところを勉強するのが目的です。
セキュリティなどの考慮は一切していないので、ここの実装を使ったらいろいろ問題が発生すること必至です(ディレクトリトラバーサルとか)。
ここでの実装を利用したことで発生する問題に対して、当方は一切責任を負えませんのであしからず(利用するとは思えないけど)。
まずはソケット通信から
HTTP は TCP/IP の上で動作するプロトコルなので、まずはソケット通信から始める。
package gl8080.http;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"));
) {
in.lines().forEach(System.out::println);
}
System.out.println("<<< end");
}
}
-
ServerSocket
でサーバー側のソケットを作成する。 - HTTP のデフォルトポート番号は
80
なので、コンストラクタで80
を渡す。 -
ServerSocket#accept()
でクライアントからの接続を待機する。 - このメソッドはブロッキング処理なので、接続があるまでプログラムはこの場所で停止する。
- クライアントからの接続があったら、
Socket#getInputStream()
で入力ストリームを取得する。 - 入力ストリームを
BufferedReader
に変換して全行を標準出力に表示させる。
この実装を動かし、 curl
コマンドで通信できるか確認してみる。
$ curl http://localhost/
start >>>
GET / HTTP/1.1
User-Agent: curl/7.37.1
Host: localhost
Accept: */*
送信されてきた HTTP リクエストのヘッダーが出力される。
しかし、これだけだとヘッダーを読み終えた段階で処理が停止する(<<< end
が出力されない)。
クライアント(curl
)側で Ctrl + C
などで通信を中断させれば、サーバー側の処理が再開される。
この現象は、Keep-Alive が HTTP/1.1 ではデフォルトで有効になっているために発生している。
Keep-Alive
ver 1.0 では、 HTTP の接続(コネクション)はリクエストのたびに破棄されることになっていた。
しかし、毎回接続しなおすのは無駄が多い。そのため ver 1.1 では一度確立した接続は維持し続け、引き続き別のリクエストを送信できるように仕様が変更された。
これが Keep-Alive と呼ばれるもので、 ver 1.1 ではデフォルトで有効になっている。
このため、クライアントの入力ストリームには「終わり」が存在せず、前述のような実装にするといつまでも次の入力を待ち続けるようになってしまう。
リクエストの終わりを判断する
HTTP リクエストの構造
HTTP のメッセージ(リクエストとレスポンス)は、大きく次の2つから成る。
- ヘッダー
- メッセージボディ
例えば、以下のような感じ。
POST / HTTP/1.1
User-Agent: curl/7.37.1
Host: localhost
Accept: */*
Content-Length: 14
Content-Type: application/x-www-form-urlencoded
Message Body!!
まずヘッダーが始まり、空行を区切りとしてメッセージボディが続く。
メッセージボディは省略可能で、ヘッダーのみの場合もありえる。
ヘッダーを判断する
前述の通り、ヘッダーとボディを区切るのは空行なので、空行を読み込むまでをヘッダーと判断することができる。
package gl8080.http;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"));
) {
String line = in.readLine();
StringBuilder header = new StringBuilder();
while (line != null && !line.isEmpty()) { // ★空行になるまで読み込みを続ける
header.append(line + "\n");
line = in.readLine();
}
System.out.println(header);
}
System.out.println("<<< end");
}
}
curl
で動作確認。
$ curl http://localhost/ -X POST -d "Message Body!!"
start >>>
POST / HTTP/1.1
User-Agent: curl/7.37.1
Host: localhost
Accept: */*
Content-Length: 14
Content-Type: application/x-www-form-urlencoded
<<< end
とりあえず、次の入力を待ち続けることはなくなった。
メッセージボディを判断する
続いてメッセージボディを抽出する。
メッセージボディの終わりを判断するには、メッセージヘッダーにある Content-Length
を使用する。
これはメッセージボディのバイト数を表している。なので、ヘッダーを読み込んだ後は、ここで指定されているバイト数だけストリームから文字を取得すればいい。
package gl8080.http;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), "UTF-8"));
) {
String line = in.readLine();
StringBuilder header = new StringBuilder();
int contentLength = 0;
while (line != null && !line.isEmpty()) {
if (line.startsWith("Content-Length")) { // ★Content-Length を取得
contentLength = Integer.parseInt(line.split(":")[1].trim());
}
header.append(line + "\n");
line = in.readLine();
}
String body = null;
if (0 < contentLength) { // ★Content-Length 分取得
char[] c = new char[contentLength];
in.read(c);
body = new String(c);
}
System.out.println(header);
System.out.println(body);
}
System.out.println("<<< end");
}
}
curl
で動作確認。
$ curl http://localhost/ -X POST -d "Message Body!!"
start >>>
POST / HTTP/1.1
User-Agent: curl/7.37.1
Host: localhost
Accept: */*
Content-Length: 14
Content-Type: application/x-www-form-urlencoded
Message Body!!
<<< end
ボディも取得できた。
サーバー側は、ボディがあるべきリクエストに Content-Length
が指定されていない場合は、 400 (bad request)
や 411 (length required)
のレスポンスを返すべきだとされている(ただし、後述するチャンク形式のリクエストの場合は Content-Length
は明示されない)。
もし、リクエストがメッセージボディを含んでいて、Content-Length を含んでいなかったら、サーバは、それによってメッセージの長さが決定できない場合は 400 (bad request) を、有効な Content-Length を受けとりたい場合は 411 (length required) を、それぞれ返すべきである。
4.4 メッセージの長さ | ハイパーテキスト転送プロトコル -- HTTP/1.1
リファクタリング1
実装がごちゃごちゃしてきたので、リファクタリングしてリクエストに関する処理を HttpRequest
というクラスに括りだす。
package gl8080.http;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
InputStream in = socket.getInputStream();
) {
HttpRequest request = new HttpRequest(in);
System.out.println(request.getHeaderText());
System.out.println(request.getBodyText());
}
System.out.println("<<< end");
}
}
package gl8080.http;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.util.stream.Stream;
public class HttpRequest {
public static final String CRLF = "\r\n";
private final String headerText;
private final String bodyText;
public HttpRequest(InputStream input) {
try (
BufferedReader in = new BufferedReader(new InputStreamReader(input, "UTF-8"));
) {
this.headerText = this.readHeader(in);
this.bodyText = this.readBody(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private String readHeader(BufferedReader in) throws IOException {
String line = in.readLine();
StringBuilder header = new StringBuilder();
while (line != null && !line.isEmpty()) {
header.append(line + CRLF);
line = in.readLine();
}
return header.toString();
}
private String readBody(BufferedReader in) throws IOException {
final int contentLength = this.getContentLength();
if (contentLength <= 0) {
return null;
}
char[] c = new char[contentLength];
in.read(c);
return new String(c);
}
private int getContentLength() {
return Stream.of(this.headerText.split(CRLF))
.filter(headerLine -> headerLine.startsWith("Content-Length"))
.map(contentLengthHeader -> contentLengthHeader.split(":")[1].trim())
.mapToInt(Integer::parseInt)
.findFirst().orElse(0);
}
public String getHeaderText() {
return this.headerText;
}
public String getBodyText() {
return this.bodyText;
}
}
チャンク形式転送エンコーディング
Content-Length
を明示する方法の場合、クライアントは送信するデータのサイズをあらかじめチェックしなければならない。
しかし、大容量のファイルなどを送信する場合など、あらかじめサイズをチェックするのが難しいケースがありえる。
そこで、データ全体のサイズは明示せず、データを細かく分けて、分割された個々のデータをサイズとセットで送信する方法が用意されている。
これを チャンク形式転送エンコーディング と呼ぶ(チャンクは、「ぶつ切り」という意味)。
チャンク形式の HTTP リクエストは、以下のようになる。
POST /chunk.txt HTTP/1.1
User-Agent: curl/7.37.1
Host: localhost
Accept: */*
Transfer-Encoding: chunked
Expect: 100-continue
5
start
c
123456789
0
3
end
0
チャンク形式かどうかは、ヘッダーの Transfer-Encoding: chunked
があるかないかで分かる。
チャンク形式の場合、ボディは以下のフォーマットになっている。
Chunked-Body = *chunk
last-chunk
trailer
CRLF
chunk = chunk-size [ chunk-extension ] CRLF
chunk-data CRLF
chunk-size = 1*HEX
last-chunk = 1*("0") [ chunk-extension ] CRLF
chunk-extension= *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
chunk-ext-name = token
chunk-ext-val = token | quoted-string
chunk-data = chunk-size(OCTET)
trailer = *(entity-header CRLF)
なんかややこしそうだけど、要は
<チャンク1のサイズ(16進数)>
<チャンク1のデータ>
<チャンク2のサイズ(16進数)>
<チャンク2のデータ>
<チャンク3のサイズ(16進数)>
<チャンク3のデータ>
:
:
0
という感じになっている。
先ほどのチャンク形式のボディの例は、
5
start
c
123456789
0
3
end
0
次のように読み取ることになる。
start123456789
0end
改行コードも文字数にカウントする点に注意(CRLF
で 2 文字)。
チャンク形式に対応させる
HttpRequest
をチャンク形式に対応させる。
package gl8080.http;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.util.stream.Stream;
public class HttpRequest {
public static final String CRLF = "\r\n";
private final String headerText;
private final String bodyText;
public HttpRequest(InputStream input) {
try (
BufferedReader in = new BufferedReader(new InputStreamReader(input, "UTF-8"));
) {
this.headerText = this.readHeader(in);
this.bodyText = this.readBody(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private String readHeader(BufferedReader in) throws IOException {
String line = in.readLine();
StringBuilder header = new StringBuilder();
while (line != null && !line.isEmpty()) {
header.append(line + CRLF);
line = in.readLine();
}
return header.toString();
}
private String readBody(BufferedReader in) throws IOException {
if (this.isChunkedTransfer()) { // ★チャンク形式かどうか判断
return this.readBodyByChunkedTransfer(in);
} else {
return this.readBodyByContentLength(in);
}
}
private boolean isChunkedTransfer() {
return Stream.of(this.headerText.split(CRLF))
.filter(headerLine -> headerLine.startsWith("Transfer-Encoding"))
.map(transferEncoding -> transferEncoding.split(":")[1].trim())
// ★Transfer-Encoding: chunked がヘッダーにあればチャンク形式
.anyMatch(s -> "chunked".equals(s));
}
// ★ここでチャンク形式のボディを読み取り
private String readBodyByChunkedTransfer(BufferedReader in) throws IOException {
StringBuilder body = new StringBuilder();
int chunkSize = Integer.parseInt(in.readLine(), 16);
while (chunkSize != 0) {
char[] buffer = new char[chunkSize];
in.read(buffer);
body.append(buffer);
in.readLine(); // chunk-body の末尾にある CRLF を読み飛ばす
chunkSize = Integer.parseInt(in.readLine(), 16);
}
return body.toString();
}
private String readBodyByContentLength(BufferedReader in) throws IOException {
final int contentLength = this.getContentLength();
if (contentLength <= 0) {
return null;
}
char[] c = new char[contentLength];
in.read(c);
return new String(c);
}
private int getContentLength() {
return Stream.of(this.headerText.split(CRLF))
.filter(headerLine -> headerLine.startsWith("Content-Length"))
.map(contentLengthHeader -> contentLengthHeader.split(":")[1].trim())
.mapToInt(Integer::parseInt)
.findFirst().orElse(0);
}
public String getHeaderText() {
return this.headerText;
}
public String getBodyText() {
return this.bodyText;
}
}
マルチバイトに対応させる
ところで、このままだとマルチバイトの文字がやってくるとうまく処理できない。
というのも、Content-Length
などのサイズはバイト数で渡ってきているのに対し、 Reader#read()
は文字数を指定してストリームから文字を読み取る。
マルチバイト文字が含まれる場合、バイト数 > 文字数となるので、余分にストリームから値を取得しようとしてしまう。
回避策が特に思いつかなかったので、 Reader
をやめて InputStream
から直接 byte
を受け取り、自力で readLine()
を実装することにした。
HTTP の仕様とは関係ないので、実装内容は割愛。
リファクタリング2
ヘッダーの読み取りが、ストリーム API を使ってはいるものの、ごちゃごちゃしてきたし何より無駄が多い。
ヘッダーを別クラスに分離するリファクタリングを行う。
package gl8080.http;
import static gl8080.http.Constant.*;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
public class HttpHeader {
private final String headerText;
private Map<String, String> messageHeaders = new HashMap<>();
public HttpHeader(InputStream in) throws IOException {
StringBuilder header = new StringBuilder();
header.append(this.readRequestLine(in))
.append(this.readMessageLine(in));
this.headerText = header.toString();
}
private String readRequestLine(InputStream in) throws IOException {
return IOUtil.readLine(in) + CRLF;
}
private StringBuilder readMessageLine(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
String messageLine = IOUtil.readLine(in);
while (messageLine != null && !messageLine.isEmpty()) {
this.putMessageLine(messageLine);
sb.append(messageLine + CRLF);
messageLine = IOUtil.readLine(in);
}
return sb;
}
private void putMessageLine(String messageLine) {
String[] tmp = messageLine.split(":");
String key = tmp[0].trim();
String value = tmp[1].trim();
this.messageHeaders.put(key, value);
}
public String getText() {
return this.headerText;
}
public int getContentLength() {
return Integer.parseInt(this.messageHeaders.getOrDefault("Content-Length", "0"));
}
public boolean isChunkedTransfer() {
return this.messageHeaders.getOrDefault("Transfer-Encoding", "-").equals("chunked");
}
}
package gl8080.http;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
public class HttpRequest {
private final HttpHeader header;
private final String bodyText;
public HttpRequest(InputStream input) {
try {
this.header = new HttpHeader(input);
this.bodyText = this.readBody(input);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
...
public String getHeaderText() {
return this.header.getText();
}
public String getBodyText() {
return this.bodyText;
}
}
レスポンスを返す
続いて、 HTTP レスポンスを返してみる。
ステータスラインのみ返す
まずは、ボディのない単純なレスポンスを返す。
package gl8080.http;
import static gl8080.http.Constant.*;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
InputStream in = socket.getInputStream();
BufferedWriter bw
= new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
) {
HttpRequest request = new HttpRequest(in);
System.out.println(request.getHeaderText());
System.out.println(request.getBodyText());
// レスポンス出力
bw.write("HTTP/1.1 200 OK" + CRLF);
}
System.out.println("<<< end");
}
}
$ curl http://localhost/ -i
HTTP/1.1 200 OK
- HTTP レスポンスの最初の行はステータスラインと呼ばれる。
- ステータスラインは、
<HTTP のバージョン> <ステータスコード> <ステータスのフレーズ> CRLF
というフォーマットで記述する。
ヘッダーとボディを返す
package gl8080.http;
import static gl8080.http.Constant.*;
import java.io.BufferedWriter;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
InputStream in = socket.getInputStream();
BufferedWriter bw
= new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
) {
HttpRequest request = new HttpRequest(in);
System.out.println(request.getHeaderText());
System.out.println(request.getBodyText());
bw.write("HTTP/1.1 200 OK" + CRLF);
bw.write("Content-Type: text/html" + CRLF);
bw.write(CRLF);
bw.write("<h1>Hello World!!</h1>");
}
System.out.println("<<< end");
}
}
$ curl http://localhost/ -i
HTTP/1.1 200 OK
Content-Type: text/html
<h1>Hello World!!</h1>
ブラウザで確認
- レスポンスのヘッダーも、リクエスト同様
<ヘッダー名>:<値>
という形式で指定する。 - ヘッダーとボディの間には空行を入れる。
リファクタリング3
レスポンスも、リクエスト同様クラスにまとめる。
package gl8080.http;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
) {
HttpRequest request = new HttpRequest(in);
System.out.println(request.getHeaderText());
System.out.println(request.getBodyText());
HttpResponse response = new HttpResponse(Status.OK);
response.addHeader("Content-Type", ContentType.TEXT_HTML);
response.setBody("<h1>Hello World!!</h1>");
response.writeTo(out);
}
System.out.println("<<< end");
}
}
package gl8080.http;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class HttpResponse {
private final Status status;
private Map<String, String> headers = new HashMap<>();
private String body;
public HttpResponse(Status status) {
Objects.requireNonNull(status);
this.status = status;
}
public void addHeader(String string, Object value) {
this.headers.put(string, value.toString());
}
public void setBody(String body) {
this.body = body;
}
public void writeTo(OutputStream out) throws IOException {
IOUtil.println(out, "HTTP/1.1 " + this.status);
this.headers.forEach((key, value) -> {
IOUtil.println(out, key + ": " + value);
});
if (this.body != null) {
IOUtil.println(out, "");
IOUtil.print(out, this.body);
}
}
}
package gl8080.http;
import static gl8080.http.Constant.*;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
...
public class IOUtil {
private static final Charset UTF_8 = StandardCharsets.UTF_8;
public static void println(OutputStream out, String line) {
print(out, line + CRLF);
}
public static void print(OutputStream out, String line) {
try {
out.write(line.getBytes(UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
...
private IOUtil() {}
}
package gl8080.http;
public enum Status {
OK("200 OK")
;
private final String text;
private Status(String text) {
this.text = text;
}
@Override
public String toString() {
return this.text;
}
}
package gl8080.http;
public enum ContentType {
TEXT_HTML("text/html")
;
private final String text;
private ContentType(String text) {
this.text = text;
}
@Override
public String toString() {
return this.text;
};
}
GET メソッドが来たらパスで指定されたファイルを返すようにする
package gl8080.http;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
) {
HttpRequest request = new HttpRequest(in);
HttpResponse response = new HttpResponse(Status.OK);
HttpHeader header = request.getHeader();
if (header.isGetMethod()) {
// ★GET メソッドの場合は、パスで指定されたファイルをローカルから取得
response.setBody(new File(".", header.getPath()));
}
response.writeTo(out);
}
System.out.println("<<< end");
}
}
package gl8080.http;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class HttpResponse {
private final Status status;
private Map<String, String> headers = new HashMap<>();
private String body;
private File bodyFile;
public HttpResponse(Status status) {
Objects.requireNonNull(status);
this.status = status;
}
public void addHeader(String string, Object value) {
this.headers.put(string, value.toString());
}
public void setBody(String body) {
this.body = body;
}
public void writeTo(OutputStream out) throws IOException {
IOUtil.println(out, "HTTP/1.1 " + this.status);
this.headers.forEach((key, value) -> {
IOUtil.println(out, key + ": " + value);
});
if (this.body != null) {
IOUtil.println(out, "");
IOUtil.print(out, this.body);
} else if (this.bodyFile != null) {
IOUtil.println(out, "");
Files.copy(this.bodyFile.toPath(), out); // ★ Files.copy() を使って簡単コピー
}
}
public void setBody(File file) {
Objects.requireNonNull(file);
this.bodyFile = file;
// ★Content-Type は、ファイルの拡張子から推測する
String fileName = this.bodyFile.getName();
String extension = fileName.substring(fileName.lastIndexOf('.') + 1);
this.addHeader("Content-Type", ContentType.toContentType(extension));
}
}
package gl8080.http;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
public enum ContentType {
// ★とりあえず使いそうなやつを宣言
TEXT_PLAIN("text/plain", "txt"),
TEXT_HTML("text/html", "html,htm"),
TEXT_CSS("text/css", "css"),
TEXT_XML("text/xml", "xml"),
APPLICATION_JAVASCRIPT("application/javascript", "js"),
APPLICATION_JSON("application/json", "json"),
IMAGE_JPEG("image/jpeg", "jpg,jpeg"),
IMAGE_PNG("image/png", "png"),
IMAGE_GIF("image/gif", "gif"),
;
private static final Map<String, ContentType> EXTENSION_CONTENT_TYPE_MAP
= new HashMap<>();
static {
// ★拡張子ごとに ContentType のインスタンスを保存しておく
Stream.of(ContentType.values())
.forEach(contentType -> {
contentType.extensions.forEach(extension -> {
EXTENSION_CONTENT_TYPE_MAP.put(extension.toUpperCase(), contentType);
});
});
}
private final String text;
private final Set<String> extensions = new HashSet<>();
private ContentType(String text, String extensions) {
this.text = text;
this.extensions.addAll(Arrays.asList(extensions.split(",")));
}
@Override
public String toString() {
return this.text;
}
public static ContentType toContentType(String extension) {
Objects.requireNonNull(extension);
return EXTENSION_CONTENT_TYPE_MAP.getOrDefault(extension.toUpperCase(), TEXT_PLAIN);
// ★不明な拡張子がきた場合は、デフォルト text/plain にする。
};
}
動作確認
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Simple HTTP Server</title>
</head>
<body>
<h1>Hello Simple HTTP Server</h1>
</body>
</html>
ブラウザで http://localhost/hello.html
にアクセス。
これで、ローカルのファイルを参照できるようになった。
ファイルが存在しない場合は 404 を返すようにする
package gl8080.http;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
try (
ServerSocket server = new ServerSocket(80);
Socket socket = server.accept();
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
) {
HttpRequest request = new HttpRequest(in);
HttpHeader header = request.getHeader();
if (header.isGetMethod()) {
File file = new File(".", header.getPath());
if (file.exists() && file.isFile()) { // ★ファイル存在チェック
respondLocalFile(file, out);
} else {
respondNotFoundError(out);
}
} else {
respondOk(out);
}
}
System.out.println("<<< end");
}
private static void respondNotFoundError(OutputStream out) throws IOException {
HttpResponse response = new HttpResponse(Status.NOT_FOUND);
response.addHeader("Content-Type", ContentType.TEXT_PLAIN);
response.setBody("404 Not Found");
response.writeTo(out);
}
private static void respondLocalFile(File file, OutputStream out) throws IOException {
HttpResponse response = new HttpResponse(Status.OK);
response.setBody(file);
response.writeTo(out);
}
private static void respondOk(OutputStream out) throws IOException {
HttpResponse response = new HttpResponse(Status.OK);
response.writeTo(out);
}
}
ブラウザで、適当に存在しないパスにアクセスする。
リファクタリング4
Main.java
がごちゃごちゃしだしたので、サーバー処理用のクラスに分割する。
あと、現在は1回のリクエストを受け付けると処理が終了してしまうので、継続してリクエストを捌けるようにする。
package gl8080.http;
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("start >>>");
SimpleHttpServer server = new SimpleHttpServer();
server.start();
}
}
package gl8080.http;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class SimpleHttpServer {
public void start() {
try (ServerSocket server = new ServerSocket(80)) {
while (true) { // ★ここで無限ループすることで、繰り返しリクエストを処理する
this.serverProcess(server);
}
} catch (Exception e) {
e.printStackTrace(System.err);
}
}
private void serverProcess(ServerSocket server) throws IOException {
try (
Socket socket = server.accept();
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
) {
HttpRequest request = new HttpRequest(in);
HttpHeader header = request.getHeader();
if (header.isGetMethod()) {
File file = new File(".", header.getPath());
if (file.exists() && file.isFile()) {
this.respondLocalFile(file, out);
} else {
this.respondNotFoundError(out);
}
} else {
this.respondOk(out);
}
}
}
private void respondNotFoundError(OutputStream out) throws IOException {
HttpResponse response = new HttpResponse(Status.NOT_FOUND);
response.addHeader("Content-Type", ContentType.TEXT_PLAIN);
response.setBody("404 Not Found");
response.writeTo(out);
}
private void respondLocalFile(File file, OutputStream out) throws IOException {
HttpResponse response = new HttpResponse(Status.OK);
response.setBody(file);
response.writeTo(out);
}
private void respondOk(OutputStream out) throws IOException {
HttpResponse response = new HttpResponse(Status.OK);
response.writeTo(out);
}
}
パスの URL エンコードに対応する
HTTP のパスでは、使用できる文字が限定されている。
使用できない文字がパスに含まれる場合、 URL エンコードと呼ばれる変換が行われたうえで、リクエストラインのパスが設定される。
例えば、 テスト
を UTF-8 で URL エンコーディングすると、 %e3%83%86%e3%82%b9%e3%83%88
になる。
Java には、 URL エンコード・デコードするためのクラスが標準で用意されているので、そいつを利用する。
package gl8080.http;
import static gl8080.http.Constant.*;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
...
public class HttpHeader {
private String path;
...
private String readRequestLine(InputStream in) throws IOException {
String requestLine = IOUtil.readLine(in);
String[] tmp = requestLine.split(" ");
this.method = HttpMethod.valueOf(tmp[0].toUpperCase());
this.path = URLDecoder.decode(tmp[1], "UTF-8"); // ★URLDecoder でデコード
return requestLine + CRLF;
}
...
}
マルチバイト文字を含むファイルを用意して、ブラウザから開けるか確認する。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>しんぷる HTTP さーばー</title>
</head>
<body>
<h1>しんぷる HTTP さーばー!!</h1>
</body>
</html>
ブラウザ表示
HTTP の静的ページを作って表示できるか確認してみる
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Sample Page</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<img src="がぞう.png" />
<a href="page2.html">ページ2</a>
</body>
</html>
body {
background-color: skyblue;
}
sample/がぞう.png
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Sample Page 2</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<h1>Page 2</h1>
<button id="back">戻る</button>
<script src="script.js"></script>
</body>
</html>
window.addEventListener('load', function() {
var backButton = document.getElementById('back');
backButton.addEventListener('click', function() {
history.back();
});
});
ブラウザで http://localhost/sample/sample.html
にアクセスしてみる。
一応開いたけど、安定しない。
CPU 使用率が跳ね上がったり、連続してアクセスすると SocketException
がスローされたり、 Chrome だと <img>
タグで指定した画像が読み込めなかったりする。
CPU 使用率跳ね上がり問題
原因を調べたところ、何回かに一回空っぽのリクエストが飛んできて、 IOUtil.java
の以下の場所で無限ループしていた模様。
...
public static String readLine(InputStream in) throws IOException {
List<Byte> list = new ArrayList<>();
while (true) {
byte b = (byte)in.read(); // ★空っぽのリクエストの場合、初っ端から -1 が返される。
list.add(b);
int size = list.size();
if (2 <= size) {
char cr = (char)list.get(size - 2).byteValue();
char lf = (char)list.get(size - 1).byteValue();
if (cr == '\r' && lf == '\n') {
break; // ★終了条件が \r\n に到達するまでなので、無限ループになってしまう。
}
}
}
byte[] buffer = new byte[list.size() - 2]; // CRLF の分減らす
for (int i = 0; i < list.size() - 2; i++) {
buffer[i] = list.get(i);
}
return new String(buffer, UTF_8);
}
...
原因はよくわからないけど、とりあえず空っぽのリクエストがきたら無視するように修正する。
...
public static String readLine(InputStream in) throws IOException {
List<Byte> list = new ArrayList<>();
while (true) {
byte b = (byte)in.read(); // ★空っぽのリクエストの場合、初っ端から -1 が返される。
+ if (b == -1) {
+ throw new EmptyRequestException();
+ }
list.add(b);
int size = list.size();
if (2 <= size) {
char cr = (char)list.get(size - 2).byteValue();
char lf = (char)list.get(size - 1).byteValue();
if (cr == '\r' && lf == '\n') {
break; // ★終了条件が \r\n に到達するまでなので、無限ループになってしまう。
}
}
}
byte[] buffer = new byte[list.size() - 2]; // CRLF の分減らす
for (int i = 0; i < list.size() - 2; i++) {
buffer[i] = list.get(i);
}
return new String(buffer, UTF_8);
}
...
package gl8080.http;
public class EmptyRequestException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
...
private void serverProcess(ServerSocket server) throws IOException {
try (
Socket socket = server.accept();
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
) {
HttpRequest request = new HttpRequest(in);
HttpHeader header = request.getHeader();
if (header.isGetMethod()) {
File file = new File(".", header.getPath());
if (file.exists() && file.isFile()) {
this.respondLocalFile(file, out);
} else {
this.respondNotFoundError(out);
}
} else {
this.respondOk(out);
}
+ } catch (EmptyRequestException e) {
+ // ignore
}
}
...
ひとまず、 CPU 使用率が跳ね上がる問題は解決した。
連続してアクセスすると落ちる問題
正確なところは分からないが、どうも単一スレッドで実行しているせいでリクエストを処理しきれていない模様。
なので、受け取ったリクエストは別のスレッドに処理させるように修正する。
package gl8080.http;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SimpleHttpServer {
private ExecutorService service = Executors.newCachedThreadPool();
public void start() {
try (ServerSocket server = new ServerSocket(80)) {
while (true) {
this.serverProcess(server);
}
} catch (Exception e) {
e.printStackTrace(System.err);
}
}
private void serverProcess(ServerSocket server) throws IOException {
Socket socket = server.accept();
this.service.execute(() -> {
try (
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
) {
HttpRequest request = new HttpRequest(in);
HttpHeader header = request.getHeader();
if (header.isGetMethod()) {
File file = new File(".", header.getPath());
if (file.exists() && file.isFile()) {
this.respondLocalFile(file, out);
} else {
this.respondNotFoundError(out);
}
} else {
this.respondOk(out);
}
} catch (EmptyRequestException e) {
// ignore
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
...
}
Executor
を使えば、簡単にスレッドプールが使える。便利。
これで、連続アクセスしても処理が落ちることはほぼなくなった(さすがに F5 押しっぱなしにしたりすると同じく SocketException
が発生するが、修正前に比べれば大分マシになった)。
Chrome だと img タグが動作しない問題
IE と Firefox なら問題なく動作するが、 Chrome だと <img>
タグの読み込みが Pending
になって表示されない。
いろいろ試したが、原因は分からず。。。
最後に
Chrome では動かないという残念な結果になったものの、簡単な HTTP サーバーを作れた。
普段は Servlet などの API で隠されているが、裏ではこんな感じで TCP/IP の上でテキストを解析してやり取りをしているのだろう、たぶん。
いつもは使うだけのものを自作するというのは、結構おもしろい。