WinFormsでのデータファイル配置と読み込み― @”….\data.txt” を卒業しよう

TL;DR

  • @"..\..\data.txt" のような相対パス依存は不安定。
  • data.txt を プロジェクトに追加 →「ビルド アクション=コンテンツ」「出力ディレクトリにコピー=新しい場合はコピーする」 に設定。
  • 実行時は AppDomain.CurrentDomain.BaseDirectory を起点に 出力フォルダ直下の data.txt を読む。
  • ユーザが編集するデータは AppData など書き込み可能領域へ。

なぜ @"..\..\data.txt" は危険なのか

  • 実行時の 作業ディレクトリ(bin\Debug など)に強く依存。
  • テスト実行・Release ビルド・発行後・CI 等で 階層が変わると壊れる
  • 将来的なメンテで「どこから相対しているの?」が分かりにくい。

正しい配置:Visual Studio のプロパティ設定

  1. ソリューション エクスプローラーで data.txt を プロジェクトに追加
  2. data.txt を選択 → プロパティ
    • ビルド アクション:コンテンツ
    • 出力ディレクトリにコピー:新しい場合はコピーする
      • 常にコピーでも動きますが、不要コピーを避けるため「新しい場合」を推奨。

これでビルド時に data.txt が bin\<構成>\ へコピーされ、実行ファイルと同じ場所に常駐します。


読み込みコード(堅牢・定番)

private void ReadFromFile()
{
    // 実行ファイルと同じフォルダの data.txt を参照
    var path = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data.txt");

    if (!System.IO.File.Exists(path))
    {
        // 授業用なら落とさず通知する方が親切
        MessageBox.Show($"データファイルが見つかりません:\n{path}",
            "読み込みエラー", MessageBoxButtons.OK, MessageBoxIcon.Warning);
        return;
    }

    // UTF-8(BOM検出あり)で開く
    using (var reader = new System.IO.StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
    {
        string line;
        while ((line = reader.ReadLine()) != null)
        {
            if (string.IsNullOrWhiteSpace(line)) continue;        // 空行スキップ
            if (line.TrimStart().StartsWith("#")) continue;        // #で始まる行はコメント

            // 「名前,電話番号」形式。カンマは最大1回までに制限
            var parts = line.Split(new[] { ',' }, 2);
            if (parts.Length < 2) continue;

            var name   = parts[0].Trim();
            var number = parts[1].Trim();
            if (name.Length == 0 || number.Length == 0) continue;

            // Dictionary<string,string> phoneBook に格納(重複名は上書き方針)
            phoneBook[name] = number;
        }
    }
}

補足

  • ReadLine() ループは EndOfStream より読みやすく安全な定番。
  • トリムで前後空白による取り違えを防止。
  • 重複キーは運用方針に合わせて「上書き」か「スキップ」を選ぶ。
    • スキップにするなら if (!phoneBook.ContainsKey(name)) phoneBook.Add(name, number);。

ミニマム動作例(電話帳サンプル)

想定UI

  • ListBox nameList:名前一覧
  • TextBox phoneNumber:電話番号表示(ReadOnly = true 推奨)
  • SelectedIndexChanged → NameSelected を配線
private void NameSelected(object sender, EventArgs e)
{
    var name = this.nameList.SelectedItem as string;
    if (string.IsNullOrEmpty(name)) return;

    if (this.phoneBook.TryGetValue(name, out var number))
        this.phoneNumber.Text = number;
    else
        this.phoneNumber.Text = "(未登録)";
}

data.txt の例

# これはコメント行
佐藤太郎,090-1111-2222
鈴木花子,080-3333-4444

よくある落とし穴

  • ファイルがプロジェクトに入っていない:プロパティ設定以前の問題。必ず「プロジェクトに追加」。
  • Release では動く/動かない:bin\Debug と bin\Release は別。コピー設定が必須。
  • 文字コード違い:Excel保存などで Shift-JIS になることも。UTF-8 に統一するのが安全。
  • 列挙順がバラバラ:Dictionary の列挙順は未定義。表示順を固定したいなら OrderBy。
foreach (var name in phoneBook.Keys.OrderBy(x => x))
    nameList.Items.Add(name);

配布・運用時の設計指針

  • 読取専用の初期データ:今回の「コンテンツ+コピー」でOK。
  • ユーザーが編集・保存するデータ:Program Files 直下は書き込み不可の可能性。
    • AppData 配下に保存するのが実務的。
var dir = System.IO.Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
    "PhoneBook");
System.IO.Directory.CreateDirectory(dir);
var userDataPath = System.IO.Path.Combine(dir, "data.txt");
  • 初回起動時に「出荷データ(実行フォルダの data.txt)」を AppData にコピー → 以降は AppData を更新・参照。

