【Ruby2.6】__FILE__ について考える。
前置き
Ruby の擬似変数__FILE__
について、色々考えます。
そもそも、__FILE__
って何?
__FILE__
は、「擬似変数」と呼ばれるものの一種です。Webで公開されているリファレンスマニュアルによると・・・
__FILE__
現在のソースファイル名
フルパスとは限らないため、フルパスが必要な場合はFile.expand_path(__FILE__)
とする必要があります。
となっています。実際、コード上で p __FILE__
で出力してみると、ファイル名のみだったり、フルパスだったり、中途半端に相対パスだったりと、さまざまな値が返ってきます。。。
Windows 環境のRubyで、以下のように確認してみました。
テスト用のファイル「C:\gyobo\test\a.rb」を作ります。内容は以下の一文のみ。
p __FILE__
これを、色々な方法で実行してみました。
C:\gyobo\test> ruby a.rb # => "a.rb" C:\gyobo> ruby test\a.rb # => "test\a.rb" C:\gyobo> ruby C:\gyobo\test\a.rb # => "C:\gyobo\test\a.rb" C:\gyobo> ruby c:\gyobo\test\a.rb # => "c:\gyobo\test\a.rb" あれ? C:\gyobo> ruby ../gyobo\test\..\test\a.rb # => "../gyobo/test/../test/a.rb" そういうこと?
4行目、たまたまドライブ文字を小文字にしてみたら、__FILE__
の中身も小文字になったので、まさか・・・と思って5行目でやんちゃなことをしてみたのですが、、、
結局のところ、ruby コマンドで渡された実行ファイルへのパス、引数に指定したパスの文字列がそのまんま、__FILE__
に入るということのようです。
require
や load
で呼び出した場合
Ruby を実行開始したファイルではなく、require
や load
で呼び出した先では、__FILE__
の値はどうなるか?
先ほどの「a.rb」と同じディレクトリに、「req.rb」というファイルを作り、下の一行だけ書きます。
require './a.rb'
実行すると・・・
C:\gyobo\test>ruby req.rb "C:/gyobo/test/a.rb"
相対パスで指定していますが、__FILE__
には絶対パスが格納されています。これは、require
だけでなく、require_relative
や load
で読み込んでも同じです。また、カレントディレクトリを変更して実行した場合も同様*1。require
で指定する相対パスで探索し、見つかったらその絶対パスが入ってくるようです。
__FILE__
で得られるもの
まとめると、以下の通りのようです。
__FILE__
には、そのファイルを呼び出した際のパス文字列が格納される。
ポイントは、「文字列」である、ということです。
__FILE__.class # => String
ファイル/ディレクトリオブジェクトではないので、格納されている文字列以上の情報は入っていません。上記1 の場合、__FILE__
の中身は相対パスの文字列でしかないので、__FILE__
だけでは、絶対パスを生成することはできません。ですので、絶対パスを取得する場合は、最初の引用の中にある通り、
フルパスとは限らないため、フルパスが必要な場合は
File.expand_path(__FILE__)
とする必要があります。
カレントディレクトリを基準に、絶対パスに展開できる File.expand_path
等を使って生成する必要があるわけです。
続きます。
【ツール作成】メインの流れ
前置き
最初に実行するメインフローの部分です。他の部分はできているので、順にメソッドを呼び出すだけになっています。
実装コード
最初に、必要なモジュール、クラスをrequire
で読み込みます。
唯一、system.iniファイルだけは、固定でパスを与えています。(実行ファイルと同じ場所に置きます。)
後は、流れにしたがって、メソッドを呼んでいくだけになっています。
require './modules/Message' require './modules/Setting' require './modules/Utility' require './modules/ProgressBar' SYSTEM_INI = "./system.ini".freeze # システム設定読み込み sys = SystemSetting.new(SYSTEM_INI) # メッセージクラスの初期設定 Message.init(sys) # ディレクトリ情報の取得 dirs = DirectorySetting.new(sys.setting_path) # 情報表示と実行確認 BackupUtils.show_dir_info(dirs) BackupUtils.show_confirmation # バックアップ実行 BackupUtils.execute_copy(dirs)
【ツール作成】ファイルコピーを実際に行うモジュール
前置き
ファイルのコピーや、ディレクトリの作成を行うモジュール群です。
実装コード
全体のモジュール名は、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モジュールとしてまとめた理由としては、、、
- コンソールだけに出力したいメッセージ、例外の詳細など、ログだけに出力したいメッセージ、両方に出力したいメッセージがあって、それぞれ一回の呼び出しでできるようにしたかった。
I18n
やlogger
の使用が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」クラスを定義、異なる部分を実装する構成としました。
青字は、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ファイルのエンコードは 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
については、また別の記事で説明します。
今日はここまで・・・