# ORM 與 Spring data jpa
## 什麼是 ORM?
ORM(Object-Relational Mapping)是一種將物件導向程式設計語言中的物件(以 Java 來說就是 class)與關聯是資料庫(Relational Database, RDB)中的資料表進行映射的技術。ORM 使得開發者可以使用物件導向的方式來操作資料庫,而不需要直接撰寫 SQL 語句。這樣可以提高開發效率,減少錯誤,並且使程式碼更具可讀性和可維護性。ORM 的主要目的是將資料庫中的資料轉換為程式語言中的物件,並且將物件的變更自動同步到資料庫中。常見的 ORM 框架有 Hibernate、JPA、MyBatis 等等。
對岸就很喜歡用 MyBatis,跟 Spring boot 一起合稱 SSM(Spring + Spring MVC + MyBatis)。
曾經做過一個專案使用 MyBatis,使用了大量個 xml 檔案來定義 SQL 語句,這樣的做法總是讓我覺得很燥 XD。
後來遇到的專案還是都是使用 Spring Data JPA 比較多,因為它是 Spring 的一部分,跟 Spring Boot 整合得很好。
### ORM 的優點
1. **簡化資料庫操作**:ORM 可以將資料庫操作封裝成物件的方法,開發者不需要關心底層的 SQL 語句,從而簡化了資料庫操作的複雜性。
2. **提高開發效率**:ORM 可以自動生成 SQL 語句,開發者不需要手動撰寫 SQL 語句,從而提高了開發效率。
3. **減少錯誤**:ORM 可以自動處理資料庫的連接、事務等操作,減少了開發者在這些方面出錯的機會。
4. **可跨平台性**:ORM 可以將資料庫操作與具體的資料庫實現解耦,開發者可以在不同的資料庫之間輕鬆切換。
5. **自動管理關聯**:利用 ORM 框架做好的 Annotation,能夠自動管理資料表之間的關聯。
6. **支援物件導向**:ORM 可以將資料庫中的資料映射為物件,開發者可以使用物件導向的方式來操作資料庫,從而提高了程式碼的可讀性和可維護性。
### ORM 的缺點
1. **性能問題**:自動產生的 SQL 語句可能不夠優化,導致性能問題。ORM 框架在處理大量資料時,可能會出現性能瓶頸。特別是在 join 或複雜查詢時,ORM 框架可能會產生大量的 SQL 語句,導致性能下降。
2. **學習曲線**:ORM 框架通常有自己的學習曲線,開發者需要花時間學習如何使用這些框架,特別是對於初學者來說,可能會感到困難。
3. **不易 Debug**:ORM 框架通常會隱藏底層的 SQL 語句,當出現問題時,開發者可能很難找到問題的根源。特別是在複雜的查詢中,ORM 框架可能會生成多條 SQL 語句,這使得 Debug 變得困難。除非自己設定打開 SQL 語句的 log。
## Spring Data JPA
### 🔹 第一章: 基礎觀念與底層架構
1. 什麼是 JPA (Java Persistence API)?
- JPA 是由 Java 官方(由Oracle 定義) 針對 **物件導向語言**與 **關聯式資料庫** 之間的 ORM 所提出的規範。
- 他本身並不是個實作,而是介面+規範,具體的實作有 Hibernate、EclipseLink 等等。
2. JPA vs Hibernate vs Spring Data JPA 的關係
| 層級 | 角色 | 說明 |
|------|------|------|
| JPA | 規範 | Java 官方針對 ORM 的規範,定義了 API 與行為。 |
| Hibernate | 實作 | 最主流的 JPA 實作,提供真正執行 SQL 的功能。 |
| Spring Data JPA | Spring 封裝與擴展 | 對 JPA 的高層封裝,簡化 Repository 實作、提供查詢衍生功能。 |
- 以由高到低的層級列出來
```mermaid
graph TD
SpringDataJPA["Spring Data JPA"]
JPA["JPA (javax.persistence)"]
Hibernate["Hibernate (org.hibernate)"]
JDBC["JDBC"]
SQL["SQL"]
SpringDataJPA --> JPA
JPA --> Hibernate
Hibernate --> JDBC
JDBC --> SQL
```
Spring Data JPA > JPA(Javax.persistence) > Hibernate(org.hibernate) > JDBC > SQL
3. ORM 原理與 EntityManager 的角色
- ORM 原理
- 把 Java class 應射成資料表(Entity <-> table)
- 把 Java class 的屬性應射成資料表的欄位(Entity field <-> table column)
- 將物件的 CRUD 操作轉換成 SQL 語句
- JPA 中的 EntityManager 角色
- EntityManager 是 JPA 的核心介面,負責管理實體物件的生命週期。
- 提供 CRUD 操作的方法,如 `persist()`, `merge()`, `remove()`, `find()` 等等。
```java
EntityManager em = ...;
em.getTransaction().begin();
em.persist(entity); // 新增
em.merge(entity); // 更新
em.remove(entity); // 刪除
```
- 而 hibernate 是 JPA 的實作,提供了具體的 EntityManager 實作。負責與資料庫進行交互,執行 SQL 語句。
4. Spring Data JPA 的抽象層級 (Repository → JPA → Hibernate)
- Spring 將 EntityManager 封裝得更高階,提供我們用 Repository 介面就能完成 CRUD 操作。
```mermaid
graph TD
JpaRepository["JpaRepository<User, Long>"]
SimpleJpaRepository["SimpleJpaRepository(Spring 自動實作)"]
EntityManager["EntityManager(JPA 提供)"]
HibernateSession["Hibernate Session(Hibernate 實作 JPA)"]
JDBC["JDBC 連接資料庫"]
JpaRepository --> SimpleJpaRepository
SimpleJpaRepository --> EntityManager
EntityManager --> HibernateSession
HibernateSession --> JDBC
```
- 也就是說我只要寫一個介面,繼承 `JpaRepository`,就能自動獲得 CRUD 的功能。如何?是不是很香?
```java
public interface UserRepository extends JpaRepository<User, Long> {}
```
---
### 🔹 第二章: JPA 設定與 Entity 映射
1. `@Entity`、`@Table`、`@Id`、`@Column` 等基本註解
- `@Entity` 是 JPA 的核心註解,標記一個類別為實體類別,對應到資料庫中的資料表。
- `@Table` 用來指定資料表的名稱,預設是類別名稱。
- `@Id` 用來標記主鍵欄位,必須唯一且不可為 null。
- `@GeneratedValue` 用來指定主鍵的生成策略,常用的有 `IDENTITY`、`SEQUENCE`、`TABLE` 等。
| 策略 | 用法 | 說明 |
|------|------|------|
| IDENTITY | `@GeneratedValue(strategy = GenerationType.IDENTITY)` | 由資料庫自動產生主鍵(通常是自增欄位,如 MySQL 的 AUTO_INCREMENT)。每次插入新資料時,資料庫自動給主鍵編號。 |
| SEQUENCE | `@GeneratedValue(strategy = GenerationType.SEQUENCE)` | 使用資料庫的序列(Sequence)產生主鍵。需要資料庫支援序列物件。 |
| TABLE | `@GeneratedValue(strategy = GenerationType.TABLE)` | 使用一個獨立的資料表來產生主鍵值。跨資料庫通用,但效能較差。 |
- `@Column` 用來指定欄位的屬性,如名稱、長度、是否可為 null 等等。
```java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false, length = 50)
private String username;
@Column(name = "email", nullable = false, unique = true)
private String email;
}
```
2. 資料型別對應 (Java 型別 ↔ 資料庫欄位型別)
| Java 型別 | 資料庫型別(預設) |
|------------------|---------------------|
| String | VARCHAR / TEXT |
| int / Integer | INTEGER |
| long / Long | BIGINT |
| boolean | BIT / BOOLEAN |
| LocalDate / Date | DATE |
| LocalDateTime | TIMESTAMP |
| BigDecimal | DECIMAL / NUMERIC |
| Float | FLOAT |
3. 嵌入物件與複合主鍵 (`@Embeddable`, `@Embedded`, `@EmbeddedId`)
- `@Embeddable` 用來標記一個類別為可嵌入的物件,通常用來表示複合主鍵或複雜資料型別。
- `@Embedded` 用來將可嵌入的物件嵌入到實體類別中。
```java
@Embeddable
public class Address {
private String street;
private String city;
private String zipCode;
}
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Address address;
}
```
- `@EmbeddedId` 用來標記一個複合主鍵類別,通常用來表示多個欄位組成的主鍵。
```java
@Embeddable
public class UserId implements Serializable {
private String username;
private String email;
}
@Entity
public class User {
@EmbeddedId
private UserId id;
}
```
4. 一些額外的 Annotation
- `@Transient`:標記一個屬性不需要映射到資料庫欄位。
- `@Lob`:標記一個屬性為大物件,通常用來表示長文本或二進位資料。
- `@Enumerated`:標記一個屬性為枚舉型別,通常用來表示固定的選項。
- 假設我有一個列舉型別 `Gender`,可以這樣使用:
```java
public enum UserType {
ADMIN,
USER,
GUEST
}
```
- 在 Entity 中使用 `@Enumerated` 註解來指定映射方式:
```java
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING) // 或 EnumType.ORDINAL
private UserType userType;
}
```
### 🔹 第三章: Repository 接口設計
1. `Repository`、`CrudRepository`、`JpaRepository`、`PagingAndSortingRepository`
| Repository 介面 | 說明 |
|------------------|------|
| Repository<T, ID> | 是 Spring data JPA 最基礎的介面,本身沒有提供任何實作好的方法。他只能當做一個標記,讓 Spring data JPA 辨識這是一個 Repository |
| CrudRepository<T, ID> | 提供基本的 CRUD方法,例如 save, findById, delete 等等 |
| PagingAndSortingRepository<T, ID> | 繼承自 CrudRepository,提供分頁與排序的功能,例如 findAll(Pageable pageable) |
| JpaRepository<T, ID> | 最常用的就是這個~<br>繼承自 PagingAndSortingRepository,提供 JPA 特有的方法,例如 findAll(Sort sort)、flush()、saveAndFlush() 等等。 |
2. 自動衍生查詢 (Query Method Naming)
- Spring Data JPA 提供了一種簡單的方式來自動生成查詢語句,只需要根據方法名稱來推斷查詢條件。
- 例如:
```java
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByUsername(String username); // 根據 username 查詢
List<User> findByEmailContaining(String email); // 根據 email 包含的字串查詢
List<User> findByAgeGreaterThan(int age); // 根據年齡大於某個值查詢
}
```
- 常用的關鍵字
| 關鍵字 | 說明 | 方法範例 | 對應 SQL |
| ----- | ---- | ------- | -------- |
| `And` | 並且 | `findByUsernameAndEmail(String u, String e)` | `WHERE username = ? AND email = ?` |
| `Or` | 或者 | `findByUsernameOrEmail(String u, String e)` | `WHERE username = ? OR email = ?` |
| `Between` | 在某個範圍內 | `findByAgeBetween(int min, int max)` | `WHERE age BETWEEN ? AND ?` |
| `LessThan` | 小於 | `findByAgeLessThan(int age)` | `WHERE age < ?` |
| `GreaterThan` | 大於 | `findByAgeGreaterThan(int age)` | `WHERE age > ?` |
| `Like` | 模糊查詢<br>效能不佳 | `findByUsernameLike(String pattern)` | `WHERE username LIKE ?`(支援 `%` 通配符) |
| `Not` | 取反 | `findByUsernameNot(String username)` | `WHERE username <> ?` |
| `IsNull` | 為 null | `findByEmailIsNull()` | `WHERE email IS NULL` |
| `IsNotNull` | 不為 null | `findByEmailIsNotNull()` | `WHERE email IS NOT NULL` |
| `OrderBy` | 排序 | `findByUsernameOrderByAgeDesc()` | `ORDER BY age DESC` |
| `CountBy` | 回傳數量(不是查詢) | `countByUsername(String username)` | `SELECT COUNT(*) FROM user WHERE username = ?` |
| `Is` / `Equals` | 等於 | `findByUsernameIs(String name)` / `Equals(...)` | `WHERE username = ?` |
| `In` | 在集合中 | `findByUsernameIn(List<String> names)` | `WHERE username IN (...)` |
| `NotIn` | 不在集合中 | `findByUsernameNotIn(List<String> names)` | `WHERE username NOT IN (...)` |
| `StartsWith` | 以...開頭<br>效能不佳 | `findByUsernameStartsWith(String prefix)` | `WHERE username LIKE 'prefix%'` |
| `EndsWith` | 以...結尾<br>效能不佳 | `findByUsernameEndsWith(String suffix)` | `WHERE username LIKE '%suffix'` |
| `Contains` | 包含<br>效能不佳 | `findByUsernameContains(String part)` | `WHERE username LIKE '%part%'` |
| `True` | 布林為 true | `findByActiveTrue()` | `WHERE active = true` |
| `False` | 布林為 false | `findByActiveFalse()` | `WHERE active = false` |
| `After` | 日期之後 | `findByCreatedAtAfter(Date date)` | `WHERE created_at > ?` |
| `Before` | 日期之前 | `findByCreatedAtBefore(Date date)` | `WHERE created_at < ?` |
> 如果我們想要在 log 中看到實際產生的 SQL,方便我們調整效能,我們可以使用
> 1. 在 application.properties 中加入以下設定:
> ```properties
> spring.jpa.show-sql=true
> spring.jpa.properties.hibernate.format_sql=true
> ```
> 2. 在 logback.xml 中加入以下設定:
> ```xml
> <logger name="org.hibernate.SQL" level="DEBUG" />
> <logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" /> <!-- 參數綁定的詳細資訊 -->
> ```
3. 自訂查詢: `@Query` 與 JPQL
- 當有時候自動衍生查詢無法滿足需求時,可以使用 `@Query` 註解來自訂查詢語句。
- `@Query` 可以使用 JPQL 或原生 SQL 語句。
```java
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
User findByEmailNative(@Param("email") String email);
```
- JPQL 是 JPA 提供的查詢語言,類似於 SQL,但可以針對 Entity 進行操作。
```java
@Query("SELECT u FROM User u WHERE u.name = :name")
List<User> findUsersByName(@Param("name") String name);
```
- JPQL 的查詢語法與 SQL 類似,但有一些差異
| 項目 | JPQL | Native Query (原生 SQL) |
|------|------|-------------------------|
| 操作對象 | Entity 類別與屬性 | 資料表與欄位 |
| 可攜性 | 跨資料庫(JPA 會自動轉換) | 依賴特定資料庫 |
| 語法 | 類似 SQL,但有限制 | 完全 SQL,功能最完整 |
| 使用時機 | 一般查詢、簡單條件 | 複雜查詢、資料庫特有語法 |
4. 分頁與排序
- Spring Data JPA 提供了分頁與排序的功能,可以使用 `Pageable` 介面來實現。
- 分頁查詢範例:
```java
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByUsername(String username, Pageable pageable);
}
```
- 排序查詢範例:
```java
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByUsername(String username, Sort sort);
}
```
- 分頁與排序的使用範例:
```java
Pageable pageable = PageRequest.of(0, 10, Sort.by("username").ascending());
Page<User> users = userRepository.findByUsername("admin", pageable);
```
- `PageRequest.of(page, size, sort)` 方法用來創建分頁請求,`page` 是頁碼(從 0 開始),`size` 是每頁的大小,`sort` 是排序條件。
---
### 🔹 第四章: 關聯映射 (Relationships)
1. 一對一: `@OneToOne`
- 一個 Entity(Table) 只對應到另一個唯一的 Entity(Table)
```java
@Entity
public class User {
@Id private Long id;
@OneToOne
@JoinColumn(name = "profile_id") // 外鍵
private UserProfile profile;
}
```
- `@JoinColumn` 用來指定外鍵欄位的名稱,預設是關聯的主鍵欄位名稱。
- 以前述的範例來說,當我要 findAll 的時候,就會產生如下的 SQL
```sql
SELECT * FROM user u
JOIN user_profile p ON u.profile_id = p.id;
```
2. 一對多 / 多對一: `@OneToMany`, `@ManyToOne`
- 一個 Entity(Table) 可以對應到多個 Entity(Table)就是OneToMany
```java
@Entity
public class User {
@Id private Long id;
@OneToMany(mappedBy = "user") // 反向關聯
private List<Order> orders;
}
```
- `mappedBy` 用來指定反向關聯的欄位名稱,表示這個關聯是由另一個 Entity 負責維護的。
- `@ManyToOne` 用來標記多對一的關聯,表示多個 Entity 對應到同一個 Entity。
```java
@Entity
public class Order {
@Id private Long id;
@ManyToOne
@JoinColumn(name = "user_id") // 外鍵
private User user;
}
```
- `@JoinColumn` 用來指定外鍵欄位的名稱,預設是關聯的主鍵欄位名稱。
> 預設 @OneToMany 是 lazy loading,@ManyToOne 是 eager loading。後面會講到 lazy loading 與 eager loading 的差異。
3. 多對多: `@ManyToMany`
- 再稍微舉個例子說明什麼是多對多
- 以學生為例
- 一個學生,可以上多門課程
- 一門課程,會有多個學生來上
- 多個 Entity 可以對應到多個 Entity,就是ManyToMany
```java
@Entity
public class User {
@Id private Long id;
@ManyToMany
@JoinTable(name = "user_role", // 關聯表名稱
joinColumns = @JoinColumn(name = "user_id"), // 外鍵欄位名稱
inverseJoinColumns = @JoinColumn(name = "role_id")) // 反向外鍵欄位名稱
private List<Role> roles;
}
```
- `@JoinTable` 用來指定關聯表的名稱與外鍵欄位名稱。
4. 映射方向與 `mappedBy`、關聯維護者
| 屬性 | 說明 |
| ---- | ---- |
| `mappedBy` | 用來指定反向關聯的欄位名稱,表示這個關聯是由另一個 Entity 負責維護的。 |
| 無`mappedBy` | 表示這個關聯是由當前 Entity 負責維護的,會在資料庫中建立外鍵欄位。 |
5. Cascade 與 FetchType 深入解析
- `Cascade` 用來指定關聯操作的級聯行為,當對一個 Entity 進行操作時,是否要對關聯的 Entity 也進行相同的操作。
| 屬性 | 說明 |
| ---- | ---- |
| `CascadeType.ALL` | 所有操作都會級聯到關聯的 Entity |
| `CascadeType.PERSIST` | 當新增當前 Entity 時,關聯的 Entity 也會被新增 |
| `CascadeType.MERGE` | 當更新當前 Entity 時,關聯的 Entity 也會被更新 |
| `CascadeType.REMOVE` | 當刪除當前 Entity 時,關聯的 Entity 也會被刪除 |
| `CascadeType.REFRESH` | 當刷新當前 Entity 時,關聯的 Entity 也會被刷新 |
| `CascadeType.DETACH` | 當分離當前 Entity 時,關聯的 Entity 也會被分離 |
- `FetchType` 用來指定查詢時的載入方式,預設是 lazy loading。
| 屬性 | 說明 |
| ---- | ---- |
| `FetchType.LAZY` | 查詢父時,不查子,延遲載入,只有在需要時才載入關聯的 Entity<br>`@OneToMany`, `@ManyToMany` 預設是 `LAZY` |
| `FetchType.EAGER` | 立即 join,立即載入,查詢時就載入關聯的 Entity<br>`@ManyToOne`, `@OneToOne` 預設是 `EAGER` |
- 用一個例子來說明FetchType
- Entity
```java
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
// getters and setters
}
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String product;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
// getters and setters
}
```
- 查詢
```java
@Transactional
public void testLazyFetch() {
User user = userRepository.findById(1L).orElseThrow();
System.out.println("User name: " + user.getName());
System.out.println("----- 延遲加載開始 -----");
System.out.println("Order 數量: " + user.getOrders().size());
}
```
- 解釋:
- 當我們查詢 User 時,因為 `@OneToMany` 的 `fetch` 屬性是 `LAZY`,所以不會立即查詢 Order。
- 當我們訪問 `user.getOrders()` 時,才會觸發查詢,這時候會產生一個 SQL 查詢語句,查詢所有與該 User 相關的 Order。
- 這樣可以減少不必要的資料庫查詢,提高效能。
- 但是如果我們在同一個事務中多次訪問 `user.getOrders()`,就會產生 N+1 問題。
- 如果我們有設定 Hibernate 的 `show_sql`,就會看到兩條 SQL 查詢語句:
```sql
SELECT u.id, u.name FROM user u WHERE u.id = ?;
SELECT o.id, o.product, o.user_id FROM orders o WHERE o.user_id = ?;
```
> 💡 實際上 JPA/Hibernate 不會使用 `SELECT *`,而是列出明確欄位名稱,這是為了確保每個欄位能正確對應到實體屬性。
- 而若是我們前面範例中的 `LAZY` 改成 `EAGER`,那麼在查詢 User 時,就會立即查詢所有的 Order,並將它們一起載入。
- 這樣的話,當我們訪問 `user.getOrders()` 時,就不會再觸發新的查詢了,因為資料已經載入。
- 只會有一條 SQL 查詢語句:
```sql
SELECT u.id, u.name, o.id, o.product, o.user_id
FROM user u
JOIN orders o ON u.id = o.user_id
WHERE u.id = ?;
```
---
### 🔹 第五章: 交易與 Entity Lifecycle
1. Entity 的生命週期 (Transient → Managed → Detached → Removed)
| 狀態 | 說明 |
|------|------|
| Transient | 新建的物件(要與DB處理的物件的EM),尚未與資料庫關聯。 |
| Managed | 物件已與資料庫關聯,任何變更會自動同步到資料庫。 |
| Detached | 物件與資料庫的關聯已斷開,變更不會自動同步。 |
| Removed | 物件已標記為刪除,將在下次 flush 時從資料庫中移除。 |
2. `@Transactional` 作用與配置
- @Transactional 是 Spring 提供的註解,用來標記一段程式碼需要使用交易(Transaction)機制。
- 常見使用場景包括:資料新增、修改、刪除等操作。
- 方法內只要有發生 Exception(未捕捉),整個交易會自動 rollback。
```java
@Transactional
public void updateUser(User user) {
user.setName("UpdatedName");
// Spring 會自動在此方法開啟與提交/回滾交易
}
```
- 若有複雜的交易管理需求,可透過 @Transactional(propagation = ..., isolation = ..., rollbackFor = ...) 進行細部設定。
3. Lazy vs Eager loading 的差異與效能考量
- 如上一章提到的範例,Lazy loading(延遲載入)在實際查詢時不會馬上抓出資料,只有在真正訪問時才會產生 SQL。
- Eager loading(立即載入)則會一次性載入所有相關資料,適合關聯小且一定會用到的情境。
> 感覺有點像 DDD 架構時,會有 Aggregate Root 的概念,當你需要用到這個 Aggregate Root 時,就會把整個 Aggregate 一起載入。
> 但若是在有龐大資料量的情況下,Eager loading 會造成效能問題。
- 若處理大量資料或不一定需要關聯欄位時,建議使用 Lazy loading 並手動搭配 fetch join 或 @EntityGraph 提前載入。
4. EntityManager 的 flush/clear/merge/persist 等操作
| 方法 | 說明 |
|------|------|
| persist() | 將 Transient 狀態轉為 Managed,新增資料到資料庫。 |
| merge() | 將 Detached 狀態轉為 Managed,合併修改後的資料。 |
| remove() | 將實體標記為 Removed,交易提交後從資料庫刪除。 |
| flush() | 強制同步 EntityManager 中的變更至資料庫(不提交交易)。 |
| clear() | 清除 EntityManager 中所有 Managed 實體,讓它們變成 Detached。 |
- 如果把 EntityManager 流程與狀態圖結合起來,會是這樣的:
```mermaid
flowchart LR
A[Transient<br>新建物件] -- persist() --> B[Managed<br>已關聯,變更自動同步]
B -- remove() --> D[Removed<br>標記刪除,flush時移除]
D -- (flush/commit) --> E[消失於資料庫]
B -- detach()/clear() --> C[Detached<br>關聯已斷開,變更不會同步]
C -- merge() --> B
```
---
### 🔹 第六章: 查詢優化與效能陷阱
1. **N+1 問題與解決方案**
* **N+1 問題定義**:當查詢一筆主資料(如 User)時,如果它有延遲載入(Lazy loading)的關聯資料(如 orders),JPA 會先執行一條查詢語句抓出所有 User,接著對於每一筆 User,再額外發出一條查詢去抓它的 orders。這會造成總共 1(主查詢)+ N(每一筆的子查詢)次資料庫存取,大幅影響效能。
* **舉例說明**:
假設你有一個 `User` Entity,每個 User 都有一組 `Order`:
```java
@Entity
public class User {
@Id Long id;
String name;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders;
}
@Entity
public class Order {
@Id Long id;
String item;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
```
若你寫了這段程式:
```java
List<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user.getOrders().size());
}
```
* 第 1 條查詢:`SELECT * FROM user`
* 第 2 ~ N+1 條查詢:每個 user 的 `SELECT * FROM orders WHERE user_id = ?`
* 若第一條查出 100 個 user,而每個 user 又都要去 select orders,那就會變成 1 + 100 次的 SQL 查詢。
* **解法 1:使用 JPQL 的 `join fetch`**
```java
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllWithOrders();
```
> 強制一次性載入所有關聯資料,減少查詢次數
* **解法 2:使用 `@EntityGraph`**(更直觀)
```java
@EntityGraph(attributePaths = {"orders"})
List<User> findAll();
```
> 這個方法其實跟前一個解法是一樣的,且更直觀。
> 但是這樣的解法沒辦法處理複雜或多層級 join 邏輯的 SQL
* **解法 3:使用 batch size 設定改善載入效率**
```properties
spring.jpa.properties.hibernate.default_batch_fetch_size=50
```
- 其實就是使用 in,類似分頁查詢的感覺,減少 select 次數而已。
```sql
-- 從
-- 查詢 100 個 user
SELECT * FROM users;
-- 每筆 orders 各查一次(100 次)
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 2;
...
SELECT * FROM orders WHERE user_id = 100;
-- 變成
-- 查詢 100 個 user
SELECT * FROM users;
-- orders 分兩次查詢,每次最多 50 個 user_id
SELECT * FROM orders WHERE user_id IN (1, 2, ..., 50);
SELECT * FROM orders WHERE user_id IN (51, 52, ..., 100);
```
> 💡 建議與 Lazy loading 搭配,避免一次性載入全部關聯資料造成記憶體爆炸。
- 或是也可以針對某個關聯欄位:
```java
@OneToMany(fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<Order> orders;
```
2. **查詢快取與一級/二級快取**
| 類型 | 描述 |
| ---- | --------------------------------------------- |
| 一級快取 | EntityManager 級別,預設啟用,同一筆資料不會重複查詢 |
| 二級快取 | SessionFactory 級別,需手動啟用,常用於跨 EntityManager 快取 |
* 一級快取:當你在同一個 EntityManager 中查詢資料時,JPA 會自動將查詢結果緩存在內存中,避免重複查詢。
```java
EntityManager em = entityManagerFactory.createEntityManager();
User user1 = em.find(User.class, 1L);
User user2 = em.find(User.class, 1L); // 不會再查詢資料庫
```
* 若使用 Hibernate,可以搭配 Ehcache、Caffeine 等快取實作
* 範例設定:
```properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
```
- 還要搭配 Entity 的 `@Cacheable` 註解:
```java
@Entity
@Cacheable
public class User {
@Id
private Long id;
@Column
private String name;
}
```
3. **使用 Specification 進行動態查詢 (JPA Criteria API)**
* 適用於查詢條件組合非常彈性的場景,例如多欄位條件篩選表單。
* 使用方式:
```java
public class UserSpecs {
public static Specification<User> hasEmail(String email) {
return (root, query, cb) -> cb.equal(root.get("email"), email);
}
}
userRepository.findAll(UserSpecs.hasEmail("test@example.com"));
```
* 可多個 Specification 用 `and()`、`or()` 組合查詢條件。
* 寫到這邊我就想,那不是跟 `findByXxx()` 一樣嗎? 而且 `findByXxx()` 更方便吧?
- 來比較一下
| 特性 | `FindbyXxx` 衍生查詢 | Specification 查詢 |
| ---- | ------------------- | ------------------ |
| 適用場景 | 單欄位、固定查詢條件 | 多欄位、動態組合查詢 |
| 可讀性 | ✅ 高 |❌ 較複雜 |
| 查詢彈性 | ❌ 固定欄位 | ✅ 可組合 and / or / not |
| 動態搜尋表單支援 | ❌ 欄位變動就要重寫 method |✅ 可彈性新增欄位條件 |
- 我只要先寫好了 `UserSpecs`,後面我想要怎麼組裝我的搜尋條件都可以
```java
public class UserSpecs {
public static Specification<User> hasEmail(String email) {
return (root, query, cb) -> cb.equal(root.get("email"), email);
}
public static Specification<User> hasRole(String role) {
return (root, query, cb) -> cb.equal(root.get("role"), role);
}
public static Specification<User> createdAfter(LocalDate date) {
return (root, query, cb) -> cb.greaterThan(root.get("createdAt"), date);
}
}
// 呼叫端範例
Specification<User> spec = Specification
.where(UserSpecs.hasEmail("test@example.com"))
.and(UserSpecs.hasRole("ADMIN"))
.and(UserSpecs.createdAfter(LocalDate.of(2024, 1, 1)));
List<User> result = userRepository.findAll(spec);``
```
- 而若使用 `findByXxx()`,如果想要換條件,那你就得另外再多寫一個 method。
- 那麼結論是就是如果你的搜尋方式非常多樣且變動性大,那麼就可以考慮使用 Specification。
---
### 🔹 第七章: 進階功能與擴充
1. **審計功能(Audit)**
* Spring Data JPA 提供了內建的審計機制,能夠自動追蹤資料的建立時間、修改時間、建立人員與修改人員。
* 常見應用場景:記錄誰在什麼時候新增/修改了一筆資料。
* 使用方式:
* 步驟 1:在 Entity 上加上 `@EntityListeners(AuditingEntityListener.class)`
* 步驟 2:新增欄位並加上相關註解
```java
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Article {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
```
* 步驟 3:在 config 中啟用
```java
@EnableJpaAuditing
public class JpaConfig {}
```
* 若要支援 `@CreatedBy` / `@LastModifiedBy`,還需提供 `AuditorAware<T>` Bean。
2. **自定義 Repository 實作**
* 當 Repository 的查詢或邏輯過於複雜,不適合寫在介面中,可以將實作抽出來:
* 定義 interface:
```java
public interface UserRepositoryCustom {
List<User> findActiveUsersWithRole(String role);
}
```
* 實作 class(命名需為 RepositoryImpl 結尾):
```java
public class UserRepositoryImpl implements UserRepositoryCustom {
@PersistenceContext EntityManager em;
@Override
public List<User> findActiveUsersWithRole(String role) {
return em.createQuery("SELECT u FROM User u WHERE u.role = :role AND u.active = true", User.class)
.setParameter("role", role)
.getResultList();
}
}
```
* 串接到主 Repository:
```java
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {}
```
3. **DTO 投影與查詢結果轉換**
* 若 Entity 欄位過多,查詢時只想取部分欄位,可以使用 DTO 投影(Projection)。
* 支援方式:
* Constructor-based DTO:
```java
public class UserSummary {
private final String name;
private final String email;
public UserSummary(String name, String email) {
this.name = name;
this.email = email;
}
}
@Query("SELECT new com.example.dto.UserSummary(u.name, u.email) FROM User u")
List<UserSummary> findUserSummaries();
```
* Interface-based Projection(適用於 Spring Data 衍生查詢):
```java
public interface UserNameOnly {
String getName();
}
List<UserNameOnly> findByEmail(String email);
```
4. **多資料來源設定(Multiple Datasource)**
* Spring Boot 預設只連接一個資料來源,但在大型系統中,常需要同時連接多個資料庫(例如:讀寫分離、資料整合)。可透過多組 `DataSource`、`EntityManagerFactory`、`TransactionManager` 的設定來實現多資料來源切換。
* 使用場景:
* 系統同時讀取 Oracle + MySQL
* 寫入資料到主資料庫,查詢從報表資料庫抓
* 需要從外部系統同步資料進本地
---
### 實作範例:主資料庫 + 報表資料庫
#### application.yml
```yaml
spring:
datasource:
primary:
url: jdbc:mysql://localhost:3306/main_db
username: root
password: pass
driver-class-name: com.mysql.cj.jdbc.Driver
report:
url: jdbc:postgresql://localhost:5432/report_db
username: postgres
password: pass
driver-class-name: org.postgresql.Driver
```
#### 主資料庫配置 PrimaryDataSourceConfig
```java
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "com.example.repository.primary",
entityManagerFactoryRef = "primaryEntityManagerFactory",
transactionManagerRef = "primaryTransactionManager"
)
public class PrimaryDataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@Primary
public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(primaryDataSource())
.packages("com.example.entity.primary")
.persistenceUnit("primary")
.build();
}
@Bean
@Primary
public PlatformTransactionManager primaryTransactionManager(
EntityManagerFactory primaryEntityManagerFactory) {
return new JpaTransactionManager(primaryEntityManagerFactory);
}
}
```
#### 報表資料庫配置 ReportDataSourceConfig
```java
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "com.example.repository.report",
entityManagerFactoryRef = "reportEntityManagerFactory",
transactionManagerRef = "reportTransactionManager"
)
public class ReportDataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.report")
public DataSource reportDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public LocalContainerEntityManagerFactoryBean reportEntityManagerFactory(
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(reportDataSource())
.packages("com.example.entity.report")
.persistenceUnit("report")
.build();
}
@Bean
public PlatformTransactionManager reportTransactionManager(
EntityManagerFactory reportEntityManagerFactory) {
return new JpaTransactionManager(reportEntityManagerFactory);
}
}
```
---
### 建議:
* 資料夾結構應清楚分開,如 `entity.primary`, `repository.report`
* 每個 DataSource 設定皆需有對應的 `@EnableJpaRepositories` 區域配置
* 若有使用 `@Transactional`,需搭配對應的 `transactionManagerRef`
* 可搭配 Spring Profile 進行環境切換與隔離
> 避免所有資料庫共用同一個 `EntityManagerFactory`,否則容易造成混亂與資料異常。
* Spring Boot 預設只連一個資料來源,但可透過多組 `DataSource`, `EntityManagerFactory`, `TransactionManager` 設定切換資料來源。
* 使用場景:系統同時讀寫來自不同資料庫(如 Oracle + MySQL)。
* 基本概念:
* 每個資料來源都需有自己的設定 class,包含:
* `@Configuration`
* `@EnableTransactionManagement`
* `@EnableJpaRepositories`
* `LocalContainerEntityManagerFactoryBean`
* `PlatformTransactionManager`
> 建議使用 profile 或 prefix(如 `spring.datasource.xxx`)清楚區分來源配置。
---
### 🔹 第八章: 與 Spring Boot 衍伸整合簡介
1. **application.yml 中的 JPA/Hibernate 參數設定**
Spring Boot 可透過 `application.yml` 管理所有 JPA 與 Hibernate 的設定:
```yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/app_db
username: user
password: pass
jpa:
hibernate:
ddl-auto: update # create, update, validate, none
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
use_sql_comments: true
```
* `ddl-auto`:控制 schema 自動更新行為,正式環境建議設為 `validate` 或 `none`
* `show-sql`:顯示執行的 SQL 語句
* `format_sql`:格式化 SQL 輸出,方便閱讀
* `dialect`:指定資料庫方言,影響 Hibernate 產生 SQL 的方式
---
2. **使用 Spring Boot Starter 自動配置 JPA**
Spring Boot 已透過 `spring-boot-starter-data-jpa` 提供完整整合,只需加入以下依賴即可啟用所有功能:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
```
* 不需手動配置 EntityManagerFactory 與 TransactionManager,Spring Boot 會根據 yml 自動裝配
* 搭配 `spring-boot-starter-web`、`H2` 或其他資料庫驅動即可快速啟動應用
---
3. **結合 Flyway 或 Liquibase 做資料版本控管**
* Spring Boot 可透過外掛套件管理資料庫 Schema 版本,推薦使用 [Flyway](https://flywaydb.org/) 。
* Flyway 實作範例:
* Maven 依賴:
```xml
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
```
* `application.yml` 設定:
```yaml
spring:
flyway:
enabled: true
locations: classpath:db/migration
```
* 放置 SQL 腳本於 `src/main/resources/db/migration/V1__init.sql`
```sql
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(255)
);
```
* 每次部署新版本時,自動比對與執行資料更新腳本,確保專案開發時 schema 一致性
---
4. **整合測試:使用 `@DataJpaTest`**
* `@DataJpaTest` 是 Spring Boot 提供的 JPA 測試專用註解,會自動設定 in-memory DB(如 H2)並掃描 Repository。
* 範例:
```java
@DataJpaTest
public class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void testFindByEmail() {
User user = new User("kai", "kai@example.com");
userRepository.save(user);
Optional<User> result = userRepository.findByEmail("kai@example.com");
assertTrue(result.isPresent());
assertEquals("kai", result.get().getName());
}
}
```
* 好處:
* 使用 H2 快速測試資料操作,不啟動整個 Spring Context
* 自動 rollback,每個測試方法都是乾淨的狀態
* 可搭配 `TestEntityManager` 進行更細緻操作
---
###### Tags : `JAVA`, `ORM`, `JPA`, `Spring Boot`