概要
HTTP サーバー自作連載の第4回として、TODO リストを題材に CRUD の流れを一つにまとめます。ここでは、一覧表示、追加、削除という基本操作を HTTP の上に載せ、CSV への永続化や排他制御まで含めて確認します。記事の目的は TODO アプリを完成品として作ることではなく、ルーティング、ボディパース、レスポンス生成、永続化の組み合わせを学習用の題材として一度つなげてみることです。
使いどころ
HTTP の上に CRUD を載せると何が必要になるかを一度まとめて見たい
一覧・追加・削除と永続化を、学習用の一つの題材で確認したい
データベースやフレームワークに進む前に、最低限の構成要素を整理したい
コード例
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("&", "&")
.replace("<", "<")
.replace(">", ">");
}
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) {}
});
}
}
}
}Version Coverage
record、テキストブロック、Charset 付き FileWriter により、学習用サンプルでも見通しを保ちやすい。
// 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
注意点
CSV への排他制御は学習には十分だが、再起動や複数プロセスをまたぐ整合性までは扱わない
HTML 出力では入力値のエスケープを入れ、表示系でも安全性を意識しておきたい
CSV の簡易処理は理解しやすい反面、引用符や改行を含む本格的な CSV には向かない
タイムスタンプ ID は学習用には分かりやすいが、衝突を避けるなら UUID など別の方針が必要になる
この段階では TODO 管理を題材にしているだけで、実運用向けのアプリ設計までは扱わない
FAQ
学習用としては動かせますが、実際のアプリケーション基盤として使う前提ではありません。永続化や認証、監査の扱いは別途必要です。
実際のアプリケーションではデータベースを選ぶ場面が多いです。この記事では HTTP と永続化のつながりを見やすくするために CSV を使っています。
基本的な排他なら synchronized でも十分です。この記事では、排他制御の場所が見えやすいように ReentrantLock を例にしています。
本番向けの構成とは考えないほうがよいです。入力検証、認証、監査、障害対応まで含めるなら、既製の基盤を前提に設計するほうが自然です。