ぎょーぼのぶろぐ

IT系の話を書いていくブログです。今はRubyの勉強中。

【ツール作成】ファイルコピーを実際に行うモジュール

前置き

ファイルのコピーや、ディレクトリの作成を行うモジュール群です。

実装コード

全体のモジュール名は、BackupUtils としています。また、ファイルをコピーする際に、FileUtils.copyを使用するので、fileutils をrequireしています。

module BackupUtils

    require 'fileutils'
self.get_dir_info メソッド

コピー前の確認用に、バックアップ元ディレクトリ、バックアップ先ディレクトリの情報を画面に表示するメソッドです。設定の中で、有効ではないディレクトリは、事前にチェックして外しているので、ここではディレクトリのハッシュ配列を受け取って、表示するだけです。

    #
    # self.get_dir_info
    #   ディレクトリ情報を受け取り、読みやすい形式で画面出力する。
    #   引数
    #       dirs    : バックアップディレクトリ情報
    #
    def self.show_dir_info(dirs)
        tmp = ""

        # バックアップ元ディレクトリを展開し、文言化する。
        dirs.src_dir.each { |key, value|
            tmp += Message.t(:info09, key: key, value: value)
        } 

        # 表示
        Message.show_and_log(:info, :info02, desc: tmp)
        Message.show_and_log(:info, :info03, desc: dirs.dest_dir)
    end
self.show_confirmation メソッド

実行確認を行うメソッドです。

  • gets メソッドで入力を取得。
  • 前後の空白改行をstripで取り除き、大文字小文字の区別をなくすためにupcaseで大文字に統一します。
  • 入力が Y だったら、バックアップ処理を継続。Nだったら、処理を中止し、他の値の場合は再度、入力を促します。
  • 空の入力の場合は、デフォルトで処理を継続します。

一応、例外処理は入れています。Ctrl-c等で強制終了したりすると、例外になります。個別に rescue Interrupt で捕捉することはできますが、ここでは意味がないので、その他の例外と合わせてメッセージを表示します。

    #
    # self.show_confirmation
    #   実行確認を行う。
    #
    def self.show_confirmation
        # 処理の実行確認メッセージ
        loop do 
            Message.show(:confirm01, "")
            answer = gets

            case answer.strip.upcase
            when "Y", ""
                Message.show(:confirm02, "")
                break
            when "N"
                Message.show(:confirm03, "")
                exit(true)
            else
                Message.show(:confirm04, "") 
            end
        end
    
    rescue => ex
        Message.exception(__method__, ex)
        exit(false)
    end
self.execute_copy メソッド

バックアップを実行するメソッドです。
最初のバックアップ先ディレクトリの作成で、"[prefix名][yyyymmddhhmmss]" の名前でディレクトリを生成するのですが、ディレクトリ名の重複チェックをしていないので、そこで例外が発生する可能性があります。しかし、年月日時分秒でディレクトリを生成しているので、狙って仕込まないとディレクトリ名が重複する可能性は皆無、と判断して、チェックは入れていません。

バックアップ元ディレクトリ毎に、ProressBarオブジェクトを生成し、ファイルコピーを実行、最後に結果を表示します。

    #
    # self.execute_copy
    #   バックアップ先へ、ディレクトリ作成、ファイルコピーを行う。
    #   引数
    #       dirs    :  バックアップ元、バックアップ先ディレクトリ情報
    #
    def self.execute_copy(dirs)

        # バックアップディレクトリの生成
        dest_dir = File.join(dirs.dest_dir, dirs.dest_prefix+Time.new.strftime("%Y%m%d%H%M%S"))
        Dir.mkdir(dest_dir) 

        # 実行開始
        dirs.src_dir.each { |key, value| 

            # プログレスバーの生成(ソースディレクトリ名とディレクトリの容量を渡す。)
            bar = ProgressBar.new(File.basename(value), get_dir_info(value)[:dir_size])

            # ディレクトリ内のファイルコピーを実行する。
            copy_files(value, dest_dir, bar)

            # 実行完了したら、改行しておく。
            Message.show(:info04)
        }

        # 結果の表示
        tmp = get_dir_info(dest_dir)
        Message.show_and_log(:info, :info05, path: dest_dir)
        Message.show_and_log(:info, :info06, size: tmp[:dir_size], fcount: tmp[:file_count], dcount: tmp[:dir_count])

    rescue => ex
        Message.exception(__method__, ex)
        exit(false)
    end
self.get_dir_info メソッド(プライベート)

ディレクトリの総サイズ、ファイル数、ディレクトリ数を収集するメソッドです。

