最近在帮我们最大的一个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 3.2 新特性简介

2012年4月08日 00:12

好久没有写Blog了,我依稀记得当初面试的时候面试官翻阅我Blog的场景,后来我也只写过一篇Blog而已。而今天,我已经成功的进入了这家企业,成为了真正的Ruby on Rails开发者。从去年暑假刚开始学习Rails,到今天,只有半年有余。不过Rails已经从最初学习时的3.0.8升级到了3.2.3。我依然记得第一次用3.1.0的时候还在疑惑怎么一些功能与书上已经开始不一致了,那会还不知道3.1.0加了很多新特性。不过那时幸亏有GitCafe团队的成员Rainux(@RainuxLuo)带领稀里糊涂的我用上了Rails 3.1,否则我可能至今都不了解Rails的版本计划和3.1的新特性。

本文简单介绍Rails 3.2的新特性,主要参考 Ruby on Rails Guide 3.2 Release Notes。不过作为新手,这Release notes我有很多是看不懂的,幸亏后来又看了Railscasts Upgrading to Rails 3.2视频,终于理解了主要更新,至于次要更新,如果我能理解的就会提及(不能保证理解完全正确,如果有错误请务必指出),如果不能理解的就不说了,一般也不会很重要(如果读者能在评论中略微指导下,我将不胜感激)。

1. Development环境下性能改进,由于集成了Active Reload插件,Rails在Development环境下只会重新加载哪些确实被改过的类文件,在大型项目中,这个新特性将明显改进效率。

2. 使用新的Journey引擎,路由识别的性能提升。

3. ActiveRecord::Relation增加一个explain方法,用以分析SQL包括索引在内的优化信息。目前只支持SQLITE3,MySQL,PostgreSQL三种Adapter。在Development环境下,config/environments/development.rb增加了个新选项config.active_record.auto_explain_threshold_in_seconds,默认值0.5,意思是当一条SQL语句运行时间超过0.5秒时将自动explain并且记入Log,这个新特性将帮助开发者留意那些效率极低的SQL语句。但是如果存在些不可避免的超过0.5秒的SQL语句,你不希望再看到Rails将它记入日志,将这句语句包含在

ActiveRecord::Base.silence_auto_explain do
  # no automatic EXPLAIN here
end

中,Rails将不会自动explain这其中的SQL。

4. Tagged Logging可以方便在多用户多IP访问应用的情况下观看Log。可以在config/environments/development.rb中增加config.log_tags选项,例如

config.log_tags = [:uuid, :remote_ip]

Log将记录例如

