Gem 初步深入 Rack (一)

suffering · 2015年02月08日 · 最后由 cisolarix 回复于 2016年02月16日 · 7473 次阅读
本帖已被管理员设置为精华贴

前段时间写了篇文章<为什么我们需要 Rack?>, 再过段时间,对 rack 一直都是一个知其然,不知道其所以然的状态。最近从源码读起,这里与大家分享一下. 注意:本帖前半段极其无聊,请大家注意。

Rack 项目的知其然

首先,创建config.ru. 这里直接引用前文的例子。

#config.ru
# 将 body 标签的内容转换为全大写.
class ToUpper
  def initialize(app)
    @app = app
  end
  def call(env)
    status, head, body = @app.call(env)
    upcased_body = body.map{|chunk| chunk.upcase }
    [status, head, upcased_body]
  end
end
# 将 body 内容置于标签, 设置字体颜色为红色, 并指明返回的内容为 text/html.
class WrapWithRedP
  def initialize(app)
    @app = app
  end
  def call(env)
    status, head, body = @app.call(env)
    red_body = body.map{|chunk| "<p style='color:red;'>#{chunk}</p>" }
    head['Content-type'] = 'text/html'
    [status, head, red_body]
  end
end

# 将 body 内容放置到 HTML 文档中.
class WrapWithHtml
  def initialize(app)
    @app = app
  end

  def call(env)
    status, head, body = @app.call(env)
    wrap_html = <<-EOF
       <!DOCTYPE html>
       <html>
         <head>
         <title>hello</title>
         <body>
         #{body[0]}
         </body>
       </html>
    EOF
    [status, head, [wrap_html]]
  end
end

# 起始点, 只返回一行字符的 rack app.
class Hello
  def initialize
    super
  end
  def call(env)
    [200, {'Content-Type' => 'text/plain'}, ["hello, this is a test."]]
  end
end
use WrapWithHtml
use WrapWithRedP
use ToUpper
run Hello.new

在 termial 中输入rackup, 而后,一个 web 服务就运行了. 关于这段例子,前文的解释是:

use 与 run 本质上没有太大的差别,只是 run 是最先调用的。 它们生成一个 statck(谢谢 @est 指出错误) 本质上是先调用 Hello.new#call, 而后返回 ternary-array, 而后再将之交给另一个 ToUpper, ToUpper 干完自己的活,再交给 WrapWithRedP, 如此一直到 stack 调用完成. use ToUpper; run Hello.new 本质上是完成如下调用: ToUpper.new(Hello.new.call(env)).call(env)

提出问题

看到这里,个人有许多疑问:

  1. rackup命令来自于哪里?
  2. rackup命令如何找到config.ru这个文件的,又是如何加载它运行它的,或者说它是如何加载代码创建服务的?
  3. use 后面跟着的是 middleware 这个知道,但是,它是如何生成加载的 stack 的?
  4. run?
  5. 所有的 middleware 只要其实例响应call方法就可以了,它们如何工作?
  6. yyyy?

rackup 命令来自于哪里?如何加载 config.ru 并生成项目的?

那么,从源码一行一行看过去吧,首先是rackup, 放在rack项目的bin文件夹下,只有几行:

#!/usr/bin/env ruby

require "rack"
Rack::Server.start

好吧,个人表示不理解,于是跳到 lib\server.rb

# lib\server.rb
def self.start(options = nil)
  new(options).start
end

-_-!

# lib\server.rb
def initialize(options = nil)
  @options = options
  @app = options[:app] if options && options[:app]
end

-_-! 好吧,Rack::Server.start 时一个参数也没有。因此 new => initialize 时也一个参数也没有。也即是说最后从类方法start又跳回了实例方法start上:

# lib\server.rb
# 这该死的start实例方法相当之长, 这里只引用关键点:
def start &blk
# ...
server.run wrapped_app, options, &blk
# ...
end

好吧,server 来自于方法,wrapped_app 也是个方法:

# lib\server.rb
def server
  @_server ||= Rack::Handler.get(options[:server]) || Rack::Handler.default(options)
end
def wrapped_app
  @wrapped_app ||= build_app app
