最近写作业要求用 C++,用惯了 PHP 和 Java 再用 C++ 真是痛苦。所以,轻松一下,换个口味,写点 Ruby 就当休息一下吧(基本上,写 PHP 和 Ruby 都算是一种娱乐,非常得开心,C++ 和 Java 就……)。

前言

照例先说废话。以前刚看到 Ruby 的时候,常常看到 RoR 被用来跟 PHP 比较,觉得这些人很无厘头:RoR 是一个框架,而 PHP 是一种语言,怎么比法?直到不久之前,才发现,原来是自己想错了。因为 Ruby 是一门程序语言,但不(只)是一门脚本语言(Scripting Language)。而 PHP 基本上可以算做是脚本语言,虽然现在也有可以编译为二进制的办法以及 GUI 编程等等,但主要还是以作为脚本语言的角色为主。所以,用 RoR 和 PHP 比较还是算合理的。

这次希望写个有点意思的程序,那就写个简单的 Web 服务器吧。要直到 Ruby (自带?)的 WEBrick 服务器也是用 Ruby 写成的。

开始

依照惯例,下面的内容并不会全部解释。我仍然假设你至少对 PHP、Python、Perl 或者 JavaScript 或者类似语言有一定基础。关于 Ruby 比较特别和神奇的部分将会解释。因为是写 Web 服务器,你至少应该对 Socket 有一定了解,当然也不用太深。开发环境仍然是 RDE,一个很方便小巧的绿色 Ruby IDE,虽然还有些 bug 不过已经很不错了,当然 EditPlus 也可以,请参见 PHP 版关于 EditPlus 配置的文章(by HonestQiao)。

原理

首先简单介绍一下这个服务器的原理。我们的程序将做下面几步:

1. 建立一个 TCP Server。
  - 这个包括:建立一个 TCP Socket,监听(listen) 80 端口(因为我们做服务器)。
2. 处理客户端的请求(request)。
3. 根据处理结果发送数据到客户端。

当然,在探索的过程中,我们将为它加上其它的功能。

按照「俄罗斯人偶开发法」……首先实现最最基本的逻辑。这个服务器暂时使用循环处理请求的办法(也不全是),下次可以挑战用线程来分别处理各个请求。

雏形

  1. require 'socket'
  2. server = TCPServer.new('127.0.0.1', 80)
  3. while (session = server.accept)
  4.     session.gets
  5.     session.print "HTTP/1.1 200/OK\rContent-type: text/html\r\n\r\n"
  6.     session.print <<HTML
  7. <html>
  8.     <head></head>
  9.     <body>
  10.     Hello, World!
  11.     </body>
  12. </html>
  13. HTML
  14.     session.close
  15. end
复制代码



这就是一个简单得不能再简单的 Web 服务器了。运行之后,在浏览器中访问,即可看到 Hello, World! 字样。

现在来看看这段代码是什么意思。

首先,所有 socket 相关的类都在 'socket' 这个库里面,所以,先 require 'socket'。

第二句是 Ruby 提供的一个快捷方式 TCPServer。这个类专门用于建立 TCP 服务器,新建 socket 和 listen 等等操作已经封装好了。注意这是最简单的程序,并没有加入任何检错代码。TCPServer.new 有两个参数,一个是要绑定的 IP (或者域名),一个是端口(或者服务)。这里我们绑定本机地址,端口选择 Web 服务器最常用的 80。

下面做的就是不停接受(accept)新进的连接,并进行处理。由于 Ruby 提供和普通 I/O 一样的 socket 方法,我们可以直接使用 gets 来获取客户端的请求(request)信息。虽然我们暂时不使用这个信息,但是这一步也是必须的。然后,简单的 HTTP/1.1 头信息。然后是输出一个简单的 HTML 文件。最后关闭这个会话。

注意 Ruby 的 HEREDOC 使用方法。很奇怪 Ruby 使用两个 << 表示 HEREDOC,倒不是因为 PHP 用 3 个而奇怪,而是因为 << 在 Ruby 中是一个非常常用的操作符(连附加项目到数组都是用它)。当然也许习惯也就好了吧。其它规则和一般 HEREDOC 一样,只是不用分号结尾。

第一个俄罗斯人偶

现在,我们将为这个 Server 增加一个功能,那就是,读出客户端要求的文件,并返回数据。如果客户端请求的是一个目录,那么,简单返回这个目录的结构信息。

