本課時(shí)講解模型在數(shù)據(jù)查詢(xún)時(shí),如何避免 N+1問(wèn)題,使用 scope 包裝查詢(xún)條件,編寫(xiě)模型 Rspec 測(cè)試。
ActiveRecord 這個(gè) gem 中,包含了兩個(gè)重要的 gem,打開(kāi)它的 源代碼,可以看到這兩個(gè) gem:activemodel 和 arel。
activemodel 為一個(gè)類(lèi)增加了許多特性,比如屬性校驗(yàn),回調(diào)等,這在后面章節(jié)會(huì)介紹。
arel 是 Ruby 編寫(xiě)的 sql 工具,使用它,可以通過(guò)簡(jiǎn)單的 Ruby 語(yǔ)法,編寫(xiě)復(fù)雜 sql 查詢(xún),我們上面使用的例子,語(yǔ)法就來(lái)自 arel。arel 還可以面向多種關(guān)系型數(shù)據(jù)庫(kù)。
ActiveRecord 在使用 arel 的時(shí)候,提供了一個(gè)方法:sanitize_sql。
在我們以上的講解中,會(huì)經(jīng)常傳遞這樣的參數(shù) ["name = ? and price=?", "foobar", 4],它會(huì)由 sanitize_sql 方法進(jìn)行處理,這是一個(gè) protected 方法,我們使用 send 來(lái)調(diào)用它:
Product.send(:sanitize_sql, ["name = ? and price=?", "Shoes", 4])
=> "name = 'Shoes' and price=4"
這是一種安全的手段,保護(hù)我們的 sql 不會(huì)被插入惡意代碼。我們不必去直接使用這個(gè)方法,除非特殊情況,我們只需要按照它的格式要求來(lái)書(shū)寫(xiě)就可以了。
N+1 是查詢(xún)中經(jīng)常遇到的一個(gè)問(wèn)題。在下一節(jié)里,我們經(jīng)常使用關(guān)聯(lián)關(guān)系的查詢(xún),比如,列出十個(gè)用戶(hù)的同時(shí),顯示它地址中的電話(huà):
users = User.limit(10)
users.each do |user|
puts user.address.phone
end
這樣就會(huì)造成,在 each 中又去查詢(xún)數(shù)據(jù),得到電話(huà)。這種情況會(huì)經(jīng)常出現(xiàn)在我的列表中,所以在列表中會(huì)經(jīng)常遇到 N+1 的問(wèn)題。
為了避免這個(gè)問(wèn)題,Rails 提供了預(yù)加載的功能,在查詢(xún)的時(shí)候,使用 includes 來(lái)解決。上面的例子修改一下:
users = User.includes(:address).limit(10)
users.each do |user|
puts user.address.phone
end
我們查看一下終端的輸出:
SELECT * FROM users LIMIT 10
SELECT addresses.* FROM addresses
WHERE (addresses.user_id IN (1,2,3,4,5,6,7,8,9,10))
這里只有兩個(gè) sql 查詢(xún),提高了查詢(xún)效率。
當(dāng)我們使用 where 查詢(xún)的時(shí)候,會(huì)遇到多個(gè)條件組合查詢(xún)。通常我們可以把它們都寫(xiě)到一個(gè) where 的條件里,比如:
Product.where(name: "T-Shirt", hot: true, top: true)
我增加了兩個(gè)條件,hot: true 和 top: true,但是,這種條件組合只能在這里使用,在其他地方,我們還要再寫(xiě)一遍,這不符合 Rails 的哲學(xué):“不要重復(fù)自己”。
Rails 提供了 scope,讓我們復(fù)用查詢(xún)條件:
class Product < ActiveRecord::Base
scope :hot, -> { where(hot: true) }
scope :top, -> { where(top: true) }
end
使用的時(shí)候,我們可以將多個(gè) scope 組合在一起:
Product.top.hot.where(name: "T-Shirt")
default_scope 可以為所有查詢(xún)加上它定義的查詢(xún)條件,比如:
class Product < ActiveRecord::Base
default_scope { where("deleted_at IS NULL") }
end
default_scope 要慎用,慎用,慎用(重要的話(huà)說(shuō)三遍),在我們程序變的復(fù)雜的時(shí)候,性能往往會(huì)消耗在數(shù)據(jù)庫(kù)查詢(xún)上,維護(hù)已有查詢(xún)時(shí),很容易忽視 default_scope 的作用。如果使用了 default_scope,而在其他地方不得不去掉它,可以使用 unscoped,然后再附上其他查詢(xún):
Product.unscoped.load.top.hot
如果一個(gè)地方使用了某個(gè) scope,而要在另一個(gè)地方把它的條件改變,可以使用 merge:
class Product < ActiveRecord::Base
scope :active, -> { where state: 'active' }
scope :inactive, -> { where state: 'inactive' }
end
看一下它的執(zhí)行結(jié)果:
Product.active.merge(User.inactive)
# SELECT "products".* FROM "products" WHERE "products"."state" = 'inactive'
我們使用where查詢(xún),得到的是 ActiveRecord::Relation 實(shí)例,它的源代碼在這里。閱讀這里的代碼,會(huì)讓你學(xué)習(xí)到更多優(yōu)雅的查詢(xún)方法。在查詢(xún)時(shí),我們還可以使用 sql 直接查詢(xún),如果你更熟悉 sql 語(yǔ)法,可以這樣來(lái)查詢(xún):
Client.find_by_sql("SELECT * FROM clients
INNER JOIN orders ON clients.id = orders.client_id
ORDER BY clients.created_at desc")
# => [
#<Client id: 1, first_name: "Lucas" >,
#<Client id: 2, first_name: "Jan" >,
# ...
]
這個(gè)例子來(lái)自這里。
它返回的是實(shí)例的集合,這在我們 Rails 內(nèi)使用很方便,但是提供 json 格式的 api時(shí),需要轉(zhuǎn)換一下,不過(guò)我們可以用 select_all 查詢(xún),得到包含 hash 的 array:
Client.connection.select_all("SELECT first_name, created_at FROM clients WHERE id = '1'")
# => [
{"first_name"=>"Rafael", "created_at"=>"2012-11-10 23:23:45.281189"},
{"first_name"=>"Eileen", "created_at"=>"2013-12-09 11:22:35.221282"}
]
pluck 可以直接在 Relation 實(shí)例的基礎(chǔ)上,使用 sql 的 select 方法,得到字段值的集合(Array),而不用把返回結(jié)果包裝成 ActiveRecord 實(shí)例,再得到屬性值。在查詢(xún)屬性集合時(shí),pluck 的性能更高。
Client.where(active: true).pluck(:id)
SELECT id FROM clients WHERE active = 1
=> [1, 2, 3]
Client.distinct.pluck(:role)
SELECT DISTINCT role FROM clients
=> ['admin', 'member', 'guest']
Client.pluck(:id, :name)
SELECT clients.id, clients.name FROM clients
=> [[1, 'David'], [2, 'Jeremy'], [3, 'Jose']]
ActiveRecord 有一個(gè)類(lèi)似的方法,select,比較下兩者的區(qū)別:
Product.select(:id, :name)
Product Load (8.5ms) SELECT "products"."id", "products"."name" FROM "products"
=> #<ActiveRecord::Relation [#<Product id: 1, name: "f">]>
Product.pluck(:id, :name)
(0.3ms) SELECT "products"."id", "products"."name" FROM "products"
=> [[1, "f"]]
前者顯示返回 AR 實(shí)例,然后取其屬性值,后者直接讀取數(shù)據(jù)庫(kù)記錄,返回?cái)?shù)組。
pluck 只能用在查詢(xún)的最后,因?yàn)樗苯臃祷亓私Y(jié)果,而不是 ActiveRecord::Relation。
ids 返回主鍵集合:
Person.ids
=> SELECT id FROM people
不要被 ids 字面迷惑,它返回的是主鍵的集合,我們可以在 model 里設(shè)定其他字段為主鍵。
class Person < ActiveRecord::Base
self.primary_key = "person_id"
end
Person.ids
=> SELECT person_id FROM people
這里有四個(gè)方法,方便我們判斷一個(gè)模型中的記錄數(shù)量。
Client.exists?(1)
Client.exists?(id: [1,2,3])
Client.exists?(name: ['John', 'Sergei'])
exists? 判斷記錄是否存在,和它類(lèi)似的方法有兩個(gè):
Client.exists? [1]
Client.any? [2]
Client.many? [3]
[1] 是否有記錄 [2] 是否至少有一條記錄 [3] 是否有多于一條的記錄
any? 和 many? 與 exists? 不同的是,他們可以使用在 Relation 實(shí)例上,比如:
Article.where(published: true).any?
Article.where(published: true).many?
還可以接收 block:
person.pets.any? do |pet|
pet.group == 'cats'
end
=> false
person.pets.many? do |pet|
pet.group == 'dogs'
end
=> true
下面五個(gè)方法,完全可以按照字面意義理解,并且適用于 Relation 上:
Client.count
Client.average("orders_count")
Client.minimum("age")
Client.maximum("age")
Client.sum("orders_count")
以上的例子來(lái)自 這里,閑暇的時(shí)候應(yīng)該多讀讀這個(gè)文檔,翻看源碼。
在深入 Rails 項(xiàng)目開(kāi)發(fā)之后,測(cè)試環(huán)節(jié)是一個(gè)重要的環(huán)節(jié)。Ruby 為我們提供了非常方便的測(cè)試框架,Rails 也可以方便的執(zhí)行這些測(cè)試框架。
在 Rails 3.x 及之前的版本里,默認(rèn)使用 TestUnit 框架,4.x 之后改為 MiniTest 框架。我們可以查看 test_case.rb 文件,看到其中的變化。
除了這兩個(gè)測(cè)試框架,Rspec 也是經(jīng)常用到的 Ruby 測(cè)試框架。
我們?cè)?Rails 里安裝 rpesc,和其他的幾個(gè) gem:
group :development, :test do
gem 'rspec-rails'
gem "factory_girl_rails"
gem "database_cleaner"
end
rspec-rails 是 rspec 的 Rails 集成,在 Rails 中初始化 rspec 的命令是:
rails generate rspec:install
它會(huì)創(chuàng)建兩個(gè)文件,和 spec 文件件。運(yùn)行 rpsec 測(cè)試的命令非常簡(jiǎn)單,rspec 就可以,他會(huì)自動(dòng)運(yùn)行 spec 文件夾下所有的 xxx_spec.rb 文件,也可以指定某個(gè)文件:
rspec spec/models/product_spec.rb
也可以只運(yùn)行某一個(gè)測(cè)試用例,這需要指定該用例開(kāi)始的行數(shù):
rspec spec/models/product_spec.rb:10
也可以運(yùn)行某一個(gè)目錄:
rspec spec/models/
factory_girl_rails 是 factory_girl 的 Rails 包裝。factory_girl 可以為我們的測(cè)試代碼提供模擬的測(cè)試數(shù)據(jù)。
database_cleaner 可以在每一次運(yùn)行測(cè)試的時(shí)候,清空測(cè)試數(shù)據(jù)庫(kù)。我們?cè)?config/database.yml 中,會(huì)設(shè)置三種運(yùn)行環(huán)境,test 環(huán)境要單獨(dú)設(shè)置數(shù)據(jù)庫(kù),也就是因?yàn)闇y(cè)試時(shí)會(huì)反復(fù)填入和刪除數(shù)據(jù)。一般,test 使用的是 sqlite 數(shù)據(jù)庫(kù),而 production 使用 mysql、postgresql 等數(shù)據(jù)庫(kù)。
我們需要配置下 spec 的運(yùn)行環(huán)境:
RSpec.configure do |config|
config.before(:each) do
DatabaseCleaner.strategy = :truncation
DatabaseCleaner.clean
end
end
在使用 generator 創(chuàng)建 model 文件的時(shí)候,rspec 會(huì)自動(dòng)創(chuàng)建它對(duì)應(yīng)的 spec 文件。我們打開(kāi) product_spec.rb 文件:
require 'rails_helper'
RSpec.describe Product, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end
我們?yōu)樗黾右粋€(gè)測(cè)試:
RSpec.describe Product, type: :model do
it "should create a product" do
tshirt = Product.create(name: "T-Shirt", price: 9.99)
expect(tshirt.name).to eq("T-Shirt")
expect(tshirt.price).to eq(9.99)
end
end
運(yùn)行一下這個(gè)測(cè)試:
rspec spec/models/product_spec.rb
.
Finished in 0.081 seconds (files took 2.37 seconds to load)
1 example, 0 failures
這個(gè)測(cè)試的目的,是確保 create 方法可以為我們創(chuàng)建一個(gè) product 實(shí)例。更多 rspec 語(yǔ)法可以查看 rspec 文檔,或者 《使用 RSpec 測(cè)試 Rails 程序》一書(shū)。