概要

HTTP サーバー自作連載の第6回では、Cookie とセッションの最小実装を扱います。HTTP がステートレスであることは理解していても、ログイン状態のような『リクエストをまたいで残る値』をどう扱うのかは、実際に一度書いてみると見えやすくなります。この記事では `Set-Cookie` と `Cookie` ヘッダーを自分で組み立て、サーバー側の `Map` をセッションストアとして使うことで、状態管理の最小形を確認します。

使いどころ

Cookie とサーバー側セッションがどう対応しているかを整理したい

ログイン状態のような値を、最小構成でどう保持するかを見たい

状態管理を自分で書くと何が増えるかを把握したい

コード例

CookieSessionServerSample.java
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CookieSessionServerSample {

    static final int PORT = 8085;
    static final Map<String, String> SESSIONS =
        new ConcurrentHashMap<>();

    static void sendResponse(OutputStream out, int statusCode,
                             String statusMessage, String contentType,
                             String body, String setCookie) 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");
        if (setCookie != null) {
            writer.print("Set-Cookie: " + setCookie + "\r\n");
        }
        writer.print("Connection: close\r\n\r\n");
        writer.flush();
        out.write(bodyBytes);
        out.flush();
    }

    static void sendRedirect(OutputStream out, String location,
                             String setCookie) 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");
        if (setCookie != null) {
            writer.print("Set-Cookie: " + setCookie + "\r\n");
        }
        writer.print("Content-Length: 0\r\n");
        writer.print("Connection: close\r\n\r\n");
        writer.flush();
    }

    static Map<String, String> parseFormBody(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;
    }

    static Map<String, String> parseCookies(String cookieHeader) {
        var cookies = new HashMap<String, String>();
        if (cookieHeader == null || cookieHeader.isEmpty()) {
            return cookies;
        }
        for (var pair : cookieHeader.split(";")) {
            var kv = pair.trim().split("=", 2);
            if (kv.length == 2) {
                cookies.put(kv[0], kv[1]);
            }
        }
        return cookies;
    }

    static String buildTopPage(String username) {
        if (username == null) {
            return """
                <html><body>
                <h1>ログイン</h1>
                <form method='POST' action='/login'>
                <input name='name' placeholder='名前' required>
                <button>ログイン</button>
                </form>
                </body></html>
                """;
        }
        return """
            <html><body>
            <h1>ようこそ %s さん</h1>
            <form method='POST' action='/logout'>
            <button>ログアウト</button>
            </form>
            </body></html>
            """.formatted(username);
    }

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

            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()) {
                var idx = line.indexOf(':');
                if (idx > 0) {
                    headers.put(line.substring(0, idx).trim().toLowerCase(),
                        line.substring(idx + 1).trim());
                }
            }

            var body = "";
            if ("POST".equals(method)) {
                var contentLength = headers.get("content-length");
                if (contentLength != null) {
                    var length = Integer.parseInt(contentLength);
                    var buf = new char[length];
                    reader.read(buf, 0, length);
                    body = new String(buf);
                }
            }

            var cookies = parseCookies(headers.get("cookie"));
            var sessionId = cookies.get("SID");
            var username = sessionId == null ? null : SESSIONS.get(sessionId);

            if ("GET".equals(method) && "/".equals(path)) {
                sendResponse(out, 200, "OK", "text/html",
                    buildTopPage(username), null);
            } else if ("POST".equals(method) && "/login".equals(path)) {
                var params = parseFormBody(body);
                var name = params.getOrDefault("name", "").trim();
                if (name.isEmpty()) {
                    sendResponse(out, 400, "Bad Request",
                        "text/plain", "name is required", null);
                    return;
                }
                var newSessionId = URLEncoder.encode(
                    UUID.randomUUID().toString(), "UTF-8");
                SESSIONS.put(newSessionId, name);
                sendRedirect(out, "/", "SID=" + newSessionId
                    + "; Path=/; HttpOnly");
            } else if ("POST".equals(method) && "/logout".equals(path)) {
                if (sessionId != null) {
                    SESSIONS.remove(sessionId);
                }
                sendRedirect(out, "/", "SID=deleted; Path=/; Max-Age=0");
            } else {
                sendResponse(out, 404, "Not Found",
                    "text/plain", "404 Not Found", null);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        System.out.println("Cookie Session 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 cookies = parseCookies(headers.get("cookie"));
var sessionId = cookies.get("SID");
var username = sessionId == null ? null : SESSIONS.get(sessionId);

Library Comparison

標準 API(Cookie ヘッダー + Map)Cookie とセッションの関係を一度手で確認したいとき。ブラウザ側とサーバー側の役割が見えやすい。有効期限、永続化、CSRF などは自前になり、実運用の前提にはしにくい。
com.sun.net.httpserver.HttpServer状態管理の骨格だけ追いたいとき。Socket 処理を少し省ける。HTTP の細部理解は減る。
Spring Session / Jakarta Security / Akka HTTP認証やセッション管理を実アプリで扱いたいとき。責務分担や周辺機能まで整理しやすい。学習コストは増えるが、実務ではこちらのほうが自然である。

注意点

この実装はメモリ上の Map を使うため、再起動や複数台構成は前提にしていない

セッション ID は予測しにくい値で生成し、Cookie の属性も意識しておきたい

HttpOnly、Secure、SameSite などの属性は、学習用でも存在だけは把握しておくと後でつながりやすい

認証やセッション失効を実務で扱うには、CSRF や有効期限管理まで含めて考える必要がある

FAQ

このセッション管理をそのまま使えますか。

そのまま使う前提ではありません。この記事は Cookie とセッションの関係を理解するための学習用であり、実運用では有効期限や分散環境対応まで含めて別途設計が必要です。

Cookie とセッションは同じものですか。

違います。Cookie はブラウザに保存される小さなデータで、セッションは通常サーバー側で管理されます。ブラウザは Cookie でセッション ID を送り返します。

なぜセッション ID を URL ではなく Cookie に入れるのですか。

URL に載せるとログやリファラに残りやすく、共有や漏洩のリスクが高まるためです。Cookie で渡すほうが扱いやすくなります。

この実装を本番で使えますか。

本番向けの構成とは考えないほうがよいです。Secure 属性、SameSite、CSRF 対策、期限切れ管理、分散環境対応まで含めると、別の基盤を前提にしたほうが整理しやすくなります。

関連書籍

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

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