上礼拜工作太无聊了,但是为了完成公司的一些Performance的Goal,所以就尝试把我们在维护的一个已经有相当年龄体态臃肿的大项目升级到Ruby 1.9.3,看看这样一次升级大约需要花费多少工作量。
首先升级Gemfile的时候,有三个Gem就不再支持Ruby 1.9,他们是rcovsystem_timerfastercsv。其中rcov在Ruby 1.9中的替代品是simplecovsystem_timer本来就是由于Fix 1.8中的一个问题,1.9中就不再需要了而fastercsv则因为Ruby 1.9自带csv库而主动退役了。升级到1.9之后,原先rcov和fastercsv的代码自然就要重写了。
接着执行
find . -name '*.rb' -exec ruby -c {} \; | grep -v 'Syntax OK'
命令,可以立即扫描出项目中不兼容Ruby 1.9语法的代码的位置,1.8和1.9主要有下列几个不兼容的部分:
1. case when在1.8中可以用冒号分割条件和值,例如`when condition: value`,而1.9中只能通过回车,分号或者then来分割,与此类似的还有while和if等。
2. 一部分原本在1.8下可以省略的括号在1.9不能省略,例如`[fn param1, param2]`这样的代码在1.9中须写成`[fn(param1, param2)]`
3. 编码问题,在1.8中我们可以直接在脚本文件中放入任何一个国家的语言而不必担心,因为Ruby 1.8完全不处理这些跨国字符,而在1.9中,如果代码中出现异国字符就必须在文件开头加入Magic Encoding信息,否则默认的ASCII编码将会发生错误。
 
当然,大部分不兼容的部分都不会在语法检查时就被发现,还必须通过更多的操作去发现:
1. YAML引擎变更,1.9后默认的YAML引擎是psych,我们项目中很多自动生成的YAML之前是用1.8默认引擎Syck,换了1.9之后无法解析,随后我就修改了config/boot.rb把YAML引擎重新改回了psych。这样还不够,由于psych版本上的不同,之前我们手动编写的config/database.yml文件(由于用了些比较高端的语法)也必须做些修改才能正常使用(我在牺牲掉了两个开发数据库所有数据之后才发现了这点)。很奇怪的是,想要完美替换YAML引擎并非这么容易,对psych的默认支持的代码存在于多个地方,最后我的代码是这个样子的:
begin
  require 'syck'
