上礼拜工作太无聊了,但是为了完成公司的一些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编写,源代码请这里下载