概要

HTTP サーバー自作連載の第2回として、GET リクエストに付くクエリ文字列を扱います。最小サーバーではパスだけを見れば十分でしたが、実際の HTTP では `?` 以降の値を読み取り、デコードし、未指定時のデフォルト値を決める場面がすぐに出てきます。この記事では、クエリ文字列の分離、URL デコード、動的レスポンス生成を順に実装し、次の POST 処理につながる土台を作ります。

使いどころ

GET リクエストで値を受け取る流れを、低レイヤから確認したい

クエリ文字列の分解と URL デコードを手で追いたい

動的レスポンスの入口として、最小の検索風 UI を作ってみたい

コード例

HttpQueryServerSample.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 HttpQueryServerSample {

    static final int PORT = 8083;

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

    static Map<String, String> parseQuery(String rawQuery)
            throws UnsupportedEncodingException {
        var params = new HashMap<String, String>();
        if (rawQuery == null || rawQuery.isEmpty()) {
            return params;
        }
        for (var pair : rawQuery.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;
    }

    static String escapeHtml(String text) {
        return text.replace("&", "&amp;")
                   .replace("<", "&lt;")
                   .replace(">", "&gt;");
    }

    static void handleRequest(Socket clientSocket) throws IOException {
        try (var in = clientSocket.getInputStream();
             var out = clientSocket.getOutputStream()) {
            var reader = new BufferedReader(
                new InputStreamReader(in, "UTF-8"));
            var requestLine = reader.readLine();
            if (requestLine == null || requestLine.isEmpty()) {
                return;
            }

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

            var parts = requestLine.split(" ");
            var method = parts[0];
            var target = parts.length > 1 ? parts[1] : "/";
            if (!"GET".equals(method)) {
                sendResponse(out, 405, "Method Not Allowed",
                    "text/plain", "405 Method Not Allowed");
                return;
            }

            var path = target;
            var rawQuery = "";
            var queryIndex = target.indexOf('?');
            if (queryIndex >= 0) {
                path = target.substring(0, queryIndex);
                rawQuery = target.substring(queryIndex + 1);
            }

            var params = parseQuery(rawQuery);

            if ("/".equals(path)) {
                var html = """
                    <html><body>
                    <h1>クエリパラメータデモ</h1>
                    <form method='GET' action='/search'>
                    <input name='keyword' placeholder='検索語'>
                    <button>検索</button>
                    </form>
                    <p><a href='/hello?name=Java'>/hello?name=Java</a></p>
                    </body></html>
                    """;
                sendResponse(out, 200, "OK", "text/html", html);
            } else if ("/hello".equals(path)) {
                var name = params.getOrDefault("name", "Guest");
                sendResponse(out, 200, "OK", "text/plain",
                    "Hello, " + name + "!");
            } else if ("/search".equals(path)) {
                var keyword = params.getOrDefault("keyword", "");
                var html = """
                    <html><body>
                    <h1>検索結果</h1>
                    <p>keyword = %s</p>
                    <p><a href='/'>戻る</a></p>
                    </body></html>
                    """.formatted(escapeHtml(keyword));
                sendResponse(out, 200, "OK", "text/html", 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 Query Server 起動中... http://localhost:" + PORT);

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

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

Version Coverage

テキストブロックと `formatted` を使うと、動的 HTML の返却を読みやすく書ける。

Java 17
var params = parseQuery(rawQuery);
var keyword = params.getOrDefault("keyword", "");
var html = """
    <html><body>
    <h1>検索結果</h1>
    <p>keyword = %s</p>
    </body></html>
    """.formatted(escapeHtml(keyword));

Library Comparison

標準 API(ServerSocket + 手動パース)クエリ文字列の分離や URL デコードを自分で確認したいとき。値がどこから来てどう扱われるかを追いやすい。理解には向くが、多値パラメータや入力検証は自前になる。
com.sun.net.httpserver.HttpServerURI の query 部分だけに注目して、簡易な実験をしたいとき。Socket レベルでどこを読んでいるかは見えにくくなる。
Spring MVC / Javalin / Playクエリパラメータを実アプリで宣言的に扱いたいとき。入力受け渡しの責務を整理しやすい。仕組みの細部は隠れるため、低レイヤの学習には向かない。

注意点

クエリ文字列は URL に残るため、検索条件のような軽い値と相性がよい

URLDecoder を通さないと、日本語や空白を含む値の確認が難しくなる

入力値を HTML に戻す場合は、最小サンプルでもエスケープ処理を入れておきたい

同じキーが複数回出る場合の扱いは、この段階では単純化している

FAQ

この実装をそのまま検索画面に使えますか。

勧めません。この記事はクエリ文字列の扱いを理解するための学習用であり、実画面では入力検証やエラーハンドリングが別途必要です。

クエリパラメータと POST ボディはどう使い分けますか。

検索条件やページ番号のように URL として共有しやすい値はクエリパラメータが向いています。長い入力や URL に出したくない値は POST ボディを選びます。

同じキーが複数ある場合はどう扱うべきですか。

実務では List として扱う場合がありますが、この記事の最小実装では最後の値だけを採用しています。必要になった段階で拡張すれば十分です。

なぜ HTML エスケープが必要ですか。

クエリで受け取った値をそのまま HTML に埋め込むと、画面表示の崩れやスクリプト注入の入口になります。最小サンプルでも変換の考え方は見ておくと役に立ちます。

関連書籍

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

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