GORM 陷阱(第一部分)

工程 | Peter Ledbrook | 2010年6月23日 | ...

您是 Grails 新手嗎?或者您是否遇到了您的第一個 GORM "怪異"?如果是這樣,那麼您需要閱讀本系列關於 GORM 陷阱的文章。這些文章不僅會強調那些經常讓人意外的小特性,還會解釋 GORM 為什麼會這樣表現。

希望您已經知道 GORM 是 Grails 附帶的資料庫訪問庫。它基於可能是最流行的 Java ORM:Hibernate。正如您可以想象的,Hibernate 是一個強大而靈活的庫,它為 GORM 帶來了巨大的好處。但使用它也有代價:GORM 使用者遇到的許多問題都源於 Hibernate 的工作方式。GORM 盡最大努力隱藏實現細節,但有時它們確實會洩露出來。

在本文的其餘部分,我將介紹物件持久化到資料庫的基礎知識。聽起來很簡單,但即使在如此基礎的操作上,GORM 的工作方式可能也與您預期的大不相同。

當我呼叫 save() 時,我 *就是* 想儲存!

儲存領域例項時遇到的問題可能是開發者首先遇到的問題。有多少人經歷過“我儲存了,為什麼它不在資料庫裡?”的階段?如果這能讓您感覺好些,我也經歷過。為什麼會發生這種情況?有幾種可能性。

別忘了驗證!

每次儲存領域例項時,Grails 都會使用您定義的約束對其進行驗證。如果領域例項中的任何值違反了這些約束,儲存將失敗,並且約束錯誤將附加到該領域例項。問題在於,這種儲存失敗是靜默發生的:除非您檢查save() 的返回值或呼叫hasErrors().

當您將使用者資料繫結到領域例項時,這通常是您想要的行為。如果使用者輸入不符合約束,這一點也不奇怪。在這種情況下丟擲異常並不合適,特別是當您的 Web 應用有相當多的併發使用者時。在這種情況下,最好始終檢查save() 的返回值並做出相應反應(它返回null如果儲存失敗,否則返回領域例項)

def book = new Book(params)
if (!book.save()) {
    // Save failed! Present the errors to the user.
    ...
}

另一方面,當您在 BootStrap 或 Grails 控制檯中設定測試資料時,您通常期望儲存能成功。如果存在任何驗證錯誤,那意味著是您的錯誤。在這種情況下,您不想費事檢查每一個save() 的返回值的返回值,並且如果 Grails 在驗證失敗時丟擲異常,您會感到非常高興。這 *不是* 預設行為,但您可以透過failOnError引數輕鬆開啟。

book.save(failOnError: true)

如果您堅持,您甚至可以將其設定為預設值:只需將grails.gorm.failOnError設定為true在 grails-app/conf/Config.groovy 中。並且別忘了,所有領域屬性都有一個隱式的nullable: false約束!

這可能是當您期望領域例項被儲存時,它們卻未被儲存的最常見原因。那麼另一個原因是什麼?

Hibernate 的會話 (Session)

在極少數情況下,您可能會儲存一個領域例項,卻發現後續查詢未能查到它,即使該例項通過了驗證。這是使用底層 Hibernate 相關的一個更廣泛問題的症狀。

Hibernate 是一個基於會話的 ORM 框架。

這一點非常重要,任何希望真正熟練使用 GORM 的人都必須理解會話是什麼以及它對應用程式有什麼影響。那麼會話是什麼?它本質上是一個由資料庫支援的、物件的記憶體快取。當您儲存一個新的領域例項時,它會隱式附加到會話,也就是說,它被新增到快取中,併成為一個由 Hibernate 管理的物件。但此時它可能還未持久化到資料庫! 以下圖表說明了這種行為。

hibernate-session-in-action

當您儲存例項時,它們會立即從會話中可用。但 Hibernate 會自行決定何時將新例項持久化到資料庫,這意味著它可以最佳化 SQL 語句的順序。通常您不會注意到這一切,因為 Grails 和 Hibernate 會處理好這些事情,但偶爾您會被意料之外。

不過,正如您所料,Grails 確實允許您控制資料何時實際持久化到資料庫。您是否在示例中見過這樣的程式碼?

book.save(flush: true)

flush: true引數強制 Hibernate 立即將所有更改持久化到資料庫。這對應於所謂的 重新整理會話 (flushing the session)

現在的危險是您會到處都放flush: true引數。不要這樣做。讓 Hibernate 完成它的工作,只在必須時手動重新整理會話,或者至少只在一批更新結束時重新整理。您只應該在資料應該出現在資料庫中卻未出現時才真正使用它。我知道這有點含糊不清,但這種操作何時有必要取決於資料庫實現和其他因素。手動重新整理非常有用的一個領域是當您與另一個應用程式或內部服務互動時,它們訪問的是與您相同的資料庫,但不在您的當前會話之內。

如果您對會話仍然有點模糊,別擔心——我們會一次又一次地回到 Hibernate 會話,因為它對於許多 GORM 相關陷阱至關重要。事實上,這就是人們遇到下一個問題的原因。

現在當我不希望你儲存時你卻儲存了?!

當您呼叫save() 的返回值時未能持久化領域例項是很常見的。現在考慮相反的情況:在沒有相應的呼叫save() 的返回值的情況下,物件卻被持久化了。如果您還沒有遇到過這種行為,我幾乎可以保證您會遇到。那麼為什麼會發生這種情況呢?

Hibernate 支援髒檢查 (dirty-checking) 的概念。這意味著 Hibernate 會檢查領域例項的(持久)屬性值是否在*從資料庫中取出該例項之後*發生了變化,並將這些更改持久化到資料庫。這解釋起來有點拗口,希望一個例子能幫助澄清。假設我們有一個Book領域類,包含titleauthor屬性,並且以下程式碼在一個控制器 action 中:

def b = Book.findByAuthor(params.author)
b.title = b.title.reverse()

注意這裡沒有呼叫save() 的返回值。當請求完成後,您會發現該書的標題在資料庫中被反轉了——更改在沒有顯式儲存的情況下被持久化了。這是因為:

  1. 這本書附加到了會話(因為它透過查詢被檢索出來);
  2. title屬性是持久化的(除非配置為 transient,所有屬性都是持久化的);並且
  3. 在會話關閉時屬性值發生了變化。

讓我們更詳細地看一下這些。首先,我已經提到物件可以“附加”到會話,即由 Hibernate 管理並由資料庫支援,但是物件是如何附加的呢?如果您以任何方式使用 GORM 檢索領域例項,例如透過get()方法或任何型別的查詢,那麼該物件會自動與會話關聯。如果您只是透過new關鍵字建立一個新例項,那麼該物件在呼叫save() 的返回值方法之前是不會附加到會話的。

其次,領域類屬性預設是持久化的,也就是說,它們在資料庫中有匹配的列來儲存其值。您可以透過將屬性名稱新增到靜態transients列表屬性來使屬性成為瞬態的,這意味著它們的值不會儲存在資料庫中。

最後,我提到如果更改在會話關閉時存在,它們就會被持久化。這是什麼意思呢?為了透過 Hibernate 對資料庫進行任何操作,您必須有一個開啟的會話。一旦會話關閉,您就不能再使用它進行資料庫訪問了。此外,會話在關閉時會被重新整理,這就是為什麼在我們上面控制器 action 結束時更改會被持久化的原因(Grails 在請求開始時自動開啟一個會話,並在請求結束時關閉它)。

您能阻止這樣的更改被持久化嗎?當然。一個選項是呼叫save() 的返回值在您的領域例項上:如果任何屬性值未能透過驗證,更改將不會被持久化。當然,如果值是有效的但您仍然不想持久化它們,您可以呼叫discard()在您的例項上。這不會重置例項屬性的值,但會確保它們不會儲存到資料庫中。

對於僅僅幾個陷阱來說,這是相當多的資訊需要消化。關鍵在於理解 Hibernate 會話如何影響領域例項的持久化。即使您還沒有完全掌握,未來的 GORM 陷阱系列文章也會提供更多資訊和示例。

通常來說,我建議您始終使用save() 的返回值來持久化物件,而不是依賴髒檢查。這使得程式碼清晰地表明您想要持久化更改,並且這些更改會同時被驗證。我還建議您始終檢查save() 的返回值在應用程式程式碼中的返回值,儘管在設定 bootstrap 或測試資料時最好使用failOnError: true選項。

如果您此時對 GORM 感到一絲畏懼或擔憂,請不要。它確實讓使用資料庫變得輕鬆有趣,並且透過本系列文章獲得的資訊,您將有信心處理可能出現的任何問題。

下次見!

獲取 Spring 郵件列表

訂閱 Spring 郵件列表,保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助力您的進步。

瞭解更多

獲取支援

Tanzu Spring 透過一個簡單的訂閱提供對 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視全部