File.join(directory, "**", "*") の部分は、directory/**/* というパスを生成しています。引数で渡されたディレクトリの「配下、サブディレクトリ内も含めて(/*/)」「全て()」という意味になります。

つづいての File::FNM_DOTMATCH オプションで、ピリオド付きの名前のディレクトリも含め、最後の rejectメソッドで、「ピリオドで終わる名前」を除外しています。これは、カレントディレクトリ(.)と、親ディレクトリ(..)を除外するためです。

ディレクトリ内のオブジェクトが取得できたら、後はサイズを合算、ファイルかディレクトリかを判断してカウントするだけです。

    private

    #
    # self.get_dir_info
    #   引数のディレクトリの情報を取得する。
    #   引数
    #       directory   :   情報を取得するディレクトリパス
    #   戻り値
    #       ハッシュ配列(ディレクトリの総サイズ、ファイル数、ディレクトリ数)
    #
    def self.get_dir_info(directory)
        dir_size = 0      # 総サイズ計算用
        file_count = 0    # 総ファイル数計算用
        dir_count = 0     # 総ディレクトリ数計算用
    
        # オブジェクト一覧を取得
        # `.`付きのフォルダーも取得するが、`.`で終わる特殊フォルダーは除外する。
        list = Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH).reject{|x| x =~ /\.$/}

        list.each { |f| 
            # ディレクトリではない場合のみ、サイズを加算する。
            if File::ftype(f) != "directory" then
                dir_size += File.size(f)    # ファイルサイズのカウント
                file_count += 1             # ファイル数のカウント
            else
                dir_count += 1              # ディレクトリ数のカウント
            end
        }

        # 総サイズを返す。
        return Hash[dir_size: dir_size, file_count: file_count, dir_count: dir_count]

    rescue => ex
        Message.exception(__method__, ex)
        exit(false)
    end
self.copy_files メソッド(プライベート)

実際にディレクトリの作成、ファイルのコピーを行うメソッドです。

ディレクトリは、中身を含めてまとめてコピーはできますが、ディレクトリの皮(?)のみをコピーすることはできません。ですので、mkdir メソッドを使って、新規作成していきます。

ここでは、ディレクトリの重複チェックを行っています。今回は、バックアップ元ディレクトリを複数選択できるのですが、バックアップ元ディレクトリの名前が同じだった場合、バックアップ先で重複してしまうためです。重複したら、_を都度つけることで対応しています。

手順は以下の通りです。

例外は基本的には出ないハズですが、一応、想定外のエラーのためのrescueはしています。

    #
    # self.copy_files
    #   ディレクトリの作成、ファイルのコピーを行う。
    #   再帰呼び出しを行うことで、配下のファイル、ディレクトリ全てを作成、コピーする。
    #   引数
    #       src     :   コピー元のディレクトリ(単体。ハッシュではないので注意)
    #       dest    :   コピー先のディレクトリ
    #       bar     :   プログレスバーオブジェクト
    #
    def self.copy_files(src, dest, bar)
        # バックアップ元のディレクトリ名を取得し、バックアップ先にその名前のディレクトリを生成する。
        dest_dir = File.join(dest, File.basename(src))

        # バックアップ先に同じ名前のディレクトリがすでにある場合は、"_"を付与。
        # ディレクトリの重複が無くなるまで、"_"を付与し続ける。
        while Dir.exists?(dest_dir)
            dest_dir = dest_dir + "_"
        end

        Dir.mkdir(dest_dir)
        Message.log(:info, :info07, dir: dest_dir.to_s)

        # バックアップ元のディレクトリ内のディレクトリ/ファイルの一覧取得(`.`, `..`は取得しない)
        list = Dir.glob(File.join(src, "*"), File::FNM_DOTMATCH).reject{|x| x =~ /\.$/}

        list.each { |f| 
            if File::ftype(f) == "directory"
                # ディレクトリの場合、そのディレクトリをソースディレクトリとして、再帰呼び出しする。
                copy_files(f, dest_dir, bar)
            else
                # ファイルの場合、プログレスバーを更新して、ファイルコピー実行
                bar.show(File.basename(f), File.size(f))
                FileUtils.copy(f, dest_dir)
                Message.log(:info, :info08, file: f.to_s)
            end
        }
    rescue => ex
        Message.exception(__method__, ex)
        exit(false)
    end
end

補足

Message モジュールについては、一つ前の記事で説明しているので、そちらを見てください。

【ツール作成】Messageモジュールを作る

前置き

今回は、コンソールへのメッセージ出力、ログへの出力を制御する Messageモジュールについてです。

なんでモジュール化?

コンソールへの出力なら、puts でいいし、ログ出力も loggerモジュールがあるので、その都度出力すればいいわけですが、今回、Messageモジュールとしてまとめた理由としては、、、

  • コンソールだけに出力したいメッセージ、例外の詳細など、ログだけに出力したいメッセージ、両方に出力したいメッセージがあって、それぞれ一回の呼び出しでできるようにしたかった。
  • I18nlogger の使用がMessageモジュール内だけに限定できるので、コードが見やすくなる。
  • I18nモジュールで、文言部分を別ファイルで管理したかったが、出力のたびに I18n.t を書かないといけないのが少し煩わしかった。
  • 想定外の例外出力の際、Backtrace を出力するようにしたかったが、各メソッドでいちいち出力フォーマットを書くのが面倒くさかった。それなら、メソッドに必要な引数を渡したら、それだけで定型で出力できるようにしたかった。

といったところです。

・・・これって、「ヘルパーメソッド」的な感じなのかな・・・

実装するメソッド

  • self.init(sys)
    SystemSetting インスタンスを引数にして、loggerの設定初期化を行うメソッドです。Messageモジュールを使用する前に必ず実行する必要があります。
    I18n' の設定は、Messageモジュールをrequire`したときに読み込まれるようにしています。

  • self.show(label, *args) コンソールだけにメッセージを出力するメソッドです。内容は、puts するだけです。I18nで文章を取得するので、引数はI18n.tメソッドで必要な引数を取っています。

  • self.log(level, label, *args) ログだけにメッセージを出力するメソッドです。loggerオブジェクト生成→出力→close を行います。必要な引数は、logレベルと I18n.tメソッドで必要な引数です。

  • self.show_and_log(level, label, *args) コンソールとログの両方にメッセージを出力するメソッドです。単にself.showメソッドとself.logメソッドを順に呼び出しているだけです。

  • self.show_exception(method, ex) 想定外の例外が発生したときに、例外が発生したメソッドと例外の詳細、バックトレースを固定のフォーマットで出力するメソッドです。必要な情報(発生メソッド名と例外オブジェクト)を受け取ってフォーマットを生成し、self.show_and_logに渡しています。

他、プライベートメソッドを二つ(実際にログファイルに書き込む処理のメソッド、I18n.t へ引数を渡すメソッド)、実装しました。

実装コード

実装コードは、以下の通りです。

module Message
    require 'I18n'
    require 'logger'

    # i18n 初期設定
    begin
        I18n.load_path = Dir["./locale/**/*.yml"]
        I18n.locale = :ja
    rescue I18n::InvalidLocaleData
        # ロケールファイルのオープンエラー
        puts "ロケールファイルの読み込みに失敗しました。\n処理を中止します。"
        exit(false)
    rescue I18n::InvalidLocale
        # 不正なロケールを指定した
        puts "ロケールの設定「:ja」が見つかりません。\n処理を中止します。"
        exit(false)
    end

    #
    # self.init
    # システム設定を受け取り、Messageクラスの設定を行う。
    #   引数
    #       system  :   設定のハッシュ。下記項目が入っていることを想定。
    #                   log_path        :   ログファイル出力パス
    #                   log_date_format :   ログ出力時の日付フォーマット
    #   戻り値
    #       なし
    #
    def self.init(sys)
        # system.ini の内容で初期化する。
        @log_path = sys.log_path
        @log_date_format = sys.log_date_format

        # logへ最初の書き込みを行ってみる。
        self.show_and_log(:info, :info01, desc: Time.new.strftime("%Y-%m-%d %H:%M"))

        return true
    end

    #
    # self.show
    #   コンソールへ表示する。
    #   引数
    #       label   :   ロケールファイルのラベル(シンボル)
    #       *args   :   パラメータ(I18n仕様)
    #
    def self.show(label, *args)
        puts t(label, *args)
    end

    #
    # self.log
    #   ログへ出力する。
    #   引数
    #       level   :   ログレベル(通常外の値を指定した場合は :unknown で出力)
    #       label   :   ロケールファイルのラベル(シンボル)
    #       *args   :   パラメータ(I18n仕様)
    #
    def self.log(level, label, *args)
        # ログファイルへ書き込み
        write_log(level, t(label, *args))
    end

    #
    # self.show_and_log
    #   コンソールとログへ出力する。(show と log を順に呼び出すだけ)
    #   引数
    #       level   :   ログレベル(通常外の値を指定した場合は :unknown で出力)
    #       label   :   ロケールファイルのラベル(シンボル)
    #       *args   :   パラメータ(I18n仕様)
    #
    def self.show_and_log(level, label, *args)
        show(label, *args)
        log(level, label, *args)
    end

    #
    # self.exception
    #   例外情報を出力する。
    #       コンソールとログの両方に出力する。
    #       メソッド名、Exceptionクラス、Exceptionメッセージ、backtrace を出力する。
    #   引数
    #       method      :   発生したメソッド名(__method__ で渡してもらう)
    #       ex          :   Exception オブジェクト
    #
    def self.exception(method, ex)        
        # コンソールとログへ出力(レベルは :fatal, 文言は:exception固定)
        show_and_log(:fatal, :exception, method: method, class: ex.class, message: ex.message, backtrace: ex.backtrace.join("\n"))
    end

    private

    #
    # self.write_log
    # ログファイルへの書き込み
    #   引数
    #       level   :   ログレベル(:debug, :info, :warn, :error, :fatal, :unknown)
    #       message :   出力する内容
    #
    #   ※ 出力直前にオープンし、出力後に必ずcloseするようにする。
    #
    def self.write_log(level, message)
        # loggerオブジェクト生成
        @logger = Logger.new(@log_path)
        @logger.datetime_format = @log_date_format

        # 指定のログレベル以外の値が来た場合は、:unknown に置き換える。
        unless [:debug, :info, :warn, :error, :fatal].include?(level) then
            level = :unknown
        end

        # 出力実行
        @logger.send(level, message)
        @logger.close

    rescue => ex
        # ここで例外が発生した場合、ログに出力できない状態なので、コンソールに直接出力する。
        puts t(:err06, path: sys.log_path)
        puts t(:exception, method: __method__, class: ex.class, message: ex.message, backtrace: ex.backtrace.join("\n"))
        exit(false)
    end

    #
    # self.t
    # メッセージ文を取得する。I18n.t へそのまま引き継ぐ。
    #   引数
    #       label   :   ロケールファイルのラベル(シンボル)
    #       *args   :   引数リスト
    #
    def self.t(label, *args)
        return I18n.t(label, *args)
    end
end

実装のポイント

I18n モジュールの設定で、ロードパスの設定に Dir["./locale/**/*.yml"] という書き方をしていますが、これは、「localeディレクトリ配下のサブディレクトリを含めてymlを拡張子に持つすべてのファイル」という意味になります。具体的には、/**/の部分が、「その配下のサブディレクトリ内も含めて」という意味になります。

例外処理については、I18n モジュールは最初の初期設定が通れば「基本的に使えるもの」として扱っています。ログ出力側は、self.write_logプライベートメソッドで例外が出る場合のみ、例外内容をコンソールに出力(puts)するようにしています。

I18n で使用するメッセージファイル(messages.yml)

I18n のメッセージは、下記のようにまとめています。文言のところ、本当はダブルクォーテーションは不要なのですが、末尾の空白とか分かり辛かったりするので、あえて括っています。

ja:
    err01: "【エラー】%{path} ファイルが見つかりません。ファイルのパスを確認してください。\n処理を中止します。"
    err02: "【エラー】%{path} ファイルの読み取りに失敗しました。%{path} ファイルの文字コードがUTF-8になっているか、確認してください。\n処理を中止します。"
    err03: "【エラー】%{param}の値が設定されていません。%{path}ファイルの内容を確認してください。\n処理を中止します。"
    err04: "【エラー】バックアップ先のディレクトリがありません。%{path} を確認してください。\n処理を中止します。" 
    err05: "【エラー】dest_prefix に使用できない文字が含まれています。「a-zA-Z0-9_-=()」以外の文字は使用できません。\n処理を中止します。"
    err06: "【エラー】ログファイルへの書き込みに失敗しました。%{path} が正しいか、確認してください。\n処理を中止します。"
    err07: "【エラー】バックアップ元のディレクトリが一つもありません。設定を確認してください。\n処理を中止します。"
    err08: "【エラー】バックアップ先ディレクトリに書き込みができません。アクセス権を確認してください。%{path}\n処理を中止します。"

    info01: "===== ファイルバックアップツール FileBackup.rb ===== %{desc}"
    info02: "【バックアップ元フォルダー】\n%{desc}"
    info03: "【バックアップ先フォルダー】\n %{desc}"
    info04: "【完了】"
    info05: "バックアップを完了しました。バックアップ先:%{path}"
    info06: " Total: %{size}Bytes, ファイル数: %{fcount}, ディレクトリ数: %{dcount}"
    info07: "【ディレクトリ作成】%{dir}"
    info08: "【ファイルコピー】%{file}"
    info09: " %{key}: %{value} \n"

    warn01: "【注意】設定されたパスは、ディレクトリではありません。 %{desc}" 
    
    confirm01: "処理を開始してよろしいですか?[Y/n]"
    confirm02: "処理を開始します。"
    confirm03: "処理を中止します。"
    confirm04: "【エラー】`y`か`n`で入力してください。"

    exception: "【エラー】例外が発生しました。例外の内容を確認してください。\n%{method}: %{class}: %{message}\n%{backtrace}\n処理を中止します。"

【ツール作成】Settingクラス関係

前置き

ファイルバックアップツールの設定関係を管理するクラスを生成します。

設定の構成

今回は、iniファイルを2つ作っています。

  • システム自体の設定に関する項目を入れる system.ini
    内容は以下のような感じです。
;system.ini
;ログ出力設定
log_path=.\log\FileBackup.log
log_date_format=%Y-%m-%d %H:%M:%S

;バックアップディレクトリ設定
setting_path=.\setting.ini
;setting.ini
[src]
src_dir1=C:\gyobo\filebackup\test\src1\
src_dir2=C:\gyobo\filebackup\test\src2\
src_dir3=

[destination]
dest_dir=C:\gyobo\filebackup\test\dest\
dest_prefix=backup_

分けている理由は、「一度設定したらそのまま使うもの」と「その都度、変更する可能性があるもの」を分けたかったからです。バックアップ元ディレクトリを変えたかったのに、うっかりログ出力先を変えてしまったり、消してしまったりしないように。

クラス構成

設定ファイルが2種類あるので、クラスもそれぞれのiniファイルの内容を格納するように2種類生成します。ただ、iniファイルを読み込んだり、共通で使用できるモジュールもあるので、それぞれ分けて作ってしまったらもったいない・・・

ということで、共通部分を定義したベースのクラス(SettingBase)を作成しておき、そこから継承する形で「SystemSetting」「DirectorySetting」クラスを定義、異なる部分を実装する構成としました。

f:id:gyobo:20201008182758p:plain
Setting周りのクラス関係

青字は、getter メソッドです。一度読み込んだら値の変更はしないので、setter は定義しません。initialize メソッドはオーバーライドしてますが、SettingBase のメソッドを呼んでから、個別の処理を追加します。

SettingBase クラス

Inifile モジュールを Mix-In して定義します。

require './modules/Inifile'
require 'securerandom'

#
#   class SettingBase
#       - iniファイルの内容を読み取り、変数に格納する。
#       - このクラスは読み取り機能のみ実装。
#         内容の取得は、このクラスを継承して実装すること
#
class SettingBase
    include Inifile

    def initialize(path)
        # path ファイルの内容を読み込む。
        @ini = read(path)
    end

    private

    #
    # yen_to_slash
    #   円記号をスラッシュに置換する。
    #   引数
    #       str :   文字列
    #   戻り値
    #       文字列内の円記号をスラッシュに置き換えた文字列
    #
    def yen_to_slash(str)
        return str.gsub("\\", "/")
    end
end
  • initialize メソッド
    system.ini のパスを受け取って、Inifileモジュールから Mix-in した read メソッドを呼び出す部分だけを実装しています。

  • yen_to_slash メソッド
    パス文字列のセパレータを\から/に変換するメソッドです。これで、iniファイル上で\/のどちらを使ってもいいようにします。

SystemSetting クラス

SettingBase クラスを継承して、System.ini 読み込み用のクラスSystemSettingクラスを定義します。
iniファイルを読み込んで、キー名と同じ名前のインスタンス変数に値を保持するようにするのですが、素直に書くと、、、

・・・
    attr_reader :log_path, :log_date_format, :setting_path

    def initialize(path)
        super(path)

        @log_path = @ini("log_path")
        @log_date_format = @ini("log_date_format")
        @setting_path = @ini("setting_path")
・・・

せっかく、iniファイルのキー名と同じ変数名にしているのに、attr_reader でも代入するところでも、変数名を羅列しないといけないのが、見た目がすごく悪いです。

いろいろと試行錯誤した結果、変数名の配列を定義して、それを利用することにしました。
できたコードは以下のようになりました。

class SystemSetting < SettingBase
    # 配列でインスタンス変数のgetterをまとめて定義
    VARS = [:log_path, :log_date_format, :setting_path].freeze
    attr_reader *VARS

    def initialize(path)
        # path の内容を読み込む。変数 @ini に格納される。
        super(path)

        # SystemSetting で必要な変数のみ取得を試みる。
        VARS.each do |param| 
            tmp = param.to_s
            
            if @ini.keys.include? tmp then
                # 該当するキーがあれば、インスタンス変数に読み込み。
                # ※ 全てのパラメータで、yen_to_slashの置換もしてしまう。
                eval("@#{tmp} = yen_to_slash(@ini[tmp])")
            else
                # 該当するキーがない場合は、エラーを表示する。
                Message.show(:err03, param: tmp, path: path)
                exit(false)
            end
        end
    end
end
  • 最初に、変数名の定数配列VARSを定義。要素はシンボルにしているのは、その方が扱いやすいので。定数配列は immutableの方がいいし。
  • attr_reader の引数は、配列を展開したもの*VARSを引数として渡せば OK です。
  • iniファイルから取得したハッシュ値@iniから値を取り出す際は、VARS.each でループさせて、キー名に該当する値を取得します。
    この際、「同じ名前を持つインスタンス変数に値を入れる」にはどうするか?ですが、今回はevalを使っています。eval は、与えた文字列をそのまま ruby コードとして解釈してくれるので、例えば tmp の値が "log_path" の場合、eval("@#{tmp} = yen_to_slash(@ini[tmp])") の行は、@log_path = yen_to_slash(@ini[tmp]) として解釈されます。これで、キー名と同じ名前のインスタンス変数に代入することができます。
  • 合わせて、VARS.each でループさせることで、欲しい変数以外のキーを無視し、欲しい変数の有無チェックを同時にすることができるようになりました。

DirectorySetting クラス

setting.ini の内容を取り込む DirectorySetting クラスです。SystemSettingクラスと同じように、SettingBaseクラスを継承し、VARS配列を利用して値を取り込みます。ただ、こちらの設定ファイルはセクションを使っていることと、バックアップ元ディレクトリは、他の値とは設定方法が異なる(複数指定可能にしています)ので、その部分は分けて取り込む必要があります。

class DirectorySetting < SettingBase
    # 配列でインスタンス変数のgetterをまとめて定義
    VARS = [:src_dir, :dest_dir, :dest_prefix].freeze
    attr_reader *VARS
   
    def initialize(path)
        
        super(path)

        # DirectorySetting で必要な変数のみ取得を試みる。
        VARS.each do |var| 
            if var == :src_dir then
                # src_dir は複数取るので別枠で処理する。
                @src_dir = get_src_dir(@ini["src"])
            else
                # destination セクションの値
                tmp = var.to_s
                dest = @ini["destination"]

                if dest.keys.include? tmp then
                    # 該当するキーがあれば、インスタンス変数に読み込み。
                    # ※ 全てのパラメータに円マーク不要なので、パスかどうかにかかわらず置換もしてしまう。
                    # self.send(tmp+"=", yen_to_slash(dest[tmp]))
                    eval("@#{tmp} = yen_to_slash(dest[tmp])")
                else
                    # 該当するキーがない場合は、エラーを表示する。
                    Message.show_and_log(:error, :err03, param: tmp, path: path)
                    exit(false)
                end    
            end
        end

        # パラメータのチェック
        if !File.directory?(@dest_dir) then
            Message.show_and_log(:error, :err04, path: @dest_dir)
            exit(false)
        end

        if @dest_prefix.match(/[^a-zA-Z0-9_\-=()]/) then
            Message.show_and_log(:error, :err05)
            exit(false)
        end

        # バックアップ先ディレクトリの書き込み権限チェック
        begin
            # 名前がランダムなフォルダーを作って消してみる。
            testdir = File.join(@dest_dir, SecureRandom.alphanumeric(10))
            Dir.mkdir(testdir)
            Dir.rmdir(testdir)
        rescue Errno::EACCES
            Message.show_and_log(:error, :err08, path: @dest_dir)
            exit(false)
        rescue => ex
            Message.exception(__method__, ex)
            exit(false)
        end
    end

    private

    #
    # get_src_dir
    #   バックアップ元ディレクトリのハッシュを受け取り、有効なパスのみのハッシュにして戻す。
    #   引数
    #       src :   ディレクトリのハッシュ配列
    #   戻り値
    #       有効なパスを持つディレクトリのハッシュ配列
    #
    def get_src_dir(src)
        tmp = Hash.new

        src.each do |key, value| 
            # 指定したディレクトリが在る場合のみ、ハッシュに追加する。
            if File.directory?(value) then
                # Windowsのパス区切り`\`は、`/`に直しておく。
                tmp[key] = yen_to_slash(value)
            else
                Message.show_and_log(:warn, :warn01, desc: "#{key}: #{value} ")
            end
        end

        # バックアップ元ディレクトリが一つもない場合
        if tmp.length == 0 then
            Message.show_and_log(:error, :err07, :fatal)
            exit(false)
        end
    
        return tmp

    rescue => ex
        Message.exception(__method__, ex)
        exit(false)
    end
end
  • バックアップ元ディレクトリは、get_src_dirメソッドを定義して処理し、他の値はSystemSettingクラスと同じように取り込みます。
  • こちらのクラスでは、値の取り込みの他に、値のチェック(パスの有無チェック、バックアップ先ディレクトリの書き込み権限チェック、prefix名の文字チェック)を行っています。
    • ディレクトリの書き込み権限チェックは、組み込みメソッドやFileUtilsなどで最適なメソッドが見つからなかったため、「実際にバックアップ先ディレクトリに新規ディレクトリを作成→削除して、例外が発生するかどうか」で判断しています。作成するディレクトリ名は、万が一にもかぶったりしないように、SecureRandomモジュールのalphanumericメソッドで名前を生成しています。指定した文字数のランダムな数値英文字の文字列を生成してくれるので、とっても便利です。
    • prefix名はディレクトリ名になるので、禁止文字が入らないようにするためのものです。そんなに厳密に正しい禁止文字を指定する必要もないので、逆に「使用を許可する文字」を指定するようにしました。

Message は何・・・?

Messageモジュールについてはまだ説明してないですが、該当のシンボルのメッセージを表示するようにしています。内容については次回、、、

【ツール作成】プログレスバークラスを作る

前置き

プログレスバーのクラスを作ります。

どうやって作る?

作りはとっても単純です。

  • 全体、実行済みの値から、進捗度を計算
  • 進捗度からバーの状態を生成
  • キャリッジリターン \r を使って、コンソール行を上書きする。

行全体を上書きしますが、一部だけ変わっているので、その行でバーが動いているように見えます。

バーのフォーマットと動き

バーは、メモリをつけた形をベースにして、src_dir ディレクトリの処理が進んでいくと、=> の記号が伸びていき、100% で完了します。

src_dir |---------+---------+---------+---------| 0% ( done / total ) copying_filename 
↓
src_dir |===================>---------+---------| 50% ( done / total ) copying_filename 
↓
src_dir |=======================================| 100% ( done / total ) copying_filename 【完了】

バーの部分は、ベースの文字列(|--+--| の部分)を作っておいて、進捗度を計算し、進捗部分(==> の部分)を作って、ベース文字列の部分を差し替えるように作っています。

今回は、コピー元ディレクトリを複数登録できるようにしていて、コピー元ディレクトリ毎に、プログレスバーオブジェクトを生成するようにしました。流れとしては、

  • コピー元ディレクトリの総ファイルサイズをtotalの値として生成
  • ファイルを一つコピーするごとに、コピーしたファイルのファイルサイズを done に加算
  • 都度、進捗割合を計算し、プログレスバー文字列を作成
  • 前に表示した内容を消して、新しいプログレスバー文字列を表示

といった感じです。

実装コード

class ProgressBar

    # 定数定義 
    EDGE = "|".freeze                   # バーの両端
    SCALE = ("---------+" * 4).freeze   # バーの目盛り部分
    BAR = ((EDGE + SCALE).chop + EDGE).freeze   # バーのベース文字列
    DONE = "=".freeze                   # 済み記号
    ARROW = ">".freeze                  # 済み記号の先端

    #
    # initialize    初期化
    #   引数
    #       title   :   プログレスバーのタイトル名
    #       total   :   プログレスバーの 100%の値
    #
    def initialize(title, total)
        # インスタンス変数 定義
        @title = title      # 進捗表示する処理名(フォルダー名とか渡す)
        @total = total      # 100% 時の値(単位は何でもいい。)
        @done = 0           # 完了した値
        @showed_length = 0  # 直前に表示した文字列の長さ(上書きの際に使用する。)
    end
    
    #
    # show 
    # プログレスバーの表示更新
    #   引数
    #       info    :   バーに表示する情報文字列
    #       size    :   進捗する値   
    #
    def show(info, size)

        # 処理したファイルサイズを加算し、進捗割合を計算
        @done += size        
        progress = get_progress(@total, @done)

        bar = BAR.dup     # BAR の内容を複製(こうしないと BAR の値が変わってしまう。。)

        if progress >= 100 then                 # 進捗が 100% 以上の場合
            bar[1..-2] = DONE*(BAR.length-2)  # 両端を除く全てを DONE 記号で埋める。
        else
            tmp = EDGE + (DONE*(progress*(SCALE.length)/100).floor).chop + ARROW    # 進捗部分を生成して、
            bar[0..tmp.length-1] = tmp                                            # 進捗部分を置き換える。
        end

        # 前回表示した行を消去
        print "\r".ljust(@showed_length)

        # 情報表示
        line = "\r" + sprintf("%s %s %s%% (%s/%s) %s...", @title[0,10], bar, progress, get_size_str(@done), get_size_str(@total), info[0,10])
        print line
        @showed_length = line.length
    end
    
    #
    # reset
    # 進捗をリセットする。
    #
    def reset
        @done = 0
        @showed_length = 0
    end

    private

    #
    # get_progress
    # 進捗度を割合計算し、100分率(%)で返す。
    #   引数
    #       total   :   100% となるサイズ
    #       done    :   実行済みのサイズ
    #   戻り値
    #       進捗度(100分率の整数値。端数は四捨五入)
    #
    def get_progress(total, done)
        if done <= 0    then return 0   end     # done が 0 以下の場合は、0 を返す。
        if total <= 0   then return 100 end     # total が 0 以下 : 100% として返す。
        if total < done then return 101 end     # total が done よりも小さい : 101% として返す。

        return ((done*100)/total).round     # 計算して返す。値は四捨五入で返す。
    end

    #
    # get_size_str      バイト数を KiB, MiB, GiB のうち、最適な単位に変換して返す。
    #   引数
    #       bytes   :   バイト数
    #   戻り値
    #       最適な単位にしたバイト数と単位を付与した文字列
    #
    def get_size_str(bytes)
        unit = ["Bytes", "KiB", "MiB", "GiB"]
        block = 1024

        # KiB, MiB, GiB かどうかを判定する。
        for n in 1..3 do
            if bytes.between?(block**n, block**(n+1)) then
                return ((bytes/block**n).floor).to_s + unit[n]
            end
        end

        # どの単位にも合致しない場合は、そのまま Bytes で返す。
        return bytes.to_s + unit[0]
    end
end

実装のポイント

コメントを多めに入れてるので、読んでいただければそのまま分かると思いますが、、、

ベースとなる 0%状態のバー文字列は、定数で最初に宣言しています。そして、加工のためにbar変数に文字列の内容を複製しているのですが、理解されている方には分かり切ったことですが、代入では値の複製にならない、必ずdupメソッドで値の複製をすること!です。

dup メソッドの後、進捗部分の文字列を置き換える処理で、bar[1..-2] のような書き方をしていますが、これが破壊的メソッドなので、line = BAR のような代入をしてしまうと、BAR の値も同時に書き換わってしまいます。

また、新しいバーをそのまま上書きしてしまうと、もし前回表示した行の方が長かった場合、長かった分の文字列が残ってしまいます。ですので、@showed_length 変数を定義して表示した文字列の長さを取っておき、新たに表示するときにその長さの空白で上書きして、確実に消えるようにしています。(もちろん、\r 付きで上書きです)

後は、バイトの表示単位を変換するget_size_str メソッドを作ったりしてるくらいですか。

例外処理については、ワザと変な引数(nil とか型違いとか)を入れない限り、ロジック上で例外が発生する可能性がないため、このクラスでは行っていません。

次は・・・設定関係のクラスかな・・・

【ツール作成】Inifileモジュールを作る

前置き

設定をiniファイル形式のテキストファイルに書いておき、読み込むためのモジュールを作ります。

Inifile gem を使わなかった理由

最初は、gem にInifileというそのまんま、iniファイルの設定を読み込むための gem があったので、それを使おうと思ってました。

しかし、gem をインストールして、色々試してみたところ、一つの仕様で悩むことになりました。

  • 行の末尾が半角「¥」円マークで終わっている場合、次の行と結合される。

どういうことかというと・・・

以下のような iniファイルを用意して、

src_dir1=c:\hoge\hogehoge\
src_dir2=c:\nyao\nyaonyao\
src_dir3=

これを Inifile gem で読み込ませると、、、

src_dir1 の値 : c:\hoge\hogehogesrc_dir2=c:\nyao\nyaonyaosrc_dir3=

となってしまうのです。

【問題点】

  • 最後の文字が\の場合に、問答無用に次の行と結合されてしまう。
    せめて、前にスペースが一つでもついてるときだけ行結合( \)、とかにしてくれてたら良かったんですが・・・
  • 行結合に使われた\記号は、取り込んだ文字列からは無くなってしまう。
    このため、どこで行結合されたのか、結果の文字列から判断するのが非常に困難になっています。

ディレクトリパスの最後に\を付けなければいいじゃないか、とも思ったのですが、ディレクトリパスの書き方として、最後に区切り文字をつけるのは通常の、問題ない書き方なので、このiniファイル内だけダメ、というのは不自然です。区切り文字を/にしてしまえば、とも思いましたが、今回は「Windows環境」で使おうとしているので、ディレクトリパスで\が使えない、というのは、けっこう不便です。(エクスプローラー上でパスをコピーした後、置換しないといけない・・・)

悩んだ結果、「もう、自作しちゃおうか」ということになりました。完璧な、汎用的なものは難しいですが、このツールで普通に使えるレベルのものは作れるかなー、と思ったので。

実装する仕様

実装する仕様は、基本的にiniファイルの仕様に従います。

INIファイル - Wikipedia

細かいところとしては、以下の通り。

  • \ で行結合はしない。
    ここが今回の肝です。
  • ;記号の後は、問答無用でコメントと判断する。(引用符で括るとかエスケープとか考えない)
    値の後ろにコメント、とか書いてもいいように。
  • キー名、値の大文字小文字は区別する。
    区別しないようにも作れますが、今回は区別しないようにする理由がないから。
  • 引用符は括っても括らなくても可。(入っていた場合は問答無用で取り除く)
    今回のツールでは、シングル・ダブルクォーテーションは値に入ってこないので。
  • 空行は許す。
    どっちでもいいですが、どうせ読み飛ばすだけなので。
  • キー名の重複は、後勝ち。
    今回はそんなにたくさんの設定入れないので、簡単な方にします。設定が多ければ、ちゃんとエラーメッセージだして終了した方が親切なんですけどね。
  • 読み込むiniファイルのエンコードUTF-8 とする。
    Windows だと標準は S-JIS なのですが、UTF-8 の方が一般的かなー、ということで。

実装

実装は以下のようになりました。

module Inifile
    #
    # read
    #   引数で指定されたIniファイルパスの内容をハッシュ配列にして返す。
    #   引数 :iniファイルパス(絶対パス、相対パスどちらも可)
    #   戻り値:2次元ハッシュ
    #           セクション無しの項目は、key => value の1次元、
    #           セクション有りの項目は、section => { key => value, ...} の2次元で格納。
    #   その他:Iniファイルの文字コードは、utf-8 固定。
    #           key が重複した場合は、後の値で上書きする。
    #           Iniファイル内で`;`のコメント使用可。`;`以降の文字列は無視する。
    #   注意 :I18n のrequireとロケールファイル設定がされていない場合、
    #           例外メッセージが正しく表示されないので注意。
    #           
    def read(path) 

        # ローカル変数の初期化
        section = ""
        key = ""
        value = ""
        hash = Hash.new { |h,k| h[k] = {} }

        # ini ファイルを、読み取り専用でオープン
        File.open(path, "r:utf-8").each do |line| 
            # `;`記号がある場合はコメントと判断し、最初の`;`記号以降を除外する。
            line.sub!(/;.*/m,"") if line.match(/;/)            
            
            # 前後の空白を除去
            line.strip!

            # セクション行の場合は、セクションを設定してスキップ
            if line.match(/^\[.*\]$/) then
                # `[]` と空白を除去して、セクション名にする。
                section = line.gsub!(/[\[\]\s]/,"")
                next
            end

            # その行が`=`を含まなければ、スキップする。
            next unless line.match(/=/) 

            # 最初の`=`で2分割して、key, value に入れる。
            # `=`の前後に空白がある場合は、それも除去する。
            key, value =  line.split(/\s*=\s*/, 2)

            # シングルクォート、ダブルクォートは全て除外。
            value = value.gsub(/[\'\"]/,"")

            # key, value の値のどちらかが空の場合は、スキップする。
            next unless key.length > 0 && value.length > 0

            # セクションがある場合は、2次元ハッシュにする。
            if section.length == 0 then
                hash[key] = value
            else
                hash[section][key] = value
            end
        end

        # ハッシュ配列を返す。
        return hash

    rescue Errno::ENOENT => ex
        puts I18n.t(:err01, path: path)
        exit(false)
    rescue ArgumentError
        puts I18n.t(:err02, path: path)
        exit(false)
    rescue StandardError => ex
        puts I18n.t(:exception, method: __method__, class: ex.class, message: ex.message, backtrace: ex.backtrace.join("\n"))
        exit(false)
    end
end

Inifileモジュールですが、メソッドはreadのみです。後ででてくる Settingクラス(設定オブジェクト用のクラス)に Mix-in する予定なので、インスタンスメソッドとして定義しています。
iniファイルの形式は、セクション定義があると一段、入れ子の形になります。ですので、先に空の2次元ハッシュを生成しておき、1行ずつ読み込んで、キーと値の組み合わせが確定したら、ハッシュに格納していきます。

細かい処理の流れは、コード中のコメントを読んでください。

あと、例外についてですが、 2パターンについて制御しています。トラップしたら、該当のエラーメッセージを表示して、エラー終了させます。 - Errno::ENOENT : 「指定したiniファイルが見つからなかった場合」
- ArgumentError : 「iniファイルの文字コードUTF-8 以外の場合」  

想定外のエラー発生のために、念のため StandardError もトラップしてますが、たぶんココには来ないハズ。。。

I18n は、多言語対応モジュールで、今回は文言ファイルの集約化を目的に使用しています。I18n については、また別の記事で説明します。

今日はここまで・・・

【ツール作成】最終構成

前置き

読者が誰もいないので、マイペースで書いてます。ついでに【その?】もなくしました。何番まで行くか分からないので。。。

ファイルバックアップツールですが、一応、ちゃんと動作するところまではできました。 ただ、プログラムの構成に自分で納得がいかず、修正を繰り返した末、なんとなくこれでいいかなーというところまで来たので、こちらでも色々アップしてみようと思います。

プログラムの構成

最初に「動くもの」としてできたものは、処理の流れにしたがってプログラミングして、一部を関数化してるだけじゃん、くらいのものでした。これだったら ruby 使った意味ないじゃん・・・ということで。

なので、クラス/オブジェクト化できるものはまとめ、外付けで使うモジュール(I18n, logger など)もこちらで使いやすいようにラッパーもどきなモジュールにまとめたりしました。

最終的な構成は、下記のような感じになりました。

  • 実行ファイル(メイン)
    実行開始するファイルで、処理の流れに沿ってメソッドを呼び出すのみ。

  • コピー実行モジュール コピーを実際に実行する際の機能をまとめたモジュール。

  • Inifile モジュール
    iniファイルから設定を読み込むためのモジュール。元々、inifile gem を使う予定だったのですが、「¥」記号の扱いがいまいちだったので、自作しました。

  • Setting クラス iniファイルから読み込んだ設定は、Settingクラスのオブジェクトとして保持して、必要に応じて各メソッドに引き渡すようにしました。今回、iniファイルはシステム関連(system.ini)と、ディレクトリ指定用(setting.ini)に分けていて、それぞれオブジェクト化します。

  • Message モジュール 今回、I18n で文言をできるだけ集約化したい、メッセージの出力はコンソールのみ、logのみ、コンソールとlogの両方に出す、等々細かくしたい、などのことをしたかったので、I18n, logger の両方を取り込んで、Message モジュールで出力系を任せられるようにしました。(一部、結合度を上げすぎないために Messageモジュールを使わなかったところもあります。)

  • ProgressBarクラス バックアップの進捗表示を行うプログレスバーを表示するためのクラスです。バックアップ元ディレクトリ毎にオブジェクトを生成し、進捗管理と表示を行います。

続きはまた。

【Ruby】Ruby でファイルバックアップツールを作ってみる その3

処理の流れを書いてみる

そんなに大きなツールではないので、処理の流れを書いて整理してみます。

1 設定ファイルの読み込み
    # iniファイルから読み込み:コピー元ディレクトリ(複数)、コピー先ディレクトリなど
    # 設定が取れなかったらエラー

2 読み込んだ設定のチェック
    # コピー元ディレクトリが存在するか?
        → パスがないものは除外する
    # 有効なコピー元ディレクトリがない場合は、エラーで終了する。
    # コピー先ディレクトリが存在するか?
        → コピー先が有効なパスではない場合、エラーで終了する。

3 確認のための情報を表示

4 実行するかどうか、確認( yes / no )

5 コピー実行
  (コピー元ディレクトリの数だけ、以下の処理を繰り返す)
    # コピー元ディレクトリの情報(総サイズ、ファイル数、ディレクトリ数など)
    # プログレスバーを生成
    # ディレクトリ直下のファイル、ディレクトリを一覧取得
    # ディレクトリの場合
        # コピー先にディレクトリを作成し、そのディレクトリを対象にして再帰呼び出し
    # ファイルの場合
        # コピー先ディレクトリにファイルをコピー。
        # プログレスバーの更新

6 結果表示

必要な機能

処理の流れを踏まえて、必要な機能を考えてみます。

  • 設定ファイルの読み込み
  • プログレスバーの機能
  • ファイルコピー関連
    • ディレクトリのパスをチェックする
    • バックアップフォルダーを生成する
    • 確認用ディレクトリ情報を生成する
    • コピーの実行確認を行う
    • コピーの実行を行う
  • メッセージ表示機能全般(「I18n」を使って、localeファイルにまとめたい)

最後の「I18n」の機能は、本来は多言語化対応のためのものですが、勉強のために使ってみます。実装は少し面倒になりますが、固定文字列を一つのファイルにまとめることで、表記揺れや誤記のチェックが簡単になります。