java-recipes

ホーム HTTP サーバー自作 › H-02: POST リクエスト対応

H-02: POST リクエスト対応

HTTP の POST リクエストを受け取り、フォームデータを読み取る方法を解説します。 Content-Length ヘッダーを使ったボディの正確な読み取り、URL エンコードされた文字列のデコード、 そして PRG パターン(Post/Redirect/Get)による 303 リダイレクトの実装を学べます。

説明・ユースケース

HTML フォームで送信ボタンを押すと、ブラウザはフォームの内容を POST リクエストとしてサーバーに送ります。 このとき、フォームデータはリクエストボディにname=%E7%94%B0%E4%B8%AD&age=30のような URL エンコード形式で格納されます。サーバー側ではこれを正しく読み取り・デコードする必要があります。

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

  • Content-Length ヘッダーを使った POST ボディの正確な読み取り
  • URLDecoder を使った URL エンコード文字列のデコード
  • 303 See Other によるリダイレクト(PRG パターン)の実装
  • ヘッダーを Map に格納して後から参照する方法
クラス / メソッド役割
Content-Length ヘッダーPOST ボディのバイト数を示す。この値ぶんだけ読む
reader.read(buf, 0, length)指定バイト数だけ読む(readLine() は使わない)
URLDecoder.decode(str, "UTF-8")URL エンコードされた文字列を元に戻す
303 See OtherPOST 送信後にリダイレクトして二重送信を防ぐ(PRG パターン)
Location ヘッダーリダイレクト先のURLを指定する

サンプルコード

Java 8 版ではクラス・Map を使ったシンプルな実装です。 Java 17 版では record で フォームデータを型安全な不変クラスとして表現し、テキストブロックで HTML を見やすく記述します。 Java 21 版では Thread.ofVirtual() による Virtual Thread で同時接続を効率的に処理します。

Sample.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 PostRequestServerSample {

    static final int PORT = 8081;

    // URL エンコードされたフォームデータをパース
    // 例: "name=%E7%94%B0%E4%B8%AD&age=30" → {"name": "田中", "age": "30"}
    static Map<String, String> parseFormData(String body) throws UnsupportedEncodingException {
        Map<String, String> params = new HashMap<>();
        if (body == null || body.isEmpty()) {
            return params;
        }
        String[] pairs = body.split("&");
        for (String pair : pairs) {
            String[] kv = pair.split("=", 2);
            String key = URLDecoder.decode(kv[0], "UTF-8");
            String value = kv.length > 1 ? URLDecoder.decode(kv[1], "UTF-8") : "";
            params.put(key, value);
        }
        return params;
    }

    static void sendResponse(OutputStream out, int status, String statusMsg,
                              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 " + 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");
        writer.print("\r\n");
        writer.flush();
        out.write(bodyBytes);
        out.flush();
    }

    static void sendRedirect(OutputStream out, String location) throws IOException {
        PrintWriter 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");
        writer.print("\r\n");
        writer.flush();
    }

    static void handleRequest(Socket socket) throws IOException {
        try (InputStream in = socket.getInputStream();
             OutputStream out = socket.getOutputStream()) {
            BufferedReader reader = new BufferedReader(
                new InputStreamReader(in, "UTF-8"));

            // リクエスト行
            String requestLine = reader.readLine();
            if (requestLine == null) return;
            System.out.println("リクエスト: " + requestLine);
            String[] parts = requestLine.split(" ");
            String method = parts[0];
            String path = parts.length > 1 ? parts[1] : "/";

            // ヘッダー読み取り(Content-Length を取得)
            Map<String, String> headers = new HashMap<>();
            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 ボディ読み取り
            String body = "";
            if ("POST".equals(method)) {
                String contentLength = headers.get("content-length");
                if (contentLength != null) {
                    int length = Integer.parseInt(contentLength);
                    char[] buf = new char[length];
                    reader.read(buf, 0, length);
                    body = new String(buf);
                }
            }

            // ルーティング
            if ("GET".equals(method) && "/".equals(path)) {
                sendResponse(out, 200, "OK", "text/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>");
            } else if ("POST".equals(method) && "/submit".equals(path)) {
                Map<String, String> params = parseFormData(body);
                System.out.println("フォームデータ: " + params);
                sendRedirect(out, "/result?name=" + params.getOrDefault("name", ""));
            } else if ("GET".equals(method) && path.startsWith("/result")) {
                sendResponse(out, 200, "OK", "text/html",
                    "<html><body><h1>送信完了</h1><p>"
                    + path + "</p><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 (ServerSocket serverSocket = new ServerSocket(PORT)) {
            while (true) {
                Socket client = serverSocket.accept();
                executor.submit(() -> {
                    try { handleRequest(client); }
                    catch (IOException e) { System.out.println("エラー: " + e.getMessage()); }
                    finally { try { client.close(); } catch (IOException ignored) {} }
                });
            }
        }
    }
}

よくあるミス・注意点

readLine() で POST ボディを読もうとするとハングアップする

POST ボディには改行が含まれないことが多く、 readLine() を使うとボディの終端まで待ち続けてハングアップします。 Content-Length ヘッダーで指定されたバイト数だけ読む reader.read(buf, 0, length) を使いましょう。

Sample.java
// NG: readLine() で POST ボディを読む(ハングアップの原因)
String body = reader.readLine(); // ← 改行が来るまで無限待ち

// OK: Content-Length ぶんだけ読む
String contentLength = headers.get("content-length");
if (contentLength != null) {
    int length = Integer.parseInt(contentLength);
    char[] buf = new char[length];
    reader.read(buf, 0, length); // 指定バイト数だけ読む
    body = new String(buf);
}

POST 後は 303 リダイレクトを使う(PRG パターン)

POST リクエストに対して直接 200 OK でHTMLを返すと、ブラウザのリロードや戻るボタンを押したときに 同じ POST が再送信されます(二重送信)。303 See Other でリダイレクトすることで ブラウザに GET リクエストを発行させ、二重送信を防止できます。これを PRG(Post-Redirect-Get)パターンといいます。

Sample.java
// NG: POST に対して 200 OK で HTML を返す(リロードで再送信される)
sendResponse(out, 200, "OK", "text/html", "<html>...</html>");

// OK: POST 後は 303 See Other でリダイレクトする
writer.print("HTTP/1.1 303 See Other\r\n");
writer.print("Location: /result\r\n"); // リダイレクト先
writer.print("Content-Length: 0\r\n");
writer.print("Connection: close\r\n");
writer.print("\r\n");

Java バージョンごとの違い

Java 8 ではフォームデータを Map として扱います。 Java 17 では record を使うことで フォームデータを型安全かつ不変なオブジェクトとして扱え、フィールドへのアクセスがformData.name() のように メソッド呼び出しとして明確になります。 Java 21 では ExecutorService が不要となり、Virtual Thread でシンプルにマルチスレッド処理を記述できます。

テストする観点

  • parseFormData に空文字列を渡したとき空の Map が返るか
  • parseFormData で日本語(URL エンコード済み)が正しくデコードされるか
  • parseFormData で値が空(key= のみ)のとき空文字列になるか
  • POST /submit へのリクエストで 303 See Other が返るか
  • 303 レスポンスに Location ヘッダーが含まれるか
  • Content-Length が 0 のとき body が空文字列になるか
  • GET /result?name=テスト にアクセスしたとき 200 OK が返るか

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