テスト観点チェックリスト

  • data.txt が存在しない場合に 落ちない/ユーザーに通知できる
  • 空行・コメント行 を含んでも例外にならない
  • 重複名 の扱い(上書き or スキップ)が仕様どおり
  • UTF-8 以外のファイルを読ませたときの挙動確認
  • Debug/Release 両方で同じ手順で動作
  • リストの 表示順 が要件どおり(必要ならソート)

発展(次の一歩)

  • OpenFileDialog でデータファイルを選択できるようにする
  • CSV を厳密に扱いたい場合は CSV ライブラリ(ダブルクオート/カンマ埋め込み対応)を導入
  • 保存(上書き)機能を追加して、AppData 側に書き戻し
  • ファイル監視(FileSystemWatcher)で 外部更新を自動反映

以下は「発展(次の一歩)」をすべて盛り込んだ最小〜実用レベルの WinForms サンプルです。

  • OpenFileDialog でデータを選択 → AppData 側にコピーして使用
  • 保存(上書き)で AppData 側へ CSV を書き戻し
  • FileSystemWatcher で外部更新を検知して自動再読込
  • CSV を“簡易パース版”でまず動かし、厳密 CSV は CsvHelper 版に差し替え可能にしてあります

想定 UI

  • ListBox nameList(人名一覧)
  • TextBox phoneNumber(電話番号表示・ReadOnly 推奨)
  • Button btnOpen(CSVを選択)
  • Button btnSave(保存)イベントはコード側で配線しているため、デザイナでのイベント設定は不要です。

