概要

HTTP サーバー自作連載の第5回では、HTML や CSS などの静的ファイル配信を扱います。ブラウザに見えている画面は、単なる文字列レスポンスではなく、ファイルを読み込み、Content-Type を付けて返す処理の上に成り立っています。この記事では `static/` ディレクトリを document root とし、パス正規化と Content-Type 判定までを含めて、学習用の最小構成を整理します。

使いどころ

HTML や CSS を返すときに、サーバー側で何をしているかを確認したい

document root とパス正規化の考え方を整理したい

静的配信と動的レスポンスの違いを、最小サーバーの中で見比べたい

コード例

StaticFileServerSample.java
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class StaticFileServerSample {

    static final int PORT = 8084;
    static final File DOCUMENT_ROOT = new File("static");

    static void sendBytes(OutputStream out, int statusCode,
                          String statusMessage, String contentType,
                          byte[] bodyBytes) throws IOException {
        var writer = new PrintWriter(
            new OutputStreamWriter(out, "UTF-8"), true);
        writer.print("HTTP/1.1 " + statusCode + " " + statusMessage + "\r\n");
        writer.print("Content-Type: " + contentType + "\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 String guessContentType(String path) {
        if (path.endsWith(".html")) return "text/html; charset=UTF-8";
        if (path.endsWith(".css")) return "text/css; charset=UTF-8";
        if (path.endsWith(".js")) return "application/javascript; charset=UTF-8";
        if (path.endsWith(".txt")) return "text/plain; charset=UTF-8";
        if (path.endsWith(".png")) return "image/png";
        return "application/octet-stream";
    }

    static File resolvePath(String requestPath) throws IOException {
        var path = "/".equals(requestPath) ? "/index.html" : requestPath;
        var file = new File(DOCUMENT_ROOT, path);
        var canonicalRoot = DOCUMENT_ROOT.getCanonicalFile();
        var canonicalFile = file.getCanonicalFile();
        if (!canonicalFile.getPath().startsWith(canonicalRoot.getPath())) {
            return null;
        }
        return canonicalFile;
    }

    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 path = parts.length > 1 ? parts[1] : "/";
            if (!"GET".equals(method)) {
                sendBytes(out, 405, "Method Not Allowed",
                    "text/plain; charset=UTF-8",
                    "405 Method Not Allowed".getBytes("UTF-8"));
                return;
            }

            var file = resolvePath(path);
            if (file == null) {
                sendBytes(out, 403, "Forbidden",
                    "text/plain; charset=UTF-8",
                    "403 Forbidden".getBytes("UTF-8"));
                return;
            }
            if (!file.exists() || file.isDirectory()) {
                sendBytes(out, 404, "Not Found",
                    "text/plain; charset=UTF-8",
                    "404 Not Found".getBytes("UTF-8"));
                return;
            }

            var bodyBytes = Files.readAllBytes(file.toPath());
            sendBytes(out, 200, "OK",
                guessContentType(file.getName()), bodyBytes);
        }
    }

    public static void main(String[] args) throws IOException {
        if (!DOCUMENT_ROOT.exists()) {
            DOCUMENT_ROOT.mkdirs();
            var index = new File(DOCUMENT_ROOT, "index.html");
            try (var writer = new OutputStreamWriter(
                    new FileOutputStream(index), StandardCharsets.UTF_8)) {
                writer.write("""
                    <html><body>
                    <h1>Static File Server</h1>
                    <p>static/index.html を配信しています。</p>
                    </body></html>
                    """);
            }
        }

        ExecutorService executor = Executors.newFixedThreadPool(10);
        System.out.println("Static File 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

初期 HTML をテキストブロックで書けるため、配信対象の例を読みやすく置きやすい。

Java 17
var bodyBytes = Files.readAllBytes(file.toPath());
sendBytes(out, 200, "OK",
    guessContentType(file.getName()), bodyBytes);

Library Comparison

標準 API(ServerSocket + Files)静的配信とパス検証の基本を学習したいとき。ファイルをどう返しているかを追いやすい。キャッシュ制御や圧縮配信など、本格的な配信機能は自前になる。
com.sun.net.httpserver.HttpServer静的配信の骨格だけを簡潔に試したいとき。HTTP メッセージの細部は見えにくくなる。
nginx / Apache / Spring Boot Static Resources実際に静的アセットを配信する基盤を選びたいとき。仕組みの手触りは減るが、運用面では自然な選択になる。

注意点

パスをそのまま結合すると、document root の外へ出てしまうケースがある

Content-Type の付け方を誤ると、ブラウザでの見え方が崩れやすい

このサンプルは分かりやすさを優先しており、大きなファイルやキャッシュ制御までは扱わない

静的配信の理解が目的なので、公開向けサーバーとしての機能までは揃えていない

FAQ

このサンプルをそのまま静的ファイル配信に使えますか。

学習用の参考にはなりますが、公開向けの静的配信基盤として使う前提ではありません。キャッシュ制御や圧縮配信などは別途必要です。

なぜ `canonicalPath` の確認が必要ですか。

単純に `new File(root, path)` とすると `../` を含むパスで外側のファイルへ到達できます。document root 配下に閉じるための基本的な確認です。

Content-Type はどこまで厳密に判定すべきですか。

学習用なら拡張子ベースで十分です。実際の配信基盤では MIME 判定、charset、圧縮、キャッシュ戦略まで含めて考えます。

画像や大きなファイルも同じ実装で返せますか。

返せますが、`readAllBytes` で全読み込みする実装はメモリ効率がよくありません。大きなファイルではストリーム転送を選ぶほうが自然です。

関連書籍

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

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