Ruby Rack及其应用(下)
原文链接 http://huiming.io/2017/04/30/ruby-rack-2.html
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。
- 目录 {:toc}
前言
Ruby Rack及其应用(上)对Rack的定义、基本原理和构建方法做了介绍,并且提到Rails、Sinatra等web框架都是在Rack之上构建的。现在让我们来看几个Rack作为中间件的典型例子:这也是Rack应用最活跃的领域。
如何使用Rack中间件
在开始之前让我们先了解一下如何使用中间件,这样你就可以动手尝试一下后面的例子。在之前的文章里我已经说明了如何在config.ru
里配置中间件,这对于任何基于Rack的web框架,如Rails、Sinatra,都是可行的。但Rails和Sinatra也有自己的方式来配置中间件。你应该全面了解这些方法,因为以不同的方式配置中间件,得到的中间件栈可能是不同的——有时候中间件在栈内的顺序很重要。
- Rails可以通过
config.middleware
来配置中间件,可以在application.rb
或者environments/<environment>.rb
文件中进行配置,具体请参考Rails Guide。 - Sinatra的方式比较简单,直接在Rack应用中使用
use
来配置即可,与config.ru
十分相似,具体请参考Sinatra README。
另外,要注意:在config.ru
中配置的中间件处于中间件栈的上层,在Rails或Sinatra应用中配置的中间件处于下层,用户请求自上而下通过栈内的中间件,任何一个中间件都可以终止用户请求而不向下传递它。
Auth
Rack中间件可以用来做HTTP鉴权(authentication and authorization)。考虑一个简单的例子:假设你有一个Rack app,只限管理员使用。那么你可以使用Rack::Auth::Basic
这个中间件,例如:
# config.ru
require 'admin_app'
use Rack::Auth::Basic, 'my auth realm' do |username, password|
# Your method returns true when passing. Otherwise the middleware returns
# 400 to client.
your_auth_method(username, password)
end
run AdminApp
说明:
Rack::Auth::Basic
的实现在rack gem(lib/rack/auth/basic.rb)里,在此不必require
。- 它使用HTTP Basic Auth做鉴权,在生产环境下要结合HTTPS使用才安全。
配置了中间件以后,AdminApp不用做任何改变就被“用户名/密码”保护了起来,不论它是Rails、Sinatra或者别的什么基于Rack的应用。
这个例子虽然简单,但值得我们分析一下Rack::Auth::Basic
的实现——如果我们想实现一个自己的鉴权中间件或者我们不想用Basic Auth的话。
它的实现也很简单,包括以下几个文件,其中需要说明的地方我用中文做了注释,英文注释是原有的。
# lib/rack/auth/basic.rb
require 'rack/auth/abstract/handler'
require 'rack/auth/abstract/request'
module Rack
module Auth
# Rack::Auth::Basic implements HTTP Basic Authentication, as per RFC 2617.
#
# Initialize with the Rack application that you want protecting,
# and a block that checks if a username and password pair are valid.
class Basic < AbstractHandler
# 每个Rack都要响应的call方法
def call(env)
auth = Basic::Request.new(env)
# unauthorized 方法返回401,要求客户端以指定的Auth方法(此处是Basic Auth)
# 提供用户名/密码。我们可以在这里指定其他Auth方法,如OAuth
return unauthorized unless auth.provided?
# bad_request 方法返回400,提示客户端Auth方法错误,即非Basic Auth
return bad_request unless auth.basic?
if valid?(auth)
# 鉴权成功后把username保存在环境变量里,以便其他的Rack中间件和应用访问
# 这是一种常用的在Rack中间件和应用之间传递信息的方式
env['REMOTE_USER'] = auth.username
# 调用下一个Rack的call方法
return @app.call(env)
end
unauthorized
end
private
# 参考下面unauthorized方法的实现了解challenge的作用
def challenge
'Basic realm="%s"' % realm
end
def valid?(auth)
@authenticator.call(*auth.credentials) #*)
end
class Request < Auth::AbstractRequest
def basic?
"basic" == scheme
end
def credentials
@credentials ||= params.unpack("m*").first.split(/:/, 2)
end
def username
credentials.first
end
end
end
end
end
# lib/rack/auth/abstract/handler.rb
module Rack
module Auth
# Rack::Auth::AbstractHandler implements common authentication functionality.
#
# +realm+ should be set for all handlers.
class AbstractHandler
attr_accessor :realm
# 每个Rack中间件的initialize方法的第一个参数都是app,即在中间件栈中下一级的
# 中间件或应用,其他的参数可选。这些参数都由Rack Builder传入,比如有
#
# use Rack::Auth::Basic, 'my auth realm' do |username, password|
# ...
# end
# run AdminApp
#
# 则app=AdminApp, realm='my auth realm', authenticator=block
def initialize(app, realm=nil, &authenticator)
@app, @realm, @authenticator = app, realm, authenticator
end
private
# 上面已经有了unauthorized方法的应用,需要说明的是此处challenge是一个方法:
# Ruby方法的缺省值不必是常量,而且取值是在所在方法被调用时发生的。
# 另外,HTTP server通过通过WWW-Authenticate header指定Auth的方法,具体可参考
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate
def unauthorized(www_authenticate = challenge)
return [ 401,
{ CONTENT_TYPE => 'text/plain',
CONTENT_LENGTH => '0',
'WWW-Authenticate' => www_authenticate.to_s },
[]
]
end
def bad_request
return [ 400,
{ CONTENT_TYPE => 'text/plain',
CONTENT_LENGTH => '0' },
[]
]
end
end
end
end
# lib/rack/auth/abstract/request.rb
module Rack
module Auth
# 这个类主要用于解析HTTP客户端请求的Authorization header,从中提取Auth方法
# 和用户名、密码等信息。参考这里了解更多关于Authorization
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
class AbstractRequest
def initialize(env)
@env = env
end
def provided?
!authorization_key.nil?
end
def parts
@parts ||= @env[authorization_key].split(' ', 2)
end
def scheme
@scheme ||= parts.first && parts.first.downcase
end
def params
@params ||= parts.last
end
private
# 按照CGI的方式,HTTP客户端请求的header都会被冠以“HTTP_”前缀、全部大写、保存在env里,
# 因此Authorization就成了HTTP_AUTHORIZATION
AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION']
def authorization_key
@authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) }
end
end
end
end
当然,我们不一定要实现一个自己的Auth中间件,可以借助于以下Gems(也是中间件):
如果你了解了原理,使用别人开发的中间件也会更得心应手。
Session
Rack中间件还可以实现HTTP session。以Sinatra为例,文档上说可以这样启用session:
require 'sinatra/base'
class App < Sinatra::Base
enable :sessions # 启用session
get '/sessions' do
"value = " << session[:value].inspect # 使用session方法读
end
get '/sessions/:value' do
session['value'] = params['value'] # 使用session方法写
end
end
实际上以上代码相当于:
require 'sinatra/base'
class App < Sinatra::Base
# 启用session
use Rack::Session::Cookie, :secret => SecureRandom.hex(64)
# ...
end
在上面的代码中:Rack::Session::Cookie
就是用于实现session的中间件,use
是Sinatra用于配制中间件的方法。另外,用于读写session变量的session
方法也很简单:
# lib/sinatra/base.rb
module Sinatra
module Helpers
def session
request.session
end
# ...
end
end
其中request#session的实现是:
# lib/rack/request.rb
module Rack
class Request
def session; @env['rack.session'] ||= {} end
# ...
end
end
至于这个@env['rack.session']
是怎么来的,让我们了解一下Rack::Session::Cookie
的实现你就明白了。以下需要注意的地方我用中文做了注释,英文注释是原有的,也要注意。
# lib/rack/session/cookie.rb
module Rack
module Session
# HTTP session的主要行为都是在Abstract::ID里实现的,子类只需要实现session对象的
# 保存和读取。除了cookie,还可以把它保存在Redis之类的数据库里,甚至直接保存在内存,
# 如Rack::Session::Pool。
class Cookie < Abstract::ID
def get_session(env, sid)
# ...
end
def set_session(env, sid, new_session, options)
# ...
end
def destroy_session(env, sid, options)
# ...
end
# ...
end
end
end
# lib/rack/session/abstract/id.rb
module Rack
module Session
module Abstract
# 注意这个常量:Rack中间件把session对象保存在env的这个key下,也就是说,
# 其他的Rack中间件和应用只有通过这个key才能访问session。
ENV_SESSION_KEY = 'rack.session'.freeze
ENV_SESSION_OPTIONS_KEY = 'rack.session.options'.freeze
# 这就是我们在上面的Sinatra示例中通过session方法所访问的对象
class SessionHash
# ...
end
# ID sets up a basic framework for implementing an id based sessioning
# service. Cookies sent to the client for maintaining sessions will only
# contain an id reference. Only #get_session and #set_session are
# required to be overwritten.
#
# All parameters are optional.
# * :key determines the name of the cookie, by default it is
# 'rack.session'
# * :path, :domain, :expire_after, :secure, and :httponly set the related
# cookie options as by Rack::Response#add_cookie
# * :skip will not a set a cookie in the response nor update the session state
# * :defer will not set a cookie in the response but still update the session
# state if it is used with a backend
# ...
class ID
# 注意:下面这个key虽然与ENV_SESSION_KEY的值相同,但意义不同:前者用于设置cookie,
# 我们可以而且应当给它赋一个有意义的值,如“example.com”;后者用于在env中存取
# seesion对象,我们无法、也不应该改变它的值。
DEFAULT_OPTIONS = {
:key => 'rack.session',
:path => '/',
:domain => nil,
:expire_after => nil,
:secure => false,
:httponly => true,
:defer => false,
:renew => false,
:sidbits => 128,
:cookie_only => true,
:secure_random => (::SecureRandom rescue false)
}
attr_reader :key, :default_options
def initialize(app, options={})
@app = app
@default_options = self.class::DEFAULT_OPTIONS.merge(options)
# ...
end
def call(env)
context(env)
end
def context(env, app=@app)
prepare_session(env)
status, headers, body = app.call(env)
commit_session(env, status, headers, body)
end
private
def prepare_session(env)
session_was = env[ENV_SESSION_KEY]
# session对象在此建立并保存在env里,但session可以是lazy loading的,
# 只在读取/写入时才访问实际的session存储。
env[ENV_SESSION_KEY] = session_class.new(self, env)
env[ENV_SESSION_OPTIONS_KEY] = @default_options.dup
env[ENV_SESSION_KEY].merge! session_was if session_was
end
# Acquires the session from the environment and the session id from
# the session options and passes them to #set_session. If successful
# and the :defer option is not true, a cookie will be added to the
# response with the session id.
def commit_session(env, status, headers, body)
session = env[ENV_SESSION_KEY]
options = session.options
# ...
end
# ...
# Allow subclasses to prepare_session for different Session classes
def session_class
SessionHash
end
# All thread safety and session retrieval procedures should occur here.
# Should return [session_id, session].
# If nil is provided as the session id, generation of a new valid id
# should occur within.
def get_session(env, sid)
raise '#get_session not implemented.'
end
# All thread safety and session storage procedures should occur here.
# Must return the session id if the session was saved successfully, or
# false if the session could not be saved.
def set_session(env, sid, session, options)
raise '#set_session not implemented.'
end
# All thread safety and session destroy procedures should occur here.
# Should return a new session id or nil if options[:drop]
def destroy_session(env, sid, options)
raise '#destroy_session not implemented'
end
end
end
end
end
以Rack::Session::Abstract::ID
为基础,我们很容实现自己的session中间件。另外,如果你想用Redis做session存贮,可以考虑redis-rack这个Gem.
Log
Rack中间件还可以做日志,这很容实现。让我来举两个简单的例子。
其一,在异常错误时做记录或者输出诊断信息:
class ExceptionCatcher
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
rescue Exception => e
# You can log it anywhere you like ...
# And output any diagnostic info you prefer
[500, ...]
end
end
当你在Rails或者Sinatra dev server上做开发时,经常可以看到类似的诊断输出,实际上都是通过这样的中间件完成的。顺便说,Sinatra使用的中间件是Sinatra::ShowExceptions
,Rails也有对应的,不妨查看一下它的中间件栈。
其二,对每个HTTP请求做记录,就像Apache的access日志那样。不过你可以记得更多一些,比如完成一次请求的耗时。在这方面Rack::CommonLogger
已经做得不错了,不妨参考一下它的实现。
另外值得指出的是,当你要输出日志的时候,你需要一个IO输出对象,这时你有几个选择:
- env['rack.errors']:一个error stream对象,该对象支持
puts
和flush
方法。按照Rack规范,该对象必须由Rack服务器(如Phusion Passenger)提供。 - evn['rack.logger']:"A common object interface for logging messages.",支持
info
、debug
等方法,但不是Rack服务器必须提供的。Rack::Logger
中间件利用env['rack.errors']提供了一个简单的实现。