Sinatra Ruby Rack 及其应用 (上)

academus · 发布于 2016年11月14日 · 最后由 academus 回复于 2016年11月16日 · 1553 次阅读
4597

前言

你可能听说过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 Rack1。下面的内容是它的扩充与深入。

什么是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')

在最后一行,我们用Webrick3这个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访问的输出,你的也应该差不多。除了那些大写的CGI8变量,还有一些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的代码是如何做到的。 

共收到 9 条回复
14534

很好的总结,不过第一个 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
4597

#1楼 @holysoros 谢谢指正!😁

4597

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

332

写得不错,👍

96

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

4597 academus Ruby Rack 及其应用 (下) 中提及了此贴 05月24日 10:19
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册