java-recipes

ホーム HTTP サーバー自作 › H-01: 最小 HTTP サーバー(GET のみ)

H-01: 最小 HTTP サーバー(GET のみ)

Java 標準ライブラリの ServerSocket と Socket だけを使って、HTTP サーバーを0から実装します。 リクエスト行のパース・パスによるルーティング・レスポンスの組み立てという HTTP の基本構造を 手を動かして学べます。

説明・ユースケース

HTTP サーバーは、クライアント(ブラウザや curl)からのリクエストを受け取り、 適切なレスポンスを返すプログラムです。通常は Spring Boot や Tomcat などのフレームワークを使いますが、 このサンプルでは Socket 通信だけで実装することで、HTTP プロトコルの仕組みを深く理解できます。

このサンプルで学べること

  • HTTP/1.1 のリクエスト行(GET /path HTTP/1.1)の構造と解析方法
  • レスポンスヘッダー(Content-Type・Content-Length)の組み立て方
  • パスによるルーティング(/ → トップ、/hello → テキスト、それ以外 → 404)
  • ExecutorService(Java 8/17)と Virtual Thread(Java 21)を使ったマルチスレッド処理
クラス / メソッド役割
ServerSocket(port)指定ポートで接続待ち受けを開始する
serverSocket.accept()クライアントからの接続を待ち、Socket を返す
socket.getInputStream()クライアントが送ったデータ(リクエスト)を受け取る
socket.getOutputStream()クライアントへデータ(レスポンス)を送る
ExecutorServiceスレッドプールでリクエストを並行処理する(Java 8/17)
Thread.ofVirtual()Virtual Thread でリクエストごとに軽量スレッドを生成(Java 21)

サンプルコード

Java 8 版では ExecutorService のスレッドプールでマルチスレッド処理を行います。 Java 17 版では var による変数宣言の簡潔化と テキストブロックによる HTML の可読性向上が追加されます。 Java 21 版では Thread.ofVirtual() による Virtual Thread と switch 式によるルーティングの簡潔化を使います。

Sample.java
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MinimalHttpServerSample {

    static final int PORT = 8080;

    // HTTP レスポンスを組み立てて送信
    static void sendResponse(OutputStream out, int statusCode,
                              String statusMessage, String contentType,
                              String body) throws IOException {
        PrintWriter writer = new PrintWriter(
            new OutputStreamWriter(out, "UTF-8"), true);
        byte[] bodyBytes = body.getBytes("UTF-8");
        writer.print("HTTP/1.1 " + statusCode + " " + statusMessage + "\r\n");
        writer.print("Content-Type: " + contentType + "; charset=UTF-8\r\n");
        writer.print("Content-Length: " + bodyBytes.length + "\r\n");
        writer.print("Connection: close\r\n");
        writer.print("\r\n");
        writer.flush();
        out.write(bodyBytes);
        out.flush();
    }

    // HTTP リクエスト行をパース(例: "GET /hello HTTP/1.1")
    static String[] parseRequestLine(String requestLine) {
        if (requestLine == null || requestLine.isEmpty()) {
            return new String[]{"GET", "/", "HTTP/1.1"};
        }
        String[] parts = requestLine.split(" ");
        if (parts.length < 2) {
            return new String[]{"GET", "/", "HTTP/1.1"};
        }
        return parts;
    }

    // リクエストを処理してレスポンスを返す
    static void handleRequest(Socket clientSocket) throws IOException {
        try (InputStream in = clientSocket.getInputStream();
             OutputStream out = clientSocket.getOutputStream()) {

            BufferedReader reader = new BufferedReader(
                new InputStreamReader(in, "UTF-8"));

            // リクエスト行を読む(例: GET /hello HTTP/1.1)
            String requestLine = reader.readLine();
            System.out.println("リクエスト: " + requestLine);

            // ヘッダーを読み飛ばす
            String line;
            while ((line = reader.readLine()) != null && !line.isEmpty()) {
                // ヘッダー行を読み飛ばす
            }

            String[] parts = parseRequestLine(requestLine);
            String method = parts[0];
            String path = parts.length > 1 ? parts[1] : "/";

            // パスに応じてレスポンスを返す
            if ("GET".equals(method)) {
                if ("/".equals(path)) {
                    sendResponse(out, 200, "OK", "text/html",
                        "<html><body><h1>Hello, Java HTTP Server!</h1>"
                        + "<p>Socket ベースの最小 HTTP サーバーです。</p>"
                        + "<p><a href='/hello'>Hello ページへ</a></p>"
                        + "</body></html>");
                } else if ("/hello".equals(path)) {
                    sendResponse(out, 200, "OK", "text/plain",
                        "Hello, World!");
                } else {
                    sendResponse(out, 404, "Not Found", "text/html",
                        "<html><body><h1>404 Not Found</h1></body></html>");
                }
            } else {
                sendResponse(out, 405, "Method Not Allowed", "text/plain",
                    "405 Method Not Allowed");
            }
        }
    }

    public static void main(String[] args) throws IOException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        System.out.println("HTTP サーバー起動中... http://localhost:" + PORT);

        try (ServerSocket serverSocket = new ServerSocket(PORT)) {
            while (true) {
                Socket clientSocket = serverSocket.accept();
                executor.submit(() -> {
                    try {
                        handleRequest(clientSocket);
                    } catch (IOException e) {
                        System.out.println("エラー: " + e.getMessage());
                    } finally {
                        try { clientSocket.close(); } catch (IOException ignored) {}
                    }
                });
            }
        }
    }
}

