Sinatra Ruby Rack 及其应用 (上)

academus · 2016年11月14日 · 最后由 3014zhangshuo 回复于 2019年04月04日 · 13050 次阅读

前言

你可能听说过 Rails、Sinatra 这些 Ruby Web 框架,也可能尝试过其中一、两个,但如果你还不了解 Rack 甚至根本没听说过它,那么你的 Ruby Web 开发还停留在表面:Ruby Rack 是前面这些 Ruby Web 框架的基础,Rails 和 Sinatra 都建立在它之上;不了解 Rack 的原理就无法真正理解你的 Ruby Web 应用的架构与工作机制、对一些复杂的问题也无能无力。任何一个正经的 Ruby Web 开发者都应该了解、掌握 Rack。

本文将深入浅出地介绍 Ruby Rack 和它的一些典型应用,如 Profiler、Logger 和 Session 等。

Ruby Rack

我在《Web 全栈技术指南》中简单介绍过 Ruby Rack[^1]。下面的内容是它的扩充与深入。

什么是 Ruby Rack

Ruby Rack 是一个接口,用于 Ruby Web 应用与应用服务器之间的交互,如图所示:

Ruby Rack

最左边的 User Agent 就是浏览器等客户端,它发起 HTTP 请求;中间的 Rack Server 是应用服务器 [^2],它响应 HTTP 请求,并调用我们的 Rack 应用;最右边是我们的应用程序——它可能是一个 Rails 或者 Sinatra 应用。Rack 服务器和 Rack 应用程序之间通过 Rack 接口交互。

那么 Rack 接口是怎样的?就像这样:

# hello.rb - v0

app = proc do |env|
  ['200', {'Content-Type' => 'text/html'}, ['Hello, Rack!']]
end

这是一个最小的可以工作的 Rack 应用程序,它揭示了 Rack 接口:

  • 一个响应call方法的对象(任何类型的对象都可以,上面只是以 proc 为例)
  • 接受一个 Hash 类型的环境变量作为输入参数(它包含了全部的 HTTP 请求信息)
  • 返回一个包含三个元素的数组,依次是:
    1. HTTP 应答代码(status code)
    2. 一个 Hash 类型的对象,包含 HTTP 应答头部信息(header)
    3. 一个响应each方法的对象,其结果将作为 HTTP 应答消息的主体(body)

很简单不是么?(难的我都放在后面了,^_-)

只要再加两行代码,这个迷你的 Web 服务就能正式运行起来:

# hello.rb - v1

require 'rack'

app = proc do |env|
  ['200', {'Content-Type' => 'text/html'}, ['Hello, Rack!']]
end

Rack::Handler::WEBrick.run(app, :Port => 8090, :Host => '0.0.0.0')

在最后一行,我们用 Webrick[^3] 这个 Rack 服务器来 run 我们的 Rack 应用。

要运行上面的代码,先安装 Gem rack(如过你安装过 Rails 或者 Sinatra 那么它已经作为依赖被安装过了):

gem install rack

假设以上代码保存在文件hello.rb中,执行

ruby hello.rb

就把我们的迷你服务器启动了。在浏览器中访问http://localhost:8090,快试试!

Rack Middleware

Rack 不是那么简单:现在让我们了解一下强大的 Rack 中间件(middleware)。

以下是一个中间件的例子 [^4]:

# timing.rb - v1

class Timing
  def initialize(app)
    @app = app
  end

  def call(env)
    ts = Time.now
    status, headers, body = @app.call(env)
    elapsed_time = Time.now - ts
    puts "Timing: #{env['REQUEST_METHOD']} #{env['REQUEST_URI']} #{elapsed_time.round(3)}"
    return [status, headers, body]
  end
end

我们可以这么使用它 [^5]:

# hello.rb - v2

require 'rack'
require './timing.rb'

app = proc do |env|
  ['200', {'Content-Type' => 'text/html'}, ['Hello, Rack!']]
end

Rack::Handler::WEBrick.run(Timing.new(app), :Port => 8090, :Host => '0.0.0.0')

快试试!看看现在我们的 Rack 应用有什么变化。

现在我来解释一下上面的程序。

Rack 中间件就是一个类,如上面的Timing,其对象响应一个call方法,这个方法的输入、输出规格与一般 Rack 应用一样。因此Timing.new(app)可以作为一个 Rack 应用直接传递给Rack::Handler::WEBrick.run。实际上,中间件可以这样一层套一层地层层嵌套下去,最后仍得出一个可以 call 的 Rack 应用。