rescue LoadError
end
require 'yaml'
YAML::ENGINE.yamler = 'syck' if defined?(YAML::ENGINE)
ENV['TEST_SYCK'] = 'true'
可以看到,相当麻烦。
2. require不再支持当前目录,这个我相信大家都知道。最佳解决方案就是将路径换成绝对路径即可(如果还希望兼容1.8的话),我们项目中有数百个地方因此需要替换,我在Vim写了个正则表达式把他们全部替换了。
3. 在老版本中我们会用Symbol#to_i的方法来求得一个唯一的对应值,在1.9中这个方法不复存在,可以使用Symbol#object_id的方法,虽然计算得到的值并不相同,但效果类似。
4. 我们原来的代码中用到了1.8的ParseDate库,可以将一个日期字符串转换为一个数组。1.9中这个库不复存在(可能感觉功能过于薄弱且类似功能完全可以交给Date库完成),但是要恢复它并不困难,只要阅读1.8中ParseDate库的源代码,就可以发现其实ParseDate.parsedate方法其实只是简单调用了Date._parse这一内部方法而已,而这一内部方法依然存在,所以我们可以很容易地将它恢复回去。
5. ftools这个库没有了,它的方法被分散在了File和FileUtils这两个库中实现,直接替换即可。
6. Date#emit_sn这个方法没有了,其实这是个私有方法,被剔除完全没有任何问题。只是由于我们的项目中在扩展Date类功能的时候多次调用了这一方法,并且这个方法由于缺乏文档又没有给出任何替代品因此难以重写。解决方案仍然是阅读1.8版本的源代码,将其实现拷贝过来(其实这不是一个好办法,但是却可以保证完美的兼容)。
7. `Time.now`返回值的字符串版本的格式发生了变化,1.8中格式类似于"Tue Aug 27 22:14:05 +0800 2013"这样的美式写法,而1.9中则是"2013-08-27 22:14:52 +0800"这样的本地写法。
8. 在Ruby 1.8中,所有Object均拥有to_a方法,而在1.9中,to_a方法被从Object类中移除,解决方案是调用Array方法。
9. 同样关于Array方法,在1.8中,将String转换成数组时会将String按照回车分割成多个元素放入数组,而1.9中则始终创建只有一个元素的数组,这是因为在1.8中字符串默认返回的迭代器就是按行遍历数组的,而1.9中字符串不再拥有默认的迭代器,因此只能采用默认行为,将字符串包装成数组返回了。解决方案是调用数组的`each_line.to_a`方法,将得到一致的效果。这个区别导致Rails 2.3的Cookie没法正常工作,因为Cookie字符串在Rails中默认就是用回车分割的。
10. String#[]在1.8中返回一个整数,而在1.9中却返回一个字符。对此我们统一用chr方法将数字转换成字符(在1.9中如果已经是字符,chr就会直接返回)
11. String#length在1.8中相当于1.9的String#bytesize,而1.9的String#length将会返回正确的字符的数量
12. Array#choice更名为Array#sample
13. 在Ruby 1.8中Date有个new系列方法,即new0,new1,new2,new!,均与不同的new方法不同,其中new!和new0相同,并且我们代码里用到了这个方法。在1.9中统统没有了,连替代品都没有。我最后找到了https://developer.uservoice.com/blog/2012/03/04/how-to-upgrade-a-rails-2-3-app-to-ruby-1-9-3/这张页面,里面有关于new!的简易实现。
14. Date#exist?更名为Date#valid_date?,后者在1.8中其实也有。
15. Array#to_s在1.8中其行为几乎等同于不加参数的join,也就是元素直接拼合成字符串,而在1.9中则会用逗号分割并且用方括号包围。应对方法就是不再调用Array#to_s,这个问题还是挺难检查出来的。
16. 在1.8中你可以在遍历hash时为当前hash增加元素,而可能因为改为链表实现的缘故,1.9中不能再这样做了,否则将直接抛掷异常。不过数组还依然是可以的。
17. Rails为1.8增加了一个处理多字节字符的模块ActiveSupport::Multibyte,可以实现一些比较高级的功能。但Rails认为Ruby 1.9的字符串天然支持多字节字符功能,因此String#mb_chars在1.9中将返回字符串本身。在我们的项目中我们有些利用Multibyte实现的特殊功能,比如将带重音字节的a字符转换成普通的英语a字符。我后来通过强制创建ActiveSupport::Multibyte对象才恢复了这一功能。
18. bigdecimal/util库中Float#to_d方法的实现不同,1.9版本中可以设置精度,默认精度是16,这造成数据库中decimal类型的column经过ActiveRecord的类型转换后返回的数据在精度上有差异。
19. RDoc::Usage被移除出了Ruby标准库,现在已经不能通过对脚本做简单注释就实现--help的功能了。并且Ruby官方没有提供任何替代品,如果要说的话,可能就是optparser库了。但是在我们项目中已经有将近100个脚本,其中大部分均使用了RDoc::Usage的办法,重写几乎是不可能的。因此我故伎重施,将1.8源代码中的与RDoc::Usage相关部分的代码提取出来,也就是rdoc目录下的usage.rb文件和markup/和ri/目录而已。
20. 由于我按照https://developer.uservoice.com/blog/2012/03/04/how-to-upgrade-a-rails-2-3-app-to-ruby-1-9-3/里的建议将Ruby 1.9的默认读写的Encoding都设置为了UTF-8,因此如果要返回二进制文件作为数据返回的话,将遭遇到`invalid byte sequence`的错误,这是因为二进制数据在render的时候会调用一次String#blank?方法,而该方法的实现就是令其与/\S/做比较,如果字符串是二进制,这一比较将发生错误,因此我修改了这一方法,在遭遇到错误后将会将字符串强制编码为Encoding::BINARY,重试后即可解决。
21. `activesupport-2.3.18/lib/active_support/core_ext/string/output_safety.rb:22: warning: regexp match /.../n against to UTF-8 string`警告,这个警告其实并不重要,它的意思是/.../n的正则表达式只匹配二进制数据。可以用silence_warnings方法忽略到这个警告或者检测版本为1.9的话就移除这个n,或者简单的将ERb替换成Erubis
22. 对于多国语言的网站,参数中存在异国文字也有可能发生编码错误,直接采用https://developer.uservoice.com/blog/2012/03/04/how-to-upgrade-a-rails-2-3-app-to-ruby-1-9-3/中的脚本即可解决这个问题。
23. 最后这个是最重要的,在Ruby 1.8的lambda中,有个众所周知的Bug,当外部存在a变量时,调用block,并且block的参数中存在同名变量时,对这一变量的赋值将导致外部变量一起被修改。我很不能理解的是,这明显是一个必然要被修复的Bug,却有人利用这一特性为外部变量赋值,这种问题甚至还出现在一些著名的Gem里。而且这个错误非常难以发现,我就有找这个错误找到睡觉在梦里继续找这个错误的痛苦经历。
 
主要的差异就这么多吧,其实升级的过程并没有非常困难,无非就是 测试-发现问题-调试代码-版本比对-解决问题 这样循环往复的过程。关键就是如何通过测试尽量去发现所有的问题,只要问题能被定位就总能解决。目前,我也只能通过跑测试用例来发现不兼容的地方,但由于我们项目测试用例其实并不健全,所以究竟有多少可能的问题就不得而知了。
 

补充

我后来又在RubyTuesday 9/21号的活动上以这个主题做了一个演讲,演讲用的Slides可以点击这里观看,这个Slides用mdpress编写,源代码请这里下载

上次由于公司里机器要面临重装,开始仓皇地将Mac本里的文件scp到我的台式机上。忙乱之中本来要删除一个无用的文件夹的,结果用rm -rf的时候tab了一下,补全出来的文件都没看清就按下了回车,毫无疑问,文件删错了。由于rm命令是不走回收站的,连删前确认都不会有,所以就这么把一个有用的文件夹删掉了。幸亏后来发现我曾在台式机上备份过这个文件,否则美好的回忆就因为这个操作失误而丢失了。

