计算机技术就像计算机本身一样飞速发展,这难道不有趣吗?一些做硬编码计算机编程的人们如今在早期 Web 迷人的时光里开始接触 HTML 和 CGI。我就是其中之一。如果你也涉猎了叫做“Web 设计”的伪码梦幻世界的话,你无疑会发现在这个时代绝大多数设计师会分属于两个阵营之一。第一阵营是采用一个所见即所得(WYSIWYG)的编辑器比如 Dreamweaver 来设计和发布网页。第二阵营使用诸如 Emacs 或 Vim 的文本编辑器来手工编码 HTML,然后通过一个 FTP 客户端将完成的网页上传到一台 Web 服务器让世界看见并欣赏它,希望如此。第一阵营牺牲灵活性换取便利性,而第二阵营正好相反。没有哪一种方法是错的,但是也没有哪一种是完全正确的。
在我早年的 Web 设计中我属于第一阵营。最近我拥抱了使用写字板的命运并加入了那些“手工制作”的队伍。我喜欢这样做所带来的灵活性上的收益,但是也付出了损失易用性的代价。在本地安装 Web 服务器并直接编辑文件不但浪费时间而且不具移植性,所以我通常修改一个页面、转换到 FTP 客户端、上载文件、转换到浏览器并刷新以查看上载后的文件。这事做起来不算快,而且还需要反复做。这就是一个高喊着“把我自动化”的过程。所以在上个夏天,我启动了最喜欢的文本编辑器并决定开始动手。
我试图写一个程序来做所有这些我需要手工来做的事情,但是更快更准确。我决定使用 Ruby 来编写自动化脚本。我试图使代码短小并具可维护性,因为稍后我还要加入一些其他特性。Ruby(作为动态类型的语言)让编写紧凑的代码变得简单,将困扰最小化。它虽是脚本语言,但是也面向对象。这使得我可以避免代码重复,从而比使用过程语言更为优雅。Ruby 还拥有一个相当出色的开源 SFTP 库可供使用(Net::SFTP[0]),因此我不必自己动手编写。(SFTP 是一个安全传输文件的网络协议。)
在这篇文章中,我将指导你逐步完成整个流程,创建这个程序自己的版本。文章中包含了完整的示例源代码,并带有逐行的代码分析。我邀请你参与进来并体验 Ruby 是如何能够轻易的将你的工作自动例行化的。
需求
我们的程序有一个基本需求:它要连接到一台远程的 SFTP 服务器并上载我们的文件。不过我们也希望它可以做到仅上载那些本地修改过的文件,并能够在判断文件上载的时候可以递归的检查其子目录。
脚本预想的流程应该是:
- 和远程服务器建立起一个 SFTP 连接
-
列出本地目录下所有的文件和子目录
-
比较本地文件的时间戳和远程目录下文件的时间戳,仅上载本地修改过的文件
-
递归访问所有本地子目录并重复步骤 2,必要时创建远程子目录
显然步骤 1 到 3 可以通过 Ruby 的内建对象和 Net::SFTP 轻松搞定。步骤 4 很有趣。尽管 Ruby 的 Dir 类提供了一种递归访问子目录的方法,但是和我们需要的还有所不同。既然 Ruby 对语言的扩展非常简单,为什么我们不自己写一个方法?不仅仅是因为这样做会很有趣,而且我们还将学到扩展 Ruby 是多么的容易。
依赖
除了 Ruby 本身以外,我们依赖的仅仅是 Net::SFTP 和 Net::SSH[1] 这两个库了。幸运的是,这两个软件包都是可以通过 Gem[2] 来安装的。假定你已经在本地机器上安装了 Ruby,并正确设置了 Gem 的路径,然后在命令提示符前输入:
gem install net-ssh --include-dependencies
gem install net-sftp --include-dependencies
编码开始!
现在我们准备开始写点儿代码。我们先来尝试连接远程服务器、列出一个给定本地目录的所有文件并关闭连接。我们将会使用 Net::SSH 和 Net::SFTP 接口以及 Ruby 的 Dir 类来实现。
1: require 'net/ssh'
2: require 'net/sftp'
3: Net::SSH.start('server', 'username', 'password') do |ssh|
4: ssh.sftp.connect do |sftp|
5: Dir.foreach('.') do |file|
6: puts file
7: end
8: end
9: end
让我们逐行看一下:
- 加载 Net::SSH 库。
- 加载 Net::SFTP 库,因为我们这两个库都会用到。
- 通过一个给定的用户名和密码建立一个 SSH 会话(诸如代理服务器之类的附加参数也可以在这里给定。更多信息参见 API 文档)。
- 打开对远程服务器的 SFTP 连接。
- 用 Dir 类列出当前工作目录的所有文件。
- 打印出每个文件名。
- 退出文件列表循环。
- -
- 关闭 SFTP 和 SSH 连接。
在我的系统上执行了脚本以后,产生的输出如下:
.
..
cgi-bin
etc
logs
public_html
temp
回头看看我们最初的需求列表,我们只用了 9 行代码就完成了步骤 1 和步骤 2。
下面来到步骤 3,比较列出文件的时间戳和其所对应的远程时间戳,仅仅上载那些修改过的文件。(为了实现这个目的,我们定义一个文件是否修改过为本地时间戳大于等于远程服务器上的时间戳。)使用 Ruby 比较时间戳想当容易。实际上,比较本身只用一行代码就能够做到。
现在让我们看看脚本:
1: require 'net/ssh'
2: require 'net/sftp'
3: Net::SSH.start('server', 'username', 'password') do |ssh|
4: ssh.sftp.connect do |sftp|
5: Dir.foreach('.') do |file|
6: next if File.stat(file).directory?
7: begin
8: local_file_changed = File.stat(file).mtime > Time.at(sftp.stat(file).mtime)
9: rescue Net::SFTP::Operations::StatusException
10: not_uploaded = true
11: end
12: if not_uploaded or local_file_changed
13: puts "#{file} has changed and will be uploaded"
14: sftp.put_file(file, file)
15: end
16: end
17: end
18: end
让我们逐行来看一下:
1. - 2. 加载 Net::SSH 和 Net::SFTP。3. - 4. 建立 SSH 会话和 SFTP 连接。
5. 遍历当前工作目录下的所有文件。
6. 由于当前我们还不能递归访问目录,所以需要查看当前文件是否是一个目录。如果是的话则跳过到循环的下个迭代。
7. - 11. 由于远程文件可能不存在, 所以我们需要捕捉当我们试图去查看一个不存在的文件的时间戳的时候 Net::SFTP 抛出的异常。我们设置了两个标志,一个用于标记本地文件是否修改过并需要上载,另一个用于标记远程文件是否存在。
12. - 13. 如果本地文件没有被上载过或者比远程文件要新的话,打印一行文字说明文件将被上载。
14. 上载本地文件到远程服务器。
15. - 18. 结束 if 语句、文件循环、SFTP 连接和 SSH 会话。
现在我们已经完成了三个需求。我们现在的脚本可以登录到远程服务器并上载所有本地系统中修改过的文件,但是脚本还只是仅仅能处理单一目录。它还不能尽如子目录去查看需要上载的文件。它也不能创建远程服务器上没有的目录。我们会在在我们宣布脚本完成以前解决这两个问题。
递归
让我们完善脚本,实现递归访问子目录并处理包含需要上载文件的目录在远程服务器不存在的情况:
1: require 'net/ssh'
2: require 'net/sftp'
3: require 'dir'
4:
5: local_path = 'C:\public_html'
6: remote_path = '/usr/jsmith/public_html'
7: file_perm = 0644
8: dir_perm = 0755
9:
10: puts 'Connecting to remote server'
11: Net::SSH.start('server', 'username', 'password') do |ssh|
12: ssh.sftp.connect do |sftp|
13: puts 'Checking for files which need updating'
14: Find.find(local_path) do |file|
15: next if File.stat(file).directory?
16: local_file = "#{dir}/#{file}"
17: remote_file = remote_path + local_file.sub(local_path, '')
18:
19: begin
20: remote_dir = File.dirname(remote_file)
21: sftp.stat(remote_dir)
22: rescue Net::SFTP::Operations::StatusException => e
23: raise unless e.code == 2
24: sftp.mkdir(remote_dir, :permissions => dir_perm)
25: end
26:
27: begin
28: rstat = sftp.stat(remote_file)
29: rescue Net::SFTP::Operations::StatusException => e
30: raise unless e.code == 2
31: sftp.put_file(local_file, remote_file)
32: sftp.setstat(remote_file, :permissions => file_perm)
33: next
34: end
35:
36: if File.stat(local_file).mtime > Time.at(rstat.mtime)
37: puts "Copying #{local_file} to #{remote_file}"
38: sftp.put_file(local_file, remote_file)
39: end
40: end
41: end
42:
43: puts ‘Disconnecting from remote server'
44: end
45: end
46:
47: puts 'File transfer complete'
哇呜!这个要比我们之前的版本长很多,但这主要是处理远程目录不存在的时候必须要做的异常检查造成的。这是 Net::SFTP 库的一个限制。当我们试图上载一个并不存在的远程目录的时候,put_file方法将会抛出一个令人讨厌的异常。put_file方法理应通过自动创建文件目录树中的缺失部分来处理这种情况。然而修改这个方法并不在本文讨论的范围以内,因此我把这个留给你作为练习。
让我们逐行来看一下新代码:
5. - 6. 定义变量用于设定将会被比较和上载的本地和远程的目录。
7. - 8. 定义变量用于设定当远程服务器不存在的时候我们需要设定的文件和目录的默认权限。
9. - 13. 建立到远程服务器的 SSH 会话和 SFTP 连接。
14. 开始递归访问每个子目录。
15. 循环当前目录下的每个条目。
15. 如果当前条目是目录而非文件则跳过至下一条目。
16. 设置local_file变量为我们遍历到的本地文件,相对于我们的当前目录。
17. 设置remote_file变量为远程服务器的目标路径,并前缀remote_dir路径以便我们可以上传至正确的地址而不是用户的 home 目录。
19. - 26. 这是另一段惹人厌的代码,如果 Net::SFTP 能在文件处理方面再智能些这些代码就不会出现在这里。我们需要检查我们试图上载的远程目录是否已经存在。为了实现这个功能,我们调用sftp.stat(..) 并传入待检查的目录名。如果 stat 抛出一个异常且属性码是 2,则说明远程目录不存在。我们则需要创建它并设置正确的权限。
27. - 35. 更令人厌恶的代码,用于检查即将上载的远程文件是否存在。然后其实我们可以不必做这个检查就创建远程文件,因为当我们上载文件的时候会自动创建。但是我们需要做这个检查,因为当上传一个新文件时我们要给远程文件设置适当的权限。如果我们不这么做的话则会使用默认的 UNIX 权限,这样做可能会禁止我们稍后的文件上载。
36. - 40. 最后,如果到了这里,说明将要上载的远程目录和文件都存在。我们比较本地文件和远程文件的修改时间。如果本地文件更新,则上载它。
40. - 48. 所有我们开启的循环的结束,关闭和远程服务器的 SFTP 连接和 SSH 会话。
这是脚本执行的时候可能产生的输出:
Connecting to remote server
Checking for files which need updating
Copying D:/html/index.php to /home/public_html/index.php
Copying D:/html/media.php to /home/public_html/media.php
Copying D:/html/contact.php to /home/public_html/contact.php
Copying D:/html/images/go.gif to /home/public_html/images/go.gif
Copying D:/html/images/stop.gif to /home/public_html/images/stop.gif
Copying D:/html/include/menu.php to /home/public_html/include/menu.php
Disconnecting from remote server
File transfer complete
我们成功了!现在我们有了一个从任意深度的本地目录树上载文件到远程服务器上的快速、方便的方法。脚本相当的聪明,当目录不存在的时候会创建目录,而且只上载那些实际修改过的文件。
改进的方向
尽管我们开发的“骨架”级脚本已经相当有用,但是依然存在一些我们仅通过少许工作就可以实现的改进:
- 处理本地删除的文件和目录。我们当前的脚本当本地的目录被删除的时候还不能从远程服务器移除。在移除远程文件之前可能需要提示用户以免重要的文件被误删除。
- 为判断文件修改增加其他检查。比较时间戳的方法的确很好,但是为什么不同时比较一下文件的大小?当所连接的服务器并没有设置准确时间的时候这种检查是很有用的。
- 记录上载文件的日志。我增加了打印到标准输出的语句,但是为什么不采用一个更加成熟的日志机制来记录文件上载的时间和地点?(如果你决定每天或每周运行脚本的话这将是很重要的。)
- 采用 Capistrano[4] 重写脚本。Jamis Buck 用于编写部署脚本的优秀框架是一个理想的选择,可作为一个永久解决方案在项目之间使用。
同时,算不得是改进的是,Net::SSH 库支持公钥认证。运行 PuTTY 的 Pageant 应用程序(请看 http://www.chiark.greenend.org.uk/~sgtatham/putty/download.html )并添加你的密钥,然后从我们的脚本的 Net::SSH.start 语句中移除你的密码。现在你可以在不将密码明文储存的情况下上载文件,也不必每次连接远程服务器的时候输入密码了。真好!
总结
随着时间的推移, 这个脚本已经为我节省了数十个小时,我不再需要手工使用 FTP GUI,也不需要通过一个恼人的 FTP 命令行程序频繁的变换目录。我希望这个脚本对你的工作也起到同样的作用。如果是这样的话,我邀请你通过我的网站( www.matthewbass.com )联系我并让我知晓。我也同样有兴趣听到你对改进此脚本的建议,或者你完成了一个漂亮的重构可以精简一到两行代码。尽情的使用 Ruby 吧!
注脚
1. Net::SFTP 是一个基于 SFTP 安全传送文件的 Ruby API。它是 Net::SSH 的一部分。
2. Net::SSH 是一个通过 secure shell 访问资源的 Ruby API。 http://rubyforge.org/projects/net-ssh/
3. Gem 是 Ruby 的包管理器。 http://www.rubygems.org
4. 用 Ruby 实现自动应用程序部署。 http://manuals.rubyonrails.com/read/book/17
查看英文原文: Automating File Uploads with SSH and Ruby
评论