Form1.cs(完成サンプル)

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace PhoneBook
{
    public partial class Form1 : Form
    {
        private readonly Dictionary<string, string> phoneBook = new Dictionary<string, string>();
        private readonly string appDataDir;
        private readonly string userDataPath;  // 実運用で参照・保存するCSV(AppData側)
        private FileSystemWatcher watcher;
        private DateTime _lastWriteByUsUtc = DateTime.MinValue;   // 保存直後の自己イベントを無視するため

        public Form1()
        {
            InitializeComponent();

            // UI 初期設定(安全側)
            this.phoneNumber.ReadOnly = true;
            this.nameList.SelectionMode = SelectionMode.One;

            // イベント配線(デザイナ設定不要)
            this.Load += Form1_Load;
            this.FormClosed += Form1_FormClosed;
            this.nameList.SelectedIndexChanged += NameSelected;
            this.btnOpen.Click += BtnOpen_Click;
            this.btnSave.Click += BtnSave_Click;

            // AppData 側の保存場所を決定
            appDataDir = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
                "PhoneBook");
            Directory.CreateDirectory(appDataDir);
            userDataPath = Path.Combine(appDataDir, "data.csv");
        }

        //=== 1) 起動時:AppData 側のCSVを用意 → 読み込み → 監視開始 =====================
        private void Form1_Load(object sender, EventArgs e)
        {
            EnsureUserDataExists();
            LoadFromCsv(userDataPath);
            RebuildNameList();
            StartWatch(userDataPath);
        }

        // 初回:実行フォルダの data.csv を AppData にコピー。なければ空ファイルを作る
        private void EnsureUserDataExists()
        {
            if (File.Exists(userDataPath)) return;

            var baseCsv = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data.csv");
            if (File.Exists(baseCsv))
            {
                File.Copy(baseCsv, userDataPath, overwrite: false);
            }
            else
            {
                File.WriteAllText(userDataPath, "", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
            }
        }

        //=== 2) OpenFileDialog:任意のCSVを選び、AppData 側に取り込む =====================
        private void BtnOpen_Click(object sender, EventArgs e)
        {
            using (var ofd = new OpenFileDialog()
            {
                Title = "CSVファイルを選択",
                Filter = "CSVファイル (*.csv;*.txt)|*.csv;*.txt|すべてのファイル (*.*)|*.*",
                CheckFileExists = true,
                Multiselect = false
            })
            {
                if (ofd.ShowDialog(this) != DialogResult.OK) return;

                try
                {
                    // 選択ファイルを AppData 側へコピー(上書き確認)
                    File.Copy(ofd.FileName, userDataPath, overwrite: true);

                    // 自己変更を無視するためにタイムスタンプ
                    _lastWriteByUsUtc = DateTime.UtcNow;

                    // 即時リロード
                    LoadFromCsv(userDataPath);
                    RebuildNameList();
                    MessageBox.Show("データを読み込みました。", "完了",
                        MessageBoxButtons.OK, MessageBoxIcon.Information);
                }
                catch (Exception ex)
                {
                    MessageBox.Show("読み込みに失敗しました。\n" + ex.Message, "エラー",
                        MessageBoxButtons.OK, MessageBoxIcon.Error);
                }
            }
        }

        //=== 3) 保存(上書き):AppData 側にCSVで書き戻す ================================
        private void BtnSave_Click(object sender, EventArgs e)
        {
            try
            {
                SaveToCsv(userDataPath);
                _lastWriteByUsUtc = DateTime.UtcNow;
                MessageBox.Show("保存しました。", "完了",
                    MessageBoxButtons.OK, MessageBoxIcon.Information);
            }
            catch (Exception ex)
            {
                MessageBox.Show("保存に失敗しました。\n" + ex.Message, "エラー",
                    MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
        }

        //=== 4) FileSystemWatcher:外部更新を自動反映(自己更新はスキップ) =============
        private void StartWatch(string path)
        {
            StopWatch(); // 再設定に備えて一旦停止
            watcher = new FileSystemWatcher()
            {
                Path = Path.GetDirectoryName(path),
                Filter = Path.GetFileName(path),
                NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName
            };
            watcher.Changed += Watcher_ChangedOrCreatedOrRenamed;
            watcher.Created += Watcher_ChangedOrCreatedOrRenamed;
            watcher.Renamed += Watcher_ChangedOrCreatedOrRenamed;
            watcher.EnableRaisingEvents = true;
        }

        private void StopWatch()
        {
            if (watcher != null)
            {
                watcher.EnableRaisingEvents = false;
                watcher.Changed -= Watcher_ChangedOrCreatedOrRenamed;
                watcher.Created -= Watcher_ChangedOrCreatedOrRenamed;
                watcher.Renamed -= Watcher_ChangedOrCreatedOrRenamed;
                watcher.Dispose();
                watcher = null;
            }
        }

        // 変更通知の嵐を避けるため、自己保存から1秒以内のイベントは無視し、UIスレッドでデバウンス
        private DateTime _lastReloadRequestUtc = DateTime.MinValue;
        private void Watcher_ChangedOrCreatedOrRenamed(object sender, FileSystemEventArgs e)
        {
            var now = DateTime.UtcNow;

            // 自分が保存した直後のイベントはスキップ
            if ((now - _lastWriteByUsUtc) < TimeSpan.FromSeconds(1))
                return;

            // 過剰トリガーを間引き
            if ((now - _lastReloadRequestUtc) < TimeSpan.FromMilliseconds(300))
                return;

            _lastReloadRequestUtc = now;

            // UI スレッドで反映
            if (this.IsHandleCreated)
            {
                this.BeginInvoke(new Action(() =>
                {
                    try
                    {
                        LoadFromCsv(userDataPath);
                        RebuildNameList();
                    }
                    catch
                    {
                        // 読み取り中に他プロセスがロック中の可能性。小さくリトライしてもよい。
                    }
                }));
            }
        }

        private void Form1_FormClosed(object sender, FormClosedEventArgs e)
        {
            StopWatch();
        }

        //=== 5) 画面連携:選択した名前の電話番号を表示 ====================================
        private void NameSelected(object sender, EventArgs e)
        {
            var name = this.nameList.SelectedItem as string;
            if (string.IsNullOrEmpty(name))
            {
                this.phoneNumber.Text = string.Empty;
                return;
            }

            if (phoneBook.TryGetValue(name, out var number))
                this.phoneNumber.Text = number;
            else
                this.phoneNumber.Text = "(未登録)";
        }

        private void RebuildNameList()
        {
            // 現状の辞書から一覧を再構築(五十音/アルファベット順に)
            this.nameList.BeginUpdate();
            try
            {
                this.nameList.Items.Clear();
                foreach (var name in phoneBook.Keys.OrderBy(x => x))
                    this.nameList.Items.Add(name);
            }
            finally
            {
                this.nameList.EndUpdate();
            }
            // 選択解除
            this.phoneNumber.Text = string.Empty;
        }

        //=== 6) CSV 読み書き(簡易パース版:カンマ/ダブルクオート非対応の前提) ==========
        // → ダブルクオートやカンマが混じる可能性がある場合は、下の CsvHelper 版に置換してください。

        private void LoadFromCsv(string path)
        {
            phoneBook.Clear();

            if (!File.Exists(path)) return;

            using (var reader = new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
            {
                string line;
                while ((line = reader.ReadLine()) != null)
                {
                    if (string.IsNullOrWhiteSpace(line)) continue;
                    if (line.TrimStart().StartsWith("#")) continue;

                    // 超シンプル:最初のカンマで2分割
                    var parts = line.Split(new[] { ',' }, 2);
                    if (parts.Length < 2) continue;

                    var name = parts[0].Trim();
                    var number = parts[1].Trim();

                    if (name.Length == 0 || number.Length == 0) continue;

                    phoneBook[name] = number; // 重複は上書き方針
                }
            }
        }

        private void SaveToCsv(string path)
        {
            // UTF-8 (BOM付き) で、最低限のエスケープ(カンマ/ダブルクオート/改行があるときは二重引用符で囲む)
            using (var writer = new StreamWriter(path, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)))
            {
                foreach (var kv in phoneBook.OrderBy(k => k.Key))
                {
                    writer.WriteLine($"{EscapeCsv(kv.Key)},{EscapeCsv(kv.Value)}");
                }
            }
        }

        private static string EscapeCsv(string value)
        {
            if (value == null) return string.Empty;

            var needQuote = value.Contains(",") || value.Contains("\"") || value.Contains("\r") || value.Contains("\n");
            var v = value.Replace("\"", "\"\""); // ダブルクオートは2つに
            return needQuote ? $"\"{v}\"" : v;
        }

        //=== 7)(オプション)CsvHelper を使う厳密CSV版 ====================================
        // NuGet: CsvHelper を追加(.NET Framework 4.x で利用可)。
        // using CsvHelper;
        // using CsvHelper.Configuration;
        // using System.Globalization;
        //
        // ・LoadFromCsv / SaveToCsv を下の実装に差し替えると、
        //   ダブルクオートやカンマ埋め込み、改行など“正しいCSV”を安全に扱えます。

        /*
        private sealed class PhoneRecord
        {
            public string Name { get; set; }
            public string Number { get; set; }
        }

        private sealed class PhoneRecordMap : ClassMap<PhoneRecord>
        {
            public PhoneRecordMap()
            {
                Map(m => m.Name).Index(0);
                Map(m => m.Number).Index(1);
            }
        }

        private void LoadFromCsv(string path)
        {
            phoneBook.Clear();
            if (!File.Exists(path)) return;

            using (var reader = new StreamReader(path, Encoding.UTF8, true))
            using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
            {
                csv.Configuration.HasHeaderRecord = false; // 先頭行をヘッダにしたい場合は true にしてCSV側にヘッダ行を用意
                csv.Configuration.RegisterClassMap<PhoneRecordMap>();
                foreach (var rec in csv.GetRecords<PhoneRecord>())
                {
                    if (string.IsNullOrWhiteSpace(rec?.Name) || string.IsNullOrWhiteSpace(rec?.Number)) continue;
                    phoneBook[rec.Name.Trim()] = rec.Number.Trim();
                }
            }
        }

        private void SaveToCsv(string path)
        {
            using (var writer = new StreamWriter(path, false, new UTF8Encoding(true)))
            using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
            {
                csv.Configuration.HasHeaderRecord = false; // ヘッダ不要
                foreach (var kv in phoneBook.OrderBy(k => k.Key))
                {
                    csv.WriteRecord(new PhoneRecord { Name = kv.Key, Number = kv.Value });
                    csv.NextRecord();
                }
            }
        }
        */
    }
}

使い方メモ

  • 起動
    • 初回起動時、実行フォルダ\data.csv があれば AppData 側(%AppData%\PhoneBook\data.csv)にコピーして使用します。
    • なければ AppData 側に空の data.csv を作ります。
  • Open(btnOpen)
    • 任意の CSV/TXT を選ぶと AppData 側へ上書きコピー → 即時リロード。
    • 以降は FileSystemWatcher が監視し、外部で編集しても自動反映されます。
  • Save(btnSave)
    • 現在の phoneBook(画面では nameList / phoneNumber)を AppData 側の data.csv に上書き保存します。
    • 保存直後のファイル変更イベントは自己起因として無視しています(ループ防止)。
  • CSVの厳密対応
    • ダブルクオートやカンマ、改行を含む値を扱う場合は、CsvHelper 版の LoadFromCsv/SaveToCsv に差し替えてください。
    • その際は NuGet で CsvHelper を追加してください。

チェックリスト

  • Open → AppData にコピーされ、自動リロードも働く
  • Save → 外部エディタで開いても壊れない(UTF-8/BOM)
  • 外部で編集 → 数秒内に UI が更新(デバウンス済み)
  • 同姓同名 → 上書き方針かスキップ方針かを明文化
  • CsvHelper 版でも動作(厳密CSVが必要なチームで)

必要なら、この上に「新規追加・削除 UI」「検索」「重複検出ダイアログ」などを足すだけで、演習用のミニアプリとして十分拡張できます。


まとめ

  • 相対パス @"..\..\data.txt" は環境依存で壊れやすい。
  • コンテンツ+コピー によって「実行ファイルと同じ場所」にデータを常備し、BaseDirectory 起点で読むのが安全。
  • 将来を見据えるなら、読取用(配布) と 書込用(ユーザ領域) を分ける設計が堅いです。
訪問数 3 回, 今日の訪問数 3回

C#

Posted by hidepon