为了防止此类事件的再度发生,我曾在Twitter上询问过别人关于实现rm时只把文件送到回收站而不删除的命令。有些人说用mv命令就行了,但是这显然是不够的,因为mv和rm命令选项并不兼容,尤其是本人惯用的rm -rf,mv显然做不到。其次把文件mv进回收站以后是不能put back的,那就更不行了。网上还有些方法提供了能够将文件送入回收站,并且能够put back的方法,这个还是不错,但是就如我之前所说,并未完全实现rm的选项功能。因此我就花了几个晚上的实现自己实现了一个符合我这一需求的工具:https://gitcafe.com/bachue/rm-trash

  • 这个工具是完全用Ruby编写的(一直以来都对纯Ruby情有独钟),考虑到Mac OS X自带Ruby 1.8.7,因此所有代码也在1.8.7上编写,并且保证在运行时绝对不依赖任何Gem(之所以不希望依赖Gem是因为以前在Linux上用Python写的实用工具的时候出错说包缺失,然后我从github的issues找出了错误的原因是没有安装依赖包,我显然不会搞Python,于是只能现场Google学习安装pip,再用pip把包装进去,搞得远比使用用C写的程序复杂的多,不能因为自己是这个语言的开发人员就认为其他人也有它的开发环境啊)。
  • 放进回收站的核心功能由AppleScript实现(所以只能跑在Mac OS X上),这里参考了这个Python的实现方法
  • 为了尽可能看起来比较炫比较有趣,所有输出信息全部(除了帮助信息外)彩色化。
  • 尽可能严格要求所有行为均与系统自带的rm命令一致,凡是不一致的都被当做Bug看待,除了一些不可能实现或是没必要实现的功能外(比如三次擦写文件块使得文件不可恢复以及试图恢复文件)。

其实起初我以为这个工具并不难做,只是把文件夹往回收站一放即可,后来做到交互式删除的时候难度倍增,使得我不得不多次大规模重构了代码以符合这种需求。幸亏项目之初就建立了完善的RSpec测试用例,否则肯定无暇顾及如此多的边缘Case。同时随着更深入的开发和研究,发现了越来越多刚开始没有发现的rm的行为细节,这些细节事实上至今为止我都没有完全模仿出来,因为程序目前的算法不允许这么做,不过由于程序已经基本可用而时间宝贵,只能等到以后有空的时间再实现罢。

Ruby的class variable由于其奇怪的特性,一直是Ruby中包含争议的部分,大部分Rubist表示不使用class variable,最早我看到的文章是这篇写于2007年的帖子,里面的

@@avar = 1
class A
  @@avar = "hello"
end
puts @@avar  # => hello

成为了一个经典案例,至于其解释就是Ruby的class variable不属于owner类本身,而属于它的继承结构。@@avar实际定义在main所在类Object中,A继承自Object,因此也继承了@@avar这个class variable。

而昨天我又发现了另一个奇怪的特性

class A
  @@a = 1
  def f
    @@a
  end

  def f=(v)
    @@a = v
  end
end

A.new.f #=> 1
A.new.f = 2
A.new.f #=> 2

class A
  def self.f1
    @@a
  end

  class << self
    def f2
      @@a
    end
  end
end

A.f1 #=> 2
A.f2 #=> 2

# 直到这里,所有内容都可以理解
class << A
  def f3
    @@a
  end
end

A.f3 
#=> warning: class variable access from toplevel
#=> NameError: uninitialized class variable @@a in Object

这段代码的问题在于

class << A; ... ;end

class A; class << self; ... ; end; end

这两段代码本该是没有区别的,但是用后者访问的到A的class variable @@a而用前者却访问不到

当晚我把这段代码在Ruby Tuesday上提出过,也没人能回答我。回去以后仔细想想,可能觉得class variable对于gateway scope的敏感程度和其它两种变量类型local variable和instance variable不一样,在Ruby中,local variable不能穿越gateway scope存在,比如

a = 1
class A
  b = 2
  def f
    local_variables
  end
end
A.new.f #=> []

无论是a还是b,都没能进入到A的instance method f的定义中。而instance variable虽然也不能穿越gateway scope,但是等到gateway scope相同的时候却可以恢复出来

class A
  def self.f1
    @a = 1
  end

  def f2
    @a = 2
  end

  def self.f3
    @a
  end

  def f4
    @a
  end
end

A.f1
A.f3 #=> 1

a = A.new
a.f2
a.f4 #=> 2

但是class variable和它们恐怕都不一致,它是可以穿越gateway scope而存在的,比如

class A
  @@a = 1
  def f
    @@a
  end

  def f=(v)
    @@a = v
  end

  def self.f1
    @@a
  end
end

A.new.f #=> 1
A.new.f = 2
A.new.f #=> 2
A.f

可以看到,@@a在class A定义的类定义内就像全部变量一样的存在,轻松突破gateway scope,可以出现在类定义所在的每个角落。如果是这样的话,那就可以猜测了,class variable表现和其他变量类型不一致,对它而言只有class Xxxx的语句才是真正的gateway scope,其它语句统统ignore掉,包括class << Xxxxx在内。可以用以下代码证明:

class << A
  @@a = 1
end
@@a #=> 1
self.class #=> Object
self.class.class_variables #=> [:@@a]

看上去class variable定义在了A类中,但其实定义在了main所在类Object中。