首先,检查这个功能需要什么数据(对象)。一目了然,需要的是一个文件类,需要一个显示类,还有一个回应类。文件类用于检查、读入文件,显示类用于发送目录数据,回应类用于封装头信息和数据以便于发送。

Ruby 的文件类 File 可以直接用于这个目的,但是,非常可惜的是文件类的文档非常不整齐,而且在 ruby-doc.org 的排列也很让人头痛,在搜索和整理之后,找到了我们将用到的下面几个方法:

File#open - 用于打开文件,附带 block 可以自动关闭文件,这个在之前第一个 Ruby 程序里已经用过了
File#directory? - 用于检查文件是否是目录
File#read(length) - 用于读入文件至 length 字节,并移动文件指针

显示类主要用于用 HTML 来显示目录,将会用到 Dir 类的几个方法:

Dir#open - 同 File
Dir#each - 用于遍历目录下的文件

现在开始写代码。首先写最底层的回应类:

  1. class Response
  2.     @@CRLF = "\r\n"
  3.     @@LFLF = "\n\n"
  4.     def initialize
  5.         @status
  6.         @body
  7.         @type
  8.     end
  9.     def status=(status_code)
  10.         case status_code
  11.             when 200
  12.                 msg = 'OK'
  13.             end
  14.         end
  15.         
  16.         @status = "HTTP/1.1 #{status_code}/#{msg}"
  17.     end
  18.    
  19.     # 这个方法是错误的,请到最后全代码部分查看正确版本
  20.     def body=(body, type = 'text/html')
  21.         @body = body
  22.         @type = type
  23.     end
  24.    
  25.     def to_s
  26.         str = @status + @@CRLF
  27.         str += "Content-Length: #{@body.size}#{@@CRLF}"
  28.         str += "Content-Type: #{@type}#{@@CRLF}"
  29.         str += "Date: Wed, 22 Aug 2007 07:31:54 GMT#{@@CRLF}"
  30.         str += "Server: RubyServer 0.1#{@@LFLF}"
  31.         str += @body
  32.     end
  33. end
复制代码



在这个类定义了两个类变量(class variable),用两个 @ 表示。CRLF 用于表示标准的分割 header 的符号,LFLF 是因为最后一个 header 信息之后,数据之前不能使用 CRLF,而必须使用两个 linefeed。

initialize 里面定义的是两个实例变量(instance variable),用一个 @ 表示,暂时不需要用到它们。

status= 方法用于设置 status 实例变量。这里先说说 Ruby 的方法名字的问题。AFAIK,Ruby 中的方法可以有三种结尾,问号,感叹号和等号。问号代表问问题,即返回真假值;叹号一般代表这个方法将会改变这个实例(而不是返回一个新的实例),或者这 个方法可能有副作用云云。但问号和叹号似乎是为了看代码方便,好像并没有强制作用。但是等号就有了:等号用于设置同名实例变量。也就是说,当你使用 object.attr = sth 的时候,实际上不是真的在直接改变 attr 变量,而是访问 attr=() 方法,sth 为参数。同理,当你 puts obj.attr 的时候,也不是在直接访问 attr 变量,而是在访问 attr 方法。

当然,这样,就非常直观地让我们可以用 resp.status = 200 来设置 HTTP 状态代码,而在幕后,resp.status 实际上被补全成了完整的 HTTP 状态头信息。

Ruby 没有 switch/case/default,而是改了个名字叫 case/when/else,就当换了个口味吧。另外,印象中 Ruby 应该是不支持 fall-though 的。

同理,body= 也是设置 body,默认为 text/html 类型。

to_s 是 Ruby 中把类转换成字符串的标准函数。在这里,我们把所有信息并拢成为一个完整的 response,然后返回。当然,这里偷懒没有使用 return 语句,最后一个表达式的值自动返回。(我也堕落了,想当初我是很痛恨这类 implicity 的)关于变量的字符串替换上次已经说过了就不赘述;字符串的连接用 +。

另外,Ruby 似乎会自动定义 +=/-= 一类的快捷方式,如果你定义 +/- 的话(如果没记错)。