end
def build_app(app)
  middleware[options[:environment]].reverse_each do |middleware|
    middleware = middleware.call(self) if middleware.respond_to?(:call)
    next unless middleware
    klass, *args = middleware
    app = klass.new(app, *args)
  end
  app
end
def app
  @app ||= options[:builder] ? build_app_from_string : build_app_and_options_from_config
end

这方法间引来引去的,实在让人抓狂,这里直接给出关键点吧。以下内容也不帖出这该死的方法链了,只帖关键点。这样写下去不如直接帖源码自己看不是吗?(我承认我只是表达一下我抓狂的心情) 关键点在于:server 方法的后半段Rack::Handler.default(options) 以及 wrapped_app方法. 这里不卖关子,前者直接决定了调用哪个服务器来运行项目,是thin还是mongrel, WEBrick之类的. wrapped_appapp两个方法则决定了会加载config.ru的内容作为运行项目. options[:builder] ? build_app_from_string : build_app_and_options_from_config 这一行可知,因为 option 根本没指定,所以会调用 build_app_from_string.

# lib\server.rb
def build_app_from_string
  Rack::Builder.new_from_string(self.options[:builder])
end

于是我们华丽地滚到了lib\builder.rb文件:

# lib\builder.rb
def self.new_from_string(builder_script, file="(rackup)")
  eval "Rack::Builder.new {\n" + builder_script + "\n}.to_app",
    TOPLEVEL_BINDING, file, 0
end

好吧,历经千动百难我们终于找到了正主。这个builder_script就是config.ru文件的内容了。eval方法在TOPLEVEL_BINDING环境下,也即我们运行rackup命令的环境下运行代码. ....但是,妈蛋,这builder_script来自于哪里?self.options[:builder]作为参数传进来的,但是泥煤的这个 options 不是一直都是空的吗!!? 好吧,这个不是options, 这个是options类方法。属于Rack::Server类. 经研究,顺序是这样的:options 方法里引用parse_options(ARGV)也即是分析用户输入内容,但是明显我们没有输入任何内容。parse_options方法开篇第一句:options = default_options, 而后在后面的代码分析 ARGV, 死命地覆盖它。 default_options 是这个方法,里面的一行指定了,options[:config], 是config.ru文件。

# lib\server.rb
def options
  @options ||= parse_options(ARGV)
end
def parse_options(args)
  options = default_options

  # Don't evaluate CGI ISINDEX parameters.
  # http://www.meb.uni-bonn.de/docs/cgi/cl.html
  args.clear if ENV.include?(REQUEST_METHOD)

  options.merge! opt_parser.parse!(args)
  options[:config] = ::File.expand_path(options[:config])
  ENV["RACK_ENV"] = options[:environment]
  options
end

def default_options
  environment  = ENV['RACK_ENV'] || 'development'
  default_host = environment == 'development' ? 'localhost' : '0.0.0.0'

  {
    :environment => environment,
    :pid         => nil,
    :Port        => 9292,
    :Host        => default_host,
    :AccessLog   => [],
    :config      => "config.ru"
  }
end

Rack 如何创建服务并生成 stack 的?

思维回溯,config.ru终于传入eval之中了,我们的项目找到根脚了,个人不禁泪流满面。而 Rack::Builder.initialize最后会将其传入的 block 通过instance_eval方法运行其实例变量环境之中。也即是config.ru的所有文本内容,在Rack::Builder实例化后,在其环境下调用了。于是 use 生成 stack, 于是 use 方法将 middlewares 加入到@use array 中去,run 方法将其参数设置为起始项目并存入@app, 而后再通过 to_app 将之转化为 stack 待用,最后用户发起请求,server 调用 call 方法,并传入 env. env 在主项目@app, 和 @use 中的 middlewares 中薪火相传,每经手一次,就可能被做些修改,最后,返回值 .

再回到builder.rb文件,也即eval所在的内容:

def self.new_from_string(builder_script, file="(rackup)")
  eval "Rack::Builder.new {\n" + builder_script + "\n}.to_app",
    TOPLEVEL_BINDING, file, 0
end

好吧,我们知道它调用了类Rack::Builder类的initialize方法。