Rack 中间件可以实现非常强大的功能。在上面的例子中,我们的 Timing 中间件为每一次调用计时,并把结果打印出来。这相当于一个 profiler。实际上中间件能做的事情更多:它可以检查内嵌应用程序@app的输入、输出,还可以修改它们。因此它还可以用于鉴权(authentication/authorization)、日志,或者给内嵌应用提供一些额外的功能,如 Session 等等。稍后我们会看到两个实际的例子。

rackup 和 Rack::Builder

rackup 和 Rack::Builder 都是 Gem rack 提供的工具,方便我们使用、构造 Rack 应用。

仍以前面的 hello Rack 和 Timing 中间件为例,实际上,我们一般这样定义我们的 Rack 应用:

# config.ru

require './hello.rb'
require './timing.rb'

use Timing
run Hello

以上代码保存在一个名为config.ru的文件中——它是 rackup 工具的缺省配置文件。

其中 hello.rb 的内容是:

# hello.rb - v3

class Hello
  def self.call(env)
    ['200', {'Content-Type' => 'text/html'}, ['Hello, Rack!']]
  end
end

这里我们定义了一个类Hello,它有一个call方法(回忆一下 Rack 的定义:任何响应call方法的对象)。

我们不需要再编写初始化 Rack 中间件和启动 Rack 服务器的代码——rackup 工具会为我们完成。

一切就绪以后,在命令行执行(要在包含 config.ru 的目录下):

rackup

啊哈,我们的迷你服务器又启动了!

rackup 默认使用 Webrick 服务器,你也可以通过参数指定其他服务器。了解更多参数选项:

rackup -h

如果你想知道 rackup 是如何构造 Rack 应用、配置中间件的,你需要了解 Rack::Builder(Gem rack 安装目录下的 lib/rack/builder.rb)。具体代码这里就不做分析了。下面再举几个例子说明一下 config.ru 如何配置 Rack 应用和中间件。

如果你要使用多个中间件,可以:

# config.ru - multi-middlewares

require './app.rb'
require './middleware1.rb'
require './middleware2.rb'
require './middleware3.rb'

use Middleware1
use Middleware2
use Middleware3
run App

Rack::Builder 将依次应用这些中间件到App上,得出一个最终的 Rack 应用,效果如同以下代码:

rack_app = Middleware1.new(Middleware2.new(Middleware3.new(App)))

你还可以在 config.ru 中配置路由,如:

# config.ru - routes

require './main.rb'
require './admin.rb'
require './m1.rb'
require './m2.rb'

map '/' do
  use m1
  run Main
end

map '/admin' do
  use m2
  run Admin
end

这样所有以/admin/开头的请求都会交由Admin处理,其余则由Main处理。这种配置实际上开启了一种“Rack 组合”模式——由几个不同的 Rack 应用组成一个新的 Rack 应用。比如说:把一个 Rails 应用和一个 Sinatra 应用(它们都是标准的 Rack 应用)组合成一个新的 Rack 应用——脑洞很大,但完全可行!

另外,Rack 中间件是可以接受参数的——甚至可以带有 code block,比如:

# config.ru

require './hello.rb'
require './timing.rb'

use Timing, :pid => true, { puts "Timing is being initialized!" }
run Hello

这里的 timing.rb 内容如下:

# timing.rb - v2

class Timing
  def initialize(app, opts = {}, &b)
    @app = app
    @pid = opts[:pid]
    yield if block_given?
  end

  def call(env)
    ts = Time.now
    status, headers, body = @app.call(env)
    elapsed_time = Time.now - ts
    puts "Timing: #{Process.pid if @pid} #{env['REQUEST_METHOD']} #{env['REQUEST_URI']} #{elapsed_time.round(3)}"
    return [status, headers, body]
  end
end

Rails/Sinatra on Rack

Rails 和 Sinatra 都是标准的 Rack 应用框架——你可能已经注意到了,它们的项目根目录下一般都有一个 config.ru 文件。你可能会想:我从没编辑过这个文件,大概也就没有使用过中间件吧?错了!Rails 和 Sinatra 都可以在它们的应用程序内配置中间件,并且在缺省情况下已经为你配置了一大堆:

在一个 Rails 项目的根目录下运行:

bin/rails middleware

看看 Rails 为你配置的中间件栈有多深 [^6]:

use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use #<ActiveSupport::Cache::Strategy::LocalCache::Middleware:0x000000029a0838>
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::RemoteIp
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActiveRecord::ConnectionAdapters::ConnectionManagement
use ActiveRecord::QueryCache
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
run Rails.application.routes