一直感觉arity这个属性的算法提及的很少(虽然arity是个数学概念,但是简单的数学概念显然无法满足Ruby的复杂的需求),即使在Ruby-doc的MethodProc中也是如此。

Returns an indication of the number of arguments accepted by a method. Returns a nonnegative integer for methods that take a fixed number of arguments. For Ruby methods that take a variable number of arguments, returns -n-1, where n is the number of required arguments. For methods written in C, returns -1 if the call takes a variable number of arguments.

这篇Ruby 2.0的文档里面仅仅提到了几种最简单的情况,还远远算不上复杂,现在我这里列出了比文档里更多的例子,以此来分析arity的行为。

测试代码是这样的:

p proc {}.arity
p proc {||}.arity
p proc {|a|}.arity
p proc {|a,b|}.arity
p proc {|a,b,c|}.arity
p proc {|*a|}.arity
p proc {|a,*b|}.arity
p proc {|a,*b, c|}.arity
p proc {|x = 0, *args|}.arity
p proc {|x=0, y=0, *args|}.arity
p proc {|x, y=0, *args|}.arity
p proc {|(x, y), z=0, *args|}.arity
p proc {|a, b = 1, *c, d|}.arity
p proc {|a = 1, b = 1, *c, d|}.arity

p proc   { |x = 0| }.arity
p lambda { |a = 0| }.arity
def f(a = 0) end; p method(:f).arity
p proc   { |x=0, y| }.arity
p lambda { |x=0, y| }.arity
def f(x = 0, y) end; p method(:f).arity
p proc   { |x=0, y=0| }.arity
p lambda { |x=0, y=0| }.arity
def f(x=0, y=0) end; p method(:f).arity
p proc   { |x, y=0| }.arity
p lambda { |x, y=0| }.arity
def f(x, y=0) end; p method(:f).arity
p proc   { |(x, y), z=0| }.arity
p lambda { |(x, y), z=0| }.arity
def f((x, y), z=0) end; p method(:f).arity

然后是结果对比:

code ruby-1.9.3 ruby-2.0.0
p proc {}.arity 0 0
p proc {||}.arity 0 0
p proc {|a|}.arity 1 1
p proc {|a,b|}.arity 2 2
p proc {|a,b,c|}.arity 3 3
p proc {|*a|}.arity -1 -1
p proc {|a,*b|}.arity -2 -2
p proc {|a,*b, c|}.arity -3 -3
p proc {|x = 0, *args|}.arity -1 -1
p proc {|x=0, y=0, *args|}.arity -1 -1
p proc {|x, y=0, *args|}.arity -2 -2
p proc {|(x, y), z=0, *args|}.arity -2 -2
p proc {|a, b = 1, *c, d|}.arity -3 -3
p proc {|a = 1, b = 1, *c, d|}.arity -2 -2
p proc { |x = 0| }.arity 0 0
p lambda { |a = 0| }.arity 0 -1
def f(a = 0) end; p method(:f).arity -1 -1
p proc { |x=0, y| }.arity 0 -1
p lambda { |x=0, y| }.arity 0 -2
def f(x = 0, y) end; p method(:f).arity -2 -2
p proc { |x=0, y=0| }.arity 0 0
p lambda { |x=0, y=0| }.arity 0 -1
def f(x=0, y=0) end; p method(:f).arity -1 -1
p proc { |x, y=0| }.arity 1 1
p lambda { |x, y=0| }.arity 1 -2
def f(x, y=0) end; p method(:f).arity -2 -2
p proc { |(x, y), z=0| }.arity 1 1
p lambda { |(x, y), z=0| }.arity 1 -2
def f((x, y), z=0) end; p method(:f).arity -2 -2

由这一对比结果,可以清楚的看到:

  • 对于proc而言,如果参数中没有可变长参数(比如*args之类的),则在1.9.3中,arity取决于从左往右没有默认值的参数的数量。2.0.0中取决于整个参数列表中没有默认值的参数数量。
  • 如果有可变长参数,则如果n为整个参数列表中没有默认值的参数数量,arity为-n - 1。
  • 对于lambda而言,可以看到,在1.9.3中情况与proc总是一致的。而在2.0.0中,如果存在有默认值的参数,则如果n为整个参数列表中没有默认值的参数数量,arity = -n -1。否则就等于参数数量。
  • 对于method而言,无论1.9.3还是2.0.0,均与2.0.0中的lambda一致(准确的应该说是lambda在2.0.0中与method保持一致)。
  • 额外的&block不影响到arity的值。

大致结果就是如此了,如果有误,请务必留言告知,谢谢。

我发现写Rails,尤其是测试部分,需要reload的次数相当频繁。比方说

user = users(:xxxxx)
xhr :put, 'change_xxxxx', :id => user.id
user.reload # necessary!
assert user.xxx, xxx
assert user.yyy, yyy

由于这里创建的user对象和被测试的action里的user对象(在内存概念上)不是同一个对象,因此,被测试的action修改了user对象在数据库里的数据,会导致外围的user信息过时,因此必须reload,否则接下来的断言语句都无法通过。

这种情况同样也有可能存在与关联对象上,比如

assert child1.father, child2.father
child1.father.name = 'new name'
child1.save
child2.reload # necessary!
assert child2.father.name, 'new name'