然后再来看显示类,这个类读入一个目录字符串并遍历和分析整个目录的文件,然后 to_s 方法用于返回生成的 HTML 报告。

  1. class ViewDir
  2.     @@page = <<PAGE
  3. <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
  4. <html>
  5.     <head>
  6.         <title>Index of DIR
  7.         <style>
  8.         <!--
  9.             table, tr, td {
  10.                 border: 1px solid gray;
  11.                 border-collapse: collapse;
  12.                 table-layout: fixed;
  13.             }
  14.         -->
  15.         </style>
  16.     </head>
  17.     <body>
  18.         <table>
  19.             <tr>
  20.                 <td width="10%">Name</td>
  21.                 <td width="10%">Last Modified</td>
  22.                 <td width="5%">Size</td>
  23.             </tr>
  24.             ROW
  25.         </table>
  26.     </body>
  27. </html>
  28. PAGE
  29.     @@row = <<ROW
  30.             <tr>
  31.                 <td>NAME</td>
  32.                 <td>DATE</td>
  33.                 <td>SIZE</td>
  34.             </tr>
  35. ROW
  36.     def initialize(dirname)
  37.         @entries = ""
  38.         @content = @@page.gsub('DIR', dirname)
  39.         Dir.open(dirname) do |dir|
  40.             dir.each do |d|
  41.                 next unless (!File.directory?(d) and File.exists?(d))
  42.                 file = File.open(d) do |f|
  43.                     row = @@row.gsub('NAME', d)
  44.                     row.gsub!('DATE', f.mtime.to_s)
  45.                     row.gsub!('SIZE', f.stat.size.to_s)
  46.                     @entries += row
  47.                 end
  48.             end
  49.         end
  50.         @content.gsub!('ROW', @entries)
  51.     end
  52.     def to_s
  53.         @content
  54.     end
  55. end
复制代码



首先我们可以试着测试一下这个类。注意在 WinXP 下面,访问「..」,也就是上级目录会出现异常「Permission Denied」 (纠正一个错误,实际上是 File.open 目录才会出错),所以这里把 .. 也除外,到上级目录的链接我们可以在 refinement 的时候做。这次的目标是简易的 Web 服务器,那就让它先实现最简易的功能好了。

  1. x = ViewDir.new('.')
  2. puts x.to_s
复制代码



上面的代码可以测试这个类的成果,应该还不错吧。

现在解释一下这个类中用到的东西。首先是类变量和成员变量,自不用说了。HEREDOC 也不用说了。需要注意的是,PHPer 熟悉的 str_replace 在 Ruby 中是 String 类的一个实例方法 gsub (还有自变版本 gsub!)。这里两个版本都使用了,代码不是非常的清晰和美观,当然,是有更好的办法的,但是我还不太熟悉,这样也能用,就先用着好了。

File#mtime 返回的是文件修改时间,是一个 Time 类,必须用 to_s 转换为 String,当然这里偷懒,没有用自定义格式的时间,to_s 就 OK 了。另一个奇怪的东西是,size 是 File 的一个类方法但却不是一个实例方法(不一致性!),要用 size,必须用 File::Stat 类,当然这个类也不是不方便,因为也是内置的东西,直接 file.stat 即可使用,只是有些不一致,不是很美观和协调。这个类有 size 方法,可以直接使用。当然,size 是 Fixnum 类(Ruby 的整数型),不能直接转换为 String,还是必须 to_s。看到这里也许会觉得 Ruby 麻烦,觉得 PHP 真是太好了。其实是有那么一点点,但是,但是最重要的是这样减少了很多无意中可能发生的错误,也是程序的逻辑更加的准确(虽然不一定更加清晰)。

然后需要注意的是,Ruby 中的 block 是会自动建立新的变量作用域(variable scope)的,也就是说 do...end 之间新建的内容外部将不能访问。

接下来让我们把这些功能和类全部集中起来。

第二个俄罗斯人偶

在开始集中这些功能之前,我们需要做一些准备工作。Web 服务器是有其自己的根目录的,比如 www 和 http_public 一类,我们这个服务器也要有一个根目录。如果直接用 ViewDir 那么根目录很有可能是系统的 root (自少在 WinXP 下面是),所以,我们必须设置一个服务器目录。当然要自己设置是很麻烦的,如果可以自动根据我们服务器的 rb 文件的位置设置就好了。当然可以。

用过 PHP 的都知道 PHP 有几个从 C 借来的魔法变量,所谓 magic variables,编译器使用的 __FILE__ 和 __LINE__,Ruby 也有这两个东西。不过 __FILE__ 只能得到当前文件名,要用目录?用 File.dirname(__FILE__)。

这里讲一个 Ruby 的规则,因为 Ruby 的类方法和实例方法是不同的,不能混用,所以,在讲解和写文档的时候,一般用 File#atime 来表示实例方法,用 File.atime 来表示类方法,这样比较清晰。

