java-recipes

ホーム HTTP サーバー自作 › H-03: TODO リスト(CSV データストア)

H-03: TODO リスト(CSV データストア)

H-01・H-02 で学んだ技術を組み合わせ、フォームから TODO を追加・削除し CSV ファイルに永続化するミニ Web アプリを実装します。 複数スレッドからの同時書き込みを防ぐ ReentrantLock の使い方と、 XSS 対策のための HTML エスケープも解説します。

説明・ユースケース

このサンプルは H-01・H-02 で学んだ GET/POST 処理・ルーティング・フォームデータのパースを 組み合わせて、実際に動く Web アプリを作るものです。 データの保存先として CSV ファイルを使うため、データベースなしで動作します。

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

  • CSV ファイルへの追記・全上書きを使った簡易データストア
  • ReentrantLock による複数スレッドからの同時 CSV アクセス制御
  • HTML エスケープ(escapeHtml)による XSS(クロスサイトスクリプティング)対策
  • System.currentTimeMillis() を一意な ID として使う簡易手法
機能実装方法
TODO 追加POST /add → CSV に追記 → 303 リダイレクト
TODO 削除POST /delete → CSV を全読み込み → 対象除外して全上書き
TODO 一覧表示GET / → CSV を読み込んで HTML を生成
同時アクセス制御ReentrantLock で CSV 操作をシリアライズ(1スレッドずつ実行)
XSS 対策escapeHtml で & < > を HTML エンティティに変換

サンプルコード

Java 8 版では CSV ファイルの読み書きに OutputStreamWriter + FileOutputStream を組み合わせて エンコーディングを明示します(Java 8 では new FileWriter(file, Charset, append) が使えないため)。 Java 17 版では record Todo で TODO を型安全に扱い、new FileWriter(file, Charset, append)(Java 11+)を使います。 Java 21 版では Virtual Thread でリクエストを効率的に処理します。

Sample.java
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class TodoHttpServerSample {

    static final int PORT = 8082;
    static final String CSV_FILE = "todos.csv";
    // 複数スレッドから同時に CSV を操作しないようにロックを使う
    static final ReentrantLock lock = new ReentrantLock();

    // CSV からTODO一覧を読み込む
    static List<String[]> loadTodos() throws IOException {
        List<String[]> todos = new ArrayList<>();
        File file = new File(CSV_FILE);
        if (!file.exists()) return todos;
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                String[] parts = line.split(",", 2);
                if (parts.length == 2) {
                    todos.add(parts);
                }
            }
        }
        return todos;
    }

    // TODO を CSV に追記(Java 8: OutputStreamWriter でエンコーディングを明示)
    static void addTodo(String id, String title) throws IOException {
        // Java 8 では new FileWriter(file, Charset, append) が使えないため
        // OutputStreamWriter + FileOutputStream を組み合わせる
        try (PrintWriter writer = new PrintWriter(
                new OutputStreamWriter(
                    new FileOutputStream(CSV_FILE, true), StandardCharsets.UTF_8))) {
            writer.println(id + "," + title.replace(",", ","));
        }
    }

    // TODO を CSV から削除
    static void deleteTodo(String id) throws IOException {
        List<String[]> todos = loadTodos();
        try (PrintWriter writer = new PrintWriter(
                new OutputStreamWriter(
                    new FileOutputStream(CSV_FILE, false), StandardCharsets.UTF_8))) {
            for (String[] todo : todos) {
                if (!todo[0].equals(id)) {
                    writer.println(todo[0] + "," + todo[1]);
                }
            }
        }
    }

    // HTML ページを生成
    static String buildHtml(List<String[]> todos) {
        StringBuilder sb = new StringBuilder();
        sb.append("<!DOCTYPE html><html><head><meta charset='UTF-8'>");
        sb.append("<title>TODO リスト</title></head><body>");
        sb.append("<h1>TODO リスト</h1>");
        sb.append("<form method='POST' action='/add'>");
        sb.append("<input name='title' placeholder='新しいTODO' required>");
        sb.append("<button>追加</button></form><ul>");
        for (String[] todo : todos) {
            sb.append("<li>").append(escapeHtml(todo[1]));
            sb.append(" <form method='POST' action='/delete' style='display:inline'>");
            sb.append("<input type='hidden' name='id' value='").append(todo[0]).append("'>");
            sb.append("<button>削除</button></form></li>");
        }
        sb.append("</ul></body></html>");
        return sb.toString();
    }

    // HTML エスケープ(XSS 対策)
    static String escapeHtml(String text) {
        return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }

    static void sendResponse(OutputStream out, int status, String msg,
                              String type, String body) throws IOException {
        byte[] bytes = body.getBytes("UTF-8");
        PrintWriter w = new PrintWriter(new OutputStreamWriter(out, "UTF-8"), true);
        w.print("HTTP/1.1 " + status + " " + msg + "\r\n");
        w.print("Content-Type: " + type + "; charset=UTF-8\r\n");
        w.print("Content-Length: " + bytes.length + "\r\n");
        w.print("Connection: close\r\n\r\n");
        w.flush();
        out.write(bytes);
        out.flush();
    }

    static void sendRedirect(OutputStream out) throws IOException {
        PrintWriter w = new PrintWriter(new OutputStreamWriter(out, "UTF-8"), true);
        w.print("HTTP/1.1 303 See Other\r\nLocation: /\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
        w.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;
            String[] parts = requestLine.split(" ");
            String method = parts[0];
            String path = parts.length > 1 ? parts[1] : "/";

            // Content-Length を取得
            int contentLength = 0;
            String line;
            while ((line = reader.readLine()) != null && !line.isEmpty()) {
                if (line.toLowerCase().startsWith("content-length:")) {
                    contentLength = Integer.parseInt(line.split(":")[1].trim());
                }
            }

            String body = "";
            if (contentLength > 0) {
                char[] buf = new char[contentLength];
                reader.read(buf, 0, contentLength);
                body = new String(buf);
            }

            lock.lock();
            try {
                if ("GET".equals(method) && "/".equals(path)) {
                    List<String[]> todos = loadTodos();
                    sendResponse(out, 200, "OK", "text/html", buildHtml(todos));
                } else if ("POST".equals(method) && "/add".equals(path)) {
                    String title = "";
                    for (String kv : body.split("&")) {
                        String[] pair = kv.split("=", 2);
                        if ("title".equals(URLDecoder.decode(pair[0], "UTF-8"))) {
                            title = URLDecoder.decode(pair[1], "UTF-8");
                        }
                    }
                    if (!title.isEmpty()) {
                        addTodo(String.valueOf(System.currentTimeMillis()), title);
                    }
                    sendRedirect(out);
                } else if ("POST".equals(method) && "/delete".equals(path)) {
                    String id = "";
                    for (String kv : body.split("&")) {
                        String[] pair = kv.split("=", 2);
                        if ("id".equals(URLDecoder.decode(pair[0], "UTF-8"))) {
                            id = URLDecoder.decode(pair[1], "UTF-8");
                        }
                    }
                    if (!id.isEmpty()) {
                        deleteTodo(id);
                    }
                    sendRedirect(out);
                } else {
                    sendResponse(out, 404, "Not Found", "text/plain", "404 Not Found");
                }
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws IOException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        System.out.println("TODO HTTP サーバー起動中... http://localhost:" + PORT);
        System.out.println("ブラウザで 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) {} }
                });
            }
        }
    }
}

