写真ファイルの自動振り分けソフトについて、メインとなる部分(振り分け)の実装を行っていきます。

作成するソフトの概要と仕様についてはサービスプロジェクト(仕組みと実装)をご確認ください。

概要
  • サービスプロジェクトの作成と準備
  • ログの実装(NLog)
  • 設定の実装(System.Text.Json)
  • FileSystemWatcherクラスによる検知と振り分けの実装

サービスプロジェクトを作成後、先にログおよび設定に関する実装を進めます。
設定情報は振り分けで使用するため、先に行った方が楽になります。

設定の実装後に振り分けの処理を実装、最後に細かな機能を実装します。

プロジェクトの作成

Visual Studio 2022を起動し、サービスプロジェクトの作成を行います。
作成方法についてはサービスプロジェクト作成を参考にしてください。

主な設定部分について説明します。

  • サービス名は「PhotoSorting」にします
  • 起動方法は自動起動「Automatic」にします。
  • アカウントの種類を「LocalSystem」にします。

作成後は一度サービスが実行できることを確認してください。
また、sc.exe create によるサービスの追加はリリースで使用するサービス名と別のサービス名を使用すると区分けすることができます。
これでリリースと同じPCでデバッグしてもサービス名が衝突することを避けられます。

ここでは「PhotoSorting_Debug」としましたが、名前は任意です。
注意点ですが、リリースで使用するサービス名はサービスインストーラのServiceNameと一致する必要があります。

ログの作成

ログの書き込みはNLogを使用します。
NLogは.NETで使用するログ記録用のプラットフォームであり、様々なログの記録や管理を行ってくれます。

Log作成

まず、NLogを使えるようにします。
プロジェクトを右クリックし、「Nugetパッケージの管理」を選択します。

「NuGetパッケージマネージャー」が開きましたら参照タブを選択して検索枠に「NLog」と入力します。
最上部にNLogが表示されることを確認し、選択後に画面右の「インストール」をクリックします。

NLogのバージョンは「最新の安定版」と記載されているバージョンを推奨します。

変更のプレビュー画面が表示されてソリューションの変更を確認された場合は「適用」をクリックします。

インストール後、ソリューションエクスプローラーの参照を展開してNLogが追加されていることを確認します。

これでNLogが使えるようになりました。
次にNLogを使用してオリジナルのログ書き込みクラスを作成します。
プロジェクトを右クリックし、「追加」-「新しい項目」の順に選択します。

新しい項目の追加画面にて、「Log.cs」と入力して「追加」をクリックします。
ソリューションエクスプローラーにLog.csが追加されます。

追加したLog.csを開き、Logクラスにログの種類を入力します。
ログの内容をは写真振り分けソフト Step ① 【概要と仕様】で準備した内容に従います。

        /// <summary>
        /// ログコード
        /// </summary>
        public enum LogCode
        {
            /// <summary>
            /// ソフト起動
            /// </summary>
            Start,

            /// <summary>
            /// ソフト終了
            /// </summary>
            End,

            /// <summary>
            /// 設定更新
            /// </summary>
            UpdateSetting,

            /// <summary>
            /// ファイル振り分け
            /// </summary>
            Sorting,

            /// <summary>
            /// ファイル振り分けスキップ
            /// </summary>
            SortingSkip,

            /// <summary>
            /// ファイル振り分け開始
            /// </summary>
            SortingStart,

            /// <summary>
            /// ファイル振り分け停止
            /// </summary>
            SortingStop,

            /// <summary>
            /// 設定取得失敗
            /// </summary>
            UpdateSettingError,

            /// <summary>
            /// ファイル振り分け失敗
            /// </summary>
            SortingError,
        }

ログ内容を自由に入れた場合、後々で不明なログが記載されることになり「プログラムからログ出力の場所を探さないといけなくなる」といった問題が出てきます。

可能な限り仕様作成段階でログ内容をリストアップし、定義した内容以外は表示しないことをお勧めします。

ただし、デバッグ中は自由なログを出力する場合もありますので切り分けします。