最近作业比较繁多,就不详细讲解了,全代码如下:

  1. require 'socket'
  2. class Response
  3.     @@CRLF = "\r\n"
  4.     @@LFLF = "\n\n"
  5.     def init
  6.         @status
  7.         @body
  8.         @type
  9.     end
  10.     def status=(status_code)
  11.         case status_code
  12.             when 200
  13.                 msg = 'OK'
  14.             when 400
  15.                 msg = 'Not Found'
  16.         end
  17.         
  18.         @status = "HTTP/1.1 #{status_code}/#{msg}"
  19.     end
  20.    
  21.     def body=(body)
  22.         if body.is_a?(Array)
  23.             @body = body[0]
  24.             @type = body[1]
  25.         else
  26.             @body = body
  27.             @type = 'text/html'
  28.         end
  29.     end
  30.    
  31.     def to_s
  32.         str = @status + @@CRLF
  33.         str += "Content-Length: #{@body.size}#{@@CRLF}"
  34.         str += "Content-Type: #{@type}#{@@CRLF}"
  35.         str += "Date: Wed, 22 Aug 2007 07:31:54 GMT#{@@CRLF}"
  36.         str += "Server: RubyServer 0.1#{@@LFLF}"
  37.         str += @body
  38.     end
  39. end
  40. class ViewDir
  41.     @@page = <<PAGE
  42. <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
  43. <html>
  44.     <head>
  45.         <title>Index of DIR
  46.         <style>
  47.         <!--
  48.             * {
  49.                 font-family: courier new, arial, tahoma;
  50.                 font-size: 12px;
  51.             }
  52.             h1 {
  53.                 font-family: tims new roman;
  54.                 font-size: 25px;
  55.             }
  56.             table, tr, td {
  57.                 border: 0;
  58.                 table-layout: fixed;
  59.             }
  60.             td {
  61.                 cell-padding: 3px;
  62.             }
  63.         -->
  64.         </style>
  65.     </head>
  66.     <body>
  67.         <h1>Index of DIR</h1>
  68.         <table>
  69.             <tr>
  70.                 <td width="160px">Name</td>
  71.                 <td width="250px">Last Modified</td>
  72.                 <td width="30px">Size</td>
  73.             </tr>
  74.             <tr>
  75.                 <td colspan="3"><hr></td>
  76.             </tr>
  77.             ROW
  78.             <tr>
  79.                 <td colspan="3"><hr></td>
  80.             </tr>
  81.         </table>
  82.     </body>
  83. </html>
  84. PAGE
  85.     @@row = <<ROW
  86.             <tr>
  87.                 <td><a href="PATH">NAME</a></td>
  88.                 <td>DATE</td>
  89.                 <td>SIZE</td>
  90.             </tr>
  91. ROW
  92.     def initialize(dirname)
  93.         @entries = ""
  94.         @content = @@page.gsub('DIR', dirname[$root.size..dirname.size])
  95.         Dir.open(dirname) do |dir|
  96.             dir.each do |d|
  97.                 next unless (!File.directory?(d) and File.exists?(d))
  98.                 File.open(d) do |f|
  99.                     if d.size > 15
  100.                         name = d[0..5] + '...' + d[-5,5]
  101.                     else
  102.                         name = d
  103.                     end
  104.                     row = @@row.gsub('NAME', name)
  105.                     row.gsub!('PATH', d)
  106.                     row.gsub!('DATE', f.mtime.to_s)
  107.                     row.gsub!('SIZE', f.stat.size.to_s)
  108.                     @entries += row
  109.                 end
  110.             end
  111.         end
  112.         @content.gsub!('ROW', @entries)
  113.     end
  114.     def to_s
  115.         @content
  116.     end
  117. end
  118. server = TCPServer.new('127.0.0.1', 80)
  119. $root = File.dirname(__FILE__)
  120. while (session = server.accept)
  121.     request = session.gets.split(/\s/)
  122.    
  123.     if (request[0] != 'GET')
  124.         next
  125.     end
  126.     file = request[1]
  127.     file = $root + file
  128.     resp = Response.new
  129.     if not File.exists?(file)
  130.         resp.status = 400
  131.         resp.body = ""
  132.     elsif File.directory?(file)
  133.         resp.status = 200
  134.         resp.body = ViewDir.new(file).to_s
  135.     else
  136.         resp.status = 200
  137.         resp.body = File.new(file, 'rb').read, 'octet/application'
  138.     end
  139.     session.write resp.to_s
  140.     session.close
  141. end
复制代码