よくあるミス・注意点

ロックなしで CSV を同時書き込みするとファイルが壊れる

複数のリクエストが同時に CSV ファイルに書き込もうとすると、内容が混在してファイルが壊れることがあります。ReentrantLock を使って CSV 操作を1スレッドずつ実行するようにしましょう。

Sample.java
// NG: ロックなしで CSV に同時書き込みすると競合が発生する
executor.submit(() -> addTodo("1", "タスクA")); // スレッド1
executor.submit(() -> addTodo("2", "タスクB")); // スレッド2(同時に書き込むと壊れる)

// OK: ReentrantLock でシリアライズする
lock.lock();
try {
    addTodo(id, title); // 1スレッドずつ実行される
} finally {
    lock.unlock(); // 必ず finally でアンロック
}

ユーザー入力をそのまま HTML に出力すると XSS 脆弱性が生じる

ユーザーが入力した文字列をそのまま HTML に出力すると、<script>alert('XSS')</script>のような文字列が実行されてしまいます(XSS 攻撃)。 必ず escapeHtml() で HTML エスケープしてから出力しましょう。

Sample.java
// NG: ユーザー入力をそのまま HTML に出力(XSS 脆弱性)
sb.append("<li>").append(todo.title()).append("</li>"); // 危険!

// OK: HTML エスケープしてから出力
sb.append("<li>").append(escapeHtml(todo.title())).append("</li>");

// escapeHtml の実装
static String escapeHtml(String text) {
    return text
        .replace("&", "&amp;")  // & を先に変換(順序重要)
        .replace("<", "&lt;")
        .replace(">", "&gt;");
}

Java バージョンごとの違い

Java 8 では new FileWriter(file, Charset, append) が使えません。 代わりに new OutputStreamWriter(new FileOutputStream(file, true), StandardCharsets.UTF_8) と 組み合わせてエンコーディングを明示します。 Java 11 以降では new FileWriter(file, StandardCharsets.UTF_8, true) と シンプルに書けます。 Java 17 では record Todo で データを型安全な不変クラスとして扱え、todo.title() のような メソッドアクセスになります。Java 21 では Virtual Thread でよりシンプルに同時接続を処理できます。

テストする観点

  • escapeHtml が & < > を正しくエスケープするか(境界値: 各文字が1つ・複数混在)
  • escapeHtml に null や空文字列を渡したときクラッシュしないか
  • loadTodos が存在しないファイルに対して空リストを返すか
  • addTodo で追加した TODO が loadTodos で読み込めるか
  • deleteTodo で削除した ID が loadTodos の結果に含まれないか
  • TODO タイトルにカンマが含まれる場合、CSV の区切り文字と混同されないか(全角カンマへの変換)
  • TODO タイトルに HTML タグが含まれる場合、buildHtml でエスケープされるか

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