はじめに
プログラミング能力を高めるため、CLI操作を学びます。
普段はGUIでの操作に慣れているため、コマンドラインは視覚的でないという理由で常に一抹の不安を感じながら使っています。また、基本的な操作しか理解していないため、CLI本来のメリットを十分に享受できていません。
そこで、コマンドライン上で動作するツールを自作し、その開発過程を通してCLI操作に慣れていこうと思います。
内容
本記事では、ファイル操作を扱うCLIツールを作成していきます。
テスト
RSpecを利用してテストコードを実装しましたが、記事が長くなるため私のruby-cli-toolsリポジトリにまとめています。
filetool
filetoolは、テキストファイルとディレクトリを操作・確認するためのCLIツールです。
以下のコマンドを備えています。
| Command | Description |
|---|---|
| show | ファイルの内容を表示 |
| stats | 行数・単語数・文字数を表示 |
| add | テキストをファイル末尾に追記 |
| search | 文字列を検索し、該当する行を表示 |
| replace | ファイル内の文字列を別の文字列に置換 |
| create | 新規ファイルを作成 |
| delete | ファイルを削除 |
| rename | ファイル名を変更 |
| mkdir | ディレクトリを作成 |
| rmdir | ディレクトリを削除 |
| ls | ディレクトリ内を一覧表示 |
| pwd | カレントディレクトリの絶対パスを表示 |
| cd | カレントディレクトリを変更 |
| seqrename | ディレクトリ内のファイルを連番でリネーム |
使用例
% filetool
Usage:
filetool show <file>
filetool stats <file>
filetool add <file> "text"
filetool search <file> "text"
filetool replace <file> "from" "to"
filetool create <file>
filetool delete <file>
filetool rename <old_name> <new_name>
filetool mkdir dirname
filetool rmdir dirname
filetool ls
filetool pwd
filetool cd <path>
filetool seqrename <prefix>
% filetool add tempfile.txt "hello world"
Added text to tempfile.txt
% filetool show tempfile.txt
hello world
% filetool stats tempfile.txt
Lines: 1
Words: 2
Chars: 12
% filetool search tempfile.txt "hello"
1: hello
% filetool search tempfile.txt "HELLO" -ic
1
% filetool replace tempfile.txt "hello" "country"
1: country world
% filetool create new_file.txt
Created new_file.txt
% filetool delete tempfile.txt
Deleted tempfile.txt
% filetool rename old_file new_file
Rename old_file to new_file
% filetool mkdir dirname
Directory created
% filetool rmdir dirname
Directory deleted
% filetool ls dirname
Directory: directory1
file1.txt
# filetool pwd
/Users/user/test/temp
# filetool cd /Users/user/test
/Users/user/test
% filetool seqrename "file"
File renamed
コード
追記を実装していく過程で大きくコードの様相が変化しました。そのため、下記のコードは初期段階のもので、完成版は私のリポジトリにあります。
require_relative "../lib/filetool.rb"
if __FILE__ == $0
command = ARGV[0]
filename = ARGV[1]
text = ARGV[2]
if command.nil? || filename.nil?
puts "Usage:"
puts " ruby filetool.rb show <file>"
puts " ruby filetool.rb stats <file>"
puts " ruby filetool.rb add <file> \"text\""
exit
end
unless File.exist?(filename)
puts "File not found: #{filename}"
exit
end
case command
when "show"
puts Filetool.show(filename)
when "stats"
result = Filetool.stats(filename)
puts "Lines: #{result[:lines]}"
puts "Words: #{result[:words]}"
puts "Chars: #{result[:chars]}"
when "add"
if text.nil?
puts "Text to add is missing"
exit
end
Filetool.add(filename, text)
puts "Added text to #{filename}"
else
puts "unknown command: #{command}"
end
end
学び1 : 「__FILE__ == $0」
__FILE__は擬似変数1で$0はグローバル変数です。
$0には、Rubyが最初に実行したプログラム(エントリーポイント)のパスが格納されます。
__FILE__は、そのプログラムの中で読み込まれるときどきのファイルが格納されます。
exit回避
RSpecテストファイルからrequire_relativeでこのファイルを読み込むと、トップレベルのコードが実行されます。その中にexitが含まれているため、RSpec実行時にもexitが呼ばれてしまいテストが途中で終了します。
このコードがあると、RSpec実行時には$0がspec/filetool_spec.rb(rspecファイル)になり、__FILE__はbin/filetoolとなるため条件式がfalseになります。
それによって、require_relative段階でのexitが実行されず、テスト終了を回避することができます。
学び2 : 「ARGV」
コマンドラインでRubyスクリプトに渡された引数は、ARGVに格納されます。
ARGV(Argument Vector)は組み込み変数で、配列形式で引数が格納されています。
module Filetool
class << self
def show(filename)
File.read(filename)
end
def stats(filename)
lines = 0
words = 0
chars = 0
File.foreach(filename) do |line|
lines += 1
words += line.split.size
chars += line.length
end
{ lines: lines, words: words, chars: chars }
end
def add(filename, text)
File.open(filename, 'a') { |f| f.puts text }
end
end
end
学び3 : 「Fileクラス」
Fileは、ファイルを読み書きするためのクラスです。入出力の基本機能を持つIOクラスを継承しています。
Fileオブジェクト
ファイルアクセスのためのメタ情報と操作をまとめたオブジェクトです。具体的には、パス・モード・ファイルポインタ・ファイルディスクリプタなどの情報を保持しています。
ファイルポインタは、ファイルのどこから読み書きするかを示すために使用します。
ファイルディスクリプタは、OSが管理するファイル番号で、指定したファイルに割り当てられます。
「openメソッド」
指定されたファイルをオープンし、Fileオブジェクトを生成して返します。
ブロックを指定して呼び出した場合は、Fileオブジェクトを引数としてブロックを実行します。ブロックの実行が終了すると、ファイルは自動的にcloseされます。
closeの役割
ファイルをopenすると、OSによってファイルディスクリプタが割り当てられ、closeするとそれが解放されます。closeし忘れるとファイルディスクリプタが解放されず、数に限りがあるため多数のファイルを扱うとエラーの原因になる可能性があります。そのため、ファイル操作後はcloseが推奨されます。
「ファイル読み込みメソッド」
ファイルを読み込むためのメソッドがいくつかあります。
read
readは、ファイル全体を一度に読み込み1つの文字列として取得します。
readlines
readlinesは、ファイルを行ごとに分割しそれを1つの要素とした配列として取得します。
File.foreach
File.foreachは、Fileクラスのクラスメソッドで、指定したファイルを1行ずつ読み込みます。ファイルのopen/closeは自動で行われます。
each_line
each_lineは、Fileオブジェクトのインスタンスメソッドで、open済みのファイルを1行ずつ読み込みます。
readとreadlinesはファイル全体をメモリに読み込むため、大きなファイルには向きません。
学び6 : 「RSpec関連」
Tempfile
Tempfileは、Rubyの標準ライブラリに含まれているクラスで、OSの一時ディレクトリ(temp)に一時的なファイルを作成します。
一時的なデータを保存したりテストデータの生成などに使用され、プログラムを終了すると自動的にクリーンナップされます。
本記事では、RSpecテストコード内で使用しました(下記コードはテストコードを一部抜粋したものです)。
引数には、作成されるファイル名のプレフィックスを指定します。
require "tempfile"
it "shows file content" do
file = Tempfile.new('test') #=> "/tmp/test20080518-6961-5fnk19-0"
file.write("hello\nworld\n")
file.close
output = `ruby bin/filetool show #{file.path}`
expect(output).to include('hello')
expect(output).to include('world')
end
バッククォート
Rubyリテラルの一種で、バッククォートで囲われた文字列をコマンドとして実行します。
返り値は、その標準出力の文字列です。
追記1 : 「PATHを通す」
現状では、% ruby bin/filetoolといったようにコマンドを実行していましたが、% filetoolだけで実行できるようにしていきます。
手順1 : 「言語指定の省略」
ファイルの先頭行にshebangを記述します。
#!/usr/bin/env ruby
shebangとは、Unix系OSのソースファイルの1行目に記述する特殊な文字列です。
役割は、そのファイルをどのインタプリタ言語で実行するのかをOSに指定することです。
手順2 : 「実行権限の付与」
ファイルに実行権限を付与します。
chmod +x bin/filetool
手順1・2の作業によってコマンド実行時に言語名(ruby)を省略することができます。
% bin/filetool
手順3 : 「PATHが通っている場所に置く」
PATHに新しいディレクトリを追加したくないのと、filetool本体をプロジェクト内に置ておきたいので、今回はPATHが通っているディレクトリにfiletool本体ではなくシンボリックリンクを作成します。
% echo $PATH | tr ':' '\n'
/usr/local/bin
/usr/bin
/bin
% ln -s ~/path/to/your/project/bin/filetool /usr/local/bin/filetool
追記2 : 「searchコマンド追加」
指定した文字列を含む行がファイル中にあるか検索し、行番号付きで表示するコマンドです。
when "search"
if arg1.nil?
puts "string to search is missing"
exit
end
puts Filetool.search(filename, arg1)
def search(filename, string)
results = []
File.foreach(filename).with_index(1) do |line, no|
if line.include?(string)
results << "#{no}: #{line}"
end
end
results
end
追記3 : 「replaceコマンド追加」
ファイル内の文字列を指定した文字列に置換し、行番号付きで表示するコマンドです。
when "replace"
if arg1.nil? || arg2.nil?
puts "string to replace is missing"
exit
end
puts Filetool.replace!(filename, arg1, arg2)
def replace!(filename, from, to)
results = []
Tempfile.create do |tmp|
File.foreach(filename).with_index(1) do |line, no|
if line.include?(from)
replaced = line.gsub(from, to)
results << "#{no}: #{replaced.chomp}"
tmp.write(replaced)
else
tmp.write(line)
end
end
tmp.flush
File.write(filename, File.read(tmp.path))
end
results
end
「Tempfile.create」
Tempfile.createは、Tempfile.openと同様にテンポラリファイルを生成します。
各メソッドの違いは以下です。
ブロックなし
メソッドにブロックを指定しない場合、openはテンポラリファイルを自動的にcloseしません。また、プログラム終了後テンポラリファイルは自動的に削除されます。
一方、createでは自動的にcloseされませんし、プログラム終了後に自動的に削除もされません。
ブロックあり
ブロックを指定した場合、openではブロック処理の後で自動的にcloseされます。また、プログラム終了後に自動的に削除されます。
一方、createではブロック処理後にcloseと削除が自動で行われます。
追記4 : 「例外処理」
CLI側に例外処理を実装しました。
begin
case command
# ...(省略)
rescue Errno::ENOENT
puts "File not found: #{filename}"
exit 1
rescue Errno::EACCES
puts "Permission denied: #{filename}"
exit 1
rescue => e
puts "Unexpected error: #{e.message}"
exit 1
end
rubyでの例外処理は、begin・rescue節を使用して記述します。
begin節の実行中に例外オブジェクトが投げられると、rescue節で指定した例外クラスと例外オブジェクトの一致判定が行われ、一致すればrescue節の処理が実行されます。
rescue節でエラータイプ(例外クラス)を省略すると、StandardErrorクラスとそのサブクラスである全ての例外を捕捉します。
また、=> eのように変数を指定することもでき、投げられた例外オブジェクトが格納されます。この変数は、発生した例外オブジェクトが格納される$!と同じ内容を参照しています。
終了ステータス
今回の例外処理と、既存のエラー回避コードに終了ステータスを実装しました。
これによって、コマンドの成功と失敗を明示的に区分できるようになり、CLIとして正しい振る舞いになりました。
追記5 : 「createコマンド追加」
新規ファイルを作成するコマンドです。
when "create"
Filetool.create(filename)
puts "Created #{filename}"
end
def create(filename)
if File.exist?(filename)
raise "File already exists"
end
File.write(filename, "")
end
File.writeはFileクラスのクラスメソッドで、内部で"w"モードでファイルを開き、書き込み後に自動でcloseします。
追記6 : 「deleteコマンド追加」
ファイルを削除するコマンドです。
when "delete"
filename = ARGV.shift or abort("File is missing")
Filetool.delete(filename)
puts "Deleted #{filename}"
def delete(filename)
raise "File not found" unless File.exist?(filename)
File.delete(filename)
end
追記7 : 「コマンドオプション追加」
searchコマンドに-i(大文字小文字を無視)や-c(一致件数のみ表示)などのオプションを指定できるようにしました。
require "optparse"
options = {}
opt = OptionParser.new do |opts|
opts.on("-i", "--ignore-case", "Ignore case") do
options[:ignore_case] = true
end
opts.on("-c", "--count", "Count matched only") do
options[:count] = true
end
end
opt.parse!
command = ARGV.shift
filename = ARGV.shift
arg1 = ARGV.shift
arg2 = ARGV.shift
when "search"
if arg1.nil?
puts "string to search is missing"
exit 1
end
puts Filetool.search(filename, arg1, options)
def search(filename, string, options = {})
results = []
pattern = options[:ignore_case] ? /#{Regexp.escape(string)}/i : string
File.foreach(filename).with_index(1) do |line, no|
if line.match?(pattern)
if options[:count]
results << no
else
results << "#{no}: #{line}"
end
end
end
if options[:count]
return results.size
end
results
end
「OptionParser」
OptionParserクラスは、コマンドラインのオプションを扱うクラスです。
まず、OptionParserオブジェクトにブロックを渡し、その中でonメソッドを使ってオプションを定義します。最後にparse!メソッドを呼ぶと、ARGVからオプションを解析し対応する処理を実行します。
「Regexp」
正規表現のクラスです。
文字列のパターンマッチや検索・置換などに使用します。
追記8 : 「renameコマンド追加」
ファイル名を変更するコマンドです。
def rename(old_name, new_name)
raise "File not found #{old_name}" unless File.exist?(old_name)
raise "File already exist: #{new_name}" if File.exist?(new_name)
File.rename(old_name, new_name)
end
when "rename"
Filetool.rename(filename, arg1)
puts "Renamed #{filename} to #{arg1}"
追記9 : 「mkdirコマンド追加」
ディレクトリを作成するコマンドです。
def mkdir(dirname)
raise "Directory exist: #{dirname}" if Dir.exist?(dirname)
Dir.mkdir(dirname)
end
when "rename"
Filetool.rename(filename, arg1)
puts "Renamed #{filename} to #{arg1}"
「Dirクラス」
ディレクトリの操作はDirクラスを使って行います。
「tmpdir」
テンポラリディレクトリを扱うための標準ライブラリです。
mkdirコマンドのテストを記述する際に使用しました。
mktmpdirは、一時ディレクトリを作成します。
ブロックを指定した場合、ブロックを抜けると自動的にディレクトリは削除されます。
ブロックを指定しない場合はディレクトリのパスを返しますが、プログラム終了時にも自動で削除されることはありません。
追記10 : 「rmdirコマンド追加」
ディレクトリを削除するコマンドです。
def rmdir(dirname)
raise "No such directory: #{dirname}" unless Dir.exist?(dirname)
raise "Directory is not empty: #{dirname}" unless Dir.empty?(dirname)
Dir.rmdir(dirname)
end
when "rmdir"
Filetool.rmdir(arg0)
puts "Directory deleted"
追記11 : 「lsコマンド追加」
ディレクトリ内を一覧表示するコマンドです。
def ls(dirname)
raise "No such directory: #{dirname}" unless Dir.exist?(dirname)
Dir.children(dirname).map do |name|
path = File.join(dirname, name)
format("%-12s%s", File.directory?(path) ? "Directory:" : "", name)
end
end
when "ls"
puts Filetool.ls(arg0)
追記12 : 「pwd、cdコマンド追加」
pwdコマンドは、カレントディレクトリの絶対パスを表示するコマンドです。
def pwd
Dir.pwd
end
when "pwd"
puts Filetool.pwd
cdコマンドは、カレントディレクトリを変更するコマンドです。
def cd(path)
Dir.chdir(path)
Dir.pwd
end
when "cd"
Filetool.cd(arg0)
追記13 : 「seqrenameコマンド追加」
def seqrename(prefix = "file")
count = 1
files = Dir.glob("*")
.select { |f| File.file?(f) }
.reject { |f| f.match?(/^#{Regexp.escape(prefix)}_\d{3}/) }
.sort
files.each do |file|
ext = File.extname(file)
loop do
new_name = "#{prefix}_#{format("%03d", count)}#{ext}"
if File.exist?(new_name)
count += 1
next
end
File.rename(file, new_name)
count += 1
break
end
end
end
when "seqrename"
prefix = ARGV.shift or abort("File is missing")
Filetool.seqrename(prefix)
puts "File renamed"
追記14 : 「TOCTTOU問題」
TOCTTOUとは、ファイルの安全性をチェックした時点と実際にファイルを使用する時点の間に時間差があり、その間に対象ファイルが変更されることで不正アクセスやデータ改ざんが発生する問題です。
いくつかのfiletoolコマンドにこの問題が存在していたため修正しました。
create
def create(filename)
File.open(filename, "wx") {}
rescue Errno::EEXIST
raise "File already exists: #{filename}"
end
File.openの第2引数で、文字列("r", "w", "a")か整数(Fileモジュール定数)でファイルのオープンモードを指定します。
"x"は排他的作成モードを表し、ファイルがすでに存在する場合はErrno::EEXISTが発生します。"wx"は「書き込み+排他的作成」を意味し、整数指定ではFile::CREATE | File::EXCL | File::WRONLYと同等の動作になります。
delete
def delete(filename)
File.delete(filename)
rescue Errno::ENOENT
raise "File not found: #{filename}"
end
rename
def rename(old_name, new_name)
File.link(old_name, new_name)
File.unlink(old_name)
rescue Errno::ENOENT
raise "File not found: #{old_name}"
rescue Errno::EEXIST
raise "File already exist: #{new_name}"
end
renameメソッドでは、new_nameの存在チェック時のTOCTTOU問題を避けるためにFile.renameではなく、File.linkと File.unlinkを組み合わせて実装しています。
File.link(old_name, new_name)は新しいファイルが既に存在する場合に Errno::EEXISTを発生させるため、別プロセスが途中で同名ファイルを作成しても競合を検出できます。
replace
def replace!(filename, from, to)
results = []
Tempfile.create(File.basename(filename), File.dirname(filename)) do |tmp|
File.foreach(filename).with_index(1) do |line, no|
replaced = line.gsub(from, to)
if replaced != line
results << "#{no}: #{replaced.chomp}"
end
tmp.write(replaced)
end
tmp.flush
tmp.fsync
File.rename(tmp.path, filename)
end
results
rescue Errno::ENOENT
raise "File not found: #{filename}"
end
flashはRubyのユーザ空間バッファの内容をOSのバッファへ書き出します。
fsyncはOSのカーネルバッファの内容を物理ストレージへ強制的に同期します。
File.write(filename, File.read(tmp.path))だと、tmpファイルを読み込むこととfilenameに上書きすることのラグがあり、別プロセスがその間にfimenameを書き換えてしまう可能性がありました。renameに変更することで1つの処理で済むため、途中状態が存在せずこの問題を防げます。
seqrename
def seqrename(prefix = "file")
count = 1
files = Dir.glob("*")
.select { |f| File.file?(f) }
.reject { |f| f.match?(/^#{Regexp.escape(prefix)}_\d{3}/) }
.sort
files.each do |file|
ext = File.extname(file)
begin
new_name = "#{prefix}_#{format("%03d", count)}#{ext}"
File.link(file, new_name)
File.unlink(file)
count += 1
rescue Errno::EEXIST
count += 1
retry
end
end
end
mkdir
def mkdir(dirname)
Dir.mkdir(dirname)
rescue Errno::EEXIST
raise "Directory exist: #{dirname}"
end
rmdir
def rmdir(dirname)
Dir.rmdir(dirname)
rescue Errno::ENOENT
raise "Directory not found: #{dirname}"
rescue Errno::ENOTEMPTY
raise "Directory is not empty: #{dirname}"
end
おわりに
コマンドライン上でファイル操作を行うツールを作成しました。基本的な機能ではありますが、FileクラスやRSpecのTempfileクラスを使うことでファイルの扱いを学ぶことができました。
今後も本記事で学んだ内容を活かしながら、別のCLIツールを作成していきたいと思います。
-
Rubyが用意している識別子で、変数のように参照できるが代入できない。いくつか種類があり、
__FILE__にはそのコードが書かれているファイルのパスが入る。 ↩