Java で簡易サーバを自作して HTTP を理解する


# はじめに

GET, POST メソッドに対応した HTTP サーバをつくってみよう、という記事です。

HTTP の勉強という意味もありますが、自作してみると意外と考えることが多くて楽しめるのでいろんな人にオススメしたくて記事を書いています。

# 想定読者

- WEB開発に携わる人
- ブラウザの開発者ツール等でリクエスト、レスポンスメッセージをなんとなく眺めたことがある人
- HTTP サーバって作れるんだ！？と思った人

# ソースコード

記事内ではだいぶ端折っているので全体が見たい方はこちらからどうぞ
[https://github.com/ksugimori/SimpleHttpServer](https://github.com/ksugimori/SimpleHttpServer)

Java 版よりちょっと機能が少ないですが Ruby 版も作ってます
[https://github.com/ksugimori/simple_http_server](https://github.com/ksugimori/simple_http_server)


# 仕様

## 実装する機能

あくまで HTTP の勉強用に作成するので、静的なページの表示ができる程度。

- GET メソッドに対応する
    - 静的なページを表示することができる
    - 存在しないリソースへのアクセスは レスポンスコード 404 を返し、エラーページに遷移する
- POST メソッドに対応する
    - とりあえずリクエストを受け取るところまで
    - 何もしないのもつまらないので POST された内容を標準出力に出力する

## 何ができれば良いか？

![figure01.png](https://qiita-image-store.s3.amazonaws.com/0/86330/93ec2360-b23b-5dc3-1c72-cacd1932ea1e.png)



WEBページを表示する際、非常に単純化して言えば次のような処理が行われています。
1. クライアントから HTTP リクエストメッセージを受け取る
2. リクエストを解析する
3. リクエストされたリソースを読み取る
4. HTTP レスポンスメッセージを作成する
5. レスポンスをクライアントに送信する

以下ではこの５ステップに沿って実装していきます。

# 実装

## ステップ０：HTTP メッセージクラス

「５ステップに沿って…」と言いましたが、その前に HTTP のベースになるリクエスト・レスポンスメッセージを表すクラスを作成しておきます。

まずは仕様を確認→ [RFC7230](https://httpwg.org/specs/rfc7230.html#http.message)


```:メッセージ全体のフォーマット
HTTP-message   = start-line
                 *( header-field CRLF )
                 CRLF
                 [ message-body ]

start-line     = request-line / status-line
```

- 一行目は Start Line。リクエストの場合 Request Line、レスポンスの場合 Status Line と呼ばれ、それぞれフォーマットが異なる
- ２行目から CRLF 区切りでヘッダーが続く
- 空行を挟んでボディ

```:request-line/status-lineのフォーマット
request-line = Method SP Request-URI SP HTTP-Version CRLF

status-line  = HTTP-version SP status-code SP reason-phrase CRLF
```

```http:request-line/status-lineの例
GET /path/to/something HTTP/1.1

HTTP/1.1 200 OK
```

Start Line 以外の構造は同じなので、抽象化した基底クラスを作りそれを継承することにしてみます。

```java:AbstractHttpMessage.java
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);
  }
}
```

```Request.java
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;
  }
}
```

```java:Response.java
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();
  }
}
```



## ステップ１：リクエストを受け取る

HTTP サーバとはいえ、基本的には普通のソケット通信を行うだけ。
main メソッドではひたすら接続を待ち、接続が確立したら別スレッドでそれを処理します。

```java:
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) );
  }
}
```


## ステップ２：リクエストを解析する

### Request-Line

```:フォーマット
request-line = Method SP Request-URI SP HTTP-Version CRLF
```

スペース区切りの文字列なので正規表現でパターンマッチさせて、HTTP メソッド、URI、HTTP バージョンを抜き出す。

```java:Request-Lineのパース
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" 。０個または１個の半角スペース・タブ文字が入るようだ → [3.2.3. Whitespace](https://httpwg.org/specs/rfc7230.html#whitespace)

```java
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` ヘッダが必ず指定される。つまり、リクエストヘッダをもとに条件分岐して以下３パターンに対応できれば良いはず。

1. Content-Length あり
2. Transfer-Encoding あり
3. どちらも存在しない

#### 1. Content-Length あり

`message-body = *OCTET` というシンプルなフォーマットなので、単純に送られた内容を `byte` 型の配列に格納するだけでOK.

読み取るバイト数は `Content-Length` をもとに決定。

```java
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）リクエストボディの送信が可能になっている。[^3]

```HTTP:
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 バイトのチャンクを送信する必要があります。

```java
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 も存在しない

この場合はリクエストボディが存在しないはずなので何もしない


## ステップ３：リクエストされたリソースを読み取る

### GET メソッドの場合
単純にリクエストされたファイルを `Files#readAllBytes` で byte 配列として読み取ってやるだけでＯＫ。


```java
// ドキュメントルートと繋げて実ファイルパスに
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 にしておきます。

```java
System.out.println("POST body: " + new String(request.getBody(), StandardCharsets.UTF_8));
Response response = new Response(protocolVersion, Status.NO_CONTENT);
```

## ステップ４：HTTP レスポンスメッセージを作成する

ファイル形式に合わせた `Content-Type` をヘッダに指定してやれば、あとはクライアント側がよしなに取り計らってくれます。[^2]

```java
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);
```


## ステップ５：レスポンスをクライアントに送信する

レスポンスメッセージのフォーマットは

```
HTTP-version SP status-code SP reason-phrase CRLF
*( header-field CRLF )
CRLF
[ message-body ]
```

なので、愚直にフォーマット通りにソケットに書き出していく。

```java
 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` ずつ分割し、上記のフォーマットに整形していきます。

```java
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 っぽいアクセスログを出力するようにしています。


#### トップページ
![01_index.png](https://qiita-image-store.s3.amazonaws.com/0/86330/55f6e283-c676-e576-7e3f-2e58014fa36a.png)

#### 他のページ
![02_about.png](https://qiita-image-store.s3.amazonaws.com/0/86330/99ba10fb-df2d-b778-a4d2-1cc2e18b3aee.png)

#### フォームの送信

特に何もせずに標準出力に書き出しているので半角スペースがパーセントエンコーディングされて `+` になってますが、フォームの内容が受けて取れていることがわかります。

![03_post.png](https://qiita-image-store.s3.amazonaws.com/0/86330/25ac68b7-b8cb-b885-16ea-a7b7a35f02b6.png)

#### 存在しないパス

ステータスコードは 404 ですが、ボディにはエラーページ用の HTML ファイルを入れているのでエラー画面に遷移できています。

![04_error.png](https://qiita-image-store.s3.amazonaws.com/0/86330/d25eaf78-4d5a-1c38-293b-1175378015a0.png)

#### チャンクされたレスポンス

ボディが 20（16進数で14）バイトずつに分割されていることが確認できました。

```bash
$ 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](https://httpwg.org/specs/rfc7230.html)
[新しいプログラミング言語の学び方 HTTPサーバーを作って学ぶ Java, Scala, Clojure](https://speakerdeck.com/todokr/xin-siihurokuraminkuyan-yu-falsexue-hifang-httpsahawozuo-tutexue-hu-java-scala-clojure)
:arrow_up: このスライドがきっかけで HTTP サーバを作ってみようと思いました。

---

[^1]: これだとクライアントが変なリクエストを投げると無限ループにハマってしまうのでイケてない気がする。。
[^2]: 特に理由がなければ付けるべきですが、一応 "MUST" ではなく "SHOULD" なので付けなくても良いみたいです → [3.1.1.5. Content-Type](https://httpwg.org/specs/rfc7231.html#header.content-type)
[^3]: 若干簡略化しています。詳しくはこちら → [4.1. Chunked Transfer Coding](https://httpwg.org/specs/rfc7230.html#chunked.encoding)