拥抱 Rails 4 —— 详述 Rails 4 的新变化
Rails 3 提供了 match
方法供我们自定义 routes,然而我们要小心使用它以避免“跨站脚本攻击”(XSS Attack)。比如像这样的 routes:
注:(r3 代表 Rails 3,r4 代表 Rails 4)
# routes.rb
match '/books/:id/purchase', to: 'books@purchase'
用户可以很轻松地使用 XSS Attack CSRF Attack,比如使用这样一个链接:
CodeSchool 的 Rail 4 教程里写的是 XSS Attack,经查证和问询,证明这是 CodeSchool 的失误,应该会很快改正过来,再次先做一个修正,并向受到误导的朋友致歉
<a href="http://yoursite.com/books/4/purchase">Get It Free!</a>
这会使用 GET 去请求这个资源,你绝对不想看到这种情况(你希望的是 POST),所以你要限制客户端可以访问此资源的方式。例如:
match '/books/:id?purchase', to: 'books@purchase', via: :post # :all 代表匹配所有的 HTTP methods
# 或者
post '/books/:id?purchase', to: 'books@purchase'
否则你就会收到如下错误提示:
You should not use the
match
method in your router without specifying an HTTP method. (RuntimeError)
过去我们使用 put
来完成对资源的更新请求,然而 put
本身是对整个资源(数据集合)进行更新,若要实现部分资源的更新(单个数据,或是几个产生变化的数据实体),put
就有点过重了,此时 patch
会更加合适。
patch
并不是什么新东西,此前就一直存在于 HTTP 1.1 协议规范之中,只不过这一次 Rails 4 把它正式的引入进来。在 Rails 4 中,put
和 patch
都指向 controller#update
,在更新部分资源时(比如 @book)会使用 patch
,生成类似下例中的页面元素:
<form action="/books/20" method="post">
<div style="margin:0;padding:0;display:inline">
<input name="utf8" type="hidden" value="✓" />
<input name="_method" type="hidden" value="patch" /> <!-- 关键就是这一行了 -->
</div>
</form>
同时还增加了一个 #patch
方法,可以在合适的时候使用:
test "update book with PATCH verb" do
patch :update, id: @book, book: { title: @book.title }
assert_redirected_to book_url(@book)
end
Concerns(关注点)是一种组织代码结构的方式,用来帮助开发者将复杂的逻辑和重复代码梳理清楚,我们在 Rails 4 中多次看到对于 Concerns 的设计和实现。先看一段老代码:
resources :messages do
resources :comments
resources :categories
resources :tags
end
resources :posts do
resources :comments
resources :categories
resources :tags
end
resources :articles do
resources :comments
resources :categories
resources :tags
end
像这样的代码存在许多的重复,Rails 4 允许我们重构它:
concern :sociable do
resources :comments
resources :categories
resources :tags
end
resources :messages, concerns: :sociable
resources :posts, concerns: :sociable
resources :articles, concerns: :sociable
可以通过传递参数来实现对个例的特化:
concern :sociable do |options|
resources :comments, options
resources :categories, options
resources :tags, options
end
resources :messages, concerns: :sociable
resources :posts, concerns: :sociable
resources :articles do
concerns :sociable, only: :create
end
甚至我们可以抽取出来变成单独的类:
# app/concerns/sociable.rb
class Sociable
def self.call(mapper, options)
mapper.resources :comments, options
mapper.resources :categories, options
mapper.resources :tags, options
end
end
# config/routes.rb
concern :sociable, Sociable
resources :messages, concerns: :sociable
resources :posts, concerns: :sociable
resources :articles do
concerns :sociable, only: :create
end
我们都听说 Rails 4 需要 Ruby 的版本不能小于 1.9.3,不过这一点所引起的变化通常都十分微妙,不容易让人注意到。
1.8.x 时代,nil.id
是合法的(一切都是对象!),但是不合理,经常惹人厌。于是 1.9.2 之后,逐渐使用 object_id
来代替,使用旧的 id
方法会抛出运行时错误:
RuntimeError: Called id for nil, which would mistakenly be 4 -- if you really wanted the id of nil, use object_id
Rails 3 无法永远摆脱这恼人的提示,因为它要同时兼容 1.8 和 1.9,于是一旦碰上可能会出现的 nil.id
就会看到上面那个错误
在 Rails 4 的世界里,手起刀落,喀嚓~~~ 从此 nil 不再聒噪,世界终于清净了……
NoMethodError: undefined method `id' for nil:NilClass
线程安全的处理在 Rails 3 中已有,不过默认是关闭的:
# config/environments/production.rb
MyApp::Application.configure do
# Enable threaded mode
# config.threadsafe!
end
这个方法在 Rails 4 中不推荐使用,新的线程安全机制在默认情况下就已经开启:
# config/environments/production.rb
MyApp::Application.configure do
config.cache_classes = true # 阻止类在请求中重新载入,并保证 Rack::Lock 不包含在中间件堆栈中
config.eager_load = true # 在新线程创建前加载全部代码
end
Book.find(:all, conditions: { author: 'Albert Yu' })
这种方法已经用了很久了吧?在 Rails 4 中,你会看到如下警告:
DEPRECATION WARNING: Calling #find(:all) is deprecated. Please call #all directly instead. You have also used finder options. These are also deprecated. Please build a scope instead of using finder options.
实际上,老式的 finders 已经被抽取成了 activerecord-deprecated_finders
gem,你要还想用就得自己安装它。
在 Rails 4 中,推荐这样用:
Book.where(author: 'Albert Yu')
没人不爱它!而且还没完,同样的变化还有:
Book.find_all_by_title('Rails 4') # r3 way
Book.find_last_by_author('Albert Yu') # r3 way
Book.where(title: 'Rails 4') # r4 way
Book.where(author: 'Albert Yu').last # r4 way
动态的 find_by
也不例外:
Book.find_by_title('Rails 4') # 接收单个参数的用法在 r3 & r4 都可以
Book.find_by(title: 'Rails4') # 不过 r4 更偏爱这样写
Book.find_by_title('Rails 4', conditions: { author: 'Albert Yu' }) # 这就不好了,得改
Book.find_by(title: 'Rails4', author: 'Albert Yu') # Wow! 太棒了!
统一使用 find_by
不仅有更好的一致性,而且更便于接收 hash 参数:
book_param = { title: 'Rails 4', author: 'Albert Yu' }
Book.find_by(book_param)
find_by
方法的内部实现其实很简单:
# activerecord/lib/active_record/relation/finder_methods.rb
def find_by(*args)
where(*args).take
end
这意味着这样用也没有问题:
Book.find_by("published_on < ?", 3.days.ago)
这两种方法不再推荐使用了:
Book.find_or_initialize_by_title('Rails 4')
Book.find_or_create_by_title('Rails 4')
会抛出如下警告:
DEPRECATION WARNING: This dynamic method is deprecated. Please use e.g. Post.find_or_initialize_by(name: 'foo') instead.
DEPRECATION WARNING: This dynamic method is deprecated. Please use e.g. Post.find_or_create_by(name: 'foo') instead.
让我们从善如流:
Book.find_or_initialize_by(title: 'Rails 4')
Book.find_or_create_by(title: 'Rails 4')
还有一种容易让人迷惑的用法
Book.where(title: 'Rails 4').first_or_create
# 若找不到…
Book.where(title: 'Rails 4').create
这方法在 Rails 3 和 Rails 4 里都可以用,它先是查询是否有符合条件的记录,若没有就以该条件创建一个。听起来还不错,然而当存在这样的代码时,其表现就不是你想的那样了:
class Book < ActiveRecord::Base
after_create :foo
def foo
books = books.where(author: 'Albert Yu')
...
end
end
产生的 SQL 是:
SELECT "books".* FROM "books" WHERE "books"."title" = 'Rails 4' AND "books"."author" = 'Albert Yu'
注意,这里的 after_create
回调原本是在创建一条记录后立刻返回所有作者是 Albert Yu 的记录,但最终的结果却是所有标题是 Rails 4 并且作者是 Albert Yu 的记录。这是因为触发该回调函数的方法调用已经有了 title: 'Rails 4'
的作用域,于是产生了作用域叠加。
Rails 4 里推荐这样来做:
Book.find_or_create_by(title: 'Rails 4')
# 若找不到…
Book.create(title: 'Rails 4')
这样就不会产生叠加副作用,真正的 SQL 语句如下:
SELECT "books".* FROM "books" WHERE "books"."author" = 'admin'
是不是经常被 #update_attributes
和 #update_attribute
还有 #update_column
搞晕?好消息来了——Rails 4 重新整理了属性更新的方法,现在的方式简单明了:
@book.update(post_params) # 会触发验证
@book.update_columns(post_params) # 构建 SQL 语句,直接执行于数据库层,不会触发验证
就这俩,不会搞错了吧?以前的方式也还能用,但是不排除会被废弃。既然 Rails 4 提供了更好用的方法,那就不要再犹豫了。
也不是所有的变化都那么显而易见的令人愉悦,一部分人大概会对接下来的变化感到不适应。以前普遍认为不要直接使用 Model.all
,因为这会产生很严重的性能问题,开发者更倾向于先对 Model 进行 scope:
def index
@books = Book.scoped
if params[:recent]
@books = @books.recent
end
end
然而,Rails 4 会抛出如下警告:
DEPRECATION WARNING: Model.scoped is deprecated. Please use Model.all instead.
WTF?Model.all
又回来了?
没错。不过你不用担心,Rails 4 里的 Model.all
不会立即执行对数据库的查询,而仅仅是返回一个 ActiveRecord::Relation
,你可以继续进行链式调用:
def index
@books = Book.all # 我不会碰数据库的哦,直到你告诉我下一个条件…
if params[:recent]
@books = @books.recent # 这时候我才会行动
end
end
当然,这并不是说不能用 scoped model 了,只不过是多了一层防范措施,以减少初学者不小心造成的性能问题。
顺着上一节的话题,我们继续讲讲 Scopes。在 Rails 4 当中,eager-evaluated scopes 不再推荐使用了,因为通常没搞清对象(obejct)的热心(eager)帮助往往会帮倒忙!
附注:eager 这个词在这里不好翻译,其原意是有“热情”、“渴望”的含义,在这里代表“在预先不知道查询请求的对象时就先做查询”。这一点固然有积极的意义,但有时候也会带来意料不到的结果。请看下文:
举个例子:
scope :sold, where(state: 'sold')
default_scope where(state: 'available')
这样定义 scope 就称之为 eager-evaluated,因为在进行查询之前并不知道具体要调用该查询的对象是谁。在 Rails 4 中,以上代码会抛出警告:
DEPRECATION WARNING: Using #scope without passing a callable object is deprecated
DEPRECATION WARNING: Calling #default_scope without a block is deprecated
按照提示所说,你需要在定义 scope 的时候传递一个 proc 对象,所以修正的方法也很简单:
scope :sold, -> { where(state: 'sold') }
default_scope -> { where(state: 'available') }
为什么呢?看一个实际的例子就明白了:
scope :recent, where(published_at: 2.weeks.ago)
这段代码的问题就在于 2.weeks.ago
的求值只会在这个 class 载入时发生一次,以后再调用的时候你还是会得到一模一样的值。
scope :recent, -> { where(published_at: 2.weeks.ago) }
scope :recent_red, recent.where(color: 'red')
转变成 proc 对象后,当你再次调用它就会重新求值(上例第二行,recent_red 调用 recent,recent 会重新求值),于是此问题就解决了。
当然,你应该在所有的 scopes 里应用这一原则,因此上例最终应写成:
scope :recent, -> { where(published_at: 2.weeks.ago) }
scope :recent_red, -> { recent.where(color: 'red') }
这个变化可能不是那么新鲜,毕竟多数开发者在 Rails 3 的时候就是这么处理的,Rails 4 只是对未处理过的 scopes 报出警告而已,算是一个小小的变化。
#not
是一个新方法,而且非常好用。我们先来看一段代码:
Book.where('author != ?', author)
你知道这个查询有什么问题么?大部分情况下它工作良好,但如果 author = nil
的话,Rails 会产生如下 SQL 语句:
SELECT "posts".* FROM "posts" WHERE (author != NULL)
它能用,但是最后括号里的部分不符合 SQL 的语法规则,这会让许多“代码洁癖”患者感到寝食难安的!(玩笑)所以他们通常会写出如下无可奈何的临时解决方案:
if author
Book.where('author != ?', author)
else
Book.where('author IS NOT NULL')
end
现在,同样的需求在 Rails 4 里可以这样写:
Book.where.not(author: author)
该查询生成的 SQL 语句非常标准:
SELECT "posts".* FROM "posts" WHERE (author IS NOT NULL)
#none
也是和 #not
一样棒的新方法,考察一下这段代码:
Class User < ActiveRecord::Base
def visible_posts # 查询可见的帖子...
case role # ...基于用户的角色
when 'Country Manager'
Post.where(country: country)
when 'Reviewer'
Post.published
when 'Bad User'
???
end
end
end
那么,对于 Bad User 我们要求不返回任何帖子,你要怎么做?比较直觉性的做法就是返回一个空数组 []
,但是对于下面的代码来说:
@posts = current_user.visible_posts
@posts.recent
会报错:
NoMethodError: undefined method `recent' for []:Array
本着“头疼医头,脚疼医脚”的精神……你可以这么搞:
@posts = current_user.visible_posts
if @posts.any?
@posts.recent
else
[]
end
但是这太丑了,不是么?你必须要检查查询数组里有没有东西,然后在明知没有的情况下再返回代表“没有”的空数组……多愚蠢啊~为什么 Rails 不能帮我们检查是否“没有”呢?在 Rails 4 里这变成了可能:
Class User < ActiveRecord::Base
def visible_posts
case role
when 'Country Manager'
Post.where(country: country)
when 'Reviewer'
Post.published
when 'Bad User'
Post.none # 空即是空,无便是无……
end
end
end
上例中的 Post.none
并不只是返回空数组,而是返回一个不去碰数据库的 ActiveRecord::Relation
,你可以获得如下的查询:
@posts = current_user.visible_posts
@posts.recent # 根据前文的条件,这个方法会产生三个可能的查询:
# 1
Post.where(country: country).recent
# 2
Post.published.recent
# 3
Post.none.recent # 不会报错,这是对的查询
#order
方法现在产生了一些新的变化,主要是针对生成的 SQL 语句,以下简明列举:
class User < ActiveRecord::Base
default_scope -> { order(:name) }
end
User.order("created_at DESC")
以上代码在 3 和 4 里产生了有所区别的 SQL:
/*in r3*/
SELECT * FROM users ORDER BY name asc, created_at desc
/*in r4*/
SELECT * FROM users ORDER BY created_at desc, name asc
另外,现在可以用 symbol 来代表排序的查询条件了:
# in r3
User.order('created_at DESC')
User.order(:name, 'created_at DESC')
# in r4
User.order(created_at: :desc)
User.order(:name, created_at: :desc)
这么做的好处还是为了增强一致性,并且利于使用 hash 传入查询条件。
说到字符串形式的查询条件,在 Rails 4 中对于这样的代码:
Post.includes(:comments).where("comments.name = 'foo'")
会抛出警告:
DEPRECATION WARNING: It looks like you are eager loading table(s) (one of: posts, comments) that are referenced in a string SQL snippet. (...)
所以你必须对字符串形式的查询显式声明其引用的表是哪一个,就像这样:
Post.includes(:comments).where("comments.name = 'foo'").references(:comments)
然而对于 hash 形式的条件传递,就不需要特意声明了:
Post.includes(:comments).where(comments: { name: 'foo' })
# or
Post.includes(:comments).where('comments.name' => 'foo' })
像下面这样没有条件的查询,尽管是字符串也无需声明 references
Post.includes(:comments).order('comments.name')
pluck
方法现在可以接受多个参数了(每个参数代表数据库表中的一个字段):
Person.pluck(:id, :name)
现在将会返回包含两个字段的记录了,一个小小的但是很有用的改进。
Post.comments.except(:order)
像上面这一句代码,你以为会排除 order 的排序,但却不尽然。因为如果 Comment 的 default_scope
是带有 order 的话,except 并无法改变 Post.comments 的查询结果。幸好 Rails 4 中多了一个新方法:
Post.comments.unscope(:order) == Post.comments.order
这样会确保你想要的结果,而不必担心 default_scope
所造成的影响。另外,unscope
方法是支持多个参数的。
当向数据库插入新的记录的时候,Rails 会对比缺省值,然后只把发生变化的字段放进 INSERT
语句里,剩下的部分由数据库自动填充。这一变化会使得增加记录效率更高,移除数据库字段也会更加安全。
Rails 3 中增加了 ActiveModel
使得我们可以创建和 ActiveRecord
一样的模型,拥有几乎全部功能却不需要和数据库关联,就像这样:
class SupportTicket
include ActiveModel::Conversion
include ActiveModel::Validations
extend ActiveModel::Naming
attr_accessor :title, :description
validates_presence_of :title
validates_presence_of :description
end
于是,你可以为其生成关系表单,做条件验证等等,非常方便。在 Rails 4 中,对 ActiveModel
做了小小的改进,现在你可以直接 include 它的“精简版”:
class SupportTicket
include ActiveModel::Model
attr_accessor :title, :description
validates_presence_of :title
validates_presence_of :description
end
ActiveModel::Model
是一个“混编模组”:
# activemodel/lib/active_model/model.rb
def self.included(base)
base.class_eval do
extend ActiveModel::Naming
extend ActiveModel::Translation
include ActiveModel::Validations
include ActiveModel::Conversion
end
end
Easy and clear!
相比 Rails 3,Rails 4 里的 Association 返回的不再是数组而是一个集合代理(CollectionProxy),这一变化是好是坏应该说莫衷一是,具体产生的影响由于演示起来篇幅过长,所以请移步这篇博客
总结起来就是输出到客户端的关系数据会有所变化,会影响到 JSON API,不过在适应了规则之后,前端工程师处理这些小变化应该是没什么问题的。
Migration 文件里新添加了一个 Helper method, 专门用于为 HABTM 关系创建关联表:
create_join_table :categories, :products, :id => false do |f|
f.integer :categories_id, :null => false
f.integer :products_id, :null => false
end
现在主键会自己初始化为 nil,除非你用别的值覆盖它。
如果你选用的数据库支持 DDL Transaction,那么所有的数据库迁移会被包裹在一个事务中完成;然而某些 SQL 命令无法在事物内部成功执行,这会造成迁移的失败。在 Rails 4 中,你可以把这些造成失败的命令抽取出来放在一个单独的 migration 里,然后使用这个方法来禁止事务处理:
class ChangeSth < ActiveRecord::Migration
self.disable_ddl_transaction!
def change
# some SQLs those can not execute in a transaction
end
end
在产品环境中,Rails 应用在初始化的时候会把所有 model 的数据库模式(schema)载入至一个 schema cache(模式缓存)中。对那些拥有庞大数量的 models 的应用程序而言,Rails 4 提供了 schema cache dump(模式缓存转储)的新功能,用来加速应用程序的启动。你可以使用这个 rake task:
$ RAILS_ENV=production bundle exec rake db:schema:cache:dump
这会生成一个 db/schema_cache.dump
文件,Rails 用它来加载 SchemaCache
实例的内部状态。
你可以选择关闭这个功能,编辑 config/production.rb
文件,添加这一行:
config.active_record.use_schema_cache_dump = false
如果你要清除 schema cache,执行:
$ RAILS_ENV=production bundle exec rake db:schema:cache:clear