要写出优美的rails代码,必须理解和掌握association的机制。它能使代码编写更加简单和方便,更能使你的代码更加简洁和优美。除此之外,也必须了解association背后的实现。有时候,代码的优美不代表一切,甚至代表着背后的丑恶。
Association魔法
先来看看什么是association,以及association如何使你的代码更加简单和优美。
举个rubyonrails guides上的例子。
一个customer有很多orders,它们的模型是这样子的:
class Customer < ActiveRecord::Base end class Order < ActiveRecord::Base end
假如要创建一个属于一个customer的order,则需要:
@order = Order.create(:order_date => Time.now, :customer_id => @customer.id)
或者删除一个customer,以及他的所有orders:
@orders = Order.find_by_customer_id(@customer.id) @orders.each do |order| order.destroy end @customer.destroy
这样的代码是非常繁琐的,并且在语义上很不清晰。让我们为这两个模型声明association:
class Customer < ActiveRecord::Base has_many :orders, :dependent => :destroy end class Order < ActiveRecord::Base belongs_to :customer end
当我们为这两个模型声明了association之后,一切就变得简单和明了了:
@order = @customer.orders.create(:order_date => Time.now) @customer.destroy
这就是assciation的魔法。
选择正确的association
使用何种association,跟数据库schema的设计有关;但最重要的,是反之,在设计数据库schema的时候,要考虑到数据的真正含义。
比如,一个company,它拥有一个account,这是明显的account从属于company的关系。
那么,它们的模型和association声明最好应该是这样的,以便跟语义匹配:
class Company has_one :account end class Account belongs_to :company end
但association的声明,还得符合数据库的表结构(特别是建立在一个遗留数据库上的时候)。这时候的外键(foreign key)应该在accounts表上,而不是companies表上。
又比如,一个teacher有很多students,同时一个students也有很多teachers。所以,它们应该是多对多的关系,那么,association的模型声明应该是这样的:
class Teacher has_and_belongs_to_many :students end class Student has_and_belongs_to_many :teachers end
这种多对多的映射,在数据库上需要有一个中间表:students_teachers。但有时候,我们希望这个中间表变得有意义起来,而不是纯粹起到关联 作用,比如在它上面添加一些其它字段。并且希望这个中间表的名字更加有意义,以便映射到一个模型。这时候,需要使用另外一种association,来建 立这三个模型之间的关联:
class Teacher has_many :relations has_many :students, :through => :relations end class Relation belongs_to :teacher belongs_to :student end class Student has_many :relations has_many :teachers, :through => :relations end
还有一种高阶用法,有时候,我们希望一个模型属于几种不同的其它模型。比如,image即属于一个lightbox,也属于一个shopping cart。那我们可以使用多态association(polymorphic association ):
class Image belongs_to :image_collection, :polymorphic => true end class Lightbox has_many :images, :as => :image_collection end class ShoppingCart has_many :images, :as => :image_collection end
同时,在做migration的时候,也要注意声明这是一种多态的关联:
create_table :images do |t| t.string :name t.references :image_collection, :polymorphic => true t.timestamps end
这个多态的reference,其实会生成两个字段:image_collection_id和:image_collection_type。
随之而来的那些方法
这是association带来的非常重要的东西,就是随着association的声明,会有一些相关的方法被创建出来。
比如我们声明了belongs_to关联之后,有四个方法被自动创建:
association(force_reload = false) association=(associate) build_association(attributes = {}) create_association(attributes = {})
又比如对于has_many关联,有如下方法被创建:
collection(force_reload = false) collection<<(object, …) collection.delete(object, …) collection=objects collection_singular_ids collection_singular_ids=ids collection.clear collection.empty? collection.size collection.find(…) collection.exist?(…) collection.build(attributes = {}, …) collection.create(attributes = {})
正是这些方法,让代码变得简单。
举个简单的例子,还是上面那个customer、order模型:
@customer.order_ids = [1, 2, 3]
这个方法会把customer原有的并且不在这个id list里的orders给清除掉,创建新的原来不存在的关联,同时,会自动save到数据库。曾经需要非常复杂的操作,现在变得如此简单。
还有,rails并不限制你为association添加自己的方法,这就是Association Extensions 。
何时保存到数据库
经过观察,发现对于大部分的关联,rails都会在赋值时自动把关联对象以及新的关联关系保存到数据库(除非你特别使用build方法来告诉rails不 要save)。但对于belongs_to关联,却是个例外。比如对于上面account和company的例子:
@account.company = @company
上面这条语句,并不会自动把@company(如果是一个new record)以及这两个对象之间的关联关系保存到数据库。
我觉得,这个例外的主要原因是:对于其它关联来讲,关联的key要么在一个中间表,要么在对方表上。比如:
@company.account = @account
外键存在于accounts表上,而不是companies表上。
而对于belongs_to的关联,外键存在于自己表上。你给一个对象设置一些property,在没有调用save之前,它是不应该保存到数据库的。在如下这个场景里,就比较好理解为什么belongs_to关联不会自动保存到数据库:
@account.name = "new account" @account.company = @company @account.value = 100.00 # @account.save
在调用上面的save语句之前,@account肯定不应该保存任何数据到数据库。
而对于其它类型的关联,自动保存是比较合理的。比如:
@company.account = @account
外键在accounts表上,这时候会自动把外键设上,并保存到数据库。而如果需要显示save才能保存,那么代码就会变得难看并且不合理:
@company.account= @account # @account.save #需要显示保存account,而我们是在操作company
当然,上面讨论的前提是:自身对象本身已经被save了,而不是一个new record。
Association的弹性
Rails很精妙的一点在于,它用convention来使你省却很多麻烦,但它从来不限制你做什么,你如果觉得它的方式不好或者不适用,你可以去改变它。
对于association也一样,它提供了很多options。
比如对于company和account,外键默认是company_id,但你可以通过设定:foreign_key选项,来指定你希望的外键名称。
又比如,对于任何关联,rails都提供了默认的sql查询语句。但我们可以通过:finder_sql来改变它的查询语句。
Association,小心!
Association的使用和创建并不是随心所欲的,你还得小心以下几点:
1. 不要随心所欲地使用名字,至少,不应该跟model本身的instance method冲突。
2. 小心cache:所有的associaiton方法,都是在最近查询的cache上操作,如果你的程序的其它部分改变了数据,就需要reload这些数据。比如
customer.orders # retrieves orders from the database customer.orders.size # uses the cached copy of orders customer.orders(true).empty? # discards the cached copy of orders # and goes back to the database
Association的罪恶
Association是魔法,但如果滥用,它是罪恶。
举一个例子。
有两个模型:
class User has_many :lightboxes end class Lightbox belongs_to :user end
我们希望找出拥有lightbox,并且lightbox的images_count大于10的所有users。可能我们会使用下面这个查询语句:
User.all(:include => [:lightboxes], :conditions => "lightboxes.id IS NOT NULL AND lightboxes.images_count > 10")
这个查询想当然地include(eager loading)了lightboxes,导致了两个罪恶:
1. 滥用include。:include在这里确实需要,如果没有:include了lightboxes,lightboxes表因为没有被bound, 所以不可以在:conditions里面使用它的字段作为查询条件。但是,:include应该在需要eager loading的时候使用,而不是把它用作:joins的替代。
在跟踪log的时候,你会看到这个查询导致了两个表之间的left join,而这在当前的查询来说不是最好的方式。使用left join和lightbox.id IS NOT NULL的方式去过滤没有lightbox的user是一种愚蠢的行为。正确并且快速的方式是使用inner join。并且在结果集里面会把lightbox的字段查询出来,而这并不是我们需要的。
2. 很多人以为eager loading是用join来实现的,其实在Rails2.2之后(我知道的是这个版本),已经改变了(http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations )。比如对于下面的查询:
User.all(:include => :lightboxes, :conditions => "company_id = 20")
数据库真正的查询是:
SELECT * FROM users WHERE company_id = 20 SELECT * FROM lightboxes WHERE lightboxes.user_id IN (....................)
这种查询方式,比之于用left oin来查询,数据结果集更加少,而且更易于做缓存(评论里QuakeWang说道)。
但是,有一种情况会改变这种默认的查询方式:
在:conditions里面有其它表的字段出现。不管这个表是否出现在:include集里面(比如只出现在joins里面),rails的eager loading的查询方式都会变成用join的方式,来查出发起表自身和:include里面所有关联的结果集。
分几种情况:
如果是通过:include绑定了其它表,那么使用的是left outer join,并且结果集中会出现这个表。
如果是通过:joins绑定了其他表,那么使用的join方式由自己指定,并且如果没有:include这个关联,结果集不会出现这个表的数据。
如果同时被:include和:joins绑定,代表的是:需要eager loading这个关联,但使用由:joins指定的表关联方式。
举两个例子吧:
Company.all(:include => :address, :joins => "INNER JOIN users ON users.company_id = companies.id", :conditions => "users.id in (1, 2)")
引起的查询时:
SELECT companies.id AS t0_r0, ...., addresses.id as t1_r0, ... FROM companies LEFT OUTER JOIN addresses ON addresses.id = companies.address_id INNER JOIN users ON users.company_id = companies.id WHERE users.id in (1, 2)
而如果稍微改变一下上面的查询语句:
Company.all(:include => :address, :joins => "INNER JOIN users ON users.company_id = companies.id AND users.id IN (1, 2)")
则引起的查询是:
SELECT companies.* FROM companies INNER JOIN users ON users.company_id = companies.id AND users.id in (1, 2) SELECT * FROM addresses WHERE addresses.id in (...)
通过上面的分析,我们了解了如何正确使用eager loading。
1. Eager loading是需要在预先获取关联数据时使用的,它通过减少N+1查询,来提高效率。它不是:joins的替代,并且较之于:joins,:include会导致结果集的增加。
2. 同时,最好在需要eager loading时,避免在:conditions里面涉及其它表的字段,而通过:joins来替代。这种方式的效率会更好,这也是Rails2.2改变查询方式的原因吧。
所以,上面那个查询正确的方式应该是下面这样的,因为我们只是把lightboxes作为一个查询条件,并不需要它的结果集:
User.all(:joins=> "INNER JOIN lightboxes ON users.id = lightboxes.user_id", :conditions => "lightboxes.images_count > 10")
当然,有人会说,这也不是“最美”的查询方式,你可以利用模型之间的association来写出一个更漂亮的查询,但上面这个查询是建立在“我们需要性能”的基础上的。
其它
关于association,还有其它一些重要的东西,比如 Association Callbacks ,就不再一一赘述了。本文完毕。
Reference: