Sinatra Ruby Rack 及其应用 (下)

academus · May 24, 2017 · Last by sitoto replied at August 29, 2021 · 12384 hits
Topic has been selected as the excellent topic by the admin.

前言

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

说明:

  1. Rack::Auth::Basic的实现在 rack gem(lib/rack/auth/basic.rb)里,在此不必require
  2. 它使用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 做存贮,可以考虑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 对象,该对象支持putsflush方法。按照 Rack 规范,该对象必须由 Rack 服务器(如 Phusion Passenger)提供。
  • evn['rack.logger']:"A common object interface for logging messages.",支持infodebug等方法,但不是 Rack 服务器必须提供的。Rack::Logger中间件利用 env['rack.errors'] 提供了一个简单的实现。

最后,欢迎你关注我的博客,了解更多技术资讯 ^_^

jasl mark as excellent topic. 24 May 18:40

刚好最近在看这个 感谢

高手!怎么没有后续文章了。。。

You need to Sign in before reply, if you don't have an account, please Sign up first.