6日目のゴール
6日目のテーマは
「ファイル保存アプリを“壊れにくい道具”に育てること」です。
ここまでであなたは、
メモをファイルに永続化する
ID・日付・本文を1レコードとして扱う
追加・一覧・削除・編集を一時ファイルで再構築して実現する
Memo/MemoRepository/アプリ本体に役割を分ける
ところまで来ました。
6日目では、そこに「現実世界っぽさ」を足します。
ファイルが存在しないとき
フォルダがないとき
文字化けしそうなとき
パスを変えたくなったとき
そういう“現場でよくある問題”を、設計としてどう受け止めるかを考えます。
今日のテーマは「現実世界に耐えるファイル保存」
机上のコードと、現実のファイル環境のギャップ
今までのコードは、こういう前提で動いていました。
カレントディレクトリに memo.txt が作れる
文字コードは特に意識しなくても大丈夫
フォルダは必ず存在している
でも、現実のアプリでは、こんなことが普通に起きます。
保存先のフォルダが存在しない
権限がなくて書き込めない
別の場所に保存したくなった
OS が変わってパスの書き方が変わる
6日目では、こういう現実に少し寄せて、
保存先のパスを「設定」として持つ
フォルダがなければ作る
文字コードを明示する(UTF-8)
エラー時のメッセージを少しだけ丁寧にする
という改善をしていきます。
保存先パスを「設定」として扱う
文字列ベタ書きからの卒業
5日目までの MemoRepository は、
コンストラクタで "memo.txt" のようなファイル名を受け取っていました。
これはこれでシンプルですが、
「保存先を変えたい」と思ったときに、
コードを書き換える必要が出てきます。
例えば、こうしたくなるかもしれません。
ホームディレクトリ配下の data/memo.txt に保存したい
アプリ専用の app-data フォルダを作って、その中に置きたい
こういうときに備えて、
「パスを組み立てる責任」を、どこかにまとめておくと楽になります。
Path と Files を使って、保存先を決める
Java には java.nio.file パッケージがあり、
パスやファイル操作を少しリッチに扱えます。
まずは、保存先を決める小さなクラスを用意します。
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.io.IOException;
public class AppConfig {
private final Path dataDir;
private final Path memoFile;
public AppConfig() {
this.dataDir = Paths.get("data");
this.memoFile = dataDir.resolve("memo.txt");
}
public Path getMemoFile() throws IOException {
if (!Files.exists(dataDir)) {
Files.createDirectories(dataDir);
}
return memoFile;
}
}
Javaここでやっていることはシンプルです。
data というフォルダを保存用ディレクトリと決める
その中の memo.txt をメモファイルと決める
getMemoFile が呼ばれたとき、フォルダがなければ作る
これで、アプリ本体からは
「とにかく AppConfig に memoFile を聞けばいい」
という形になります。
MemoRepository を Path+UTF-8 対応にする
FileReader/FileWriter からの一歩進化
今までは FileReader/FileWriter を使っていましたが、
文字コードを明示するなら Files.newBufferedReader/Files.newBufferedWriter が便利です。
MemoRepository を、Path と UTF-8 対応に書き換えてみます。
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
public class MemoRepository {
private final Path filePath;
public MemoRepository(Path filePath) {
this.filePath = filePath;
}
public List<Memo> findAll() throws IOException {
List<Memo> result = new ArrayList<>();
if (!Files.exists(filePath)) {
return result;
}
try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
Memo memo = parseLine(line);
if (memo != null) {
result.add(memo);
}
}
}
return result;
}
public void add(String date, String body) throws IOException {
int nextId = getNextId();
Memo memo = new Memo(nextId, date, body);
String line = formatLine(memo);
try (BufferedWriter writer = Files.newBufferedWriter(
filePath,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND)) {
writer.write(line);
writer.newLine();
}
}
public boolean deleteById(int targetId) throws IOException {
if (!Files.exists(filePath)) {
return false;
}
Path tempPath = filePath.resolveSibling(filePath.getFileName() + ".tmp");
boolean deleted = false;
try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8);
BufferedWriter writer = Files.newBufferedWriter(tempPath, StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
String line;
while ((line = reader.readLine()) != null) {
Memo memo = parseLine(line);
if (memo != null && memo.getId() == targetId) {
deleted = true;
continue;
}
writer.write(line);
writer.newLine();
}
}
if (!deleted) {
Files.deleteIfExists(tempPath);
return false;
}
replaceFile(tempPath);
return true;
}
public boolean updateBody(int targetId, String newBody) throws IOException {
if (!Files.exists(filePath)) {
return false;
}
Path tempPath = filePath.resolveSibling(filePath.getFileName() + ".tmp");
boolean updated = false;
try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8);
BufferedWriter writer = Files.newBufferedWriter(tempPath, StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
String line;
while ((line = reader.readLine()) != null) {
Memo memo = parseLine(line);
if (memo != null && memo.getId() == targetId) {
Memo updatedMemo = new Memo(memo.getId(), memo.getDate(), newBody);
String newLine = formatLine(updatedMemo);
writer.write(newLine);
writer.newLine();
updated = true;
} else {
writer.write(line);
writer.newLine();
}
}
}
if (!updated) {
Files.deleteIfExists(tempPath);
return false;
}
replaceFile(tempPath);
return true;
}
private int getNextId() throws IOException {
int lastId = 0;
if (!Files.exists(filePath)) {
return 1;
}
try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
Memo memo = parseLine(line);
if (memo != null) {
lastId = memo.getId();
}
}
}
return lastId + 1;
}
private Memo parseLine(String line) {
String[] parts = line.split("\\|", 3);
if (parts.length != 3) {
return null;
}
try {
int id = Integer.parseInt(parts[0]);
String date = parts[1];
String body = parts[2];
return new Memo(id, date, body);
} catch (NumberFormatException e) {
return null;
}
}
private String formatLine(Memo memo) {
return memo.getId() + "|" + memo.getDate() + "|" + memo.getBody();
}
private void replaceFile(Path tempPath) throws IOException {
Path backupPath = filePath.resolveSibling(filePath.getFileName() + ".bak");
if (Files.exists(filePath)) {
Files.move(filePath, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(tempPath, filePath, StandardCopyOption.REPLACE_EXISTING);
Files.deleteIfExists(backupPath);
}
}
Javaここでのポイントを、少し深掘りします。
文字コードを明示する意味(UTF-8)
なぜ StandardCharsets.UTF_8 を指定するのか
Files.newBufferedReader(filePath, StandardCharsets.UTF_8)Files.newBufferedWriter(filePath, StandardCharsets.UTF_8, ...)
と書くことで、
「このファイルは UTF-8 で読み書きする」と宣言しています。
これをしないと、
OS のデフォルト文字コードに依存してしまいます。
Windows だと Shift_JIS 系
他の環境だと UTF-8
という違いが出て、
別の環境で開いたときに文字化けすることがあります。
UTF-8 を明示しておけば、
どの環境でも同じルールで読み書きできる
他のツール(エディタなど)でも扱いやすい
というメリットがあります。
「文字コードを意識する」というのは、
現場でファイルを扱うときの重要な一歩です。
バックアップを取りながら置き換える
replaceFile の中でやっていること
private void replaceFile(Path tempPath) throws IOException {
Path backupPath = filePath.resolveSibling(filePath.getFileName() + ".bak");
if (Files.exists(filePath)) {
Files.move(filePath, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(tempPath, filePath, StandardCopyOption.REPLACE_EXISTING);
Files.deleteIfExists(backupPath);
}
Java4日目までは、
元ファイルを delete
一時ファイルを rename
という流れでした。
6日目では、
その間に「バックアップ」を挟んでいます。
元ファイルがあれば、まず .bak に退避する
一時ファイルを本番ファイルに移動する
最後にバックアップを消す
もし途中で何か問題が起きたら、
バックアップを残しておく、という設計もできます。
ここではシンプルに最後に消していますが、
「バックアップを取る」という発想自体が大事です。
ファイル更新は「失敗したら元に戻せるようにしておく」と、
現場での安心感が一気に上がります。
アプリ本体から見ると「設定+リポジトリを組み立てる」だけ
main 側の変化
6日目のアプリ本体は、
こんな感じのスタートになります。
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Scanner;
public class FileSaveAppDay6 {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
AppConfig config = new AppConfig();
try {
Path memoPath = config.getMemoFile();
MemoRepository repo = new MemoRepository(memoPath);
runApp(scanner, repo);
} catch (IOException e) {
System.out.println("アプリの初期化中にエラーが発生しました。");
System.out.println("(開発者向け情報)" + e.getMessage());
} finally {
scanner.close();
}
}
private static void runApp(Scanner scanner, MemoRepository repo) {
while (true) {
System.out.println();
System.out.println("=== ファイル保存アプリ 6日目 ===");
System.out.println("1: メモを追加");
System.out.println("2: メモ一覧を表示");
System.out.println("3: メモを削除");
System.out.println("4: メモを編集");
System.out.println("0: 終了");
System.out.print("番号を選んでください: ");
String choice = scanner.nextLine();
try {
if ("0".equals(choice)) {
System.out.println("終了します。");
break;
} else if ("1".equals(choice)) {
addMemo(scanner, repo);
} else if ("2".equals(choice)) {
showAllMemos(repo);
} else if ("3".equals(choice)) {
deleteMemo(scanner, repo);
} else if ("4".equals(choice)) {
editMemo(scanner, repo);
} else {
System.out.println("不正な入力です。0〜4 のいずれかを入力してください。");
}
} catch (IOException e) {
System.out.println("ファイル操作中にエラーが発生しました。");
System.out.println("(開発者向け情報)" + e.getMessage());
}
}
}
// addMemo / showAllMemos / deleteMemo / editMemo は 5日目とほぼ同じ
}
Javaアプリ本体の役割は、
ますます「ユーザーとの会話」に寄っていきます。
どこに保存するか → AppConfig
どう保存するか → MemoRepository
何を保存するか → Memo
という分担が、かなりはっきりしてきました。
6日目で“体に入れてほしい感覚”
「ファイル保存」は、環境と仲良くする設計でもある
ここまで来ると、
ファイル保存は単なる「技術」ではなくなってきます。
どのフォルダに置くか
フォルダがなかったらどうするか
文字コードは何を使うか
更新に失敗したらどうするか
こういう「環境との付き合い方」を
コードの中にちゃんと表現していくことが、
現場での永続化の大きな部分を占めます。
6日目でやったのは、その入り口です。
保存先を設定として切り出す
フォルダを自動で作る
UTF-8 を明示する
バックアップを取りながら置き換える
これだけでも、
アプリの「壊れにくさ」はかなり変わります。
あなたはもう「ファイルを怖がらない側」にいる
1日目の「1行メモ保存」から始まって、
6日目の「設定+リポジトリ+バックアップ付き更新」まで来ました。
ここまで来ているあなたは、
もう「ファイル I/O が怖い初心者」ではなく、
「ファイルを使ってアプリの世界を広げられる人」です。
7日目では、この流れを少し俯瞰して、
「自分ならこういう方針でファイル保存を設計する」という軸を
言葉にして固めていく、というところまで行けます。