よくあるミス・注意点

ヘッダーと空行を正しく読み飛ばさないとボディが読めない

HTTP リクエストは「リクエスト行 → ヘッダー行 → 空行(CRLF のみ)→ ボディ」という順序で構成されています。 ヘッダーを読み飛ばさずにボディを読もうとすると、ヘッダーの内容がボディとして混入します。 空行(line.isEmpty())が来るまでループするのが正しい実装です。

Sample.java
// NG: ヘッダーを読み飛ばさずにボディを読む
String firstLine = reader.readLine(); // GET / HTTP/1.1
String body = reader.readLine(); // ← 実際はヘッダー行("Host: localhost" など)が入る

// OK: 空行まですべてのヘッダーを読み飛ばす
String requestLine = reader.readLine();
String line;
while ((line = reader.readLine()) != null && !line.isEmpty()) {
    // ヘッダー行を読み飛ばす(空行が来たらループを抜ける)
}
// ここからボディを読む

Content-Length は必ず送る

レスポンスに Content-Length を含めないと、 一部のブラウザや HTTP クライアントがレスポンスの終端を判断できず、ハングアップすることがあります。 ボディのバイト数(文字列長ではなく UTF-8 でエンコードした後のバイト数)を正確に計算して送信しましょう。

Sample.java
// NG: String の文字数を Content-Length にする(マルチバイト文字で不正な値になる)
writer.print("Content-Length: " + body.length() + "\r\n");

// OK: UTF-8 エンコード後のバイト数を使う
byte[] bodyBytes = body.getBytes("UTF-8");
writer.print("Content-Length: " + bodyBytes.length + "\r\n");

Java バージョンごとの違い

Java 8 ではスレッドプール(ExecutorService)でリクエストごとにスレッドを割り当てます。 スレッド数の上限(newFixedThreadPool(10))を超えると 待ち行列が発生します。Java 21 の Virtual Thread(仮想スレッド)はこの制約を大幅に緩和し、 OS スレッドを使わずに何万もの同時接続を効率的に処理できます。

テストする観点

  • GET / へのリクエストで 200 OK と HTML ボディが返るか
  • GET /hello へのリクエストで 200 OK と「Hello, World!」が返るか
  • 存在しないパス(/unknown)に GET したとき 404 Not Found が返るか
  • POST メソッドを送ったとき 405 Method Not Allowed が返るか
  • レスポンスの Content-Length がボディのバイト数と一致するか
  • 日本語を含む HTML ボディを返したとき文字化けしないか(UTF-8 エンコード)
  • parseRequestLine にnullや空文字列を渡したときクラッシュせずデフォルト値を返すか

GitHub でソースコードを見る →