总是写很多reload语句显然很不舒服,为此我今天在回家的地铁上想了一种解决方案,就是在后台全局的部分,以ActiveRecord类名和实例为key简单的缓存一部分数据,这部分数据与数据库里的数据在本次request返回内一致,request结束后自动清除以免内存泄露和影响下一个request。而前面的ActiveRecord对象,本身仅存储那些还未被保存的数据。

方案大致是这样的(以Rails 2.3为例):

require 'active_record'

module ActiveRecord
  class Base
    def request_cache
      self.class.request_cache[self]
    end

    def request_cache=(v)
      self.class.request_cache[self] = v
    end
  end

  class << Base
    def request_cache
      @request_cache ||= Hash.new {|h, k| h[k] = Hash.new {|h, k| h[k] = {}}}
      @request_cache[name]
    end
  end
end

ActionController::Dispatcher.middleware.use(Class.new do
  def initializer(app)
    @app = app
  end

  def call(env)
    @app.call(env)
  ensure
    ActiveRecord::Base.request_cache.clear
  end
end)

这里我把缓存就设计在ActiveRecord的class variable内(三级Hash,分别是类-实例对象-属性名),然后用Middleware的方式去清理缓存,之所以选择用Middleware的方式是因为ActiveRecord原本和HTTP Request是不能发生关联的,否则Console和Unit test里就无法执行了,设计成Middleware则能很好的解决这一问题。

由于选择的三级Hash的设计中没有直接和数据库关联的信息,因此其实也可以用于非ActiveRecord类(唯一要做的可能仅仅是自己实现一种标示方法),毕竟当前可以用于存储的服务早已不止数据库一种了。

至于对于ActiveRecord的修改我现在对ActiveRecord的实现还不够熟悉,无法完成,以后学习这一部分了再说吧。但是大致的思路是这样的:

  • 当从数据库读入数据的时候(包括reload),将缓存里的数据翻新
  • 当保存数据进数据库的时候,将数据同样保存进缓存
  • 当从一个ActiveRecord对象读取属性的时候,现在被当前对象修改过的属性中搜索,如果搜索不到再从缓存里搜索,如果还是搜索不到就采用默认方法

大致就是这些了,其实想法还是很简单的,希望早日自己能把它变成现实。

为什么这么说呢?从没有见过运用如此广泛,几乎是所有Ruby Web Framework基础的开源项目中,这个文件里竟然有如此之多令人费解的部分(完全可以认为是Bug)

简单的举个例子吧,首先,对于Rack来说,.ru文件和.rb文件的区别在哪里?

def self.parse_file(config, opts = Server::Options.new)
  options = {}
  if config =~ /\.ru$/
    cfgfile = ::File.read(config)
    if cfgfile[/^#\\(.*)/] && opts
      options = opts.parse! $1.split(/\s+/)
    end
    cfgfile.sub!(/^__END__\n.*\Z/m, '')
    app = new_from_string cfgfile, config
  else
    require config
    app = Object.const_get(::File.basename(config, '.rb').capitalize)
  end
  return app, options
end

代码中告诉了我们答案,如果是.ru文件,Rack::Builder不仅仅会将其读出,而且还会从中解析出以#\开头的部分,作为Rack::Server启动的选项,举个例子:

#\--port 3000
run Proc.new {|env| [200, {"Content-Type" => "text/html"}, "Hello Rack!"]}

比如这个config.ru文件,rackup在执行这个文件是将启动出3000端口的服务器而非9292。

这本来是个不错的设计啦,但是仔细看代码就会发现,只支持一个选项,第二个选项用方括号运算符是匹配不到的,额。。。你妹。。

不得不承认Rack::Builder支持map包含map,也就是形成树结构,这点不错。更重要的是Rack仅仅执行那些不得不执行的map的代码块,如果路由没匹配到就不予执行这些都很好。但是当你发现Rack::Builder没接收到一个请求就会创建一个新的URLMap对象然后把路由计算成正则表达式,并且计算出的正则表达式不予缓存,每次都重新计算,就会感觉之前争取到的性能在这里又浪费了的惋惜啊。。好吧,这一定是考虑到开发模式下的需求。

接着就是最坑的一个Bug了

require 'rack'

infinity = Proc.new {|env| [200, {"Content-Type" => "text/html"}, 'root']}
builder = Rack::Builder.new do
  map '/' do
    run infinity
  end

  use Rack::CommonLogger

  map '/version' do
    map '/' do
      run Proc.new {|env| [200, {"Content-Type" => "text/html"}, "infinity 0.1"] }
    end

    map '/last' do
      run Proc.new {|env| [200, {"Content-Type" => "text/html"}, "infinity beta 0.0"] }
    end
  end
end
Rack::Handler::Thin.run builder, :Port => 9292
curl 0.0.0.0:9292/version/last

虽然看上去结果应该是infinity beta 0.0,但实际上结果是root,额。。

这个Bug的关键在于use Rack::CommonLogger这句语句的位置,可以看到,它写在两个map的中间。而use方法是这么实现的:

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) }
end

