40
40

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 5 years have passed since last update.

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

Last updated at Posted at 2018-06-30

はじめに

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 された内容を標準出力に出力する

何ができれば良いか?

figure01.png

WEBページを表示する際、非常に単純化して言えば次のような処理が行われています。

  1. クライアントから HTTP リクエストメッセージを受け取る
  2. リクエストを解析する
  3. リクエストされたリソースを読み取る
  4. HTTP レスポンスメッセージを作成する
  5. レスポンスをクライアントに送信する

以下ではこの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/status-lineのフォーマット
request-line = Method SP Request-URI SP HTTP-Version CRLF

status-line  = HTTP-version SP status-code SP reason-phrase CRLF
request-line/status-lineの例
GET /path/to/something HTTP/1.1

HTTP/1.1 200 OK

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

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;
  }
}
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();
  }
}

ステップ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 バージョンを抜き出す。

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" 。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パターンに対応できれば良いはず。

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

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

トップページ

01_index.png

他のページ

02_about.png

フォームの送信

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

03_post.png

存在しないパス

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

04_error.png

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

ボディが 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
:arrow_up: このスライドがきっかけで HTTP サーバを作ってみようと思いました。


  1. これだとクライアントが変なリクエストを投げると無限ループにハマってしまうのでイケてない気がする。。

  2. 若干簡略化しています。詳しくはこちら → 4.1. Chunked Transfer Coding

  3. 特に理由がなければ付けるべきですが、一応 "MUST" ではなく "SHOULD" なので付けなくても良いみたいです → 3.1.1.5. Content-Type

40
40
4

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?