这里修正一个错误:Ruby 是支持 attr=() 这样的方法的,前面说过,但是很遗憾,这个方法并不想完整的方法一样,支持多个参数,而只支持一个参数,所以,前面的 body=() 方法做了调整,这也算 Ruby 的一个 gotcha 吧。

另外,太长的字符串用 Ruby 的方式来截短了。注意这一行:

  1. name = d[0..5] + '...' + d[-5,5]
复制代码



两种数组下标 [] 的用法都涉及到了,也涉及到了负下标的用法。

Ruby 支持范围,也就是说 0..5 就是 0 到 5 (包含),而三个点则是不包含(这个有些违背常理)。范围有专门的 Range 类,有 each 方法可以直接遍历。用于数组的下标的时候,将选取和返回一个包括这个数组从 0-5 的所有元素的新数组。当下标是一个数组的时候,前一个代表从哪个开始,后一个代表共选取多少个。负下标代表从末尾开始数。

上面的语句意思是,「选择 d 字符串的第 0、1、2、3、4、5 字节,加上 ...,在加上 d 字符串的最后 5 个字节」。

其实还有另外一种办法。那就是

  1. name = d.dup # d.clone
  2. name[5...-5] = '...'
复制代码



这两句的意思分别是,「复制一个 d 对象,并命名为 name」,「选择 name 对象的第 5 至倒数第 5 字节,并把一段替换成 ...」。从这里也看出了 Ruby 数组(及字符串)的灵活。当然也有些容易混淆。

  1. @content = @@page.gsub('DIR', dirname[$root.size..dirname.size])
复制代码



这一句是否有些奇怪?主要是第二个参数的部分吧。$root 是全局变量,代表着服务器根目录的物理地址,或者说 full path。而后面的 dirname 是我们当前目录的地址。其实上面的那一段的意思就是,把服务器根目录的地址从当前目录的地址中减掉。例如:

  1. $root = '/path/to/our/server'
  2. $requested_url = '/'
  3. $current_working_dir = '/path/to/our/server/'
复制代码



因为必须要转换成物理地址才能够读取文件,但是又不想把这个地址曝露给客户端,所以用了上面的办法。

这段代码还用了一个 statement modifier 和一个比较新鲜的东西 unless。其实 unless 就是 if not。但是 unless 用在 statement modifier 的时候很好理解。

  1. next unless (!File.directory?(d) and File.exists?(d))
复制代码



上面的代码的意思是:「跳过这个!除非 d 存在而且不是目录」。如果用 if not 的话:「如果文件不存在或者文件是目录的话,跳过这个!」

传说中 unless 在双重否定的时候会比较清晰,但是个人觉得偶尔会搞混,看自己吧。

另外,Win 平台下面读二进制文件一定要用加 b 参数,否则不能读完。也因为这个,Win 平台下面不能不打开二进制文件就直接读,必须先打开(加 b 参数),然后再读。(注意 File.read 和 File#read 都是存在的)

其它就没什么说的了,还是老话,做作业的空闲写着玩,不严谨、没结构、代码质量低、错误漏洞百出……等等都是正常的,不是我的作风,也不是 Ruby 的作风……

另:这个服务器确实很简易……不支持下级目录、报错都不会……等等,只是写着完,若是要写得比较能用,那就不在本文讨论之列了,只希望能给大家一些 Ruby 的印象。希望能满足那些不能被「简单弱智的 example 代码,枯燥的手册,干瘪的讲解和理科生的翻译」满足的人们。

希望大家看了能对 Ruby 产生兴趣,也欢迎各位提出理性的讨论、意见和建议。



Ruby 学习第二季精彩预告

Ruby 很强大,也很简单,那么她的 GUI 功力如何呢?在给程序员们带来欢乐的同时,Ruby 能否为广大的用户提供好的用户界面呢?第三个 Ruby 程序将使用 Ruby 的赠品 GUI 库 Tk,看看 Ruby 能否把更复杂的事情变得更简单。

Ruby 已经发明多年,却因为英文文档少而少有被人注意。在 Java 正处颓势的时候,很多语言都慢慢地在崭露头角,它们都各司其职,朝着自己设计的方向发展,Ruby 也不例外。而今年来 RoR 的超高的开发效率一夜间让人们认识了 Ruby,也在一夜间,几乎所有的 Web 相关语言都有了自己类似于 RoR 的框架。第四个 Ruby 程序将使用 RoR,并顺带介绍 MVC,ActiveRecord 等等相关的技术(当然是用人文的方式~)。