为了让middleware处于middleware stack的外面,代码先判断当前有没有执行过map方法,如果执行过的话,先将它放进middleware stack,外面放middleware对象。这样执行的时候就能先执行到middleware了(大概作者是这么想的,但是这么做完全是错的,实际情况是先放进middleware stack的先被执行到)。如果你没有执行过map方法,那么虽然现在middleware stack上看上去只有middleware对象,但是等会执行的时候会把map包进来,这样就解决问题了。但是它显然没有想到,如果我把use写两个map中间的情况。此时,middleware stack对象会先把第一个map写进去,然后跟着middleware。然后在下一个map执行的时候,重新赋值map对象,并在最后被middleware stack的两个元素包住。这样请求一进来的时候就会发现,竟然是第一个map直接接受请求,连middleware都不是(之前讲过,实际情况是先写进middleware stack的先被执行到)。

算起来好像只有把use语句写在所有map之前的时候才是能真正正常工作的。大概确实很少人不这么做吧,否则这么明显的问题为何不被解决呢。

虽然我们可以看到,Rack::Builder实现了自己的路由和middleware调用方法,但是无论是Rails还是Sinatra,都无一基于Rack::Builder,不仅仅是因为Rack::Builder没有更多的功能,已有功能也不容易扩展,而且,里面坑太多了,还不如自己从头写呢。

最近在帮我们最大的一个Rails项目(多大?想象下上万行Ruby代码的项目吧)做Rails升级,从2.3.2升级到2.3.17(别笑,这种项目升3是没有指望的),升级过程总体上顺利。升级完后跑Test Case,有一些小错误发生。一个小错误似乎是由于Rails 2.3.2存在Bug使得明明应该发生错误的Test Case竟然能通过,而在2.3.17的时候已经修复,导致这个Test Case理所当然的发生了错误。还有另外一个错误是在测试中向response header中写入了被标记为secure和httponly的cookie,但是测试发现这些cookie不存在的错误。起先我还不以为然,以为是redirect后造成cookie在测试时不能正常读取,但是后来总觉得有点奇怪,毕竟这个Test Case在2.3.2的时候是通过的,还有就是调试发现,在执行好redirect_to方法后,被写入的cookie依然是存在的,只有当请求结束后的测试时cookie才会消失。太诡异了!于是仔细跟踪代码流,并对比了下2.3.2和2.3.17的区别,发现了在2.3.17的actionpack/lib/action_controller/cookies.rb中的CookieJar类(这个是存储Cookie的最核心的数据结构了,继承自Hash)的[]=方法中,在向response header写入cookie前,调用了下一个叫做write_cookie?的方法,这个方法在2.3.2中是不存在的。代码如下:

# Sets the cookie named +name+. The second argument may be the very cookie
# value, or a hash of options as documented above.
def []=(key, options)
  if options.is_a?(Hash)
    options.symbolize_keys!
  else
    options = { :value => options }
  end

  options[:path] = "/" unless options.has_key?(:path)
  super(key.to_s, options[:value])
  @controller.response.set_cookie(key, options) if write_cookie?(options)
end

从代码中可以看到,write_cookie?方法并不阻止super的调用,但是阻止了set_cookie的调用。而通过调试可知,在测试时这个write_cookie?这个方法的的确确返回了false,造成了cookie在测试时没有正常写入!

而这个问题的始作俑者,write_cookie?的代码是这样的:

def write_cookie?(cookie)
  @secure || !cookie[:secure] || defined?(Rails.env) && Rails.env.development?
  # 其中@secure来自initialize时controller.request.ssl?的结果
end

在测试过程中,三个条件均为false,导致最终结果是false。

由于这个站点的安全级别较高,在生产环境中一定是用HTTPS协议运行的,所以在测试的时候也模拟HTTPS的环境,在cookie中写入了secure标志并在测试中有相应的assert。在2.3.2中,由于没有这个方法的存在,测试没有任何问题。但是在2.3.17中,明确要求了,要么cookie是被设置成secure的,要么必须是https请求,要么当前是development模式。但是在我们的测试中,我们并没有让所有测试都用https来做,这也是不合理的嘛。因此我认为,在这段代码中,Rails犯了三个错误,第一,应该把Rails.env.test?也加入或条件,即如果是测试环境,也总会让write_cookie?返回true。第二,如果write_cookie?返回false,super方法也不应当调用,不该造成cookie已经被写入的假象。第三,后台应该有安全警告,以说明本次cookie写入失败的原因,否则对开发者而言实在是莫名其妙。

本来想给Rails提交个patch的,但是得知Rails 2.3除了Fix严重安全漏洞以外不再接受任何Patch了(链接),因此还是写成Blog让大家看到吧。我最后在项目中加了这个一个Monkey Patch:

# The write_cookie? always returns false in our test cases because 
# we set secure in cookie and our request in test env is http 
# rather than https, so Rails will refuse to write value in cookie. 
# This hack will resolve this problem. 
class ActionController::CookieJar < Hash
  alias_method :__origin_write_cookie?, :write_cookie?
  def write_cookie?(cookie)
    __origin_write_cookie?(cookie) || defined?(Rails.env) && Rails.env.test?
  end
end

解决掉了这个问题。至于另外两点就懒得用Monkey Patch做了,还是算了吧。