def initialize(default_app = nil,&block)
  # middlewares 默认为空[]; @map默认不存在. @run 默认等于创建时的项目, @warmup默认不存在.
  @use, @map, @run, @warmup = [], nil, default_app, nil
  # 在Builder实例中运行block, 也即是在builder运行环境中再执行block中的代码.
  instance_eval(&block) if block_given?
end

太过细节的东西不要再讲了,我们再简化一下吧:Rack::Builder 类是核心所在。它包含了生成 middlewares stack 的核心代码,决定了 middlewares 的调用顺序。也决定了它们如何被调用。它提供的use, map, run, to_app等方法,决定了一切:

def use(middleware, *args, &block)
  if @map
    mapping, @map = @map, nil
    @use << proc { |app| generate_map app, mapping }
  end
  @use << proc { |app| middleware.new(app, *args, &block) }
  @use
end
def run(app)
  @run = app
end
def map(path, &block)
  @map ||= {}
  @map[path] = block
end

def to_app
  app = @map ? generate_map(@run, @map) : @run
  fail "missing run or map statement" unless app
  app = @use.reverse.inject(app) { |a,e| e[a] }
  @warmup.call(app) if @warmup
  app
end

def call(env)
  to_app.call(env)
end
  • use方法将 middleware 放到一个 proc 之中,再将此 proc 放到@use这个 array 之中。
  • run方法将起始项目放到@app实例变量之中。
  • map方法用于生成简单的 rack 项目内路由。暂时不放入讨论之中。
  • to_app方法生成 stack, 并等待最后从 server 处传传进来。用户打开浏览器地址,server(如 thin) 就会调用这个 call 方法,并传入 env 作为其值。问题的关键点在于to_app方法最后返回的那个app, 它本质上已经将项目以及 middlewares 全部串在了一起,并且保证一次调用 call, 就会按规则走完 middlewares 全流程。

_ (是否对前面那么啰嗦,后面语言简洁的转折表示不适应?)

抽象问题,模拟过程

这里不再去解释源码的调用链了,我们来写一段小程序来模拟这个创建 stack,并调用它们的全过程:

# t5.rb
@use = []
class Mdw
  def initialize(app)
    @app = app
  end

  def call(env)
    puts env = "mdw1:" + env
    @app.call(env)
  end
end

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

  def call(env)
    puts env = "mdw2:" + env
    @app.call(env)
  end
end

startApp = proc {|app| puts tmp = 'startApp:' + app }

def use(md)
  @use << proc {|app| md.new(app) }
end

def call(s)
  @use.reverse.inject(@app){|a, w| w[a] }.call(s)
end

def run(a)
  @app = a
end

use Mdw
use Mdw2
run startApp

call('env')

运行ruby t5.rb, 可以得到如下内容:

mdw1:env
mdw2:mdw1:env
startApp:mdw2:mdw1:env

startAppmiddlewares中用的puts方法输出了值的全变化过程. 这里没有模拟rack返回[statusCode, Headers, Body]模式的部分。只模拟了链式调用的生成,以及调用的过程。

相关资料

一切的关键在于@use.reverse.inject(@app){|a, w| w[a] }.call(s)这行代码,在 @use 变量上调用 inject 的过程,以及 block 调用的特殊方法。 有多少人知道,proc/lambda是可以通过[ ]来调用的?

关于inject:

Combines all elements of enum by applying a binary operation, specified by a block or a symbol that names a method or operator. If you specify a block, then for each element in enum the block is passed an accumulator value (memo) and the element. If you specify a symbol instead, then each element in the collection will be passed to the named method of memo. In either case, the result becomes the new value for memo. At the end of the iteration, the final value of memo is the return value for the method. If you do not explicitly specify an initial value for memo, then the first element of collection is used as the initial value of memo.

关于block:

prc[params,...] Invokes the block, setting the block’s parameters to the values in params using something close to method calling semantics. Generates a warning if multiple values are passed to a proc that expects just one (previously this silently converted the parameters to an array). Note that prc.() invokes prc.call() with the parameters given. It’s a syntax sugar to hide “call”.

