Ruby on Rails 編程常常會將您寵壞。這一不斷發(fā)展的框架會讓您從其他框架的沉悶乏味中解脫出來。您可以用習(xí)以為常的幾行代碼片斷表達(dá)自己的意圖。而且還可以使用 ActiveRecord。
對于我這樣的一個老 Java? 程序員而言,ActiveRecord 多少有點(diǎn)生疏。通過 Java 框架,我通常都會在獨(dú)立的模型和模式之間構(gòu)建一種映射。像這樣的框架就是映射框架。通過 ActiveRecord,我只定義數(shù)據(jù)庫模式:或者用 SQL 或者用稱為遷移(migration)的 Ruby 類。將對象模型設(shè)計建立于數(shù)據(jù)庫結(jié)構(gòu)之上的那些框架稱為包裝框架。與大多數(shù)包裝框架不同,Rails 能通過查詢數(shù)據(jù)庫表發(fā)現(xiàn)對象模型的特征。與構(gòu)建復(fù)雜查詢不同,我使用模型在 Ruby(而非 SQL)中遍歷關(guān)系。這樣一來,我既獲得了包裝框架的簡單性,又具備了映射框架的大部分功能。ActiveRecord 易于使用和擴(kuò)展。有時,甚至有些過于簡單。
與任何數(shù)據(jù)庫框架一樣,ActiveRecord 讓我極易做出很多惹麻煩的事。我所能獲取的列太多,又很容易遺漏重要的結(jié)構(gòu)化數(shù)據(jù)庫特性,比如索引或空約束。我并不是說 ActiveRecord 是個不好的框架。只不過若是需要擴(kuò)展,您需要知道如何堅固自己的應(yīng)用程序。在本篇文章中,我將帶您親歷在使用 Rails 這一獨(dú)樹一幟的持久性框架時可能需要的一些重要優(yōu)化。
基礎(chǔ)管理
生成受模式支持的模型異常容易,只需很少的代碼,即 script/generate model model_name。正如您所知,該命令可生成模型、遷移、單元測試甚至一個默認(rèn)的 fixture。在該遷移中填上一些數(shù)據(jù)列,并輸入一些測試數(shù)據(jù)、編寫幾個測試、添加幾個驗證就算大功告成,這樣做真是很有誘惑力。但請您三思而行。您應(yīng)該考慮總體的數(shù)據(jù)庫設(shè)計,要特別注意以下這些事情:
- Rails 不會讓您擺脫基本的數(shù)據(jù)庫性能問題。數(shù)據(jù)庫需要信息,這些信息經(jīng)常以索引的格式才能有不錯的性能。
- Rails 不會讓您擺脫數(shù)據(jù)完整性問題。雖然大多數(shù) Rails 開發(fā)人員都不喜歡在數(shù)據(jù)庫中保留限制,但您應(yīng)該考慮像空列這樣的事情。
- Rails 為很多元素提供了方便的默認(rèn)屬性。有時,像文本字段的長度這樣的默認(rèn)屬性對于大多數(shù)實用的應(yīng)用程序而言都會過大。
- Rails 不會強(qiáng)制您創(chuàng)建有效的數(shù)據(jù)庫設(shè)計。
在您繼續(xù)跋涉,深入學(xué)習(xí) ActiveRecord 之前,應(yīng)該首先確保您已經(jīng)打好了足夠的基礎(chǔ)。請確保索引結(jié)構(gòu)可以為您所用。如果給定的表很大,如果將在列上而不是 id 上搜索,如果索引能對您有所幫助(更多細(xì)節(jié),請參見數(shù)據(jù)庫管理器文檔 —— 不同的數(shù)據(jù)庫以不同方式使用索引),那么就需要創(chuàng)建索引。無需采用 SQL 創(chuàng)建索引 —— 可以簡單地使用遷移創(chuàng)建。可以輕松地使用 create_table 遷移創(chuàng)建索引,也可以創(chuàng)建一個額外的遷移來創(chuàng)建索引。以下是一個遷移示例,可用來為 ChangingThePresent.org (請參見 參考資料)創(chuàng)建索引:
清單 1. 在遷移中創(chuàng)建索引
class AddIndexesToUsers ActiveRecord::Migration
def self.up
add_index :members, :login
add_index :members, :email
add_index :members, :first_name
add_index :members, :last_name
end
def self.down
remove_index :members, :login
remove_index :members, :email
remove_index :members, :first_name
remove_index :members, :last_name
end
end
ActiveRecord 會負(fù)責(zé) id 上的索引,我顯式地添加了可在各種搜索中使用的索引,原因是此表很大、不經(jīng)常更新卻經(jīng)常被搜索。通常,我們會等到對給定的查詢中的問題有一定的把握后才會采取相應(yīng)動作。這種策略可以讓我們不必二次猜測數(shù)據(jù)庫引擎。但從用戶這方面來看,我們知道該表將會很快具有數(shù)百萬的用戶,如果在經(jīng)常搜索的列上沒有索引,該表的效率會很低。
另外兩個常見問題也與遷移有關(guān)。如果字符串和列都不應(yīng)該為空,那么就請確保正確編寫了遷移。大多數(shù) DBA(數(shù)據(jù)庫管理員)都會認(rèn)為 Rails 為空列提供了錯誤的默認(rèn)屬性:默認(rèn)情況下列可以為空。如果希望創(chuàng)建一個不能為空的列,您必須顯式地添加參數(shù) :null => false。如果具有字符串列,請務(wù)必確保編寫應(yīng)用程序的限值。默認(rèn)地,Rails 遷移會將 string 列按 varchar(255) 編碼。通常,這個值過于龐大。應(yīng)該盡量保持能如實反應(yīng)應(yīng)用程序的數(shù)據(jù)庫結(jié)構(gòu)。與提供無任何限制的 login 相反,如果應(yīng)用程序限制 login 只能為 10 個字符,那么就應(yīng)該相應(yīng)地編寫數(shù)據(jù)庫,如清單 2 所示:
清單 2. 用限值和非空列編寫遷移
t.column :login, :string, :limit => 10, :null => false
此外,還應(yīng)該考慮默認(rèn)值以及其他任何能安全提供的信息。通過一點(diǎn)預(yù)備工作,就可以節(jié)省日后跟蹤數(shù)據(jù)完整性問題的大量時間。在考慮數(shù)據(jù)庫基礎(chǔ)的同時,還應(yīng)該注意哪些頁是靜態(tài)且容易緩存的。在優(yōu)化查詢和緩存頁面這兩個選項當(dāng)中,如果您能 “消受” 復(fù)雜性,緩存頁面將會帶來更大的回報。有時,頁面或片段都是純靜態(tài)的,比如一列狀態(tài)或一組經(jīng)常問到的問題。在這種情況下,緩存更勝一籌。而在其他的一些時候,您可能會決定犧牲數(shù)據(jù)庫性能,以減少復(fù)雜性。對于 ChangingThePresent,根據(jù)問題和環(huán)境的具體情況,我們二者都嘗試了。如果您也決定要犧牲查詢性能,就請繼續(xù)閱讀吧。
N+1 問題
默認(rèn)情況下,ActiveRecord 關(guān)系十分懶散。這意味著框架會一直等待訪問關(guān)系直到您實際訪問了該關(guān)系。比方說,每個成員都會有一個地址。可以打開一個控制臺并輸入如下命令:member = Member.find 1??梢钥吹阶芳拥饺罩镜娜缦聝?nèi)容,如清單 3 所示:
清單 3. 從 Member.find(1) 登錄
^[[4;35;1mMember Columns (0.006198)^[[0m ^[[0mSHOW FIELDS FROM members^[[0m
^[[4;36;1mMember Load (0.002835)^[[0m ^[[0;1mSELECT * FROM members WHERE
(members.`id` = 1) ^[[0m
Member 具有到此地址的關(guān)系,并由宏 has_one :address, :as => :addressable, :dependent => :destroy 定義。注意當(dāng) ActiveRecord 加載了 Member 時,您并不會看到地址字段。但如果在控制臺中鍵入 member.address,就可以在 development.log 中看到清單 4 中的內(nèi)容:
清單 4. 訪問關(guān)系會強(qiáng)制數(shù)據(jù)庫訪問
^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;35;1mAddress Load (0.252084)^[[0m ^[[0mSELECT * FROM addresses WHERE
(addresses.addressable_id = 1 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
所以 ActiveRecord 并不會為地址關(guān)系執(zhí)行查詢,直到您實際訪問 member.address。通常,這種懶散設(shè)計會工作得很好,因為持久性框架無需移動如此多的數(shù)據(jù)來加載成員。但如果您想要訪問很多成員以及所有成員的地址,如清單 5 所示:
清單 5. 用地址檢索多個成員
Member.find([1,2,3]).each {|member| puts member.address.city}
由于您應(yīng)該看到針對每個地址的查詢,所以就性能而言,結(jié)果并不盡如人意。清單 6 給出了問題的全部:
清單 6. N+1 問題的查詢
^[[4;36;1mMember Load (0.004063)^[[0m ^[[0;1mSELECT * FROM members WHERE
(members.`id` IN (1,2,3)) ^[[0m
^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;35;1mAddress Load (0.000989)^[[0m ^[[0mSELECT * FROM addresses WHERE
(addresses.addressable_id = 1 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;36;1mAddress Columns (0.073840)^[[0m ^[[0;1mSHOW FIELDS FROM addresses^[[0m
^[[4;35;1mAddress Load (0.002012)^[[0m ^[[0mSELECT * FROM addresses WHERE
(addresses.addressable_id = 2 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;36;1mAddress Load (0.000792)^[[0m ^[[0;1mSELECT * FROM addresses WHERE
(addresses.addressable_id = 3 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
結(jié)果正如我所預(yù)見的那樣糟糕。所有成員共用一個查詢,而每個地址各用一個查詢。我們檢索了三個成員,所以一共用了四個查詢。如果是 N 個成員,就會有 N+1 個查詢。這就是可怕的 N+1 問題。大多數(shù)持久性框架都采用熱關(guān)聯(lián)(eager association)來解決該問題。Rails 也不例外。如果需要訪問關(guān)系,就可以選擇將其包括到初始查詢中。ActiveRecord 使用 :include 選項來實現(xiàn)此目的。如果將查詢更改為 Member.find([1,2,3], :include => :address).each {|member| puts member.address.city},結(jié)果就會稍好一些:
清單 7. 解決 N+1 問題
^[[4;35;1mMember Load Including Associations (0.004458)^[[0m ^[
[0mSELECT members.`id` AS t0_r0, members.`type` AS t0_r1,
members.`about_me` AS t0_r2, members.`about_philanthropy`
...
addresses.`id` AS t1_r0, addresses.`address1` AS t1_r1,
addresses.`address2` AS t1_r2, addresses.`city` AS t1_r3,
...
addresses.`addressable_id` AS t1_r8 FROM members
LEFT OUTER JOIN addresses ON addresses.addressable_id
= members.id AND addresses.addressable_type =
'Member' WHERE (members.`id` IN (1,2,3)) ^[
[0m
^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:
98:in `find'^[[0m
該查詢的速度也會更快。一個查詢會檢索所有成員和地址。這就是熱關(guān)聯(lián)的工作原理。
通過 ActiveRecord,還可以嵌套 :include 選項,但嵌套深度只有一級。例如,有多個 contacts 的 Member 以及有一個 address 的 Contact 就屬于這種情況。如果想要為某個成員的聯(lián)系人顯示所有城市,就可以使用清單 8 中所示的代碼:
清單 8: 為某個成員的聯(lián)系人獲取城市
member = Member.find(1)
member.contacts.each {|contact| puts contact.address.city}
該代碼應(yīng)該能夠工作,但必須要針對此成員、每個聯(lián)系人以及每個聯(lián)系人的地址進(jìn)行查詢。通過用 :include => :contacts 包括 :contacts,可以稍許提高性能。也可以通過將二者都包括進(jìn)來進(jìn)一步地改進(jìn),如清單 9 所示:
清單 9: 為某個成員的聯(lián)系人獲取城市
member = Member.find(1)
member.contacts.each {|contact| puts contact.address.city}
通過使用嵌套包含選項還能獲得更好的改進(jìn):
member = Member.find(1, :include => {:contacts => :address})
member.contacts.each {|contact| puts contact.address.city}
該嵌套包含可讓 Rails 熱包含 contacts 和 address 關(guān)系。一旦要在給定的查詢中使用關(guān)系,就可以采用熱加載技術(shù)。此技術(shù)是我們在 ChangingThePresent.org 中使用得最為頻繁的一種性能優(yōu)化技術(shù),但它還是有一些限制的。當(dāng)必須要連接兩個以上的表時,最好還是采用 SQL。如果需要進(jìn)行報告,最好是簡單地采取數(shù)據(jù)庫連接,跨過 ActiveRecord 以及 ActiveRecord::Base.execute("SELECT * FROM...")。通常來講,熱關(guān)聯(lián)足夠解決問題?,F(xiàn)在,我將轉(zhuǎn)變話題,探討 Rails 開發(fā)人員所關(guān)心的另一個麻煩問題:繼承。
繼承和 Rails
當(dāng)大多數(shù) Rails 開發(fā)人員第一次接觸到 Rails 時,他們就會立刻被迷住。它太簡單了。您只需在數(shù)據(jù)庫表上創(chuàng)建一個 type 類,然后再從父類中繼承子類即可。Rails 會負(fù)責(zé)其余的事情。比如,有一個名為 Customer 表,它可以從名為 Person 類繼承。一個客戶可以有 Person 的所有列,外加信譽(yù)度和訂購歷史。清單 10 顯示了該種解決方案的簡潔之美。主表具有父類和子類的所有列。
清單 10. 實現(xiàn)繼承
create_table "people" do |t|
t.column "type", :string
t.column "first_name", :string
t.column "last_name", :string
t.column "loyalty_number", :string
end
class Person ActiveRecord::Base
end
class Customer Person
has_many :orders
end
在很多方面,這種解決方案都可以很好地工作。代碼簡單且無重復(fù)性。這些查詢簡單且性能很好,因為您無需進(jìn)行任何連接來訪問多個子類,ActiveRecord 可以使用 type 列決定哪個記錄能夠返回。
在某些方面,ActiveRecord 繼承十分有限。如果已有的繼承等級非常寬,繼承就會失效。例如,在 ChangingThePresent,內(nèi)容有很多類型,每種類型都有自己的名稱、或短或長的描述、某些常見的表示屬性以及幾個定制屬性。我們很希望 cause、nonprofit、gift、member、drive、registry 以及其他一些類型的對象都能夠從通用的基類中繼承,以便我們能以同樣的方式處理所有類型的內(nèi)容。但我們卻不能如此,因為 Rails 模型將會在單一表中擁有我們所有對象模型的實質(zhì)內(nèi)容,這不是一個可行的解決方案。
探索其他可選方案
我們針對此問題試驗了三種解決方案。第一,我們在類自身的表中放置每個類,使用視圖為內(nèi)容構(gòu)建通用表。我們很快拋棄了此種解決方案,因為 Rails 不能很好地處理數(shù)據(jù)庫視圖。
我們的第二個解決方案是使用簡單的多態(tài)。通過這種策略,每個子類都會擁有其自身的表。我們將通用列推入每個表。例如,比方說我需要一個名為 Content 的子類,它只包含 name 屬性,以及 Gift、Cause 和 Nonprofit 子類。Gift、Nonprofit 和 Cause 都可有 name 屬性。由于 Ruby 是動態(tài)類型的,所以這些子類無需從通用基類中繼承。它們只需對相同的一組方法進(jìn)行響應(yīng)。ChangingThePresent 在幾個地方使用了多態(tài)以提供通用的行為,尤其是在處理圖像的時候。
第三種方法是提供一種通用的功能,但采用的是關(guān)聯(lián)而非繼承。ActiveRecord 具有一種稱為多態(tài)關(guān)聯(lián)的特性,非常適合將通用行為附加給類,完全無需繼承。在之前的 Address,您已經(jīng)看到了多態(tài)關(guān)聯(lián)的示例。我可以使用相同的技術(shù)(而非繼承)附加通用屬性用于內(nèi)容管理。考慮名為 ContentBase 的類。通常,為了將該類關(guān)聯(lián)到另一個類,可以使用 has_one 關(guān)系和一個簡單的外鍵。但您可能更想讓 ContentBase 能與多個類共同工作。這時,您需要一個外鍵,還需要一個能定義目標(biāo)類的類型的列。而這恰好是 ActiveRecord 多態(tài)關(guān)聯(lián)所擅長的方面。請參看清單 11。
清單 11. 站點(diǎn)內(nèi)容關(guān)系的兩個方面
class Cause ActiveRecord::Base
has_one :content_base, :as => :displayable, :dependent => :destroy
...
end
class Nonprofit ActiveRecord::Base
has_one :content_base, :as => :displayable, :dependent => :destroy
...
end
class ContentBase ActiveRecord::Base
belongs_to :displayable, :polymorphic => true
end
通常,belongs_to 關(guān)系只有一個類,但 ContentBase 中的關(guān)系卻是多態(tài)的。外鍵不僅具有標(biāo)識記錄的標(biāo)識符,而且還具有標(biāo)識表的一個類型。使用這種技術(shù),我獲得了繼承的諸多益處。常見的功能在單一類中就都包括了。但這也帶來了幾個副作用。我無需將 Cause 和 Nonprofit 中的所有列都放在單一表中。
一些數(shù)據(jù)庫管理員不太看好多態(tài)關(guān)聯(lián),原因是他們不怎么使用真正意義上的外鍵,但對于 ChangingThePresent,我們自由地使用了多態(tài)關(guān)聯(lián)。實際上,數(shù)據(jù)模型并不像理論上那樣美好。不能使用諸如引用完整性這樣的數(shù)據(jù)庫特性,也不能依賴于工具來基于列的名稱發(fā)現(xiàn)這些關(guān)系。簡潔的對象模型的好處對我們來說要比此方式所存在的問題更為重要。
create_table "content_bases", :force => true do |t|
t.column "short_description", :string
...
t.column "displayable_type", :string
t.column "displayable_id", :integer
end
結(jié)束語
ActiveRecord 是一種功能完善的持久性框架。用它可以構(gòu)建可伸縮的可靠系統(tǒng),但與其他數(shù)據(jù)庫框架一樣,您必須要格外注意框架所生成的 SQL。當(dāng)偶爾遇到問題時,您必須調(diào)整自己的方式和策略。保留索引、借助 include 使用熱加載和在某些地方使用多態(tài)關(guān)聯(lián)代替繼承是三種可用來改進(jìn)代碼庫的方法。在下月,我將帶您親歷另一個示例去領(lǐng)略如何編寫真實世界中的 Rails。
您可能感興趣的文章:- 對優(yōu)化Ruby on Rails性能的一些辦法的探究