36. 30 CHAPTER 4. 最简单的WEB框架
require 'decorator'
app = Rack::Builder.new {
use Rack::ContentLength
use Decorator , :header => "==============<br/>"
run lambda {|env| [200, {"Content-Type"=>"text/html"}, ["hello world"]]}
}.to_app
Rack::Handler::WEBrick.run app, :Port => 3000
把这些文件保存为test-rack-builder.rb,运行应该得到和原先一样的结果。
4.1.2 路由
一个Web程序通常用不同的代码处理不同的URL,很多Web应用程序把这种对应关
系的处理叫做路由。最简单的路由就是一个路径和一个代码块之间的一一对应关
系。
一个简单的路由
利用Rack::Builder的map方法我们可以这样编写一个Rack程序:
#!/usr/bin/env ruby
require "rubygems"
require 'rack'
app = Rack::Builder.new {
map '/hello' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["hello"]] }
end
map '/world' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["world"]] }
end
map '/' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["all"]] }
end
}.to_app
Rack::Handler::WEBrick.run app, :Port => 3000
当你输入任何以http://localhost:3000/hello开始的URL,浏览器都可以得到hello。
当你输入任何以http://localhost:3000/world开始的URL,浏览器都可以得到world。
除此之外,你将得到all。
37. 4.1. RACK::BUILDER 31
路由实现
use和run方法
Rack::Builder的具体实现大体上和3.4.2(p. 25)描述的一致。
def initialize(&block)
@ins = []
instance_eval(&block) if block_given?
end
def use(middleware, *args, &block)
@ins << lambda { |app| middleware.new(app, *args, &block) }
end
def run(app)
@ins << app #lambda { |nothing| app }
end
和我们自己实现的builder不同之处在于我们用一个单独的@app实例变量来保存run的
参数–即原始的Rack应用程序,而这里的run直接把app放到数组的最后。所以这个
数组的成员依次包含所有的中间件,最后一个成员是将被前面所有这些中间件包装
的Rack应用程序。
def to_app
@ins[-1] = Rack::URLMap.new(@ins.last) if Hash === @ins.last
inner_app = @ins.last
@ins[0...-1].reverse.inject(inner_app) { |a, e| e.call(a) }
end
to_app首先取得@ins数组的最后一个成员,如果最后一个成员不是一个Hash的话,
实现的效果就和我们的Builder完全一样了。
map方法
所以不同之处在于最后一个成员是Hash的情况:如果最后一个成员是Hash,那么
就会根据这个Hash生成一个Rack::URLMap的实例,这个实例作为被其他中间件包
装的Rack应用程序。这个Hash是map方法产生的。
def map(path, &block)
if @ins.last.kind_of? Hash
@ins.last[path] = self.class.new(&block).to_app
else
@ins << {}
map(path, &block)
end
end
38. 32 CHAPTER 4. 最简单的WEB框架
map方法取一个路径path和一个代码块block为参数。当@ins的最后一个成员不是Hash的
时候,就加入一个新的Hash在@ins的末尾。由于to_app方法总是把最后一个成员作
为被前面所有中间件包装的Rack应用程序,由此可以看出,如果在Builder.new的代
码块出现了一个map的话,那么不可以在相同的范围内出现run,也就是说,下面这
样的情况是不合法的:
Rack::Builder.new {
use ....
use....
run ....
map ... do
.....
end
}
回到前面的map方法。考虑到第一次调用map的情况,程序首先在@ins内部加入一
个空的Hash,然后递归调用map方法。由于此时@ins数组最后一个成员已经是一
个Hash,所以下面的语句建立了一个对应关系。
@ins.last[path] = self.class.new(&block).to_app
这个对应关系的关键字是path参数,但它的值并非代码块本身,而是用这个代码块
作为参数继续调用Rack::Builder.new方法,并用to_app方法产生一个Rack应用程序。
假设我们有这样一个Rack::Builder的构造过程:
#!/usr/bin/env ruby
require "rubygems"
require 'rack'
app = Rack::Builder.new {
use Rack::ContentLength
map '/hello' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["hello"]] }
end
}.to_app
Rack::Handler::WEBrick.run app, :Port => 3000
那么现在@ins数组将包括两个成员:一个是创建中间件Rack::ContentLength对应
的lambda对象,最后一个是Hash,其中包含了路径/hello对应的一个Rack应用程
序–这个应用将调用我们用run运行的lambda对象:
lambda {|env| [200, {Content-Type => text/html}, [hello]] }
如果我们继续声明map:
#!/usr/bin/env ruby
require "rubygems"
39. 4.1. RACK::BUILDER 33
require 'rack'
app = Rack::Builder.new {
use Rack::ContentLength
map '/hello' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["hello"]] }
end
map '/world' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["world"]] }
end
}.to_app
Rack::Handler::WEBrick.run app, :Port => 3000
则现在@ins数组还是只有二个成员:第一个中间件不变,最后一个是Hash,有了两
个对:
'hello' => lambda {|env| [200, {Content-Type => text/html}, [hello]] }
'world' => lambda {|env| [200, {Content-Type => text/html}, [world]] }
回到to_app方法:
def to_app
@ins[-1] = Rack::URLMap.new(@ins.last) if Hash === @ins.last
inner_app = @ins.last
@ins[0...-1].reverse.inject(inner_app) { |a, e| e.call(a) }
end
如果最后一个成员是一个Hash,将会用这个成员创建一个新的Rack::URLMap应用
程序。Rack::URLMap内部保存了这个URL和Rack程序之间的对应关系,如果用户
在url输入了http://localhost:3000/hello开始的URL,那么将调用第一个应用程序。
当它同时也作了一些处理,这个匹配的路径’/hello’将变为环境里面的SCRIPT_NAME,
而截取的剩余部分则变为PATH_INFO。如果我们修改程序如下:
#!/usr/bin/env ruby
require "rubygems"
require 'rack'
app = Rack::Builder.new {
map '/hello' do
run lambda {|env| [200, {"Content-Type" => "text/html"},
["SCRIPT_NAME=#{env['SCRIPT_NAME']}", "PATH_INFO=#{env['PATH_INFO']}"]] }
end
map '/world' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["world"]] }
end
40. 34 CHAPTER 4. 最简单的WEB框架
}.to_app
Rack::Handler::WEBrick.run app, :Port => 3000
则在浏览器输入http://localhost:3000/hello/得到:
SCRIPT_NAME=/helloPATH_INFO=/
而在浏览器输入http://localhost:3000/hello/everyone得到:
SCRIPT_NAME=/helloPATH_INFO=/everyone
这样做的目的是你可以在这个/hello“应用程序”内部实现你自己的分派。
嵌套map
回忆到map方法的实现:
def map(path, &block)
if @ins.last.kind_of? Hash
@ins.last[path] = self.class.new(&block).to_app
.......
我们递归地用Builder.new创建保存到hash所需要的Rack应用程序,这意味着我们还
可以在map内部使用use, run,甚至是嵌套的map。
#!/usr/bin/env ruby
require "rubygems"
require 'rack'
app = Rack::Builder.new {
use Rack::ContentLength
map '/hello' do
use Rack::CommonLogger
map '/ketty' do
run lambda {|env| [200, {"Content-Type" => "text/html"},
["from hello-ketty",
"SCRIPT_NAME=#{env['SCRIPT_NAME']}",
"PATH_INFO=#{env['PATH_INFO']}"]] }
end
map '/everyone' do
run lambda {|env| [200, {"Content-Type" => "text/html"},
["from hello-everyone",
"SCRIPT_NAME=#{env['SCRIPT_NAME']}",
"PATH_INFO=#{env['PATH_INFO']}"]] }
end
map '/' do
41. 4.2. RACKUP 35
run lambda {|env| [200, {"Content-Type" => "text/html"},
["from hello catch all",
"SCRIPT_NAME=#{env['SCRIPT_NAME']}",
"PATH_INFO=#{env['PATH_INFO']}"]] }
end
end
map '/world' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["world"]] }
end
map '/' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["here"]] }
end
}.to_app
Rack::Handler::WEBrick.run app, :Port => 3000
4.2 rackup
我们讨论的应用程序最后一行都是用一个handler去运行一个app,带上某些参数。
显然作为一个Web框架这样做是不合适的。
4.2.1 rackup配置文件
Rack提供的最简单的rackup命令允许用一个配置文件去运行我们的应用程序。
rackup做的事情很简单,如果你提供一个配置文件config.ru(你可以取任何名字,但
后缀必须为ru),然后运行
rackup config.ru
那么它所做的事情相当于:
app = Rack::Builder.new { ... 配置文件 ... }.to_app
然后运行这个app。
把前面的程序改成
map '/hello' do
run lambda {|env| [200, {"Content-Type" => "text/html"},
["SCRIPT_NAME=#{env['SCRIPT_NAME']}", "PATH_INFO=#{env['PATH_INFO']}"]] }
end
map '/world' do
run lambda {|env| [200, {"Content-Type" => "text/html"}, ["world"]] }
end
42. 36 CHAPTER 4. 最简单的WEB框架
并保存到文件config.ru,运行rackup config.ru即可。
你可以看到我们去掉了对rubygems和rack的引入,不再需要硬编码什么Web服务器,
也不须要在程序中指定端口。
rackup提供了一些命令行参数:
rackup --help
Usage: rackup [ruby options] [rack options] [rackup config]
Ruby options:
-e, --eval LINE evaluate a LINE of code
-d, --debug set debugging flags (set $DEBUG to true)
-w, --warn turn warnings on for your script
-I, --include PATH specify $LOAD_PATH (may be used more than once)
-r, --require LIBRARY require the library, before executing your script
Rack options:
-s, --server SERVER serve using SERVER (webrick/mongrel)
-o, --host HOST listen on HOST (default: 0.0.0.0)
-p, --port PORT use PORT (default: 9292)
-E, --env ENVIRONMENT use ENVIRONMENT for defaults (default: development)
-D, --daemonize run daemonized in the background
-P, --pid FILE file to store PID (default: rack.pid)
Common options:
-h, --help Show this message
--version Show version
你可以指定rackup运行的web服务器以及端口。例如:
rackup -s thin -p 3000
当然,你必须安装in服务器。
4.2.2 rackup 实现
我们要看看rackup是如何实现的,借此了解一个基于Rack的Web框架的实现,将对
我们后面实现自己的Web框架大有好处。
rackup本身的实现只有一句语句:
#!/usr/bin/env ruby
require "rack"
Rack::Server.start
显然Rack::Server才是我们的重点。
43. 4.2. RACKUP 37
4.2.3 Rack::Server接口
Rack::Server的接口非常简单,包括两个类方法、一个构造函数和5个实例方法。
module Rack
class Server
def self.start
def self.middleware
def initialize(options = nil)
def options
def app
def middleware
def start
def server
end
end
类方法
类方法start是Rack::Server的入口,它只不过创建一个新的server实例,并调用它
的start实例方法。
def self.start
new.start
end
另一个类方法装配一些缺省的中间件:
def self.middleware
@middleware ||= begin
m = Hash.new {|h,k| h[k] = []}
m["deployment"].concat
[lambda {|server| server.server =~ /CGI/ ? nil : [Rack::CommonLogger, $stderr] }]
m["development"].concat
m["deployment"] + [[Rack::ShowExceptions], [Rack::Lint]]
m
end
end
rackup 根据不同的环境(可以用-E开关选择环境)装载不同的中间件:
• 对于缺省的development环境,它会装载ShowExceptions和Lint中间件。
49. Chapter 5
中间件:第二轮
5.1 再议响应体
我们首先在2.3.1(p. 14)中提到了如何设置响应体,然后在3.2(p. 20)提到了响应体必须
能够响应each方法,现在我们将对响应体做更加深入的探讨。
Rack的规格书中对响应体的说明如下:
e Body
e Body must respond to each and must only yield String values. e Body
itself should not be an instance of String, as this will break in Ruby 1.9. If the
Body responds to close, it will be called aer iteration. If the Body responds to
to_path, it must return a String identifying the location of a file whose contents
are identical to that produced by calling each; this may be used by the server as
an alternative, possibly more efficient way to transport the response. e Body
commonly is an Array of Strings, the application instance itself, or a File-like
object.
意思可以分几点:
• 对响应体唯一的要求是必须能够响应each方法,each每次只能产生字符串值
• 由于Ruby 1.9已经不支持String.each方法,所以响应体不应该是一个字符串
• 响应体可以响应一个to_path方法,如果确实如此的话,那么这个方法应该返
回一个文件的路径名,这可以更加高效地处理文件的情况
• 响应体通常是字符串数组、应用程序自己或者类File对象。
• 如何响应体能够响应close方法,在each迭代完成后应该调用close方法。我们
可以在这里实现一些清除工作,例如关闭文件等。
我们可以写一个响应体是File对象的例子。
43
50. 44 CHAPTER 5. 中间件:第二轮
use Rack::ContentLength
use Rack::ContentType, "text/plain"
run lambda {|env| [200, {}, File.new(env['PATH_INFO'][1..-1])] }
把程序保存为file.ru,并执行rackup file.ru,然后在浏览器输入http://localhost:
9292/file.ru,我们可以得到file.ru文件的内容。之所以如此是因为我们的响应体
是一个File对象,因此它能够响应each方法,Handler会不断调用each方法,并把每
次得到的一个字符串输出。所以each方法有很多好处:
• 响应体可以是任何类的任何对象,只要它必须能够响应each方法,each每次只
能产生字符串值,这给我们很多实现的可能性和灵活性
• 可以减少资源的损耗和不必要的处理工作,例如使用File对象的好处可以让我
们不需要一次读入整个文件的所有内容,拼装成一个字符串,然后一次性输
出。不然的话,如果文件足够大,我们的系统必然会因为内存的消耗殆尽而
崩溃。
• 可以让Web服务器有更多的机会和选择进行优化,例如根据不同的内容类型
可以选择每次each输出、一次性输出或者缓存到一定大小再输出整个响应的
内容。
现在我们再来考虑为什么响应体通常也可能是应用程序自身。
我们把3.2(p. 21)重新取到这里。
1 class Decorator
2 def initialize(app)
3 @app = app
4 end
5 def call(env)
6 status, headers, body = @app.call(env)
7 new_body = "===========header==========<br/>"
8 body.each {|str| new_body << str}
9 new_body << "<br/>===========footer=========="
10 [ status, headers, [new_body]]
11 end
12 end
我们去掉了3.2(p. 21)设置’Content-Length’相关的代码–这可以交给其他中间件去做。
这个中间件看起来没有什么问题。但是假设我们原始的Rack应用程序的响应体是一
个文件,即在前面的config.ru文件中加入对Decorator的使用:
use Rack::ContentLength
use Rack::ContentType, "text/plain"
use Decorator
run lambda {|env| [200, {}, File.new(env['PATH_INFO'][1..-1])] }
52. 46 CHAPTER 5. 中间件:第二轮
5.3 HTTP协议中间件
5.3.1 Rack::Chunked
HTTP协议有一种分块传输编码的机制(Chunked Transfer Encoding),即一个HTTP消
息可以分成多个部分进行传输。它对HTTP请求和HTTP响应都是适用的。我们在
这里主要考虑从服务器向客户端传输的响应。
一般来说,HTTP服务一次性地把所有的内容都传输给客户端,这个内容的长度
在’Content-Length’头字段中声明。之所以需要这个字段的原因是客户端需要知道
响应到什么地方结束了。但在某些时候,服务端可能预先不知道将要传输的内容
大小或者因为性能的原因不希望一次性生成并传输所有的响应(压缩是这样一个例
子),那么它就可以利用这种分块传输的机制一块一块地传输数据。一般来说,这
些“块”的大小是一样的,但这并不是一个强制性的要求。
要利用分块传输机制,服务器首先要写入一个Transfer-Encoding头字段并令它的值
为“’chunked”’,每一块的内容包括二个部分(CRLF表示回车加换行):
1. 一个16进制的值表示块的大小,后跟一个CRLF
2. 数据本身后跟一个CRLF
最后一个块只需一行,它的块大小为0,最后整个HTTP消息以一个CRLF结束。下面
是整个HTTP消息的一个例子:
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
25
This is the data in the first chunk
1C
and this is the second one
0
注意最后一个0以后还有一个空行
现在Rack::Chunked的代码就很容易理解了:
def call(env)
status, headers, body = @app.call(env)
headers = HeaderHash.new(headers)
53. 5.3. HTTP协议中间件 47
if env['HTTP_VERSION'] == 'HTTP/1.0' ||
STATUS_WITH_NO_ENTITY_BODY.include?(status) ||
headers['Content-Length'] ||
headers['Transfer-Encoding']
[status, headers, body]
else
dup.chunk(status, headers, body)
end
end
HeaderHash是一个Hash的子类,它的key对大小写不敏感,但是内部保存的key保持原
来的大小写。
require 'rubygems'
require 'rack'
include
h = Rack::Utils::HeaderHash.new({})
h["abc"] = "234" #=> "234"
h["ABC"] #=> "234"
h.keys #=> ["abc"]
它可以用来方便地存取HTTP的头信息。
call方法首先判断当前的HTTP_VERSION是否1.0,或者状态为STATUS_WITH_NO_ENTITY_BODY,
或者headers里面是否已经包含Content-Length和Transfer-Encoding头字段,如果任何
一种情况存在,则Rack::Chunked不做任何事情,不然的话就调用
dup.chunk(status, headers, body)
chunked方法是典型的返回self作为响应体的例子:
def chunk(status, headers, body)
@body = body
headers.delete('Content-Length')
headers['Transfer-Encoding'] = 'chunked'
[status, headers, self]
end
这意味着Rack::Chunked必定有一个each方法:
def each
term = "rn"
@body.each do |chunk|
size = bytesize(chunk)
next if size == 0
yield [size.to_s(16), term, chunk, term].join
end
yield ["0", term, "", term].join
end
66. 60 CHAPTER 5. 中间件:第二轮
# Set the Vary HTTP header.
vary = headers["Vary"].to_s.split(",").map { |v| v.strip }
unless vary.include?("*") || vary.include?("Accept-Encoding")
headers["Vary"] = vary.push("Accept-Encoding").join(",")
end
这段代码用来处理Vary头, 如果其中已经包含了*或者Accept- Encoding,则无需
处理。不然,由于后面将对内容进行编码,因此需要在Vary的“选择”请求头中加
入Accept-Encoding。
下面的代码对内容进行编码,是一个case语句。Deflater中间件有三种编码可用,分
别是gzip,deflate和identity。如果选出的编码不是其中任何一种(select_best_encoding
返回nil),则按照前面讨论过的协议要求必须返回一个406响应。
indentity编码即不做任何编码,亦可原封不动返回。
case encoding
when "gzip"
.........
when "deflate"
.........
when "identity"
[status, headers, body]
when nil
message = "An acceptable encoding for the requested resource
#{request.fullpath} could not be found."
[406, {"Content-Type" => "text/plain",
"Content-Length" => message.length.to_s}, [message]]
end
gzip编码
上述case语句中gzip分支代码如下:
when "gzip"
headers['Content-Encoding'] = "gzip"
headers.delete('Content-Length')
mtime = headers.key?("Last-Modified") ?
Time.httpdate(headers["Last-Modified"]) : Time.now
[status, headers, GzipStream.new(body, mtime)]
程序设置Content-Encoding头为gzip,删除Content-Length头字段。
获取mtime为文档的最后修改时间。最后在响应中返回
[status, headers, GzipStream.new(body, mtime)]
响应体是一个新的GzipStream对象。
67. 5.3. HTTP协议中间件 61
一个问题是为什么我们要删除Content-Length头信息?
由于我们使用了一定的压缩算法,如果要设置正确的Content-Length,
那必须是压缩以后的内容长度。要获得压缩内容的长度,很可能需要把
所有的原始内容读到内存,显然对于极大的文件这是无法接受的。
那么消息编码后的传输长度如何确定呢?回忆5.3.3(p. 55)的讨论,在无
法确认当前消息的长度的时候,可以通过chunked传输编码或者通过服
务端关闭连接来确定传输长度。按照RFC2616,此时应该删除Content-
Length头字段。
另外一点,一旦删除了Content-Length头字段,应用程序本身、Web框
架、Web服务器就可以使用Rack::Chunked(5.3.1(p. 46))中间件来利用chunked传
输机制。如果还存在Content-Length头字段,Rack::Chunked将不起任何
作用。
根据Rack对响应体的要求,GzipStream必须能够响应each,每次产生一个字符串:
class GzipStream
def initialize(body, mtime)
@body = body
@mtime = mtime
end
def each(&block)
@writer = block
gzip =::Zlib::GzipWriter.new(self)
gzip.mtime = @mtime
@body.each { |part| gzip.write(part) }
@body.close if @body.respond_to?(:close)
gzip.close
@writer = nil
end
def write(data)
@writer.call(data)
end
end
Zlib::GzipWriter的构造方法取一个对象,这个对象是压缩数据的输出对象,一般
来说,这个输出对象是一个IO对象(如File)。但是事实上,输出对象只要能够响
应write方法就可以了。当你调用这个GzipWriter实例的write方法时,GzipWriter对
象会用经过Gzip压缩算法压缩过的内容调用输出对象的write方法。
上述代码中,GzipStream的each方法首先创建一个Zlib::GzipWriter对象gzip,由于构
造函数传入的参数是self,因此当调用gzip.write时,gzip会调用当前GzipStream实例
的write方法。
68. 62 CHAPTER 5. 中间件:第二轮
each接着调用body的each方法,每次取得原始的内容,然后把内容写入gzip,gzip把
数据压缩后调用当前GzipStream实例的write方法,而write则用这个压缩后的数据调
用紧跟each的代码块。
deflate编码
case语句中上述分支代码如下:
when "deflate"
headers['Content-Encoding'] = "deflate"
headers.delete('Content-Length')
[status, headers, DeflateStream.new(body)]
和gzip编码相关的代码没多大区别。
DeflateStream实现也没有多少复杂的地方,请自行参阅代码。
你可以用下面的代码进行测试,用telnet或者firebug观察返回的响应头:
use Rack::Chunked
use Rack::Deflater
run lambda {|env| [200, {'Content-Type'=>"text/html"},["abcde"*1000]]}
不同的Web服务器处理方式有所不同,in自动会在必要的是使用Rack::Chunked中
间件,所以你不必自己use。而WEBrick不恰当地设置了Content-Length。
5.3.6 Rack::Etag
在5.3.2(p. 48)中,我们讨论了如何利用Etag实现HTTP缓存。
Rack::ConditionalGet中间件需要程序自己计算Etag,它所做的事情是比较请求和响
应中的Etag,并设置合适的响应头和状态。这样做的好处是你可以在应用程序代码
根据某种算法计算Etag,一旦Etag匹配,中间件ConditionalGet可以完全不去调用生
成具体内容的each方法,从而节约了服务器的CPU资源,网络的传输也大大减少。
某些情况下,你可能无法在程序内部计算Etag–需要计算ETag的片段非常多,很多
片段都有可能发生变化。典型的一个例子是一个Rails程序,某一个页面是否发生改
变不但取决于模型的内容,而且取决于外部Layout的内容,而所有这些信息可能随
着用户的不同而有所不同。这个时候你可能选择一种方案:每次服务端照样生成内
容,但是在输出之前对整个响应体做计算一个Etag–如果内容未发生变化,就不需
要把内容再传输到客户端。
def call(env)
status, headers, body = @app.call(env)
if !headers.has_key?('ETag')
69. 5.3. HTTP协议中间件 63
parts = []
body.each { |part| parts << part.to_s }
headers['ETag'] = %("#{Digest::MD5.hexdigest(parts.join(""))}")
[status, headers, parts]
else
[status, headers, body]
end
end
Etag中间件只处理头字段中尚未包含“Etag”的情况。它读取整个Body的内容,并转
换为一个字符串数组,最后用Digest::MD5.hexdigest计算出整个Etag。
显然Etag需要和Rack::ConditionalGet配合才能起作用:
use Rack::ConditionalGet
use Rack::ETag
run lambda{|env| [200, {'Content-Type'=>'text/html'},["any string here"]]}
用rackup运行这个文件。第一次响应为200,第二次则为304。
如果响应体的内容不是很大(例如Content-Type为text/html)的情况,把所有内容组
合成一个字符串并计算其MD5的开销应该是可以承受的。但如果响应体是一个大
文件,那么这种方式显然不可行。所以请谨慎使用,至少判断一下响应体的类型。
5.3.7 Rack::Head
RFC2616要求HEAD请求的响应体必须为空,这就是Rack::HEAD所做的事情:
def call(env)
status, headers, body = @app.call(env)
if env["REQUEST_METHOD"] == "HEAD"
[status, headers, []]
else
[status, headers, body]
end
end
5.3.8 Rack::MethodOverride
浏 览 器 和Web服 务 器 一 般 不 直 接 支 持PUT和DELETE方 法, 而 只 支 持POST方 法。
而Rest风格的编程对于PUT、DELETE和POST有比较严格的区分。所以我们需要
用POST方法来模拟PUT和DELETE方法。
一般可以有两种方法用POST来模拟:
70. 64 CHAPTER 5. 中间件:第二轮
1. 在POST提交的表单数据中嵌入一个隐含的字段来区分这到底是一个什么方
法。
<form action="......" method="post">
<input name="_method" type="hidden" value="put" />
....
</form>
表单中有一个隐含的“_method”字段,它的值是“put”,表示此请求其实是一
个PUT而不是POST。
2. 在HTTP请求中加入一个扩展的X_HTTP_METHOD_OVERRIDE请求头,这个
头在Rack环境中变为HTTP_ X_HTTP_METHOD_OVERRIDE。
下面是Rack::MethodOverride的具体实现,分别处理这两种情况。
HTTP_METHODS = %w(GET HEAD PUT POST DELETE OPTIONS)
METHOD_OVERRIDE_PARAM_KEY = "_method".freeze
HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE".freeze
def call(env)
if env["REQUEST_METHOD"] == "POST"
req = Request.new(env)
method = req.POST[METHOD_OVERRIDE_PARAM_KEY] ||
env[HTTP_METHOD_OVERRIDE_HEADER]
method = method.to_s.upcase
if HTTP_METHODS.include?(method)
env["rack.methodoverride.original_method"] = env["REQUEST_METHOD"]
env["REQUEST_METHOD"] = method
end
end
@app.call(env)
end
如果该请求是POST请求, 而且在请求数据中包含“_method”值或者在环境中包
含HTTP_ X_HTTP_METHOD_OVERRIDE值,而且它们的值是合法的HTTP方法,
那么此中间件将把原始的POST保存起来,并设置新的REQUEST_METHOD值。
下面的程序测试Rack::MethodOverride是否起到作用:
use Rack::MethodOverride
map '/' do
71. 5.4. 程序开发中间件 65
form = <<-HERE
<form action="/user" method="post">
<input name="_method" type="hidden" value="put" />
<input name="name" type="text" value="" />
<input type="submit" value="Modify!">
</form>
HERE
run lambda {|env| [200, {"Content-Type" => "text/html"}, [form]] }
end
map '/user' do
run lambda {|env|
req = Rack::Request.new env
res = Rack::Response.new
if(req.put?)
res.write("you modify user name to #{req.params['name']}")
else
res.write("we only support put method to modify user,
yours is #{req.request_method}")
end
res.finish
}
end
当用户请求http://localhost:9292,程序返回一个表单,并在其中加入了一个隐
含 字 段“_method”, 把 它 的 值 设 为“put”。 用 户 提 交 表 单 后, 程 序 得 到 的req为 一
个PUT请求。
如果去掉对MethodOverride的使用,那么请求的方法依旧为POST,而不是我们期
望的PUT。
5.4 程序开发中间件
所有的程序开发都遵循类似的模式,我们需要读写日志、评测性能、检查是否符合
一定的规范等等。Rack提供了不少程序开发相关的中间件。
5.4.1 Rack::CommonLogger
任何一个Web程序必须记录日志信息,CommonLogger用Apache common log的格
式把每一个请求的信息记录到一个logger中去。日志的具体格式可以参考http:
//httpd.apache.org/docs/1.3/logs.html#common。
这个logger必须是一个合法的错误流Error Stream,它必须符合:
• 能够响应 puts, write和flush方法
72. 66 CHAPTER 5. 中间件:第二轮
• 我们应该可以用一个参数调用puts ,只要这个参数能够响应to_s
• 我们应该可以用一个参数调用write,只要这个参数是String
• 我们应该可以无参数地调用flush,从而保证日志信息确实被写入
标准输出是符合这个条件的:
use Rack::CommonLogger, $stderr
如果没有给定任何错误流,那么Rack::CommonLogger会从环境变量的rack.errors去
获得对应的值。
5.4.2 Rack::Lint
Rack::Lint检查请求和响应是否符合Rack规格书,这正是我们完全理解Rack规格的好
时机。如果你自己去实现Web服务器、Web框架、Rack中间件,甚至是实现Rack的
替代品时,Rack::Lint都是一个很好的工具。
Rack的检查分两种,一种是静态检查,在它的call方法实现;另外一种是动态检查,
在它的each方法实现。
如果检查没有通过则抛出Rack::Lint::LintError,为了代码编写方便,定义了一个新
的assert方法
class LintError < RuntimeError; end
module Assertion
def assert(message, &block)
unless block.call
raise LintError, message
end
end
end
include Assertion
assert方法取一个消息参数和一个代码块,如果代码块执行的结果为false(或nil),那
么抛出LintError错误。
call检查
def call(env=nil)
dup._call(env)
end
def _call(env)
## It takes exactly one argument, the *environment*
assert("No env given") { env }
check_env env
73. 5.4. 程序开发中间件 67
env['rack.input'] = InputWrapper.new(env['rack.input'])
env['rack.errors'] = ErrorWrapper.new(env['rack.errors'])
## and returns an Array of exactly three values:
status, headers, @body = @app.call(env)
## The *status*,
check_status status
## the *headers*,
check_headers headers
## and the *body*.
check_content_type status, headers
check_content_length status, headers, env
[status, headers, self]
end
call主要检查下面的内容:
• 请求的环境(env)是否符合规格
• 响应的状态、头字段、内容类型以及内容长度
检查环境
def check_env(env)
## The environment must be an instance of Hash that includes
## CGI-like headers. The application is free to modify the
## environment.
assert("env #{env.inspect} is not a Hash, but #{env.class}") {
env.kind_of? Hash
}
环境必须是一个Hash。
if session = env['rack.session']
## store(key, value) (aliased as []=);
assert("session #{session.inspect} must respond to store and []=") {
session.respond_to?(:store) && session.respond_to?(:[]=)
}
## fetch(key, default = nil) (aliased as []);
assert("session #{session.inspect} must respond to fetch and []") {
session.respond_to?(:fetch) && session.respond_to?(:[])
}
74. 68 CHAPTER 5. 中间件:第二轮
## delete(key);
assert("session #{session.inspect} must respond to delete") {
session.respond_to?(:delete)
}
## clear;
assert("session #{session.inspect} must respond to clear") {
session.respond_to?(:clear)
}
end
如 果 环 境 包 含rack.session关 键 字, 它 应 该 保 存 请 求 的session, 它 必 须 是 一 个 类
似Hash的对象,也就是说,假设这个对象叫做session,那么它必须响应下列方
法:
• store方法和别名[]=方法,即可以用session.store(key,value)或者session[key]=value保
存关键字、值对。
• fetch方法和别名[],即可以用session.fetch(key,default=nil)或者session[key]获取
关键字对应的值。
• delete方法,即可以用session.delete(key)删除key对应的关键字、值对。
• clear,即session.clear可以清除所有的条目
显然,session不必一定是 Hash类型,这是Ruby编程常见的Duck Typing。
## <tt>rack.logger</tt>:: A common object interface for logging messages.
## The object must implement:
if logger = env['rack.logger']
## info(message, &block)
assert("logger #{logger.inspect} must respond to info") {
logger.respond_to?(:info)
}
## debug(message, &block)
assert("logger #{logger.inspect} must respond to debug") {
logger.respond_to?(:debug)
}
## warn(message, &block)
assert("logger #{logger.inspect} must respond to warn") {
logger.respond_to?(:warn)
}
## error(message, &block)
75. 5.4. 程序开发中间件 69
assert("logger #{logger.inspect} must respond to error") {
logger.respond_to?(:error)
}
## fatal(message, &block)
assert("logger #{logger.inspect} must respond to fatal") {
logger.respond_to?(:fatal)
}
end
如果环境中存在着rack.logger关键字,则Rack程序可以使用它进行日志工作。这个
对象必须响应下面的方法:
• info(message, &block)
• debug(message, &block)
• warn(message, &block)
• error(message, &block)
• fatal(message, &block)
这是在很多编程语言和框架日志的一个事实标准。
%w[REQUEST_METHOD SERVER_NAME SERVER_PORT
QUERY_STRING
rack.version rack.input rack.errors
rack.multithread rack.multiprocess rack.run_once].each { |header|
assert("env missing required key #{header}") { env.include? header }
}
对于Ruby应用服务器,Rack要求它们提供的env至少包含上述关键字。
env包括的关键字可以分作二类:
• 来自HTTP请求,类CGI的头。全部大写,只有一个部分(中间没有用“.”)。它
又包含两类:
◦ “HTTP_”开头的关键字。这些值直接来自客户端提供的HTTP请求头字
段,Web服务器在调用Rack之前必须在这些头字段之前加上“HTTP_”,
例如如果请求头中包含Accept字段,那么必须在环境中包含“HTTP_ACCEPT”。
有两个头例外:CONTENT_TYPE和CONTENT_LENGTH,它们之前不
应该加上“HTTP_”。
## The environment must not contain the keys
## <tt>HTTP_CONTENT_TYPE</tt> or <tt>HTTP_CONTENT_LENGTH</tt>
## (use the versions without <tt>HTTP_</tt>).
76. 70 CHAPTER 5. 中间件:第二轮
%w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header|
assert("env contains #{header}, must use #{header[5,-1]}") {
not env.include? header
}
}
◦ 从用户请求的其他部分得到,它们没有“HTTP_”这样的前缀。
REQUEST_METHOD、SERVER_NAME、SERVER_PORT和QUERY_STRING这
几个是必须提供的。
• 不是从HTTP请求消息直接得到的,一般来说小写。至少包括两部分,中间
用“.”分隔。它也包括两类:
◦ Rack保留, 前缀是“rack.”。 其中rack.version、 rack.input、 rack.errors、
rack.multithread 、rack.multiprocess和 rack.run_once这几个关键字是必须
提供的。
◦ Web服务器自己使用的,前缀必须不是“rack.”。例如in服务器可能使
用thin.xxx这样的关键字。
对上述这些必要的关键字对应的值,还有一系列规范:
## * <tt>rack.version</tt> must be an array of Integers.
assert("rack.version must be an Array, was #{env["rack.version"].class}") {
env["rack.version"].kind_of? Array
}
## * <tt>rack.url_scheme</tt> must either be +http+ or +https+.
assert("rack.url_scheme unknown: #{env["rack.url_scheme"].inspect}") {
%w[http https].include? env["rack.url_scheme"]
}
## * There must be a valid input stream in <tt>rack.input</tt>.
check_input env["rack.input"]
## * There must be a valid error stream in <tt>rack.errors</tt>.
check_error env["rack.errors"]
rack.version的值必须是一个数组,如[1, 0], [1, 1]分别表示Rack1.0和Rack1.1;rack.url_scheme的
值必须是http或者https;rack.input和rack.errors对应的值必须是合法的输入和错误
流,具体的要求稍后讨论。
## * The <tt>REQUEST_METHOD</tt> must be a valid token.
assert("REQUEST_METHOD unknown: #{env["REQUEST_METHOD"]}") {
env["REQUEST_METHOD"] =~ /A[0-9A-Za-z!#$%&'*+.^_`|~-]+z/
}
## * The <tt>SCRIPT_NAME</tt>, if non-empty, must start with <tt>/</tt>
assert("SCRIPT_NAME must start with /") {
77. 5.4. 程序开发中间件 71
!env.include?("SCRIPT_NAME") ||
env["SCRIPT_NAME"] == "" ||
env["SCRIPT_NAME"] =~ /A//
}
## * The <tt>PATH_INFO</tt>, if non-empty, must start with <tt>/</tt>
assert("PATH_INFO must start with /") {
!env.include?("PATH_INFO") ||
env["PATH_INFO"] == "" ||
env["PATH_INFO"] =~ /A//
}
## * The <tt>CONTENT_LENGTH</tt>, if given, must consist of digits only.
assert("Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}") {
!env.include?("CONTENT_LENGTH") || env["CONTENT_LENGTH"] =~ /Ad+z/
}
## * One of <tt>SCRIPT_NAME</tt> or <tt>PATH_INFO</tt> must be
## set. <tt>PATH_INFO</tt> should be <tt>/</tt> if
## <tt>SCRIPT_NAME</tt> is empty.
assert("One of SCRIPT_NAME or PATH_INFO must be set
(make PATH_INFO '/' if SCRIPT_NAME is empty)") {
env["SCRIPT_NAME"] || env["PATH_INFO"]
}
## <tt>SCRIPT_NAME</tt> never should be <tt>/</tt>, but instead be empty.
assert("SCRIPT_NAME cannot be '/', make it '' and PATH_INFO '/'") {
env["SCRIPT_NAME"] != "/"
}
正则表达式中,“A”表示字符串的开始,“z”表示字符串的结束。上面这些语句分
别指出:
• REQUEST_METHOD是一个字符串,值不可以为空,匹配上述正则表达式。
• SCRIPT_NAME和PATH_INFO两者必须至少有一个。
◦ 如果存在SCRIPT_NAME的话,它的值可以是空字符串,也可以是一个
以斜杠/开始的字符串。
◦ 对PATH_INFO的要求和SCRIPT_NAME完全一样。
但是SCRIPT_NAME代表的是一个应用的名字,所以不可以是单独的一个斜
杠,必要的话可以让SCRIPT_NAME为空字符串,让PATH_INFO为一个单独
的斜杠。
• CONTENT_LENGTH不是必须的。但如果有的话,必须一个字符串,其中应
该完全都是数字。
78. 72 CHAPTER 5. 中间件:第二轮
现在回过头来看看rack.input对应的输入流需要满足什么条件:
## === The Input Stream
##
## The input stream is an IO-like object which contains the raw HTTP
## POST data.
def check_input(input)
## When applicable, its external encoding must be "ASCII-8BIT" and it
## must be opened in binary mode, for Ruby 1.9 compatibility.
assert("rack.input #{input} does not have ASCII-8BIT as its external encoding") {
input.external_encoding.name == "ASCII-8BIT"
} if input.respond_to?(:external_encoding)
assert("rack.input #{input} is not opened in binary mode") {
input.binmode?
} if input.respond_to?(:binmode?)
## The input stream must respond to +gets+, +each+, +read+ and +rewind+.
[:gets, :each, :read, :rewind].each { |method|
assert("rack.input #{input} does not respond to ##{method}") {
input.respond_to? method
}
}
end
输入流会包含原始的HTTP POST数据。如果合适的话,它应该使用ASCII-8BIT外部
编码(external_encoding、用二进制模式打开。外部编码是Ruby 1.9出现的一个概念,
它表示保存在文件中的文本编码。 (与之对应的内部编码则是用于在Ruby中表示文
本的编码)。二进制模式可以保证读到原始的数据。
除了编码的要求之外,输入流必须能够响应:gets, each, read, rewind这4个方法。
rack.errors对应的错误流要求稍低些,能够响应puts、write和flush方法即可。
## === The Error Stream
def check_error(error)
## The error stream must respond to +puts+, +write+ and +flush+.
[:puts, :write, :flush].each { |method|
assert("rack.error #{error} does not respond to ##{method}") {
error.respond_to? method
}
}
end
检查状态码
79. 5.4. 程序开发中间件 73
## === The Status
def check_status(status)
## This is an HTTP status. When parsed as integer (+to_i+), it must be
## greater than or equal to 100.
assert("Status must be >=100 seen as integer") { status.to_i >= 100 }
end
响应消息中返回的状态码必须能够用to_i转换为一个整数,这个整数必须大于100
检查响应头
响应头必须能够响应each方法,并且每次产生一个关键字和一个对应的值。Hash符
合这个条件,我们前面谈到的Rack::Utils::HeadersHash被很多Rack中间件用于响应
头。
## === The Headers
def check_headers(header)
## The header must respond to +each+, and yield values of key and value.
assert("headers object should respond to #each,
but doesn't (got #{header.class} as headers)") {
header.respond_to? :each
}
header.each { |key, value|
## The header keys must be Strings.
assert("header key must be a string, was #{key.class}") {
key.kind_of? String
}
## The header must not contain a +Status+ key,
assert("header must not contain Status") { key.downcase != "status" }
## contain keys with <tt>:</tt> or newlines in their name,
assert("header names must not contain : or n") { key !~ /[:n]/ }
## contain keys names that end in <tt>-</tt> or <tt>_</tt>,
assert("header names must not end in - or _") { key !~ /[-_]z/ }
## but only contain keys that consist of
## letters, digits, <tt>_</tt> or <tt>-</tt> and start with a letter.
assert("invalid header name: #{key}")
{ key =~ /A[a-zA-Z][a-zA-Z0-9_-]*z/ }
## The values of the header must be Strings,
assert("a header value must be a String, but the value of " +
"'#{key}' is a #{value.class}") { value.kind_of? String }
## consisting of lines (for multiple header values, e.g. multiple
## <tt>Set-Cookie</tt> values) seperated by "n".
value.split("n").each { |item|
## The lines must not contain characters below 037.
assert("invalid header value #{key}: #{item.inspect}") {
item !~ /[000-037]/
80. 74 CHAPTER 5. 中间件:第二轮
}
}
}
end
对于每一个关键字/值对,即每一个响应头字段和它们的值,分别要求如下:
• 对关键字key而言
◦ key必须是字符串,而不是Symbol
◦ key不能是“Status”这个字符串
◦ key不能包括“:”和“n”这两个字符
◦ key必须以字母开头,后跟多个字母、数字、“-”或者“_”,但不能以“-”或
者“_”这两个字符结尾。
• 对它们的值(value)来说
◦ value必须是字符串
◦ value的值不可以包含值为037以下的字符,即控制字符
检查内容类型
## === The Content-Type
def check_content_type(status, headers)
headers.each { |key, value|
## There must be a <tt>Content-Type</tt>, except when the
## +Status+ is 1xx, 204 or 304, in which case there must be none
## given.
if key.downcase == "content-type"
assert("Content-Type header found in #{status} response, not allowed") {
not Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include? status.to_i
}
return
end
}
assert("No Content-Type header found") {
Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include? status.to_i
}
end
headers.each检查每一个头,如果发现’Content-Type’,可能发生两种状况:
• 如果assert发现,状态码不是1xx、204或者304这个条件不成立–即它们确实
是1xx、204或者304中间的某一个状态,则抛出异常
81. 5.4. 程序开发中间件 75
• 如果状态码不是1xx、204或者304这个条件成立,那么执行assert后面的代码,
即return,后续代码不会执行
如果没有发现Content-Type,则程序会做each之后的那个assert,这次要求状态必须
是1xx、204或者304。
综合两者就是说,状态码是1xx、204或者304的响应必须不能有Content-Type头,其
他所有的状态必须有Content-Type头。
检查内容长度
## === The Content-Length
def check_content_length(status, headers, env)
headers.each { |key, value|
if key.downcase == 'content-length'
## There must not be a <tt>Content-Length</tt> header when the
## +Status+ is 1xx, 204 or 304.
assert("Content-Length header found in #{status} response, not allowed") {
not Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include? status.to_i
}
..............
..............
..............
return
end
}
end
代码开头部分和Content-Type 一样:对那么状态为1xx、204或者304的响应而言,必
须不能设置Content-Length。
如果不是这些状态,而且确实存在着Content-Length,就要检查Content-Length的值
是否和实际的内容长度一致。上面的代码中被省略的代码如下:
bytes = 0
string_body = true
if @body.respond_to?(:to_ary)
@body.each { |part|
unless part.kind_of?(String)
string_body = false
break
end
bytes += Rack::Utils.bytesize(part)
}
82. 76 CHAPTER 5. 中间件:第二轮
if env["REQUEST_METHOD"] == "HEAD"
assert("Response body was given for HEAD request, but should be empty") {
bytes == 0
}
else
if string_body
assert("Content-Length header was #{value}, but should be #{bytes}") {
value == bytes.to_s
}
end
end
end
代码只判断响应体是数组或者能够转换成一个数组的情况。
首先计算是这个body的长度,以及body完全为字符串。
@body.each { |part|
unless part.kind_of?(String)
string_body = false
break
end
bytes += Rack::Utils.bytesize(part)
}
接下去检查当请求方法为GET时,响应体的长度必须为0。如果不是GET而且整个
响应体确实是字符串时,那么Content-Length响应头的值(value)必须等于响应体实
际的长度。
each 检查
call方法是一种静态的检查,each方法则对响应体的进行动态检查。
## === The Body
def each
@closed = false
## The Body must respond to +each+
@body.each { |part|
## and must only yield String values.
assert("Body yielded non-string value #{part.inspect}") {
part.kind_of? String
}
yield part
}
83. 5.4. 程序开发中间件 77
首先检查响应体必须能够响应each方法,并且每次必须产生一个字符串(part)。
if @body.respond_to?(:to_path)
assert("The file identified by body.to_path does not exist") {
::File.exist? @body.to_path
}
end
而如果响应体能够响应to_path方法,那么它返回的值应该是一个路径名,这个路径
名所代表的文件应该存在。
5.4.3 Rack::Reloader
某些时候,当修改了应用程序以后,我们希望框架能够重新载入修改后的代码。例
如,在开发的过程中,我们不希望每次改动代码都要重启整个Web服务器。正因为
如此,Rail提供了development和production等不同的环境。
例如我们有一个简单的程序,包括两个文件。一个是test-reloader.ru:
require 'simple'
run Simple.new
另外一个simple.rb:
class Simple
def call(env)
[200, {'Content-Type'=>'text/html'},["first"]]
end
end
现在用rackup test-reloader.ru启动程序。在浏览器输入http://localhost:9292,将
得到: first。然后我们修改代码,把simple.rb中的first改为second。不管你如何
刷新,浏览器得到的永远是first,除非你退出并重启rackup。
使用Rack::Reloader很简单,只需要在test-reloader.rb加入一行:
use Rack::Reloader
require 'simple'
run Simple.new
重新启动rackup,但这一次如果以按上述过程把first改为second,浏览器输出的结
果就会马上发生改变(可能需要刷新几次浏览器,因为重新加载有一定的时间–下面
我们可以看到如何配置这个时间)。
由于Rack::Reloader非常高效,你甚至可以生产环境下用它来重新载入源代码。
84. 78 CHAPTER 5. 中间件:第二轮
Rack::Reloader实现
def initialize(app, cooldown = 10, backend = Stat)
@app = app
@cooldown = cooldown
@last = (Time.now - cooldown)
@cache = {}
@mtimes = {}
extend backend
end
在初始化Rack::Reloader空间件的时候,你可以指定间隔的时间(cooldown),以及一
个模块backend。这个模块提供一个rotation方法,用来计算所有已加载的文件和相
关信息。
def call(env)
if @cooldown and Time.now > @last + @cooldown
if Thread.list.size > 1
Thread.exclusive{ reload! }
else
reload!
end
@last = Time.now
end
@app.call(env)
end
Rack在每一个请求到达的时候进行检查,只有当超过设定的间隔时间,它才可能去
做一个重载。它同时判断当前是否有多个线程存在(read.list.size > 1):如果只有一
个线程,那么直接调用reload!,不然的话,则在一个临界区内执行reload!。
read.exclusive在临界区内执行代码,这个临界区是针对整个Ruby进程的,在临界
区内,所有已经存在的线程将不被调度。虽然不完全正确,但你大致可以认为这
让Ruby的所有其他Green read暂停运行。
def reload!(stderr = $stderr)
rotation do |file, mtime|
previous_mtime = @mtimes[file] ||= mtime
safe_load(file, mtime, stderr) if mtime > previous_mtime
end
end
85. 5.4. 程序开发中间件 79
rotation是由Stat模块提供的,它提供所有已加载的文件和对应的最后修改时间。如
果此文件上次最后加载的时间是在最后修改时间之前,即加载后又被修改了,那么
程序调用safe_load执行真正的加载工作。
# A safe Kernel::load, issuing the hooks depending on the results
def safe_load(file, mtime, stderr = $stderr)
load(file)
stderr.puts "#{self.class}: reloaded `#{file}'"
file
rescue LoadError, SyntaxError => ex
stderr.puts ex
ensure
@mtimes[file] = mtime
end
safe_load确保加载过程出现的加载错误和语法错误不会抛出异常,另外它确保文件
的最后加载时间设置为当前的最后修改时间。
现在我们来看看Stat模块的rotation是如何实现的。
def rotation
files = [$0, *$LOADED_FEATURES].uniq
paths = ['./', *$LOAD_PATH].uniq
files.map{|file|
next if file =~ /.(so|bundle)$/ # cannot reload compiled files
found, stat = figure_path(file, paths)
next unless found && stat && mtime = stat.mtime
@cache[file] = found
yield(found, mtime)
}.compact
end
rotation首先获得当前的程序名字($0)和所有已经被加载的文件($LOADED_FEATURES),
保存到files变量。接着把paths设置为所有的加载路径($LOAD_PATH)。
对每一个文件,程序首先判断它是否为C库–它们是无法重新加载的(so是linux、
而bundle是mac os x下面的库文件后缀)。接着ratation调用figure_path方法去寻找文
件,如果找到文件,则用该文件和它的最后修改时间去调用rotation后面的代码
块–我们已经在reload!程序中看到了。
figure_path方法分两种情况:
• 如果给定文件名file是绝对路径,那么直接调用safe_stat(file)
86. 80 CHAPTER 5. 中间件:第二轮
• 否 则, 尝 试 所 有 的Ruby加 载 路 径, 把 它 们 和 文 件 名File.join起 来, 再 去 调
用safe_stat(file),直至真正的文件找到为止
具体的实现请参见Rack::Reloader的源代码。
5.4.4 Rack::Runtime
在日志、性能评测、分析的过程中,我们希望知道一个请求的处理时间,Runtime中
间件计算这个时间,并把它放在X-Runtime响应头中。代码解释了一切:
class Runtime
def initialize(app, name = nil)
@app = app
@header_name = "X-Runtime"
@header_name << "-#{name}" if name
end
def call(env)
start_time = Time.now
status, headers, body = @app.call(env)
request_time = Time.now - start_time
if !headers.has_key?(@header_name)
headers[@header_name] = "%0.6f" % request_time
end
[status, headers, body]
end
end
有一点需要注意,call并不是处理请求唯一的地方,很多展现逻辑往往是在body的each中
实现的。
5.4.5 Rack::Sendfile
请注意:本节内容仅供理解SendFile机制所用,Rack::Sendfile的实现并非如下所述。
本节内容也是不完整的,还需要补充lighttpd和apache。本节可能在后续版本中删
除。
Web应用程序经常需要处理大文件,包括图片、PDF、Word、视音频文件等供客户
端下载和展示。如果文件和应用逻辑没有任何关系,那么可以完全由代理服务器
(如ngnix、lighttpd、apache等)处理,无需Ruby应用服务器的任何处理。
但某些时候,你需要根据用户请求的URL搜索具体的文件位置,或者你需要对文件
的存取进行控制。典型的例子是某个文件只能被某些用户存取。这个时候请求必须
97. 5.6. 会话管理 91
设置cookie头
真正把cookie写到响应头的方法是Utils的set_cookie_header!方法。
set_cookie_header!方法可以分为两个主要部分。首先是处理value为Hash的情况:
def set_cookie_header!(header, key, value)
case value
when Hash
domain = "; domain=" + value[:domain] if value[:domain]
path = "; path=" + value[:path] if value[:path]
# According to RFC 2109, we need dashes here.
# N.B.: cgi.rb uses spaces...
expires = "; expires=" + value[:expires].clone.gmtime.
strftime("%a, %d-%b-%Y %H:%M:%S GMT") if value[:expires]
secure = "; secure" if value[:secure]
httponly = "; HttpOnly" if value[:httponly]
value = value[:value]
end
Rack::Session::Cookie中间件调用set_cookie_header!方法的时候,value就是一个Hash,
其中包含了和session的各种cookie选项。根据5.6.1(p. 86)描述的cookie相关规范,代
码实现下面的功能:
• 如果value[:domain]选项存在,则cookie的domain属性为“; domain= + value[:domain]”
• 如果value[:path]选项存在,则cookie的path属性为 “; path= + value[:path]”
• 如果value[:expires]选项存在,则cookie的expires属性为“; expires= +value[:expires]转
换为GMT时间格式的值”
• 如果value[:secure]选项存在,则cookie的secure属性为“; secure” (回忆这个cookie属
性其实是一个boolen值)
• 如果value[: httponly]选项存在,则cookie的secure属性为“; HttpOnly” (HttpOnly是
一个安全相关的cookie选项,并非所有浏览器都支持)
• 从value哈希中取得真正的key对应的value,即value[:value],并将它设置为value变
量的值
接下去的任务是真正地设置Set-Cookie响应头字段的值:
value = [value] unless Array === value
cookie = escape(key) + "=" +
value.map { |v| escape v }.join("&") +
"#{domain}#{path}#{expires}#{secure}#{httponly}"
98. 92 CHAPTER 5. 中间件:第二轮
case header["Set-Cookie"]
when Array
header["Set-Cookie"] << cookie
when String
header["Set-Cookie"] = [header["Set-Cookie"], cookie]
when nil
header["Set-Cookie"] = cookie
end
nil
如果value中包括多个值,用“&”符号它们连接起来,然后把所有的cookie属性加在
后面,我们就得到了一个完整的cookie值,形如:
rack.session=......;domain=....;path=.....;expires=...;secure;HttpOnly
代码的最后判断header中是否已经存在Set-Cookie的值:如果有的话,header[Set-
Cookie]加变成包含多个cookie的数组,不然的话,直接设置为当前的cookie。
记性好的读者可能会注意到5.4.2(p. 67)中我们曾经讲到过header的所有值必须被字符
串,包括Set-Cookie的检查:
## === The Headers
def check_headers(header)
.....
header.each { |key, value|
........
## The values of the header must be Strings,
assert("a header value must be a String, but the value of " +
"'#{key}' is a #{value.class}") { value.kind_of? String }
## consisting of lines (for multiple header values, e.g. multiple
## <tt>Set-Cookie</tt> values) seperated by "n".
value.split("n").each { |item|
## The lines must not contain characters below 037.
assert("invalid header value #{key}: #{item.inspect}") {
item !~ /[000-037]/
}
}
}
end
如果是多个cookie的话,那么Set-Cookie对应的多个cookie也应该用“n”分开,而不
是一个数组。
确实,如果header是一个普通的Hash,那么上面的检查就会出错。然而,某些中间
件(我们前面已经看到过)会使用一个HeaderHash,它的each实现如下:
def each