array.inject(i){|a, b| b[a]}的运行过程中,前一轮的 b[a] 被存到内存中,并加载了下一轮的 a 变量中。因为 a 和 b 都是 proc, 所以 procA[procB] 本质上是调用这个代码块。在这个过程中,初始点 app, 会被放在最底层,而 middleware 会一层一层套在上面。@use.reverse.inject(@app){|a, w| w[a] }返回的是最外层的那个 middleware 的实例。模拟的代码,只是将 call() 方法手动引用了而已.(本来,它是等着用户行为,再由 server 来调用,传值的)

模拟推衍过程

下面附上示例代码t5.rb运行过程的推衍过程: 注意:以下内容极其无聊,请精神正常的人不要较真:

最后数行代码的推衍过程
# 前面定义middleware类的过程请无视.
# step 1, `use Mdw;useMdw2;run startApp`
@app = proc {|app| puts tmp = 'startApp:' + app }
@use = [proc {|app| Mdw.new(t) }, proc {|app| Mdw2.new(t) }]
# step 2: `@use.reverse.inject(@a){|a, w| w[a] }`
# step 2.1, 第一轮
a = proc {|app| puts tmp = 'startApp:' + app }
e = proc {|app| Mdw2.new(t)
# step 2.2, 第二轮
a = proc {|app| Mdw2.new(t)[proc {|app| puts tmp = 'startApp:' + app }]
# 实际值: a = Md2.new(proc {|app| puts tmp = 'startApp:' + app })
e = proc {|app| Mdw.new(t) }
# step 2.3, 第三轮
a = proc {|app| Mdw.new(t) }[proc {|app| Mdw2.new(t)[proc {|app| puts tmp = 'startApp:' + app }]]
# 实际值: a = proc {|app| Mdw.new(t) }[Mdw2.new(proc {|app| puts tmp = 'startApp:' + app })]
# 实际值: a = Mdw.new(Mdw2.new(proc {|app| puts tmp = 'startApp:' + app }))
# step 3, @use.reverse.inject(@a){|a, w| w[a] }.call('s')
# step 3.1
Mdw.new(Md2.new(proc {|app| puts tmp = 'startApp:' + app })).call('s')
# step 3.2
Mdw2.new(proc {|app| puts tmp = 'startApp:' + app }).call('Mdw1:' + 's')
# step 3.3
proc {|app| puts tmp = 'startApp:' + app }.call('Mdw2:'+'Mdw1:' + 's')
'startApp:' + 'Mdw2:'+'Mdw1:' + 's'
# 如果想实现链式调用, 一个作为另一个的参数, 形成套娃机制. 需要彼此为参数的设定.

看代码,何其苦!虽说真正的猛士敢于面对惨淡的人生,敢于正视淋漓的鲜血,但是且容我们叫一声苦。

匿名 #2 2015年02月08日

最简单的 rack 应用,两行代码就够。 require 'rack' Rack::Handler::WEBrick.run -> (env) {[200,{"Content-type"=>"text/html"},["hello Ruby"]]}

匿名 #4 2015年02月08日

#3 楼 @suffering lambda 用起来更拉风 😄

相当拉轰~

匿名 #6 2015年02月08日

#5 楼 @suffering 楼主,你的“<为什么我们需要 Rack?>”,连接失效了,https://ruby-china.org/topics/!https://ruby-china.org/topics/21517markdown语法弄错了,我猜,是你的 😄

呃,谢谢,马上改过来。

它们生成一个 statck

笔误?

#9 楼 @est , 这行是引用之前写的<为什么我们需要 Rack?>里的解释。实际上,userun 根本只是将内容加到@use@app 之中。生成 stack 是在to_app 方法中。 我都没有注意到这一点。能再回头去看能一眼认以前的错误,说明有进步~~ 倒是蛮高兴的。

#9 楼 @est , 已更正,谢谢。

#11 楼 @suffering 不用谢。你的文章帮助更大。

多谢分享!

果断收藏,谢谢分享。

options[:builder] ? build_app_from_string : build_app_and_options_from_config 这一行可知,因为 option 根本没指定,所以会调用 build_app_from_string.

这里应是会调用后面的 build_app_and_options_from_config

#15 楼 @scys77 我也觉得是调用 build_app_and_options_from_config

suffering Rack 中间件简单理解及例子 提及了此话题。 09月09日 10:53
需要 登录 后方可回复, 如果你还没有账号请 注册新账号