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

  • 2
    いいね
  • 4
    コメント
  • '16/07/14: @from_kyushu さんの助言に従って、mlsdを使用するよう変更しました。

コマンドラインから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オプションでサイズが違ったらアップロードできるようにしました