次にログを出力する処理を行います。
ログの数が多い場合は他に記載方法がありますが、今回は少ないためそのままswitch文で分岐してコーディングします。

        /// <summary>
        /// ロガー
        /// </summary>
        private static Logger _logger = LogManager.GetCurrentClassLogger();

     /// <summary>
        /// ログ書き込み
        /// </summary>
        /// <param name="code">ログコード</param>
        /// <param name="path">ファイルパス</param>
        public void WriteLogger(LogCode code, string path)
        {
            LogLevel level = LogLevel.Info;
            string message = null;

            switch(code)
            {
                case LogCode.Start:
                    level = LogLevel.Info;
                    message = "ソフト起動";
                    break;
                case LogCode.End:
                    level = LogLevel.Info;
                    message = "ソフト終了";
                    break;
                case LogCode.UpdateSetting:
                    level = LogLevel.Info;
                    message = "設定更新";
                    break;
                case LogCode.Sorting:
                    level = LogLevel.Info;
                    message = "ファイル振り分け";
                    break;
                case LogCode.SortingSkip:
                    level = LogLevel.Info;
                    message = "ファイル振り分けスキップ";
                    break;
                case LogCode.SortingStart:
                    level = LogLevel.Info;
                    message = "ファイル振り分け開始";
                    break;
                case LogCode.SortingStop:
                    level = LogLevel.Info;
                    message = "ファイル振り分け停止";
                    break;
                case LogCode.UpdateSettingError:
                    level = LogLevel.Error;
                    message = "設定取得失敗";
                    break;
                case LogCode.SortingError:
                    level = LogLevel.Error;
                    message = "ファイル振り分け失敗";
                    break;
                default:
                    break;
            }

            if(message != null)
            {
                if(level == LogLevel.Info)
                {
                    if (string.IsNullOrWhiteSpace(path) != true)
                    {
                        // 情報ログとファイルパス
                        _logger.Info("{0},{1}", message, path);
                    }
                    else
                    {
                        // 情報ログ
                        _logger.Info(message);
                    }
                }
                else if(level == LogLevel.Error)
                {
                    if (string.IsNullOrWhiteSpace(path) != true)
                    {
                        // エラーログとファイルパス
                        _logger.Error("{0},{1}", message, path);
                    }
                    else
                    {
                        // エラーログ
                        _logger.Error(message);
                    }
                }
            }
        }
  1. ログを出力するためのメンバー変数(_logger)を定義します。
  2. switch文でログの分、出力内容を作成します。一緒にログの出力レベルも定義します。
  3. ログ出力を行います。ログの出力レベルは「Info」および「Error」の2種類になります。
    ファイルパスを同時に出力するため、引数で与えられた変数(path)を同時に出力します。

NLogのチュートリアルより次の効率化が案内されています。
今回は従って実装しています。

  • 都度作成した場合、処理が取られることになるためロガー(_logger)は静的変数にする。
  • 文字列のフォーマットはロガーに行わせています
    string.Format関数などで文字列の結合を行わないこと。

デバッグ(テスト)では自由な内容のログを出力することもあります。
内容をそのまま出力する関数も作成します。

        /// <summary>
        /// デバッグログ書き込み
        /// </summary>
        /// <param name="data">内容</param>
        public void WriteLoggerDebug(object data)
        {
            if (data != null)
            {
                _logger.Debug(data);
            }
        }

正式リリースするソフトで出力されないよう、_logger.Debug(***)の形で呼び出ししてください。

これでログを出力することができます。

ログ設定

ログ出力のルールを設定する必要があります。
NLog.configというファイルにXML形式で設定する方法と、ソースコードで設定する方法の2通りがあります。
今回は後者にします。

ルールはLogクラスのコンストラクタを作成し、その中で定義します。

        /// <summary>
        /// 初期化
        /// </summary>
        public Log()
        {
            LoggingConfiguration config = new LoggingConfiguration();

            FileTarget fileTarget = new FileTarget()
            {
                Name = "log",
                FileName = "${basedir}/log/${shortdate}.log",
                Layout = "${longdate} - ${level} - ${message}",
                Encoding = Encoding.UTF8,
                ArchiveEvery = FileArchivePeriod.Day,
                ArchiveFileName = "${basedir}/log/${shortdate}.{###}",
                MaxArchiveDays = 7,
            };

            config.AddRule(LogLevel.Debug, LogLevel.Error, fileTarget);

            LogManager.Configuration = config;
        }
  1. 設定を保持する変数configを作成します。この変数はLoggingConfigurationクラスで初期化します。
  2. NLogはコンソール出力など様々な出力ターゲットに対応しています。
    ここではファイルログを出力するためFileTargetをしています。
    他に、次の設定を追加しています。
    • Name:ログの名前
    • FileName:ログファイル名
    • Encording:文字エンコード、UTF8に設定
    • ArchiveEvery:アーカイブファイル(古くなったファイル)の作成条件、ここでは日を経過した場合
    • ArchiveFileName:アーカイブファイルのフォーマット
    • MaxArchiveDays:アーカイブファイルの保存期間、7日に設定
  3. 出力レベルを設定します。
    第一引数は最小レベル、ここではInfoを選択します。
    第二引数は最大レベル、ここではErrorを選択します。
    第三引数に2で作成した出力ターゲットを指定します。