就这样了。由此可见,Rails程序员熟悉Rails本身的代码是很重要的吧,仅仅局限在使用Rails框架上实在是太肤浅了,根本对不起四年大学本科的学习嘛,何况Rails这种项目由于是开源的本来就问问多多嘛,不熟悉的话稍微有点什么问题就束手无策了。我们组有些同事就是这样的,拿了个比VIM先进的多的RubyMine,叫他调试下出错的Test Case就各种震惊各种迷茫的,实在是,哎,不说了,说出来比我自己干还累啊。。

安装Rails 4.0 beta

2012年12月16日 21:56

首先当然要按照先前的《Fedora / Debian 用 RVM 安装 Ruby on Rails 最新版本》先安装RVM和Ruby 1.9.3,Rails 3.2.9(Ruby至少依赖到1.9.3哦,至于安装Rails 3.2.9,是因为似乎Rails 4.0的部分组件依然在依赖3.2.9的Rails组件,挺奇怪的),然后

git clone https://github.com/rails/rails.git

下载rails源码,接着,运行一次

bundle install

安装所有依赖的gem(务必保证所有gem都要安装成功),接着运行

ruby install.rb 4.0.0.beta

额,理论上这样安装应该是成功的,但是实际上当我运行install.rb的时候,却出现了如下错误:

Installing activesupport...
Installing activemodel...
Installing activerecord...
ERROR:  While executing gem ... (Gem::DependencyError)
    Unable to resolve dependencies: activerecord requires activerecord-deprecated_finders (= 0.0.1)
Installing actionpack...
ERROR:  While executing gem ... (Gem::DependencyError)
    Unable to resolve dependencies: actionpack requires journey (~> 2.0.0)
Installing actionmailer...
ERROR:  While executing gem ... (Gem::DependencyError)
    Unable to resolve dependencies: actionmailer requires actionpack (= 4.0.0.beta)
Installing railties...
ERROR:  While executing gem ... (Gem::DependencyError)
    Unable to resolve dependencies: railties requires actionpack (= 4.0.0.beta)
Installing Rails...
ERROR:  While executing gem ... (Gem::DependencyError)
    Unable to resolve dependencies: activemodel requires activesupport (= 3.2.9); rails requires actionpack (= 4.0.0.beta), activerecord (= 4.0.0.beta), actionmailer (= 4.0.0.beta), railties (= 4.0.0.beta); sprockets-rails requires actionpack (>= 3.0)

很奇怪,虽然bundle install完全安装成功了,但是这些明明出现在Gemfile中的gem却没有安装成功,我的解决方案是,额,手动再安装一遍:

cd `rvm gemdir`/bundler/gems
for f in `ls`; do cd `pwd`/$f; gem build *.gemspec; gem install *.gem; done

这样就可以了

最后运行下

rails -v
# Rails 4.0.0.beta

看到4.0.0 beta就算OK了

不过这样似乎还不足以创建一个新的Rails 4.0 App,你必须再安装好新的coffee-rails 4.0.0.beta和sass-rails 4.0.0.beta,这两个项目你依然需要通过git clone下两个项目的源码,bundle install(在运行这句命令前最好把Gemfile中的几个github项目勾掉,每次都下载一遍实在太慢了),然后gem build *.gemspec,gem install *.gem后才能安装成功。

快尝试创建一个Rails 4的项目吧:

然后在玩玩Live Stream这个Rails 4的新特性,看着SSE数据流连续不断的送到浏览器,帅爆了呢~

这篇文章只是随手写写玩玩的,我对Ruby的Block和Lambda其实理解并不怎么深刻,知识现在够用就差不多了吧。

第一种方法最传统了:

def f1(a)
  yield a
end

f1(1){|a| a.to_s} # "1"
f1(1, &:to_s) # "1" 

这种方法要求传入一个block,但是不传入也可以,可以用block_given?方法判断是否被传入了block。在第二个示例中虽然看上去要求是一个参数,但是再传入一个block并不会出错,&:to_s是{|a| a.to_s}这个block的简写

下面是第二种方法:

def f2(a, &b)
  b[a]
end

f2(1){|a| a.to_s} # "1"
f2(1, &:to_s) # "1"

这种方法和前一种其实效果一致,虽然看上去要求两个参数,但是block依旧是可选的,如果不传也不会出错,一样可以用block_given?方法判断是否被传入了block。唯一的区别就是这种方法可以为传入的block赋个值,以便于在再传给另外一个方法作为参数。

下面是第三种方法:

def f3(a, b)
  b[a]
end

f3(1, lambda{|a| a.to_s}) # "1"
f3(1, :to_s.to_proc) # "1"

这种方法虽然和第二种方法只有一个字符之差,但其实天差地远,b不能被传入一个block,无论是{|a| a.to_s}还是&:to_s都将被视为错误,它必须被传入一个lambda。因此在示例中我用两种方法创建了lambda,注意第二个示例其实是第一个示例的简写,在Ruby中&:to_s只能创建block,而:to_s.to_proc则可以创建proc对象(这里有件更加神奇的事,可以通过hack to_proc修改这个方法的返回值,但是依然要求必须是Proc对象,并且无法通过hack这个Proc对象的call方法或是[]方法修改它的执行行为,并且写在这个Proc中的return,next,break的语意也只按proc的语意处理,下面会提及)。至于proc,lamdba和block三者的差异?lamdba和proc其实差别很小,主要差异在binding和参数传递上,不过lambda {|a| a.to_s}其实也返回一个Proc对象,而lambda和block的区别主要是返回方法不一致,这个在很多Ruby书中都有详细介绍。但是它们其实可以相互转换,看下列代码:

