# 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`