相比而言 Sinatra 要轻便许多:它有条件地配置了 4~7 个中间件(针对版本 v1.4.7),在 lib/sinatra/base.rb 中:

def setup_default_middleware(builder)
  builder.use ExtendedRack
  builder.use ShowExceptions       if show_exceptions?
  builder.use Rack::MethodOverride if method_override?
  builder.use Rack::Head
  setup_logging    builder
  setup_sessions   builder
  setup_protection builder
end

但是,不论这些 Rack 应用框架如何组织、定义自己的中间件栈,你都可以在 config.ru 中使用 Rack::Builder 所支持的标准语法来配置你的中间件——虽然一般情况下你不必这么做,但这样做有一个好处:你在 config.ru 中配置的中间件处于你的中间件栈顶部 [^7],也就是说,它最先响应服务器的请求、最后给出答案,因此具有最大的权威。

Rack env

以为 Rack 就这么结束了?并没有!——我之前说过,Rack 没那么简单。前面我们只提了一下call接受一个环境变量env作为输入,并提到它包含了全部的 HTTP 请求信息,但并没有仔细讲讲它。现在是时候了——它很重要!

让我们检查一下 env 都包含些啥:

require 'rack'

app = proc do |env|
  env.to_a.sort_by {|e| e[0] }.each {|k, v| puts %Q(#{k}=#{v}) }
  [200, {}, []]
end

Rack::Handler::WEBrick.run(app, :Port => 8090)

这个简单的 Rack 应用会把 env 的内容都打印出来。照前面的样子启动它,然后访问它,你就能看到:

GATEWAY_INTERFACE=CGI/1.1
HTTP_ACCEPT=*/*
HTTP_HOST=localhost:8090
HTTP_USER_AGENT=curl/7.35.0
HTTP_VERSION=HTTP/1.1
PATH_INFO=/
QUERY_STRING=
REMOTE_ADDR=127.0.0.1
REMOTE_HOST=127.0.0.1
REQUEST_METHOD=GET
REQUEST_PATH=/
REQUEST_URI=http://localhost:8090/
SCRIPT_NAME=
SERVER_NAME=localhost
SERVER_PORT=8090
SERVER_PROTOCOL=HTTP/1.1
SERVER_SOFTWARE=WEBrick/1.3.1 (Ruby/2.3.0/2015-12-25)
rack.errors=#<IO:0x0000000220dad0>
rack.hijack=#<Proc:0x000000024176c8@/home/user/.rvm/gems/ruby-2.3.0/gems/rack-1.6.4/lib/rack/handler/webrick.rb:76 (lambda)>
rack.hijack?=true
rack.hijack_io=
rack.input=#<StringIO:0x000000024180a0>
rack.multiprocess=false
rack.multithread=true
rack.run_once=false
rack.url_scheme=http
rack.version=[1, 3]

这是我从 localhost 上用 curl 访问的输出,你的也应该差不多。除了那些大写的 CGI[^8] 变量,还有一些 rack.xxx 变量,这些都是由 Rack 服务器设置并传递给 Rack 应用程序的。

CGI 变量大都可以顾名思义,前面的 Timing 中间件作为一个示例也用到了REQUEST_METHODREQUEST_URI,这里就不详细介绍了,感兴趣的读者可以参考脚注 [^8]。下面对 rack.xxx 变量做一些介绍:

  • rack.input 一个 IO 对象,可以读取 raw HTTP request。
  • rack.errors 一个 IO 对象,用于错误输出。一般地,Rack 服务器会把它输出到服务器日志文件。它也是 Rack::Logger 和 Rack::CommonLogger 的输出对象。
  • rack.hijackrack.hijack?rack.hijack_io可以实现 websocket。
  • rack.multiprocessrack.multithread: 这两个对象指示了 Rack 应用的运行环境是否是多进程、多线程。这里需要着重说明一下:Rack 服务器可以根据负载情况同时启用 Rack 应用的多个实例,既有可能通过多进程(每个进程一个实例),也有可能通过多线程(一个进程,多个线程,每线程一个实例),还可能把二者结合起来(多进程,同时每个进程内多线程实例)。服务器具体通过什么方式启动应用,每种服务器都不一样,你需要查看服务器的文档说明。比如 Phusion Passeger 可以使用多进程或者混合模式(在企业版中);Unicorn 多进程;Thin 多线程(可配置)。一般来说使用多进程方式比较安全:如果要使用多线程,你不但要保证你的 Rack 应用是线程安全的,还要保证你用到的所有中间件都是线程安全的。
  • rack.run_once 这个变量说明服务器是否只运行你的 Rack 应用实例一次就把它释放掉。这就是说服务器会对每个 HTTP 请求构造一个新的 Rack 应用实例(包括所有的中间件初始化工作)。一般来说只有 CGI 服务器会这样做 [^9](你肯定听说过 CGI 服务器效率不高吧?)。
  • rack.url_scheme http 或 https
  • rack.version Rack Spec 的版本(不是 Gem Rack 的版本)。我一直还没告诉你:Rack 不但是一个接口、一个 Gem 的名字,还是一个规范

一般而言你不必直接操作这些 rack.xxx 变量(也不应该这么做,除非你十分清楚这么做的后果,像作者这样^_-),但是你应该清楚它们的意义,这有助于你深刻理解 Rack 以及处理一些复杂问题。另外,Rack env 不但可以用于从 Rack 服务器向 Rack 应用和中间件传递一些信息,还可以用于在 Rack 中间件之间或者中间件与应用之间传递消息。在本文的下半部我们将看到这是如何实现的,以及这样做的意义。

Rack 的介绍到此为止,本文下半部将介绍一些 Rack 技术的常见应用,如 Profiler、Logger 和 Session 等中间件,了解它们是如何工作的。同时,欢迎你关注我的博客,获得更多技术资讯。

[^1]: Web 服务器->编程语言与技术->Ruby [^2]: 常见的 Rack 应用服务器有 Phusion Passenger,Unicorn,Thin,Puma 和 Webrick 等等。 [^3]: Webrick 一般用于开发环境,你的生产环境应该使用 Phusion Passenger 或者 Unicorn 等高性能的 Rack 服务器。 [^4]: 我喜欢直接从代码开始,我也建议读者手工输入并运行全部的代码示例,并且反复强调一点:技术文章不要只去读,要做! [^5]: 一般我们不这么用,后面的 rackup 一节会展示通常的用法,但二者的本质是一样的,只是表现形式不同。 [^6]: 更多关于 Rails on Rack 的信息可参考:Rails on Rack [^7]: 实际上 Rack 服务器也可以(而且并不少见)给 Rack 应用加上一些额外的中间件,用于输出 DEBUG 日志等一些工作。这些中间件在所有中间件栈的位置比 config.ru 中的还要“高”。 [^8]: CGI 即通用网关接口(Common Gateway Interface),我在《Web 全栈技术指南》的Web 服务器->编程语言与技术->CGI一节做过介绍。 [^9]: 细心的读者可能会问:Rack 应用难道不是对每个请求构造一个新的实例么?比如一个从 Sinatra::Base 继承来的类,对每次请求都会生成新的实例,成员变量也都重新初始化了。其实并没有!以 Sinatra 为例,它只是每次从初始化好的、无状态的 Rack 应用对象 dup 一个实例,用完就释放,下次再 dup 一个新的。具体你要看看 Sinatra 或者 Rails 的代码是如何做到的。

很好的总结,不过第一个 rackup 例子 config.ru 这样写是有问题的:

# config.ru

require './hello.rb'
require './timing.rb'

use Timing
run app

app 是没有定义的,hello.rb 中的 app 定义当然不会泄露到 config.ru 中;可能的解决方法是把 hello.rb 这样改:

class Hello
  def initialize
    puts 'Initialize Hello app'
  end

  def call(env)
    ['200', {'Content-Type' => 'text/html'}, ['Hello, rackup']]
  end
end

然后,在 config.ru 中:

require './hello.rb'
require './timing.rb'

use Timing
run Hello.new

@holysoros 我更新了示例代码,并且新增了一节:Rails/Sinatra on Rack,欢迎斧正!😀

写得不错,👍

我还是个不正经的 Ruby Web 开发者 😭

academus Ruby Rack 及其应用 (下) 提及了此话题。 05月24日 10:19

先顶贴 mark,过几天就用 rack 自己做一个框架

看完之后我决定要变成一个正经的 web 开发者😀

mr_zou123 Rack Middleware 的理解 提及了此话题。 03月16日 23:04

总结得很好,感谢分享。

传递 block 部分的例子会抛出语法错误,要改成这样

use(Timing, pid: true) { puts "Timing is being initialized" }
# 或者
use Timing, pid: true do
  puts "Timing is being initialized"
end
需要 登录 后方可回复, 如果你还没有账号请 注册新账号