概要

HTTP サーバー自作連載の第3回として、POST リクエストの受信とフォームデータのパースを扱います。クエリ文字列までは URL だけを見れば十分でしたが、フォーム送信を扱う段階になると、リクエストボディの読み取り、Content-Length の解釈、URL エンコードされた値のデコードが必要になります。この記事では、学習用の最小サーバーに POST ハンドリングを足し、フォーム送信から PRG パターンで結果画面へ戻すところまでを一通り確認します。

使いどころ

POST リクエストのボディ読み取りとフォームデータの扱いを手で追いたい

GET と POST で必要になる処理の違いを整理したい

PRG パターンがなぜ必要かを、最小サーバーの流れで理解したい

コード例

PostRequestServer.java
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class PostRequestServer {

    static final int PORT = 8081;

    // フォームデータを record で表現(Java 16+)
    record FormData(String name, String age) {
        boolean isValid() {
            return name != null && !name.isEmpty();
        }
    }

    /** URL エンコードされたフォームデータをパース */
    static Map<String, String> parseFormData(String body)
            throws UnsupportedEncodingException {
        var params = new HashMap<String, String>();
        if (body == null || body.isEmpty()) return params;
        for (var pair : body.split("&")) {
            var kv = pair.split("=", 2);
            var key = URLDecoder.decode(kv[0], "UTF-8");
            var value = kv.length > 1
                ? URLDecoder.decode(kv[1], "UTF-8") : "";
            params.put(key, value);
        }
        return params;
    }

    /** HTTP レスポンスを送信 */
    static void sendResponse(OutputStream out, int status,
            String statusMsg, String contentType,
            String body) throws IOException {
        var writer = new PrintWriter(
            new OutputStreamWriter(out, "UTF-8"), true);
        var bodyBytes = body.getBytes("UTF-8");
        writer.print("HTTP/1.1 " + status + " "
            + statusMsg + "\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\r\n");
        writer.flush();
        out.write(bodyBytes);
        out.flush();
    }

    /** 303 リダイレクトを送信(PRG パターン) */
    static void sendRedirect(OutputStream out,
            String location) throws IOException {
        var writer = new PrintWriter(
            new OutputStreamWriter(out, "UTF-8"), true);
        writer.print("HTTP/1.1 303 See Other\r\n");
        writer.print("Location: " + location + "\r\n");
        writer.print("Content-Length: 0\r\n");
        writer.print("Connection: close\r\n\r\n");
        writer.flush();
    }

    /** リクエストを処理する */
    static void handleRequest(Socket socket)
            throws IOException {
        try (var in = socket.getInputStream();
             var out = socket.getOutputStream()) {
            var reader = new BufferedReader(
                new InputStreamReader(in, "UTF-8"));

            // リクエスト行の解析
            var requestLine = reader.readLine();
            if (requestLine == null) return;
            var parts = requestLine.split(" ");
            var method = parts[0];
            var path = parts.length > 1 ? parts[1] : "/";

            // ヘッダー読み取り
            var headers = new HashMap<String, String>();
            String line;
            while ((line = reader.readLine()) != null
                    && !line.isEmpty()) {
                int idx = line.indexOf(':');
                if (idx > 0) {
                    headers.put(
                        line.substring(0, idx)
                            .trim().toLowerCase(),
                        line.substring(idx + 1).trim());
                }
            }

            // POST ボディ読み取り
            var body = "";
            if ("POST".equals(method)) {
                var cl = headers.get("content-length");
                if (cl != null) {
                    int len = Integer.parseInt(cl);
                    var buf = new char[len];
                    reader.read(buf, 0, len);
                    body = new String(buf);
                }
            }

            // ルーティング
            if ("GET".equals(method) && "/".equals(path)) {
                var html = """
                    <html><body>
                    <h1>フォームデモ</h1>
                    <form method='POST' action='/submit'>
                    名前: <input name='name'><br>
                    年齢: <input name='age' type='number'><br>
                    <button>送信</button>
                    </form></body></html>
                    """;
                sendResponse(out, 200, "OK",
                    "text/html", html);
            } else if ("POST".equals(method)
                    && "/submit".equals(path)) {
                var paramMap = parseFormData(body);
                var formData = new FormData(
                    paramMap.getOrDefault("name", ""),
                    paramMap.getOrDefault("age", ""));
                System.out.println("受信: " + formData);
                sendRedirect(out, "/result");
            } else if ("GET".equals(method)
                    && path.startsWith("/result")) {
                sendResponse(out, 200, "OK", "text/html",
                    "<html><body><h1>送信完了</h1>"
                    + "<a href='/'>戻る</a></body></html>");
            } else {
                sendResponse(out, 404, "Not Found",
                    "text/plain", "404 Not Found");
            }
        }
    }

    public static void main(String[] args)
            throws IOException {
        ExecutorService executor =
            Executors.newFixedThreadPool(10);
        System.out.println(
            "HTTP サーバー起動: http://localhost:" + PORT);
        try (var serverSocket = new ServerSocket(PORT)) {
            while (true) {
                var client = serverSocket.accept();
                executor.submit(() -> {
                    try { handleRequest(client); }
                    catch (IOException e) {
                        System.err.println(e.getMessage());
                    }
                    finally {
                        try { client.close(); }
                        catch (IOException ignored) {}
                    }
                });
            }
        }
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

テキストブロック、`var`、record により、フォーム送信の処理を読みやすく整理しやすい。

Java 17
// Java 17: テキストブロック + record で構造化
record FormData(String name, String age) {
    boolean isValid() {
        return name != null && !name.isEmpty();
    }
}
var html = """
    <html><body>
    <h1>フォーム</h1>
    <form method='POST' action='/submit'>
    名前: <input name='name'><br>
    <button>送信</button>
    </form></body></html>
    """;

Library Comparison

標準 API(ServerSocket + 手動パース)POST ボディの読み取りや PRG パターンを自分で確認したいとき。フォーム送信の流れを分解して追いやすい。フォーム処理の理解には向くが、入力検証や CSRF 対策は自前になる。
com.sun.net.httpserver.HttpServerPOST 処理の骨格だけ簡潔に試したいとき。Socket の受け付け処理を少し省ける。HTTP の低レイヤを追う学習効果は薄くなる。
Spring Boot / Play / Jakarta EE などの既製基盤フォーム送信や入力検証を実アプリとして扱いたいとき。バリデーションや例外処理もまとめて整理しやすい。抽象化は増えるが、実務ではこちらのほうが自然な構成になる。

注意点

Content-Length の扱いが曖昧だと、ボディ読み取りで不足や待ちが発生しやすい

URLDecoder には文字コードを明示し、日本語や空白を含む入力でも挙動が崩れないようにしたい

POST の結果をそのまま返すと再送信しやすいため、PRG パターンを早めに見ておくと理解しやすい

この段階では CSRF、入力検証、監査ログなど実務向けの論点までは扱わない

Java 8 / 17 ではスレッドプールの上限がそのまま同時処理数に影響する

FAQ

この POST サンプルをそのまま業務画面に使えますか。

そのまま使う前提ではありません。この記事は POST ボディの読み取りと PRG パターンを理解するための学習用です。

POST と GET でフォームデータの送信方法はどう違いますか。

GET は URL の `?` 以降に値を載せ、POST はリクエストボディに載せます。共有しやすさや見え方の違いもあるため、用途に応じて使い分けます。

PRG パターンとは何ですか。

Post-Redirect-Get の略で、POST の結果を直接返さず、一度リダイレクトしてから GET で表示する形です。ブラウザの再読み込みによる再送信を避けやすくなります。

Virtual Thread と従来のスレッドプールのどちらを選ぶべきですか。

学習用サンプルで Java 21 を使えるなら Virtual Thread のほうが流れを追いやすいです。Java 17 以前では Executors.newFixedThreadPool を使う構成になります。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます