使用 Perl 同步 FTP 文件

作者:Luis E. Muñoz

最初,这篇文章存储在一个极简主义的网站上。我和成千上万使用该网站的用户一样,使用 FTP 来维护内容。这曾经意味着,在对网站上的一个或多个页面进行更改后,我必须转到我的 FTP 客户端并上传更改后的文件。

这就是 Perl 的懒惰性发挥作用的地方。我想要一个脚本,它可以自动找出需要从我的本地网站副本上传的内容,以及需要删除的内容。我将这个脚本称为 ftpsync,并在此展示它。按照惯例,您可以下载完整的脚本

脚本的开始

在开始时,我调用 strict warnings 来帮助我捕获代码中的错误。您应该始终在您的脚本中这样做,因为错误和警告最终会节省大量的调试时间,例如,如果您在 5,000 行代码中的某个地方拼错了变量名。不要惊慌,这个脚本要短得多。

调用 Net::FTP 是为了为 FTP 客户端提供此脚本所需的功能。主要是,客户端需要遍历 FTP 服务器中的文件和目录层次结构。为了在我的本地磁盘上做同样的事情,使用了 File::Find。File::Find 使编写遍历文件系统的代码变得轻而易举。我稍后会讨论如何使用它。

为了轻松地为未来的用户生成脚本的文档,调用了 Pod::Usage。这个模块转换并显示脚本中的 POD 文档。您应该始终为您的脚本编写 POD 文档。然后我导入 Getopt::Std 来解析命令行选项。

                1   #!/usr/bin/perl
                2
                3   # This script is (c) 2002 Luis E. Muñoz, All Rights Reserved
                4   # This code can be used under the same terms as Perl itself.
                5   # with absolutely NO WARRANTY. Use at your own risk.
                6
                7   use strict;
                8   use warnings;
                9   use Net::FTP;
               10   use File::Find;
               11   use Pod::Usage;
               12   use Getopt::Std;

在第 14 行,我告诉 Perl 变量,Getopt::Std 将在其中存储命令行选项。这个模块实际上有两种返回命令行信息的方式:一个哈希和一组单独的变量,这些变量以它们代表的选项命名。我倾向于喜欢后一种方法,因为它更容易发现变量被使用的地方。但这主要是个人品味问题。

               14   use vars qw($opt_s $opt_k $opt_u $opt_l $opt_p $opt_r $opt_h
               15               $opt_d $opt_P $opt_i $opt_o);
               16
               17   getopts('i:o:l:s:u:p:r:hkvdP');

第 17 行对 getopts() 的调用实际上告诉 Getopt::Std 要查找哪些选项,以及这些选项是否需要参数。在一个选项字母后跟一个冒号字符 (:) 告诉模块为相应的选项接受一个参数。

如果我想将解析命令行选项的结果保留在一个哈希中,而不是大量的 $opt_ 变量,我可以使用以下代码

               14   use vars qw(%my_opts);
               15
               16   getopts('i:o:l:s:u:p:r:hkvdP', \%my_opts);
用法消息和默认值

在我的脚本中,我保留 -h 选项来提供某种形式的帮助,这与许多其他 Perl 程序员的习惯相同。第 19 行到第 23 行的 if 代码块安排在命令行中指定 -h 选项时调用带有适当参数的 pod2usage。

               19   if ($opt_h)
               20   {
               21       pod2usage({-exitval => 2,
               22                  -verbose => 2});
               23   }

pod2usage 由 Pod::Usage 定义,如第 21 行所示,它使脚本在转储完整文档后终止,并向操作系统返回值 2。您也可以让脚本仅转储其 SYNOPSIS 部分,其中包含自定义消息以及文档和各种其他内容。请查阅 Pod::usage 的文档以获取完整信息。

               25   $opt_s ||= 'localhost';
               26   $opt_u ||= 'anonymous';
               27   $opt_p ||= 'someuser@';
               28   $opt_r ||= '/';
               29   $opt_l ||= '.';
               30   $opt_o ||= 0;
               31
               32   $opt_i = qr/$opt_i/ if $opt_i;

第 25 行到第 30 行为用户未指定的命令行选项定义了一些合理的默认值。我使用 ||= 运算符简洁地完成了此操作。此运算符评估其左侧值或左值。假值会导致将其右侧值或右值分配给左值。它的工作方式如下:如果未指定命令行选项,则其对应的 $opt_ 变量将为 undef,它评估为假。因此,它被设置为 ||= 运算符右侧的任何内容。如果指定了命令行选项,则 $opt_ 将评估为真,并且其值保持不变。

在第 32 行,我要求 Perl 编译作为 -i 参数的任何内容(存储在 $opt_i 中)作为正则表达式或规则,正如它们开始被称呼的那样。这是通过 qr// 运算符完成的。您可以通过查看 perlop 的文档来了解有关此运算符的更多信息。

关于原理的简要讨论

为了执行同步,脚本从两个文件系统树收集信息,一个本地树和一个远程树。我选择从两个树收集所有信息,然后在稍后执行同步。根据我的经验,这比我尝试一次完成所有操作的代码更简洁、更易于维护。我将把关于远程树和本地树的数据存储在两个哈希中,我在第 36 行和第 37 行声明并初始化了它们。

               36   my %rem = ();
               37   my %loc = ();

一旦信息安全地捕获到相应的哈希中,代码就可以专注于差异并采取适当的措施。

查找本地文件

在匹配远程 FTP 站点的内容和本地副本上的内容时,重要的是将苹果与苹果进行比较。由于文件系统布局并不总是那么直接,我选择比较相对路径名。因此,在查找本地文件之前,我执行 chdir() 到用户使用 -l 选项指定的路径,如第 44 行所示。

               44   chdir $opt_l or die "Cannot change dir to $opt_l:   $!\n";

在此步骤之后,我使用 File::Find 提供的 find() 来遍历本地树。以下是第 46 行到第 69 行的代码。我将逐位解释这段代码,我保证。

               46   find(
               47        {
               48            no_chdir       => 1,
               49            follow         => 0,   # No symlinks, please
               50            wanted         => sub
               51            {
               52                return if $File::Find::name eq '.';
               53                $File::Find::name =~ s!^\./!!;
               54                if ($opt_i and $File::Find::name =~ m/$opt_i/)
               55                {
               56                    print "local: IGNORING $File::Find::name\n"
               57                    return;
               58                }
               59                my $r = $loc{$File::Find::name} =
               60                {
               61                    mdtm => (stat($File::Find::name))[9],
               62                    size => (stat(_))[7],
               63                    type => -f _ ? 'f' : -d _ ? 'd'
               64                        : -l $File::Find::name ? 'l' : '?',
               65                };
               66                print "local: adding $File::Find::name (",
               67                "$r->{mdtm}, $r->{size}, $r->{type})\n" if $opt
               68            },
               69        }, '.' );

在第 48 行,我告诉 find() 我不希望脚本使用 no_chdir 参数 chdir() 进入沿途的每个目录。我希望绝对确定我看到的是与用户使用 -l 选项指定的路径一致且相对的路径名。

在第 49 行,我使用 follow 参数阻止跟随符号链接。我不想处理它们,主要是因为当它们向上指向文件系统时,它们可能会引入无限循环。我的网站中不使用符号链接,因此我认为这没有问题。

在第 50 行,终于有一些有趣的代码了。find() 接受一个用户提供的函数,该函数将为找到的每个文件系统对象调用。我使用 wanted 参数指定它,尽管有多种方法可以调用此函数。请参阅 perldoc File::Find 以获取更多信息。

wanted 参数需要对 sub 的引用,我在第 50 行到第 68 行中定义了它。第 52 行确保我不将当前目录 (.) 包含在集合中。find() 在 $File::Find::name 标量中传递每个文件系统对象的完整路径名。

find() 函数,当按所示方式使用时,生成相对路径名,例如 ./dir/foo.htm。但是,我认为使用诸如 dir/foo.htm 之类的名称更容易。两者都是相对路径的合法形式。为了实现这一点,第 53 行从所有路径名中删除前导 ./。

完成此操作后,在第 54 行到第 58 行执行检查,以查看当前路径名是否与 -i 指定的忽略正则表达式匹配。这是一个简单的模式匹配问题,如果检测到匹配,则退出 sub { .. },并在指定 -v 选项时提供信息性消息。

到第 59 行,所有测试都已通过,因此我们应该从该文件收集信息。我将收集三个信息元素:修改时间或 mdtm、文件大小和文件类型。前两个是使用 stat() 调用收集的,它返回一个值列表。请注意,我在第 62 行中使用了特殊的 stat(_) 构造。

事实证明,stat() 是一个有点昂贵的操作,因此最好尽可能少地执行它们。当 Perl 将裸 _作为将导致调用 stat() 的函数或运算符的参数时,它会使用上次执行的结果。因此,即使 Perl 的 stat() 函数多次使用,上述构造也仅导致单个 stat() 调用。这同样适用于我在第 63 行和第 64 行中使用的 -x 文件运算符,以为此文件分配类型。

所有这些信息,都保存在对匿名哈希的引用中,都存储在 %loc 中。此外,最后一个条目的副本保存在 $r 词法标量中,以便在指定 -v 选项时在第 66 行和第 67 行中提供信息性消息。因此,当 find() 为文件系统中找到的每个对象调用此 sub 时,%loc 将填充整个树的数据。

连接到 FTP 站点

我选择在收集本地文件信息后连接到 FTP 站点,原因有很多。第一个也是最重要的原因是,作为经验法则,每当您编写网络客户端代码时,请尽量减少对服务器的影响。通过这种方式,我节省了收集本地信息的时间的连接。

               74   my $ftp = new Net::FTP ($opt_s,
               75                           Debug           => $opt_d,
               76                           Passive         => $opt_P,
               77                           );
               78
               79   die "Failed to connect to server '$opt_s': $!\n" unless $ftp
               80   die "Failed to login as $opt_u\n" unless $ftp->login($opt_u,
               81   die "Cannot change directory to $opt_r\n" unless $ftp->cwd($
               82   warn "Failed to set binary mode\n" unless $ftp->binary();
               83
               84   print "connected\n" if $opt_v;

第 74 行到第 77 行处理与服务器的连接,如果连接失败,则触发第 79 行的错误消息。在第 80 行,尝试使用命令行中传递的凭据进行身份验证,如果失败,则再次产生致命错误。可以通过将参数传递给 new() 方法来控制 FTP 连接的许多参数。最终信息在模块的文档中。

在第 81 行,我们将远程目录更改为用户提供的任何目录。最后,在第 82 行,我们为任何后续传输请求二进制模式。如果在命令行中提供了 -v 选项,则第 84 行会打印进度消息。

查找远程文件

通过 FTP 从远程文件收集相同的数据是一项更困难的任务,因为通过 FTP 没有等效于 stat() 调用的方法。因此,我求助于解析目录列表的结果。我选择支持 UNIX 样式的目录列表,因为它非常简洁,而且因为它是我 FTP 服务器使用的列表。

基本上,第 86 行到第 132 行的代码模拟了 File::Find 所做的工作,File::Find 先前通过递归函数 scan_ftp 显示。此函数接受 Net::FTP 对象、要使用的路径名以及对 %rem 的引用以注册相关信息。我将在下面评论代码的每个块。

               86   sub scan_ftp
               87   {
               88       my $ftp     = shift;
               89       my $path    = shift;
               90       my $rrem    = shift;
               91
               92       my $rdir = $ftp->dir($path);
               93
               94       return unless $rdir and @$rdir;
               95
               96       for my $f (@$rdir)
               97       {
               98           next if $f =~ m/^d.+\s\.\.?$/;
               99
              100           my $n = (split(/\s+/, $f, 9))[8];
              101           next unless defined $n;
              102
              103           my $name = '';
              104           $name = $path . '/' if $path;
              105           $name .= $n;
              106
              107           if ($opt_i and $name =~ m/$opt_i/)
              108           {
              109               print "ftp: IGNORING $name\n" if $opt_d;
              110               next;
              111           }
              112
              113           next if exists $rrem->{$name};
              114
              115           my $mdtm = ($ftp->mdtm($name) || 0) + $opt_o;
              116           my $size = $ftp->size($name) || 0;
              117           my $type = substr($f, 0, 1);
              118
              119           $type =~ s/-/f/;
              120
              121           warn "ftp: adding $name ($mdtm, $size, $type)\n" if 
              122
              123           $rrem->{$name} =
              124           {
              125               mdtm => $mdtm,
              126               size => $size,
              127               type => $type,
              128           };
              129
              130           scan_ftp($ftp, $name, $rrem) if $type eq 'd';
              131       }
              132   }
              133
              134   scan_ftp($ftp, '', \%rem);

在第 92 行,我请求分配的 $path 的目录列表,该列表作为对 $rdir 中列表的引用存储。@$rdir 中的每个条目都包含来自 FTP 服务器的一行输出。在第 94 行,如果我们得到一个空或无效的答案,我们会提前放弃。

第 96 行到第 132 行之间是循环,在该循环中分析命令输出的每一行。第 98 行的测试确保不会浪费时间分析当前 (.) 和父 (..) 目录。稍后,在第 100 行,尝试获取文件名,通常在输出的第九列。如果找不到名称,则在第 101 行跳过该条目。如您所见,第 100 行的拆分限制了返回的列数。我这样做是为了以防万一有一天我需要解析符号链接,许多服务器将符号链接报告为 Foo -> Bar。

在第 103 行到第 105 行中,我使用传递给此函数的 $path 以及最近解析的目录条目构造完整的路径名。从第 107 行开始的 if 代码块检查是否应忽略某个条目。如果是,则第 109 行和第 110 行打印合适的消息,然后跳到下一个条目。

第 113 行检查我们是否已经看到过此条目。如果远程 FTP 树有我无法检测到的循环,则可能会发生这种情况。我使用 exists 构造作为一种习惯,以避免在 %rem 哈希中自动生成条目,尽管在这种情况下可以删除它。

第 115 行到第 117 行负责捕获在此阶段找到的每个项目的相关信息。我们使用 MDTM FTP 命令来获取修改时间,使用 SIZE 命令来查找文件的大小(以八位字节为单位),最后,使用目录列表的第一个字母来猜测类型。Net::FTP 的 MDTM 方法自动将结果日期转换为自 Epoch UTC 以来的秒数,与其他时间相关函数(如 time() 和 stat() 返回的值)相同。

但是,在这里,我允许 -o 参数,即时间偏移量,添加到结果中。这允许轻松校正时间偏差。事实证明,它对于补偿时区差异也很有用,因为有时 FTP 服务器返回的时间不是 UTC 时间。

我可能应该仅对文件而不是目录或任何其他类型的对象执行 MDTM。但我选择保持原样,因为我想使用各种 FTP 服务器进行检查,以查看它们是否为目录和其他对象返回有意义的时间戳。无论如何,可以重写此代码以避免下面看到的无用的 MDTM。

              115           my $type = substr($f, 0, 1);
              116           my $mdtm = ($type eq 'f' ? $ftp->mdtm($name) || 0 : 
              117           my $size = $ftp->size($name) || 0;

第 123 行到第 128 行存储有关此条目的收集信息。在第 130 行,如果当前条目是目录,则进行递归调用。在第 134 行,我开始递归。

同步

一旦所有数据都已很好地收集到 %loc 和 %rem 中,剩下的就是处理差异。我使用的简单同步仅上传本地文件中远程端缺少或过旧的文件,然后删除本地端不存在的远程文件。以下代码负责上传。

              138   for my $l (sort { length($a) <=> length($b) } keys %loc)
              139   {
              140       warn "Symbolic link $l not supported\n"
              141           if $loc{$l}->{type} eq 'l';
              142
              143       if ($loc{$l}->{type} eq 'd')
              144       {
              145           next if exists $rem{$l};
              146           print "$l dir missing in the FTP repository\n" if $o
              147           $opt_k ? print "MKDIR $l\n" : $ftp->mkdir($l)
              148               or die "Failed to MKDIR $l\n";
              149       }
              150       else
              151       {
              152           next if exists $rem{$l} and $rem{$l}->{mdtm} >= $loc
              153           print "$l file missing or older in the FTP repositor
              154               if $opt_v;
              155           $opt_k ? print "PUT $l $l\n" : $ftp->put($l, $l)
              156               or die "Failed to PUT $l\n";
              157       }
              158   }

第 138 行的循环遍历所有必须匹配的本地文件。我从最短路径名到最长路径名执行此操作,以便我们可以按顺序创建任何所需的目录。第 140 行和第 141 行产生适当的警告并跳过可能已找到的任何符号链接。

第 143 行到第 157 行的代码值得解释一下。脚本期望看到两种不同的文件系统对象类,文件和目录。对于目录,在第 143 行到第 149 行中处理,任何具有相同名称的远程对象都会导致跳过该条目。否则,在第 146 行和第 147 行中生成适当的消息,并在需要时创建远程目录。如果发生故障,脚本会在第 148 行简单地 die() 以避免造成更多麻烦。

如果本地对象不是目录,则跳过条件还指出远程对象较旧。此检查在第 151 行完成。然后,从第 152 行到第 157 行执行类似的代码,使用 Net::FTP 的 put() 方法上传丢失的文件。整个过程为每个本地对象重复执行。

其他测试集也是可能的,但我选择将其排除在外。例如,本地文件可能具有与远程目录相同的名称。在这种情况下,我不清楚该怎么做:删除远程目录并上传本地文件?;die()? 我将把这个决定作为留给读者的练习。

接下来,分析远程文件。在这种情况下,唯一可能的措施是删除远程文件(如果其本地副本不存在)。第 162 行开始的循环确保文件以与前一个代码片段的循环完全相反的顺序扫描。这允许 Net::FTP 模块的 delete() 方法发出的 DELE FTP 命令自动删除所有文件和空目录。

              162   for my $r (sort { length($b) <=> length($a) } keys %rem)
              163   {
              164       if ($rem{$r}->{type} eq 'l')
              165       {
              166           warn "Symbolic link $r not supported\n";
              167           next;
              168       }
              169
              170       next if exists $loc{$r};
              171
              172       print "$r file missing locally\n" if $opt_v;
              173       $opt_k ? print "DELETE $r\n" : $ftp->delete($r)
              174           or die "Failed to DELETE $r\n";
              175   }

与本地文件一样,第 164 行到第 168 行为符号链接发出适当的消息并跳过它们。第 170 行跳过任何本地副本在 %loc 中的远程文件。在第 172 行到第 174 行中,打印一条消息,并且 FTP 命令以信息性消息的形式回显或执行以删除文件。如果检测到错误,脚本会预防性地 die()。

结论

此脚本并非旨在成为完整的同步解决方案,并且某些情况根本未处理。但是,此工具很好地满足了我的需求。现在,我可以在我的网页本地副本中随意修改,然后在稍后简单地运行如下命令

            bash-2.05a$ ./perl/ftpsync -s my.ftp -u lem -p 37337 \
               -l /my/local/site -i 'CVS|(^\.)|/\.|(~$)' -v -o 14400
            connected
            perl/index-en.htm file missing or older in the FTP repository
            create_this dir missing in the FTP repository
            Untitled.gif file missing locally

并在几分钟后拥有一个更新的网站,而无需记住我触摸了哪些文件。我提供给 -i 的 regexp 应该阻止任何以点开头的 CVS 控制文件以及任何 Emacs 备份被触及。我还指定了 14,400 秒(4 小时)的偏移量,以补偿此 FTP 服务器在我的本地时区而不是 UTC 中运行的事实,尽管它应该是 UTC。

电子邮件:luismunoz@cpan.org

加载 Disqus 评论