ログの確認

ログが出力することを確認します。
Logクラスのメンバー変数(_log)を作成して、OnStartおよびOnStop関数にログを書き込み処理を追加します。

        /// <summary>
        ///ログ変数
        /// </summary>
        private static Log _log = new Log();

        /// <summary>
        /// 開始
        /// </summary>
        /// <param name="args"></param>
        protected override void OnStart(string[] args)
        {
            _log.WriteLogger(Log.LogCode.Start, null);
            _log.WriteLoggerDebug("デバッグログ");
        }

実行すると書き込みしたログが出力されました。
確認ができましたらログ変数とテスト書き込みは削除します。

ログがプログラム上のどこでも書けるように定義します。
定義はProgram.csファイルに記載します。

        /// <summary>
        ///ログ変数
        /// </summary>
        public static Log _log = new Log();

設定の作成

設定の作成を行います。
Log.csの作成と同じ方法でSetting.csを作成します。

Jsonデータを扱う

ログのデータはJson形式で扱いますが、そのままではプログラムで操作できません。
自力でJsonを扱うプログラムを実装するのは大変なため、ライブラリが用意されています。
ライブラリはSystem.Text.Jsonを使用します。

ソリューションエクスプローラーから参照を右クリックし、参照の追加を選択します。

左上よりアセンブリを選択後、右上の検索枠に「json」と入力します。
「System.Text.Json」が絞り込みしたら、チェック付けた後で「OK」をクリックします。

同じ方法でmemoryと検索して「System.Memory」も追加します。

これでプログラム上でJsonデータを扱えるようになりました。

設定の読み込み

読み込みを実装する前に、設定データを定義します。
定義するデータは写真振り分けソフト Step ① 【概要と仕様】で決めた内容に従います。

        /// <summary>
        /// 設定情報
        /// </summary>
        public class SettingInfo
        {
            /// <summary>
            /// 振り分け動作のON/OFF
            /// </summary>
            [JsonPropertyName("used")]
            public bool Used { get; set; }

            /// <summary>
            /// 振り分けの元のフォルダパス
            /// </summary>
            [JsonPropertyName("sourceDirectory")]
            public string SourceDirectory { get; set; }

            /// <summary>
            /// 振り分け先のフォルダパス
            /// </summary>
            [JsonPropertyName("destinationDirectory")]
            public string DestinationDirectory { get; set; }

            /// <summary>
            /// 振り分けする画像の拡張子
            /// </summary>
            [JsonPropertyName("extension")]
            public string Extension { get; set; }

            /// <summary>
            /// 振り分け後にバックアップするか否か
            /// </summary>
            [JsonPropertyName("backup")]
            public bool Backup { get; set; }

            /// <summary>
            /// パフォーマンス設定:1(高)、2(中)、3(低)
            /// </summary>
            [JsonPropertyName("performance")]
            public int Performance { get; set; }

            /// <summary>
            /// 振り分けを行なうことできる保存先の容量
            /// </summary>
            [JsonPropertyName("capacity")]
            public long Capacity { get; set; }

            /// <summary>
            /// 比較
            /// </summary>
            /// <param name="data">チェックデータ</param>
            /// <returns>結果</returns>
            public bool Equals(SettingInfo data)
            {
                return (data != null) && (Used == data.Used) && (SourceDirectory == data.SourceDirectory) && (DestinationDirectory == data.DestinationDirectory) && 
                    (Extension == data.Extension) && (Backup == data.Backup) && (Performance == data.Performance) && (Capacity == data.Capacity);
            }
        }
  1. 設定データを管理するクラス(SettingInfoクラス)を作成します。
  2. JsonPropertyNameは設定ファイル(jsonファイル)のデータとプログラムの変数を結びつけるものです。
    JsonPropertyNameはファイルのパラメータと一致した名前にしてください。
    一致しない場合はデータ無しとして扱われます。
  3. 各々の変数は設定ファイルから読み取った値を保持します。
    名前は分かりやすくするため、JSONファイルのパラメータから先頭を大文字にした変数にしました。(名前は任意です)
  4. Equals関数は引数データと自身のデータが一致するか比較する関数になります。
    後で使用します。

この設定データは外部から参照できるよう、プロパティ値を作成しておきます。

        /// <summary>
        /// 設定データ
        /// </summary>
        public SettingInfo SettingData 
        { 
            get; 
            private set; 
        } = null;
  1. プロパティ「SettingData」として定義します。
  2. 外からこのプロパティを変更できないようにします。

