FTPにサイズが異なるファイルを再帰的にアップロードする

2016-07-13

コマンドラインからFTPにファイルを再帰的にアップロードしたかったのだけど、ftpコマンドやRubyのNet::FTPはファイル1つをコピーとか、低レベルの操作しか用意されてない。

そこでNet::FTPを拡張して、再帰的にコピーするメソッドを追加したクラスを作ってみた:

# ftpext.rb
require 'net/ftp'

module Net
class FTPExt < FTP
UPDATED = lambda {|path, entry|
mtime = File.mtime(path)
return mtime > entry.modify
}
SIZE_CHANGED = lambda {|path, entry|
size = File.size?(path)
return size != entry.size
}

def copy_r(src, dest, pred=nil, &progress_callback)
# Confirm remote root directory exists.
begin
remote_root = mlst(dest)
rescue Net::FTPPermError => e
mkdir(dest)
end
return do_copy_r(src, dest, pred, &progress_callback)
end

private

def do_copy_r(src, dest, pred=nil, &progress_callback)
count = 0
chdir(dest)

remote_entries = get_cwd_entries()
subdirs = []

# Process files.
Dir.entries(src).each do |fn|
next if fn =~ /^\.\.?$/ # Exclude '.' and '..'
path = "#{src}/#{fn}"
if File.directory?(path)
subdirs << fn
next
end

progress_callback.call(path) if progress_callback
if !remote_entries.include?(fn) || !pred || pred.call(path, remote_entries[fn])
put(path, "#{dest}/#{fn}")
count += 1
end
end

# Process sub directories.
subdirs.each do |dn|
path = "#{src}/#{dn}"
dest_path = "#{dest}/#{dn}"
mkdir(dest_path) if !remote_entries.include?(dn)
count += do_copy_r(path, dest_path, pred, &progress_callback)
end

return count
end

def get_cwd_entries()
entries = {}
mlsd() do |entry|
entries[entry.pathname] = entry
end
return entries
end
end
end

これを使って、コマンドラインからコピー元のローカルディレクトリと転送先のディレクトリを指定するとコピーするスクリプト:

# ftp-cp-r.rb
require 'optparse'
require './ftpext.rb'

def main
params = ARGV.getopts('u', 'host:', 'user:', 'password:', 'port:', 'update', 'size')
if !params['host'] || !params['user'] || !params['password']
$stderr.puts 'All parameters are required: host, user, password'
exit 1
end
params['update'] ||= params['u']

if ARGV.size != 2
$stderr.puts "2 parameters required: [src] [dest]"
exit 1
end
src = ARGV.shift
dest = ARGV.shift
port = params['port'] || Net::FTP::FTP_PORT

pred = lambda {|path, entry|
if params['update']
return false unless Net::FTPExt::UPDATED.call(path, entry)
end
if params['size']
return false unless Net::FTPExt::SIZE_CHANGED.call(path, entry)
end
return true
}

begin
ftp = Net::FTPExt.new
ftp.connect(params['host'], port)
ftp.login(params['user'], params['password'])
ftp.binary = true

count = ftp.copy_r(src, dest, pred) do |path|
$stderr.print "\r#{path} "
end
$stderr.puts "\nDone: \##{count}"
ensure
ftp.quit if ftp
end
end

main

これを使って、

$ ruby ftp-cp-r.rb --host=ホスト名 --user=ユーザ名 --password=パスワード "ローカルディレクトリ" "リモートディレクトリ"

で再帰的にコピーできる。

  • lsで取得できるFTPのディレクトリに含まれるファイル一覧の結果はサーバ次第?で、テストしたサーバではファイルの日付は取れるが時刻を取得する方法がわからなかった
    • ls -Rだと時刻も返ってくるが、カレントディレクトリだけじゃなくサーバに含まれるファイル全体が返ってきてしまった
    • 6ヶ月未満だと年が含まれない、とかいう話も
    • ディレクトリのファイル一覧の取得はmlsdで変更時刻など詳しい内容が取得できる
  • 本来なら「日付が新しかったらコピー」という条件を選べるといいと思うが、上記の件もありひとまずサイズが違ったらで
    • --updateコマンドラインオプションでローカルファイルのほうが新しかったら、--sizeオプションでサイズが違ったらアップロードできるようにしました