Java | 1 日 90 分 × 7 日アプリ学習 初級編:ファイル保存アプリ

Web APP Java
スポンサーリンク

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 からの一歩進化

今までは FileReaderFileWriter を使っていましたが、
文字コードを明示するなら Files.newBufferedReaderFiles.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);
}
Java

4日目までは、

元ファイルを 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日目では、この流れを少し俯瞰して、
「自分ならこういう方針でファイル保存を設計する」という軸を
言葉にして固めていく、というところまで行けます。

タイトルとURLをコピーしました