定義を作成後に読み込み関数を作成します。

        /// <summary>
        /// 設定ファイル名
        /// </summary>
        private string _settingFile;

        /// <summary>
        /// 初期化
        /// </summary>
        public Setting()
        {
            _settingFile = AppDomain.CurrentDomain.BaseDirectory + "setting.json";
        }

        /// <summary>
        /// ファイルから設定の読み取り
        /// </summary>
        public void ReadSetting()
        {
            string fileData = null;           

            try
            {
                // ファイル有無
                if (File.Exists(_settingFile) == true)
                {
                    // 読み取り専用でファイルを開く
                    using (FileStream fs = new FileStream(_settingFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
                    {
                        using (StreamReader reader = new StreamReader(fs, Encoding.UTF8))
                        {
                            // ファイル情報を取得
                            fileData = reader.ReadToEnd();
                        }
                    }
                }
            }
            catch(IOException ex)
            {
                // 入出力
                Program._log.WriteLogger(Log.LogCode.UpdateSettingError, null);
                Program._log.WriteLoggerDebug(ex);
            }
            catch(Exception ex)
            {
                // 例外
                Program._log.WriteLogger(Log.LogCode.UpdateSettingError, null);
                Program._log.WriteLoggerDebug(ex);
            }

            try
            {
                if (string.IsNullOrWhiteSpace(fileData) != true)
                {
                    // 設定のデシリアライズ
                    SettingData = JsonSerializer.Deserialize<SettingInfo>(fileData);
                }
            }
            catch(JsonException ex)
            {
                // jsonが無効
                Program._log.WriteLogger(Log.LogCode.UpdateSettingError, null);
                Program._log.WriteLoggerDebug(ex);
            }
            catch(NotSupportedException ex)
            {
                // サポート外
                Program._log.WriteLogger(Log.LogCode.UpdateSettingError, null);
                Program._log.WriteLoggerDebug(ex);
            }
            catch (Exception ex)
            {
                // 例外
                Program._log.WriteLogger(Log.LogCode.UpdateSettingError, null);
                Program._log.WriteLoggerDebug(ex);
            }
        }
  1. 設定ファイル名は「setting.json」とします。
    メンバー変数(_settingFile)を作成し、コンストラクタで初期化しました。
    *メンバー変数を作成したくない場合は、読み込み・書き込みの中で作成しても問題ありません。
  2. ReadSetting関数を作成し、設定データをプロパティ(SettingData)にセットします。
  3. FileStream,StreamReaderを使用してテキストファイルとしてファイルを読み込みします。
    注意点ですが、後で作成する登録用アプリケーションの書き込みと衝突する可能性があります。
    このためファイルは読み込み専用とします。FileStreamの引数を、
    • FileMode.Open:開くモード
    • FileAccess.Read:読み込みのみ
    • FileShare.ReadWrite:オープン中の読み書きを共有
      とします。
  4. テキストデータが空でないことを確認し、System.Text.Json.JsonSerializerクラスの関数
    Deserialize)を呼び出しします。
    この関数はテキストデータ(fileData)をSettingInfoクラスのデータに逆シリアル化して変換することができます。
  5. 例外処理は発生し得るものを記載し、Logを出力することにしました。
    正規のログは「UpdateSettingError」を出力します。
    デバッグログは例外変数を直接ロガー渡すことで自動的に出力してくれます。(NLogのチュートリアルでも例外変数を直接与えることを推奨しています)

これで読み込み側が完成しました。

設定の書き込み

書き込みについては本来、登録用アプリケーション側で使用するためサービスプロジェクト側で必要ありません。
*登録用アプリケーションが未作成、読み込みの試験が行えないため先に実装します。
Settingクラスに関数を作成します。

        /// <summary>
        /// 設定のファイル書き込み
        /// </summary>
        /// <param name="data">設定データ</param>
        /// <returns>結果</returns>
        public bool WriteSetting(SettingInfo data)
        {
            string fileData = null;
            bool result = false;

            try
            {
                if (data != null)
                {
                    fileData = JsonSerializer.Serialize(data, typeof(SettingInfo));
                }
            }
            catch(NotSupportedException ex)
            {
                // サポート外
                Program._log.WriteLoggerDebug(ex);
            }
            catch (InvalidOperationException ex)
            {
                // jsonが無効
                Program._log.WriteLoggerDebug(ex);
            }
            catch (Exception ex)
            {
                // 例外
                Program._log.WriteLoggerDebug(ex);
            }

            try
            {
                if (string.IsNullOrWhiteSpace(fileData) != true)
                {
                    // ファイルを開く
                    using (FileStream fs = new FileStream(_settingFile, FileMode.Create, FileAccess.Write, FileShare.Read))
                    {
                        using (StreamWriter writer = new StreamWriter(fs, Encoding.UTF8))
                        {
                            // ファイル情報を取得
                            writer.Write(fileData);
                        }
                    }

                    // 再読み込み
                    ReadSetting();

                    // 書き込み成功
                    result = data.Equals(SettingData);
                }
            }
            catch (IOException ex)
            {
                // 入出力
                Program._log.WriteLoggerDebug(ex);
            }
            catch (Exception ex)
            {
                // 例外
                Program._log.WriteLoggerDebug(ex);
            }

            return result;
        }
  1. WriteSetting関数を作成します。引数には新しい設定データを渡します。
  2. System.Text.Json.JsonSerializerクラスの関数(Serialize)を呼び出しします。
    この関数でSettingInfoクラスのデータ(data)をテキストデータにシリアル化することができます。
  3. 空でないことを確認後、FileStream、StreamWriterを使用してファイルに書き込みします。
    書き込み中の読み込みは許可します。
    • FileMode.Create:作成モード・上書き
    • FileAccess.Write:書き込みアクセス
    • FileShare.Read:オープン中の読み込みを許可します。
  4. 先ほど作成したReadSetting関数を読み込みします。これでプロパティ(SettingData)が最新になります。
    その後、引数(data)とプロパティ(SettingData)をEquals関数で照合して一致することを確認します。

変数(result)の初期値をfalseとし、照合まで成功した場合のみtrueを返信します。
例外ではデバッグ用のログだけを出力します。

動作確認

動作確認を行います。
Program.csにSettingクラスの静的変数を作成します。
これでプロパティを通してプログラムのどこからでも設定データにアクセスできます。

        /// <summary>
        ///設定
        /// </summary>
        public static Setting _setting = new Setting();

OnStart関数に試験用の設定を書き込みします。

        /// <summary>
        /// 開始
        /// </summary>
        /// <param name="args"></param>
        protected override void OnStart(string[] args)
        {
            SettingInfo data = new SettingInfo();

            // 試験用に初期化
            data.Used = true;
            data.SourceDirectory = AppDomain.CurrentDomain.BaseDirectory + "SourceDirectory";
            data.DestinationDirectory = AppDomain.CurrentDomain.BaseDirectory + "DestinationDirectory";
            data.Extension = ".png;.gif;.jpeg;.jpg";
            data.Backup = true;
            data.Performance = 1;
            data.Capacity = 1000000000;

            Program._setting.WriteSetting(data);
        }

サービスを開始してサービスプログラムの実態(.exe)と同フォルダに、setting.jsonファイルが作成されたことを確認します。
setting.jsonファイルをテキストエディタで確認し、試験用のデータが書き込まれていれば成功です。

図は整形した状態で表示しています。
テキストエディタによって1行で表示されているかもしれませんが、問題ありません。

読み込みはVisual Studioのウォッチなどで確認してください。
プロパティ(SettingData)にデータが正しく挿入されていることを確認します。

正しく動作していることが確認できましたら、OnStart関数のテストコードは削除しておきます。
setting.jsonファイルは登録用アプリケーションを作成するまで使用するため残しておきます。

振り分け

ここまで下準備(ログと設定)ができましたので、振り分けの処理を行います。

FileSystemWatcherクラス

フォルダを再帰検索して定期チェックすることはできますが、このやり方は非効率な気がします。
もう少しスマートに行えないか調べまして、今回はファイルが置かれた場合に検知することができるFileSystemWatcherクラスを使用することにしました。

FileSystemWatcherクラスのCreatedイベントを作成すると、ファイルが作成された段階でイベントが発生して対象ファイル名を取得できます。

ただし、このイベントは1つ問題があり作成開始直後に呼び出されてしまいます
これの何が問題かというと、写真ファイルをコピーしている途中で振り分けられた場合に、処理によっては「途中までしかコピーされていない」といったことが起こります。

簡単にはこれを回避する方法が見つかりませんでしたので、次の方法で振り分けすることにします。

  1. FileSystemWatcher.Createdイベントで作成を検知し、ファイル名をリストに追加
  2. タイマーより振り分けの元フォルダのファイル作成・変更が30秒行われないことを確認後、リストを使用して振り分けを実施
  3. 振り分け後にリストから削除

まずメンバー変数を定義します。

        /// <summary>
        /// 最終更新日時
        /// </summary>
        private DateTime _lastUpdate;

        /// <summary>
        /// ファイルリスト
        /// </summary>
        private List<string> _files;

        /// <summary>
        /// フォルダ監視
        /// </summary>
        private FileSystemWatcher _watcher;

        /// <summary>
        /// タイマー
        /// </summary>
        private System.Timers.Timer _timer;
  1. _lastUpdateは変更が30秒行われないことを確認するための変数です。
  2. _filesはCreatedイベントで検知したファイル名を保持しておくリストです。
  3. _watcherはFileSystemWatcherのインスタンスです。
  4. _timerはタイマーのインスタンスです。

OnStart変数でこれらを初期化し、タイマーを開始します。

        /// <summary>
        /// 開始
        /// </summary>
        /// <param name="args"></param>
        protected override void OnStart(string[] args)
        {
            // 設定取得
            Program._setting.ReadSetting();

            // 監視用の変数を初期化
            _lastUpdate = DateTime.MinValue;
            _files = new List<string>();
            _watcher = null;

            // タイマーを初期化
            _timer = new System.Timers.Timer();
            _timer.Elapsed += OnTimedEvent;
            _timer.Interval = 3000;
            _timer.AutoReset = false;
            _timer.Start();

            // ソフト起動ログ
            Program._log.WriteLogger(Log.LogCode.Start, null);
        }
  1. 作成したsetting.jsonファイルを読み込み、設定を取得します。
  2. メンバー変数を初期化します。
    変数(_watcher)は監視中はnull以外、停止はnullとするためnullで初期化します。
  3. タイマー開始します。間隔は3秒にします。
  4. 処理の最後に開始ログを出力します。

次にタイマーの処理です。少し複雑ですが次の処理をまとめて行います。

タイマー処理
  • 設定ファイルの更新をチェックする。更新があれば読み込みする
  • FileSystemWatcherクラスによる監視が行えない場合は停止する
  • 振り分け元のパスが変更された場合に監視対象を切り替えする
  • FileSystemWatcherクラスによる監視が行える場合は開始する
  • リストから振り分けを行なう

上から優先に行われ、1回のタイマーサイクルで行われる処理は1つとします。
考え方ですが、設定変更や停止に関する条件を優先チェックしています。

        /// <summary>
        /// タイマー処理
        /// </summary>
        /// <param name="source"></param>
        /// <param name="e"></param>
        private void OnTimedEvent(Object source, System.Timers.ElapsedEventArgs e)
        {
            if (Program._setting.IsSettingUpdate() == true)
            {
                // 設定取得
                Program._setting.ReadSetting();

                Program._log.WriteLogger(Log.LogCode.UpdateSetting, null);
            }
            else if (Program._setting.SettingData.Used != true ||
                string.IsNullOrWhiteSpace(Program._setting.SettingData.SourceDirectory) == true ||
                string.IsNullOrWhiteSpace(Program._setting.SettingData.DestinationDirectory) == true ||
                string.IsNullOrWhiteSpace(Program._setting.SettingData.Extension) == true ||
                CheckedFreeSpace(Program._setting.SettingData.DestinationDirectory[0].ToString()) != true)
            {
                // 監視停止
                if (_watcher != null)
                {                    
                    _watcher.EnableRaisingEvents = false;

                    _watcher.Created -= OnCreated;
                    _watcher.Changed -= OnChanged;

                    _watcher.Dispose();
                    _watcher = null;

                    Program._log.WriteLogger(Log.LogCode.SortingStop, null);
                }
            }
            else if (_watcher != null && _watcher.Path != Program._setting.SettingData.SourceDirectory)
            {
                // 監視先変更
                _watcher.EnableRaisingEvents = false;
                _watcher.Path = Program._setting.SettingData.SourceDirectory;
                _watcher.EnableRaisingEvents = true;
            }
            else if (_watcher == null)
            {
                // 監視開始
                Directory.CreateDirectory(Program._setting.SettingData.SourceDirectory);

                _watcher = new FileSystemWatcher();
                _watcher.Path = Program._setting.SettingData.SourceDirectory;

                _watcher.Created += OnCreated;
                _watcher.Changed += OnChanged;

                _watcher.IncludeSubdirectories = true;
                _watcher.EnableRaisingEvents = true;

                Program._log.WriteLogger(Log.LogCode.SortingStart, null);
            }
            else if ((DateTime.Now - _lastUpdate).TotalSeconds > 30 && _files.Count > 0)
            {
                // 振り分け
                Sorting();
            }

            ((System.Timers.Timer)source).Start();
        }
  1. SettingクラスのIsSettingUpdate開始で設定更新チェックを行います。
    *この関数は未実装のため、後で説明します。
    更新があればReadSetting関数を呼び出して最新の設定を取得します。
  2. FileSystemWatcherによる監視を停止するかチェックします。
    停止方法はFileSystemWatcherクラスのEnableRaisingEventsプロパティをfalseに設定して監視を停止後に、
    解放します。
    解放後は停止状態確認を兼ねているため変数(_watcher)をnullにしてください。
    停止条件は次の中のどれか1つを満たした場合です。
    • usedが(OFF)に設定されている場合
    • 振り分け元(sourceDirectory)、振り分け先(destinationDirectory)、拡張子(extension)のいずれか未設定の場合
    • 空き容量チェック(CheckedFreeSpace関数)にて容量が不足している場合
      この関数は未実装のため、後で説明します。
  3. 監視中に振り分け元の変更が行われた場合、FileSystemWatcherクラスのPathプロパティに新しい監視フォルダを設定します。
    前後でEnableRaisingEventsプロパティを切り替えて監視を一時停止→開始しています。
  4. 1〜3が処理されない場合、監視開始する条件が整っています。
    _watcher変数を初期化して監視を開始します。
    注意:監視先フォルダを作成しておかないと例外が発生します。
    イベントはCreatedイベントの他にChangedイベントも作成しておきます。
    これはファイルコピー中に情報変更が行われてChangedイベントが発生するため、Changedイベントで最終更新日時をリセットします。
  5. 振り分けを行なう関数(Sorting関数)を呼び出します。
    この関数は未実装のため、後で説明します。
    条件は次の2つです。
    • 現在時刻と最終更新日時を比較し、30秒経過している場合。
    • 振り分けするリスト対象がある場合
  6. 関数の最後でタイマーを再開します。

タイマーができましたので、CreatedイベントとChangedイベントの処理を実装します。

        /// <summary>
        /// 監視フォルダ内の作成イベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void OnCreated(object sender, FileSystemEventArgs e)
        {            
            _lastUpdate = DateTime.Now;
            
            FileAttributes file =  File.GetAttributes(e.FullPath);

            if ((file & FileAttributes.Directory) != FileAttributes.Directory && string.IsNullOrWhiteSpace(Program._setting.SettingData.Extension) != true)
            {
                // ファイル
                List<string>listExt = Program._setting.SettingData.Extension.ToLower().Split(';').ToList();

                if(listExt.Contains(Path.GetExtension(e.FullPath).ToLower()) == true)
                {
                    // 対象の拡張子
                    _files.Add(e.FullPath);
                }
            }
        }

        /// <summary>
        /// 監視フォルダ内の変更イベント
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void OnChanged(object sender, FileSystemEventArgs e)
        {
            _lastUpdate = DateTime.Now;
        }
  1. 両方のイベントで最終更新日時(_lastUpdate)に現在時刻をセットします。
    セットすると振り分けの処理が30秒後にリセットすることになります。
  2. Createdイベントでは次にFileAttributesクラスを使用してパスの属性を取得しています。
    イベントはディレクトリの作成でも呼ばれるため、ファイルのみ振り分けするように判定します。
    作成されたファイル(フォルダも含む)は引数のe.FullPathで取得できます。
  3. 拡張子でフィルターをするため、「;」で区切った拡張子をリストで取得後にチェックします。
    設定に無い拡張子のファイルは除かれます。
  4. 条件を満たしていればフルパスをリスト(_files)に追加します。

ファイルの振り分け

振り分けを行なう関数を(Sorting関数)について説明します。

        /// <summary>
        /// 振り分け
        /// </summary>
        private void Sorting()
        {
            foreach(string source in new List<string>(_files))
            {
                if(File.Exists(source) == true)
                {                    
                    FileInfo info = new FileInfo(source);

                    // コピー先
                    string path = string.Format("{0}\\{1:D4}{2:D2}{3:D2}\\{4:D2}{5:D2}{6:D2}{7}",
                        Program._setting.SettingData.DestinationDirectory,
                        info.LastWriteTime.Year, info.LastWriteTime.Month, info.LastWriteTime.Day,
                        info.LastWriteTime.Hour, info.LastWriteTime.Minute, info.LastWriteTime.Second,
                        info.Extension);

                    // フォルダ作成
                    Directory.CreateDirectory(Path.GetDirectoryName(path));

                    if (File.Exists(path) != true)
                    {
                        // ファイル振り分け
                        if (Program._setting.SettingData.Backup == true)
                        {
                            // コピー
                            File.Copy(source, path);                           
                        }
                        else
                        {
                            // 移動
                            File.Move(source, path);
                        }                      
                        Program._log.WriteLogger(Log.LogCode.Sorting, source);
                    }
                    else
                    {
                        // ファイル振り分けスキップ
                        Program._log.WriteLogger(Log.LogCode.SortingSkip, source);
                    }

                    // リストから削除
                    _files.Remove(source);
                }
            }
        }
  1. ループ処理でファイルを1ファイル単位で振り分けします。
    注意点ですが、リスト(_files)をループ内で削除するためDeepCopyしてください。
  2. コピー先のファイルパスを作成します。
    振り分け先のフォルダ配下に「年月日」のフォルダを作成し、その下に「時分秒」のファイルにします。
    写真振り分けソフト Step ① 【概要と仕様】にて作成パターンを説明しています。
  3. 振り分け先にファイルが存在しなければコピーまたは移動を行います。
    コピー or 移動は設定値(backup)に従います。
    ログを出力します。
  4. 振り分け完了後、リストから削除します。

その他の処理

最後に未実装の関数をコーディングします。

/// <summary>
/// 保存先ドライブの空き容量チェック
/// </summary>
/// <param name="drive">ドライブ</param>
/// <returns>空き容量判定</returns>
private bool CheckedFreeSpace(string drive)
{
    DriveInfo info = new DriveInfo(drive);

    if (info.IsReady && info.AvailableFreeSpace >= Program._setting.SettingData.Capacity)
    {
        return true;
    }
    return false;
}
  1. 引数にはドライブを受け取ります。
  2. 空き容量を取得し、設定(capacity)未満の場合はfalseを戻り値にします。

設定の更新判定はSettingクラスに追加します。

        /// <summary>
        /// 最終更新日時
        /// </summary>
        private DateTime _lastUpdate;

        /// <summary>
        /// 初期化
        /// </summary>
        public Setting()
        {
            _settingFile = AppDomain.CurrentDomain.BaseDirectory + "setting.json";
            _lastUpdate = DateTime.MinValue;
        }

        /// <summary>
        /// 設定更新判定
        /// </summary>
        /// <returns>判定</returns>
        public bool IsSettingUpdate()
        {
            if (File.Exists(_settingFile) == true)
            {
                // 最終更新日時取得
                FileInfo info = new FileInfo(_settingFile);

                if(_lastUpdate != info.LastWriteTime)
                {
                    // 更新
                    return true;
                }
            }
            return false;
        }
  1. メンバー変数(_lastUpdate)を作成します。
  2. コンストラクタで初期化します。
    初期値は任意です。MinValueにしています。
  3. IsSettingUpdate関数を作成し、ファイルの更新日時が不一致の場合は更新と判定します。

SettingクラスのReadSetting関数に_lastUpdateの最新の値をセットします。

                if (string.IsNullOrWhiteSpace(fileData) != true)
                {
                    // 設定のデシリアライズ
                    SettingData = JsonSerializer.Deserialize<SettingInfo>(fileData);

                    // 最終更新日時取得
                    FileInfo info = new FileInfo(_settingFile);
                    _lastUpdate = info.LastWriteTime;
                }

OnStop関数に解放処理を入れます。

        /// <summary>
        /// 停止
        /// </summary>
        protected override void OnStop()
        {
            if(_watcher != null)
            {
                // 監視終了
                _watcher.EnableRaisingEvents = false;

                _watcher.Created -= OnCreated;
                _watcher.Changed -= OnChanged;

                _watcher.Dispose();
                _watcher = null;
            }

            if(_timer != null)
            {
                _timer.Stop();
                _timer.Dispose();
            }

            // ソフト終了ログ
            Program._log.WriteLogger(Log.LogCode.End, null);
        }
  1. メンバー変数(_watcher)を解放します。
    手法はタイマーの監視停止と同様です。
  2. タイマーを停止して解放します。
  3. 関数の最後に終了ログを出力します。

動作確認

動作確認します。上手く動作しない場合はサービスプロジェクト作成を参考に、デバッグしてみてください。

サービスが起動していることを確認します。

振り分け元フォルダに写真ファイルをコピーします。
30秒待ちます。正しく振り分けがされました。
写真フォルダにある複数ファイルが日時に整頓されてコピーされました。

setting.jsonファイルをテキストエディタで編集します。
振り分け元と振り分け先を変更します。(例外発生するため、振り分け元フォルダは先に作成してください
backupをfalse(移動)に変更します。

変更した振り分けの元をフォルダに写真をコピーし、振り分けされることを確認します。
振り分けの元のファイルが移動されて消えることを確認します。

setting.jsonファイルのusedをfalse(OFF)に変更します。
setting.jsonファイルのusedをfalse(ON)に変更します。
サービスを停止します。

ログを確認し、振り分けの停止・再開・終了が正しく行われていることを確認します。

まとめ

サービスプロジェクト側の実装を進めました。これでメイン部分が実装できました。
長くなりましたので次を宿題として今回はここまでにします。

宿題
  • 30秒でコピーが行われない場合の対応
  • FileSystemWatcherのエラー処理
  • 空フォルダの削除
  • パフォーマンス設定の対応

など