Started GET "/users/1" for 10.10.10.10 at 2012-04-07 22:15:35 +0800
[952ab51671f8d31f14069f6a372bb1f5] [10.10.10.10] Processing by UsersController#show as HTML
[952ab51671f8d31f14069f6a372bb1f5] [10.10.10.10]   Parameters: {"id"=>"1"}
[952ab51671f8d31f14069f6a372bb1f5] [10.10.10.10]   User Load (0.7ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1  [["id", "1"]]
[952ab51671f8d31f14069f6a372bb1f5] [10.10.10.10]   Rendered users/show.html.erb within layouts/application (13.1ms)
[952ab51671f8d31f14069f6a372bb1f5] [10.10.10.10] Completed 200 OK in 288ms (Views: 139.8ms | ActiveRecord: 7.6ms)
[d1a1f1ebb6c56d0fc3f09d592ad4c36c] [10.10.10.10]

这样的信息,可以看到,每行之前都有一个UUID和请求的IP地址。其中UUID是Rails根据HTTP Request生成的独一无二的ID。通过这个功能,我们能够把Log中相同的HTTP Request和相同的请求IP地址取分开来。

5. ~/.railsrc现在可以直接写入rails new命令的默认参数。如果你像我一样,习惯创建没有Test Case,不自动运行bundle install命令的Rails项目,你可以运行

echo -T --skip-bundle > ~/.railsrc

这样以后-T --skip-bundle将作为rails new命令的默认参数。

6. rails命令现在接受d命令,等同于destroy命令,方便像我这样的newbie理解。之前我也确实困惑过,为什么generate可以缩写为g,而destroy命令不能缩写为d呢?现在这样就好多了。

7. rails generate的 model / migration / scaffold 命令生成Model属性的时候可以这样写

rails g scaffold Post title:string:index subtitle author:uniq 'price:decimal{7,2}'

可以看到,原来每个属性只能写两列,而现在可以只写一列或是三列。一列表示默认类型是String,三列中第三列可以使用的modifier有index,表示本列添加index;uniq,表示本列添加unique index。如果类型是数值型,还可以加上{x,y}这样的格式表示数据库中数值的精度和小数的精度(留意price:decimal{7,2}左右的引号,shell中{}有特殊含义,因此可以用引号来保持原意)。

8. rails generate plugin命令被移除,请使用rails plugin new替代。

9. 移除config.paths.app.controller,请使用config.paths['app/controller']替代。

10. Rails::Plugin类过时,并将在Rails 4.0中被彻底移除。

11. 如果你在ApplicationController中指定了layout,同时使用了:only或:expect过滤。那么如果过滤条件失败,Rails现在将使用默认的layout。

12. ActionController::TestCase现在支持用cookies直接修改或清除cookie。而在此之前则不得不使用HTTP_COOKIE或CookieJar这样的方法。

13. send_file方法现在可以猜测MIME类型,如果:type没有提供的话。

14. MIME添加了包括PDF和ZIP在内的几种类型

15. 当Controller的父类显式指定过layout的情况下,子类不再按照约定的方法查询layout。

class ApplicationController
  layout "application"
end
 
class PostsController < ApplicationController
end

比如上述例子,PostsController将不会试图去寻找一个posts的layout。如果你想恢复原来的功能,删除ApplicationController中的layout语句或是显式指定PostsController的layout为nil。

16. ActionController::UnknownAction过时,请用AbstractController::ActionNotFound替代。

17. ActionController::DoubleRenderError过时,请用AbstractController::DoubleRenderError替代。

18. ActionController#rescue_action,ActionController#initialize_template_class和ActionController#assign_shortcuts过时。

19. ActionView::Helpers::FormBuilder支持button_tag方法,默认行为等同于submit_tag

<%= form_for @post do |f| %>
  <%= f.button %>
<% end %>

20. Date helpers支持:use_two_digit_numbers选项,设置为true表示日期的月和日都有两个数字构成,如果小于零则之前补零。

21. form_for方法支持:namespace选项,作为form的id的前缀,确保form的id的唯一性。

22. 限制select_year方法生成的year条目的最大数量为1000,可以通过:max_years_allowed选项设置上限。

23. content_tag_for和div_for方法支持直接传入ActiveRecord对象的集合。例如

@items.each do |item|
  content_tag_for(:li, item) do
     Title: <%= item.title %>
  end
end

现在可以被写为

content_tag_for(:li, @items) do |item|
  Title: <%= item.title %>
end

24. 由timestamps创建的created_at和updated_at字段现在默认为not null。

25. 可以使用ActiveRecord::Base.store功能添加ActiveRecord的Key-Value存储器,例如

class User < ActiveRecord::Base
  store :settings, accessors: [ :color, :homepage ]
end
 
u = User.create(color: 'black', homepage: '37signals.com')
u.color                          # Accessor stored attribute
u.settings[:country] = 'Denmark' # Any attribute, even if not specified with an accessor

如果要让上述代码能够成功运行,必须确保users表有settings字段,类型为text。运行

u = User.create(color: 'black', homepage: '37signals.com')

时,将color和homepage的属性以json的形式存储在users表的settings字段中。注意,如果User自身也有color或homepage字段,这些字段将不被使用。我们可以通过u.color或是u.settings[:color]的方法访问被存储的color字段。试图给settings添加没有被accessors指定的属性也是可以的,唯一的区别就是,将不会在ActiveRecord的实例下添加这个属性的getter和setter。所以上述代码中的u.settings[:country] = 'Denmark'可以正常运行,u.settings[:country]也确实可以取到被存储的值,并且这个值确实在数据库中被持久化。但是不能使用u.country来访问这个值。

26. ActiveRecord::Relation添加pluck,传入这个ActiveRecord的一个字段,将返回包含所有Relation中存储的Record的这个字段的值的数组。

> User.where('id <= 3').pluck(:name)
=> ["bachue", "deltamaster", "anne"]

27. ActiveRecord::Relation添加uniq方法,请看示例

> Client.select('DISTINCT name')
=> [#<User name: "bachue">, #<User name: "bachue">, #<User name: "bachue">]
> User.select(:name).uniq
=> [#<User name: "bachue">]
> User.select(:name).uniq.uniq(false)
=> [#<User name: "bachue">, #<User name: "bachue">, #<User name: "bachue">]

28. :class_name选项现在可以传入Symbol,之前传入Symbol会出错,只能传入类名的字符串。//也曾让我这个newbie confusing了好一阵。

29. 在Development环境下,db:drop命令现在也会drop掉test数据库,和db:create会创建test数据库相对应。

30. 大小写不敏感的uniqueness检查时,如果MySQL的column中已经使用了大小写不敏感的校验,那么Adapter将不会再调用MySQL的LOWER函数来进行这个检查。

31. ActiveRecord::Relation新增first_or_create,first_or_create!和first_or_initialize方法,当Relation没有找到搜索结果时,将创建同时包含Relation的参数和first_or_create的参数的记录,例如

> User.create name: 'bachue', age: 22
=> #<User id: 1, name: "bachue", age: 22>
> User.where(name: 'bachue').first_or_create!(age: 16)
=> #<User id: 1, name: "bachue", age: 22>
# No new record is created
> User.count
=> 1
> User.where(name: 'anne').first_or_create!(age: 16)
=> #<User id: 2, name: "anne", age: 16>
# New record is created
> User.count
=> 2

32. 在Development环境下,config/environments/development.rb增加了新语句

config.active_record.mass_assignment_sanitizer = :strict

功能是当违反mass assignment protection的时候,Rails将抛出ActiveModel::MassAssignmentSecurity::Error异常来阻止这一赋值,而此前仅仅是给出一个warning而并不阻止。

//感觉可以考虑把这句话移到全局的配置文件,阻止Production环境上有人试图用mass assignment攻击系统。

33.  现在将不会自动关闭Thread的数据库连接,例如

Thread.new { Post.find(1) }.join

你现在要在Thread闭包结束时显式关闭连接,例如

Thread.new {
  Post.find(1)
  Post.connection.close
}.join

如果之前你在项目中使用线程的话现在要注意了 //不知道什么情况!Release notes中就这么写的,为啥要修改成这样啊!此前几乎从没有关注过Rails的数据库连接机制好不好!

34. set_table_name,set_inheritance_column,set_sequence_name,set_primary_key,set_locking_column方法过时了,使用setter替代。例如以前写的

class Project < ActiveRecord::Base
  set_table_name "project"
end

现在将被写成

class Project < ActiveRecord::Base
  self.table_name = "project"
end

这种方法的好处在于,你可以使self.table_name变成方法,例如

class Post < ActiveRecord::Base
  def self.table_name
    "special_" + super
  end
end
 
Post.table_name # => "special_posts"

//大爱ruby的local variable和method可以相互替换的特点。也由此可见在ruby中写set_xxxx是没有前途的。

35. ActiveModel::AttributeMethods的define_attr_method方法过时,这方法本来就是用于支持类似于set_table_name方法的,但现在它们都过时了。

36. 新增ActiveSupport:TaggedLogging类,用来包装任何标准的Logger类以提供tagging功能,请看示例

Logger = ActiveSupport::TaggedLogging.new(Logger.new(STDOUT))
 
Logger.tagged("BCX") { Logger.info "Stuff" }
# Logs "[BCX] Stuff"
 
Logger.tagged("BCX", "Jason") { Logger.info "Stuff" }
# Logs "[BCX] [Jason] Stuff"
 
Logger.tagged("BCX") { Logger.tagged("Jason") { Logger.info "Stuff" } }
# Logs "[BCX] [Jason] Stuff"

37. 在Date,Time和DateTime类中的beginning_of_week方法现在可以接受一个参数,代表一周的哪一天属于一周的第一天,请看示例

> Date.today.beginning_of_week :monday
=> Mon, 02 Apr 2012 
> Date.today.beginning_of_week :sunday
=> Sun, 08 Apr 2012 
> Date.today.beginning_of_week :saturday
=> Sat, 07 Apr 2012 
> Date.today.beginning_of_week :wednesday
=> Wed, 04 Apr 2012

38.  String类添加safe_constantize方法,用以将字符串转换成同名的Class对象。和constantize方法的区别在于如果无法找到这个Class对象,将返回nil而不是抛掷异常。例如

> 'User'.safe_constantize
=> User(id: integer, name: string, age: integer, created_at: datetime, updated_at: datetime) 
> 'User1'.safe_constantize
=> nil 
> 'User'.constantize
=> User(id: integer, name: string, age: integer, created_at: datetime, updated_at: datetime) 
> 'User1'.constantize
NameError: uninitialized constant User1

39.  增加Array#prepend方法,alias Array#unshift。增加Array#append方法,alias Array#<<。//之前想找给Array push元素的方法,除了<<就找不到了,现在好了。

40.  Time新增all_day,all_week,all_quarter,all_year方法可以生成相应的Range对象,例如:

> Time.now.all_week
=> 2012-04-02 00:00:00 +0800..2012-04-08 23:59:59 +0800

//但是前面才刚提到关于week添加一个选项设定一周的那一天是这周的第一天,这里却又看不到了。

41. 新增ActiveSupport::Cache::NullStore用于Development环境和Testing环境。

42. 移除ActiveSupport::SecureRandom,请使用标准库中的SecureRandom替代。

43. ActiveSupport::Base64过时,请使用::Base64替代。

44. ActiveSupport::Memoizable过时,请使用Ruby的memoization pattern替代。

45. Module#synchronize过时,没有替代方案,请使用ruby标准库的monitor。

46. ActiveSupport::MessageEncryptor#encrypt和ActiveSupport::MessageEncryptor#decrypt方法过时。

47. ActiveSupport::BufferedLogger#silence,如果你不想记录当前语句块的Log,请修改它的Log Level。

48. ActiveSupport::BufferedLogger给你的Log自动创建目录的行为过时,请自行创建。

49. ActiveSupport::BufferedLogger#auto_flushing过时,请像这样设定日志文件的sync level。或是优化你的文件系统。FS cache现在控制着flushing。

f = File.open('foo.log', 'w')
f.sync = true
ActiveSupport::BufferedLogger.new f

50. ActiveSupport::BufferedLogger#flush过时,请设定文件句柄的sync,或是优化你的文件系统。