概要

HTTP サーバー自作連載の第4回として、TODO リストを題材に CRUD の流れを一つにまとめます。ここでは、一覧表示、追加、削除という基本操作を HTTP の上に載せ、CSV への永続化や排他制御まで含めて確認します。記事の目的は TODO アプリを完成品として作ることではなく、ルーティング、ボディパース、レスポンス生成、永続化の組み合わせを学習用の題材として一度つなげてみることです。

使いどころ

HTTP の上に CRUD を載せると何が必要になるかを一度まとめて見たい

一覧・追加・削除と永続化を、学習用の一つの題材で確認したい

データベースやフレームワークに進む前に、最低限の構成要素を整理したい

コード例

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

    static final int PORT = 8082;
    static final String CSV_FILE = "todos.csv";
    static final ReentrantLock lock = new ReentrantLock();

    // TODO を record で表現(Java 16+)
    record Todo(String id, String title) {}

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

    /** TODO を CSV に追記 */
    static void addTodo(String id, String title)
            throws IOException {
        try (var writer = new PrintWriter(new FileWriter(
                CSV_FILE, StandardCharsets.UTF_8, true))) {
            writer.println(id + ","
                + title.replace(",", ","));
        }
    }

    /** TODO を CSV から削除 */
    static void deleteTodo(String id) throws IOException {
        var todos = loadTodos();
        try (var writer = new PrintWriter(new FileWriter(
                CSV_FILE, StandardCharsets.UTF_8, false))) {
            for (var todo : todos) {
                if (!todo.id().equals(id)) {
                    writer.println(
                        todo.id() + "," + todo.title());
                }
            }
        }
    }

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

    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 {
        var bytes = body.getBytes("UTF-8");
        var 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 {
        var w = new PrintWriter(
            new OutputStreamWriter(out, "UTF-8"), true);
        w.print("HTTP/1.1 303 See Other\r\n"
            + "Location: /\r\n"
            + "Content-Length: 0\r\n"
            + "Connection: close\r\n\r\n");
        w.flush();
    }

    public static void main(String[] args)
            throws IOException {
        ExecutorService executor =
            Executors.newFixedThreadPool(10);
        System.out.println(
            "TODO サーバー起動: http://localhost:" + PORT);
        try (var serverSocket = new ServerSocket(PORT)) {
            while (true) {
                var client = serverSocket.accept();
                executor.submit(() -> {
                    try {
                        // handleRequest の実装は本文参照
                        client.close();
                    } catch (IOException ignored) {}
                });
            }
        }
    }
}

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

Version Coverage

record、テキストブロック、Charset 付き FileWriter により、学習用サンプルでも見通しを保ちやすい。

Java 17
// Java 17: record で型安全に、FileWriter に Charset 指定
record Todo(String id, String title) {}
// ...
try (var writer = new PrintWriter(
        new FileWriter(CSV_FILE,
            StandardCharsets.UTF_8, true))) {
    writer.println(todo.id() + "," + todo.title());
}

Library Comparison

標準 API(ServerSocket + CSV)CRUD、永続化、排他制御が一つのサンプルでどうつながるかを見たいとき。仕組みをまとめて確認しやすい。理解には向くが、実アプリの TODO 管理基盤としては別の構成を考えたい。
com.sun.net.httpserver.HttpServer + CSVSocket レベルの処理を減らして、CRUD サーバーの流れだけ確認したいとき。HTTP の細部は見えにくくなるため、学習の焦点が少し変わる。
Spring Boot / Play / Jakarta EE + DB実際の CRUD アプリケーションとして作るとき。永続化、入力検証、認証を現実的に扱いやすい。依存は増えるが、実務で必要な責務を整理しやすい。

注意点

CSV への排他制御は学習には十分だが、再起動や複数プロセスをまたぐ整合性までは扱わない

HTML 出力では入力値のエスケープを入れ、表示系でも安全性を意識しておきたい

CSV の簡易処理は理解しやすい反面、引用符や改行を含む本格的な CSV には向かない

タイムスタンプ ID は学習用には分かりやすいが、衝突を避けるなら UUID など別の方針が必要になる

この段階では TODO 管理を題材にしているだけで、実運用向けのアプリ設計までは扱わない

FAQ

この TODO サーバーをそのまま使えますか。

学習用としては動かせますが、実際のアプリケーション基盤として使う前提ではありません。永続化や認証、監査の扱いは別途必要です。

CSV ではなくデータベースを使うべきですか。

実際のアプリケーションではデータベースを選ぶ場面が多いです。この記事では HTTP と永続化のつながりを見やすくするために CSV を使っています。

ReentrantLock と synchronized のどちらを使うべきですか。

基本的な排他なら synchronized でも十分です。この記事では、排他制御の場所が見えやすいように ReentrantLock を例にしています。

このサーバーを本番で使っても大丈夫ですか。

本番向けの構成とは考えないほうがよいです。入力検証、認証、監査、障害対応まで含めるなら、既製の基盤を前提に設計するほうが自然です。

関連書籍

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

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