def f4(a, &b)
  f3(a, b)
end

f4(1, &:to_s) # "1"

def f5(a, b)
  f1(a, &b)
end

f5(1, :to_s.to_proc) # "1"

可以看到在第一个示例中,一个方法调用时传入的block参数在方法内部会被当作成proc对象,因而f3也可以接受。而在第二个示例中,一个proc对象前面加上&符号跟在方法后面就被当作block,很有意思吧。更有意思的是,似乎无论怎么转换,代码块中return,next,break语句的语意似乎并不改变,几次试验下来都是如此,这点我至今还是没有想通。例如:

def f6(&b)
  puts "class of b is: #{b.class.inspect}"
  3.times {b.call}
end

def f7(b)
  puts "class of b is: #{b.class.inspect}"
  3.times {b.call}
end

f6 {puts 1; break}
# output:
# class of b is: Proc
# 1

f7 lambda {puts 1; break}
# class of b is: Proc
# 1
# 1
# 1

可以看到虽然都是在方法中调用代码块,并且在方法中b都是proc对象,代码内容也完全一致,但是由于f6传入的block形式而f7中传入的lambda形式,因此最后的结果存在差异。证明了break的语意并没有因为都转换成了proc对象而发生转变。

由于我所维护的Rails项目规模很大,所以被分割成了多个项目,而其中仅Rails项目就有不下6个,而我在开发我所属的子项目的时候,通常至少同时运行4个项目才能把流程完整的运行起来。而这四个项目中就有三个在生产环境上会监听默认端口(80/443),因此,这些项目的开发者都希望当其他项目与自己的项目产生交互的时候,会采用默认的端口。也许他们在自己开发的时候就是这么做的并且没有发生什么问题,但是作为另一个项目的开发者,遇到这种情况就非常麻烦了。虽然我们有时都会留下一个配置文件供开发者配置,但是由于这个配置文件本身也被Git托管(这个做法其实很不正规),所以我一旦修改配置文件,这个文件就至始至终保持被修改的状态,非常麻烦,经常干扰到我原本华丽而流畅的Git操作(瀑布汗。。)。

所以就希望能寻找到一种办法,能够使服务器不再根据端口,而是根据域名判断请求应该送达的服务器。这个功能其实Apache,Nginx等网页服务器都具备,但是开发环境下的Rails服务器不比真正的网页服务器,没有这种功能。

当然,不能直接简单地用个网页服务器了事,因为传统网页服务器不能实现对Ruby代码的Debug(何况最近还特别迷恋pry神器),而这是我需要的基本功能。除此以外,我还需要它的路由层次必须高于/etc/hosts文件,因为我们还要时常切换到QA环境或是生产环境,而我们切换的方法就是修改/etc/hosts。所以在防火墙甚至于更底层实现都是我不能接受的。

于是我在Twitter和Ruby China发起了提问(http://ruby-china.org/topics/6102),收到了不少响应,比如RVM+Pow的手法,确实不曾听说过,感觉不错的样子。还有Passenger + Apache的办法,来自于Railscasts介绍的办法:http://railscasts.com/episodes/122-passenger-in-development。不过,我更希望的是一种更加简单的办法,不需要复杂的GUI控制,完全透明,而且最好是平台无关的,毕竟我比起Mac OS X还是更希望用Linux开发,因此不希望采用Mac Only的办法。

在反复思虑后,选择了@RainFlying提出的用Apache/Nginx做反向代理的办法,将发送到Apache/Nginx的请求通过代理转发到真正的Rails服务器上,以此实现了让多个Rails App时同时监听80端口的假象。

于是,我就在目前正在使用的Ubuntu 12.04上安装了nginx-light包,这个包比起nginx-full来说仅仅包含了最核心的nginx,不带过多的Modules,保持环境的轻量。然后在/etc/nginx/sites-available里创建了一个新文件,内容如下:

server {
        server_name test1.com;
        location / {
                proxy_pass http://localhost:3000/;
        }
}

server {
        server_name test2.com;
        location / {
                proxy_pass http://localhost:3001/;
        }
}

由于每个站点的配置仅有简单的几行,因此我把所有配置都包含在一个文件里,看上去简洁明了。

然后修改/etc/hosts文件,将域名指向本地服务器。这个步骤也暗示了我随时随地可以通过修改/etc/hosts切换环境,而不需要任何其他操作,完全和以前一样。

最后运行

sudo service nginx restart

重启Nginx。虽然网上有说用

sudo service nginx reload

也可以,但是我在尝试后发现似乎并没能成功更新配置信息,因此还是使用restart命令。

到这里,其实反向代理因此可以工作了,但是由于Nginx服务器毕竟不比为开发设计的Rails服务器,所有的连接都有超时时间。如果在连接时进入了Debug模式,我希望连接能够一直保持等待直到Debug结束,在查阅了Nginx的文档后,发现可以用proxy_read_timeout选项设置Nginx等待被代理服务器的响应时间,默认为60秒,我将这个时间设置到了36000秒,即10个小时,差不多足够了吧。

明天就把这个方法在团队中推广下,大家以后不用再抢端口了吧。