316
320

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

簡単なHTTPサーバーを作る

Last updated at Posted at 2015-08-09

簡単な HTTP サーバーを実装することで HTTP を学ぶ。

ローカルの HTML ファイルをブラウザから開けるようになるのを目指す。

実装はこちら

免責事項

あくまで車輪の再発明による HTTP および HTTP サーバーのローレベルなところを勉強するのが目的です。
セキュリティなどの考慮は一切していないので、ここの実装を使ったらいろいろ問題が発生すること必至です(ディレクトリトラバーサルとか)。

ここでの実装を利用したことで発生する問題に対して、当方は一切責任を負えませんのであしからず(利用するとは思えないけど)。

まずはソケット通信から

HTTP は TCP/IP の上で動作するプロトコルなので、まずはソケット通信から始める。

Main.java
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でリクエストを送信
$ 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!!

まずヘッダーが始まり、空行を区切りとしてメッセージボディが続く。
メッセージボディは省略可能で、ヘッダーのみの場合もありえる。

ヘッダーを判断する

前述の通り、ヘッダーとボディを区切るのは空行なので、空行を読み込むまでをヘッダーと判断することができる。

Main.java
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 を使用する。
これはメッセージボディのバイト数を表している。なので、ヘッダーを読み込んだ後は、ここで指定されているバイト数だけストリームから文字を取得すればいい。

Main.java
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 というクラスに括りだす。

Main.java
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");
    }
}
HttpRequest.java
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 を使ってはいるものの、ごちゃごちゃしてきたし何より無駄が多い。
ヘッダーを別クラスに分離するリファクタリングを行う。

HttpHeader.java
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");
    }
}
HttpRequest.java
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 レスポンスを返してみる。

ステータスラインのみ返す

まずは、ボディのない単純なレスポンスを返す。

Main.java
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 というフォーマットで記述する。

ヘッダーとボディを返す

Main.java
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で確認
$ curl http://localhost/ -i
HTTP/1.1 200 OK
Content-Type: text/html

<h1>Hello World!!</h1>

ブラウザで確認

http.JPG

  • レスポンスのヘッダーも、リクエスト同様 <ヘッダー名>:<値> という形式で指定する。
  • ヘッダーとボディの間には空行を入れる。

リファクタリング3

レスポンスも、リクエスト同様クラスにまとめる。

Main.java
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");
    }
}
HttpResponse.java
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);
        }
    }
}
IOUtil.java
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() {}
}
Status.java
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;
    }
}
ContentType.java
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 メソッドが来たらパスで指定されたファイルを返すようにする

Main.java
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");
    }
}
HttpResponse.java
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));
    }
}
ContentType.java
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 にする。
    };
}

動作確認

hello.html
<!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 にアクセス。

http.JPG

これで、ローカルのファイルを参照できるようになった。

ファイルが存在しない場合は 404 を返すようにする

Main.java
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);
    }
}

ブラウザで、適当に存在しないパスにアクセスする。

http.JPG

リファクタリング4

Main.java がごちゃごちゃしだしたので、サーバー処理用のクラスに分割する。
あと、現在は1回のリクエストを受け付けると処理が終了してしまうので、継続してリクエストを捌けるようにする。

Main.java
package gl8080.http;

public class Main {
    
    public static void main(String[] args) throws Exception {
        System.out.println("start >>>");
        
        SimpleHttpServer server = new SimpleHttpServer();
        server.start();
    }
}
SimpleHttpServer.java
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 エンコード・デコードするためのクラスが標準で用意されているので、そいつを利用する。

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

    ...
}

マルチバイト文字を含むファイルを用意して、ブラウザから開けるか確認する。

にほんご.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>しんぷる HTTP さーばー</title>
  </head>
  <body>
    <h1>しんぷる HTTP さーばー!!</h1>
  </body>
</html>

ブラウザ表示

http.JPG

HTTP の静的ページを作って表示できるか確認してみる

sample/sample.html
<!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>
sample/style.css
body {
  background-color: skyblue;
}

sample/がぞう.png

がぞう.png

sample/page2.html
<!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>
sample/script.js
window.addEventListener('load', function() {
    var backButton = document.getElementById('back');
    
    backButton.addEventListener('click', function() {
        history.back();
    });
});

ブラウザで http://localhost/sample/sample.html にアクセスしてみる。

http.JPG

http.JPG

一応開いたけど、安定しない。
CPU 使用率が跳ね上がったり、連続してアクセスすると SocketException がスローされたり、 Chrome だと <img> タグで指定した画像が読み込めなかったりする。

CPU 使用率跳ね上がり問題

原因を調べたところ、何回かに一回空っぽのリクエストが飛んできて、 IOUtil.java の以下の場所で無限ループしていた模様。

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

   ...

原因はよくわからないけど、とりあえず空っぽのリクエストがきたら無視するように修正する。

IOUtil.java
   ...

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

   ...
EmptyRequestException.java
package gl8080.http;

public class EmptyRequestException extends RuntimeException {
    private static final long serialVersionUID = 1L;
}
SimpleHttpServer.java
    ...

    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 使用率が跳ね上がる問題は解決した。

連続してアクセスすると落ちる問題

正確なところは分からないが、どうも単一スレッドで実行しているせいでリクエストを処理しきれていない模様。

なので、受け取ったリクエストは別のスレッドに処理させるように修正する。

SimpleHttpServer.java
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 タグが動作しない問題

http.JPG

IE と Firefox なら問題なく動作するが、 Chrome だと <img> タグの読み込みが Pending になって表示されない。
いろいろ試したが、原因は分からず。。。

最後に

Chrome では動かないという残念な結果になったものの、簡単な HTTP サーバーを作れた。

普段は Servlet などの API で隠されているが、裏ではこんな感じで TCP/IP の上でテキストを解析してやり取りをしているのだろう、たぶん。

いつもは使うだけのものを自作するというのは、結構おもしろい。

参考

316
320
1

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
316
320

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?