為什麼要使用 Spring Data JPA Repository 的 save() 呼叫傳回的實例?
1. 概述
使用 Spring Data JPA 時,呼叫repository.save(entity)方法看似簡單。許多人在保存後傾向於繼續使用原始的實體實例,並假設它能準確地代表持久化後的資料。然而,這種假設並非總是正確的。
在本教程中,我們將解釋為什麼始終使用**save()**方法**傳回的實例至關重要**,並透過具體的單元測試來展示在持久化和合併操作場景下發生的情況。最後,我們將清楚地闡明忽略回傳值會導致哪些難以察覺且難以診斷的錯誤。
2. 問題介紹
Spring Data JPA 中的CrudRepository.save()方法具有以下簽名:
<S extends T> S save(S entity);
該簽章已經暗示save()傳回一個實體實例,但它並不能保證該實例與我們傳入的實例是同一個實例。
此外,該方法的 JavaDoc 文件建議:
保存指定的實體。由於保存操作可能完全改變了實體實例,因此請使用傳回的實例進行後續操作。
然而,在使用 Spring Data JPA 時,經常會看到類似這樣的程式碼:
repository.save(article);
// ... continue using the article object
乍一看,這似乎完全合理。然而,這種方法可能會導致一些不易察覺的錯誤和誤解。因此,有時有效,有時無效。
要理解其中的原因,我們需要區分兩個截然不同的場景:持久化一個新實體和合併一個實體。
和往常一樣,我們將透過範例來講解。我們將創建一個簡單的 Spring Boot 應用程序,並使用單元測試來進行演示。
接下來,我們來準備一個實體類別:
@Entity
@Table(name = "baeldung_articles")
public class BaeldungArticle {
@Id
@GeneratedValue
private Long id;
private String title;
private String content;
private String author;
// getters and setters are omitted
}
接下來,我們建立一個BaeldungArticleRepo介面來擴充JpaRepository:
@Repository
interface BaeldungArticleRepo extends JpaRepository<BaeldungArticle, Long> {
}
為了簡單起見,我們將使用記憶體中的 H2 資料庫作為我們這個簡單 Spring Boot 應用的資料儲存。不過,本教學將省略資料來源配置。
由於我們將使用單元測試來示範save()方法的行為,讓我們建立一個單元測試類別:
//... Spring Boot test related annotations omitted
public class ReturnedValueOfSaveIntegrationTest {
@Autowired
private BaeldungArticleRepo repo;
@PersistenceContext
private EntityManager entityManager;
// ...
}
正如我們所看到的,我們將BaeldungArticleRepo和entityManger注入到這個測試類別中,我們將在後面的測試方法中使用它。
接下來,讓我們建立測試方法來示範save()'s在兩種場景下的行為。
3. 持續性場景
讓我們從最簡單的情況開始:保存一個全新的實體:
@Test
void whenNewArticleIsSaved_thenOriginalAndSavedResultsAreTheSame() {
BaeldungArticle article = new BaeldungArticle();
article.setTitle("Learning about Spring Data JPA");
article.setContent(" ... the content ...");
article.setAuthor("Kai Yuan");
assertNull(article.getId());
BaeldungArticle savedArticle = repo.save(article);
assertNotNull(article.getId());
assertSame(article, savedArticle);
}
在這個範例中,我們首先建立一個新的BaeldungArticle實例( article )並設定其屬性。但是,我們沒有為其分配 ID .呼叫repo.save(article), JPA 會將其持久化到資料庫中並傳回持久化的實體。
此外, assertSame()斷言表明,當我們建立一個新物件**article,**並呼叫**repo.save(article),**傳回的物件**savedArticle,**和**article**實際上是同一個物件。
為了理解它為何如此運作,讓我們快速看一下 Spring Data JPA 的SimpleJpaRepository類別中的save()方法實作:
@Transactional
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) {
this.entityManager.persist(entity);
return entity;
} else {
return (S)this.entityManager.merge(entity);
}
}
正如我們所觀察到的,將一個新**entity**傳遞給**save()**方法會導致呼叫**EntityManager.persist(entity)** ,這將持久化該**entity**並傳回我們傳遞給它的同一個**entity**物件。
在我們的測試中,我們沒有為article物件分配 ID。因此,JPA 將其視為一個新實體,並在內部呼叫了EntityManager.persist()方法。結果,原始物件和傳回的物件指向同一個引用。
這種行為常常讓我們認為重新賦值save()的結果沒有必要。然而,一旦遇到合併場景,這種假設就不成立了。
4. 合併場景
現在,讓我們來看看當我們保存一個已經有 ID 的實體時會發生什麼。
我們先來看一種新的測試方法:
@Test
@Transactional
void whenArticleIsMerged_thenOriginalAndSavedResultsAreNotTheSame() {
// prepare an existing theArticle
BaeldungArticle theArticle = new BaeldungArticle();
theArticle.setTitle("Learning about Spring Boot");
theArticle.setContent(" ... the content ...");
theArticle.setAuthor("Kai Yuan");
BaeldungArticle existingOne = repo.save(theArticle);
Long id = existingOne.getId();
// create a detached theArticle with the same id
BaeldungArticle articleWithId = new BaeldungArticle();
articleWithId.setTitle("Learning Kotlin");
articleWithId.setContent(" ... the content ...");
articleWithId.setAuthor("Eric");
articleWithId.setId(id); //set the same id
BaeldungArticle savedArticle = repo.save(articleWithId);
assertEquals("Learning Kotlin", savedArticle.getTitle());
assertEquals("Eric", savedArticle.getAuthor());
assertEquals(id, savedArticle.getId());
assertNotSame(articleWithId, savedArticle);
assertFalse(entityManager.contains(articleWithId));
assertTrue(entityManager.contains(savedArticle));
}
首先,我們快速了解為什麼這個測試方法需要使用@Transactional註解。這是因為我們想在這個測試方法中檢查某個實體是否由 JPA 管理。 ` @PersistenceContext的預設PersistenceContextType是TRANSACTION,這意味著我們使用交易作用域的持久化上下文。
如我們所見, save()方法的實作使用了@Transactional註解。因此,如果我們不使用@Transactional,每次呼叫save()後持久化上下文都會關閉.這樣一來,在事務範圍之外,所有實體都會被分開。
如果我們執行這個測試,它就會通過。接下來,我們來看看這個測試做了什麼,以及它告訴我們什麼。
首先,我們透過呼叫repo.save()方法持久化了一個新的BaeldungArticle實體( theArticle ),並獲得了 H2 資料庫產生的 ID。
然後,我們建立了一個名為 articleWithId 的新**BaeldungArticle**對象**articleWithId,**將先前持久化實體的 ID 指派給**articleWithId.id** ,並在其他欄位中設定不同的值。
隨後,我們呼叫了repo.save(articleWithId) ` 並將傳回值賦給savedArticle.此時, **JPA 將articleWithId辨識為一個分離實體**。在內部,JPA 使用EntityManager.merge()先建立一個新的託管實例,然後將分離實例的狀態合併到該實例中。最後,返回合併後的託管實體。
因此, articleWithId和savedArticle不是同一個對象**,**即使斷言表明它們的字段(包括它們的 ID)保存著相同的值。
我們中的一些人可能會想,“好吧,雖然它們不是同一個對象,但只要它們的屬性值相同,就沒有區別;我們可以在後續處理中使用任何一個。”
最後兩個使用entityManager.contains()的斷言顯示**articleWithId是一個分離實體。另一方面, savedArticle是一個託管實體。**
這就是我們必須使用傳回實例的關鍵原因。接下來,讓我們了解為什麼使用託管實例至關重要。
5. 分離式與管理式
JPA 中的分離式執行個體和託管式執行個體之間存在許多差異,例如:
- 事務參與-託管執行個體參與目前事務,但分離實例不參與。
- 自動 SQL 執行-託管執行個體在重新整理時會自動觸發
INSERT/UPDATE/DELETE語句。分離實例則不會。 - 延遲載入-託管執行個體可以初始化延遲關聯,但分離實例則不行。
- 級聯行為 - 託管實例參與級聯持久化/更新操作,而分離實例不參與。
- 生命週期回呼 – 託管執行個體會觸發
@PreUpdate, @PostUpdate等回呼,但分離實例不會。 - 更多差異,例如刷新能力、回滾行為、重新連接、身分保證等。
本教學不會逐一示範上述清單中的每一項。相反,我們將以「生命週期回呼」為例,說明為什麼使用託管實例至關重要。
讓我們在BaeldungArticle類別中新增一個新的@Transient欄位和一個markAsSaved()方法:
@Entity
@Table(name = "baeldung_articles")
public class BaeldungArticle {
//... existing fields omitted
@Transient
private boolean alreadySaved = false;
@PostPersist
@PostUpdate
private void markAsSaved() {
this.alreadySaved = true;
}
// ... getters and setters
}
我們在markAsSaved()中加入了@PostPersist和@PostUpdate註解,以便在實體儲存時自動設定alreadySaved標誌。
現在,讓我們建立一個測試來展示為什麼在後續處理中使用save()傳回的託管實例很重要:
@Test
void whenArticleIsMerged_thenDetachedObjectCanHaveDifferentValuesFromTheManagedOne() {
// prepare an existing theArticle
BaeldungArticle theArticle = new BaeldungArticle();
theArticle.setTitle("Learning about Java Classes");
theArticle.setContent(" ... the content ...");
theArticle.setAuthor("Kai Yuan");
BaeldungArticle existingOne = repo.save(theArticle);
Long id = existingOne.getId();
// create a detached theArticle with the same id
BaeldungArticle articleWithId = new BaeldungArticle();
theArticle.setTitle("Learning Kotlin Classes");
theArticle.setContent(" ... the content ...");
theArticle.setAuthor("Eric");
articleWithId.setId(id); //set the same id
BaeldungArticle savedArticle = repo.save(articleWithId);
assertNotSame(articleWithId, savedArticle);
assertFalse(articleWithId.isAlreadySaved());
assertTrue(savedArticle.isAlreadySaved());
}
同樣地,在這個測試中,我們首先準備了一個現有的資料庫條目並取得了它的ID。然後,我們建立了一個名為articleWithId,並將該ID指派給它。
呼叫repo.save(articleWithId),回傳值savedArticle和articleWithId alreadySaved欄位值不同。這是因為生命週期回呼(@PostPersist, @PostUpdate)僅在託管實體上執行。分離的實例不會收到這些更新。
此時繼續使用分離實例是錯誤且危險的。
6. 結論
本文透過範例分析了 JPA 倉庫的save()方法的回傳值。當我們持久化一個新實體時,傳回的實例通常與原始實體相同。但是,當我們合併一個分離的實體時,傳回的實例總是一個不同的物件。
因此,我們應該始終使用傳回的實例,因為只有該實例才是:
- 由 JPA 管理
- 追蹤變化
- 透過生命週期回調更新
- 已正確與資料庫同步
使用傳回的實例可以確保行為正確,並避免與持久性相關的細微錯誤。
與往常一樣,範例的完整原始程式碼可在 GitHub 上找到。