Spring Data JPA(系列文章共 2 篇)
- Spring Data JPA 最佳實踐【1/2】:實體設計指南
- Spring Data JPA 最佳實踐【2/2】:存儲庫設計指南
這一系列文章是我在審查一個包含大量不良實踐的大型遺留代碼庫時撰寫的總結。為了解決這些問題,我創建了這份指南,旨在向我之前的同事推廣 Spring Data JPA 在設計實體方面的最佳實踐。
現在是將這份指南從塵封中取出、更新併發布給更廣泛受眾的時候了。該指南內容詳實,我決定將其拆分為兩篇獨立的文章。
文中的一些示例可能看起來顯而易見,但事實並非如此——這只是從您經驗豐富的角度得出的看法。它們都來自生產代碼庫中的真實案例。
1 深入 Spring Data JPA
為了便捷快速地開發數據庫驅動的軟件,推薦使用以下庫和框架:
- Spring Boot — 通過提供自動配置、起步依賴和約定優於配置的默認值(例如,內嵌服務器、Actuator),簡化了在 Spring 框架之上構建 Web 應用程序的過程。它利用了 Spring 現有的依賴注入模型,而非引入新的模型。
- Spring Data JPA 在為數據庫操作創建存儲庫時節省時間。它提供了現成的接口用於 CRUD 操作、事務管理以及通過註解或方法名定義查詢。另一個優勢是其與 Spring 上下文的集成,以及依賴注入帶來的相應好處。
- Lombok – 通過生成 getter、setter 和其他重複性代碼,減少了樣板代碼。
實體代表數據庫表中的行。它們是使用 @Entity 和其他 JPA 註解標註的普通 Java 對象。DTO(數據傳輸對象) 是普通 Java 對象,用於以相較於底層實體受限或轉換後的形式呈現數據。
在 Spring 應用程序中,存儲庫 是一種特殊的接口,提供對數據庫/數據的訪問。這類存儲庫通常使用 @Repository 註解,但實際上,當您繼承自 JpaRepository、CrudRepository 或其他 Spring Data JPA 存儲庫時,無需單獨標註。如果您不繼承 Spring Data 的基礎接口,可以使用 @RepositoryDefinition。此外,在共享的基礎接口上使用 @NoRepositoryBean。
服務 是封裝業務邏輯和功能的特殊類。控制器 是您應用程序的端點;用户與控制器交互,控制器繼而注入服務而非存儲庫。
為清晰起見,您的項目應按職責或其他方式組織成不同的包。代碼組織是一個好話題,但總是依賴於您的服務、代碼約定等。給出的示例代表一個具有單一業務領域的微服務。
entity– 數據庫實體,repository– 數據訪問存儲庫,service– 服務,包括存儲過程的包裝器,controller– 應用程序端點,dtos– DTO 類。
當 Spring Boot 應用程序啓動時,基於 application.properties/application.yml 中的配置,到數據庫的連接會被自動配置。常見屬性包括:
spring.datasource.url– 數據庫連接 URLspring.datasource.driver-class-name– 數據庫驅動類,Spring Boot 通常可以從 JDBC URL 推斷出它,僅在推斷失敗時設置此屬性。spring.jpa.database-platform– 要使用的 SQL 方言spring.jpa.hibernate.ddl-auto– Hibernate 應如何創建數據庫模式,可用值:none|validate|update|create|create-drop
2 使用 Spring Data JPA 開發實體
在設計與數據庫交互的軟件時,正確使用 Java 持久化 API(JPA)註解的簡單 Java 對象起着至關重要的作用。這類對象通常包含映射到表列的字段,被稱為實體。並非每個字段都是一對一映射的:關係、嵌入的值對象和 @Transient 字段都很常見。
至少,一個實體類必須使用 @Entity 註解來標記該類為數據庫實體,並使用 @Id 或 @EmbeddedId 聲明一個主鍵。JPA 還要求一個無參構造函數(public 或 protected)。包含 @Table 以顯式定義目標表也是一個好習慣。@Table 註解是可選的,當您需要覆蓋默認表名時使用它。
使用 @Entity 註解時,最好設置 name 屬性,因為此名稱用於 JPQL 查詢。如果省略它,JPQL 將使用簡單的類名,設置它可以解耦查詢與重構.
還有一個有用的註解 @Table,可以在表名與命名策略不同時幫助您選擇表名。
以下示例演示了不好和好的用法:
@Entity
@Table(name = "COMPANY")
public class CompanyEntity {
// 字段省略
}
// 後續使用:
Query q = entityManager.createQuery("FROM " + CompanyEntity.class.getSimpleName() + " c")
這裏,@Entity 上缺少 name 屬性,因此在查詢中使用類名。這可能在重構時導致代碼脆弱。這裏還有另一個問題:它使用了 entityManager 而不是預配置的 Spring Data JPA 存儲庫。entityManager 提供了更多的靈活性,但也讓您可能在代碼庫中製造混亂,而不是使用更可取的數據獲取方式。
您發現這裏還有一個不良實踐了嗎?沒錯,就是使用字符串拼接來構建查詢。在這種情況下,它不會導致 SQL 注入,但最好避免這種方法,尤其是在像這樣將用户輸入傳遞給查詢時。
@Entity(name = "Company")
@Table(name = "COMPANY")
public class CompanyEntity {
// 字段省略
}
// 後續使用:
Query q = entityManager.createQuery("FROM Company c");
在改進版本中,顯式指定了實體名稱,因此 JPQL 查詢可以通過名稱引用實體,而不必依賴類名。
注意:JPQL 實體名稱(@Entity(name))和 @Table 中的物理表名是兩個獨立的概念。
3 避免魔法數字/字面量
明智地選擇字段的類型:
- 如果字段代表數字枚舉,則使用
Integer或適當的小型數值類型。 - 如果選擇類型,則基於值域範圍和可空性(如果列可為空,則使用包裝類型,如
Integer);並記住,在 JPA 中,較小的數值類型很少帶來實際好處。 - 如果值是貨幣或需要精確計算,則使用具有適當精度/小數位數的
BigDecimal。 - 如果您需要關於枚舉的詳細信息,將在後面介紹。
例如,假設一個字段 statusCode 代表公司的狀態。使用數字類型並在註釋中記錄每個值的含義,會導致代碼難以閲讀且容易出錯:
// 公司狀態:
// 1 – 活躍
// 2 – 暫停
// 3 – 解散
// 4 – 合併
@Column(name = "STATUS_CODE")
private Long statusCode;
相反,應創建一個枚舉並將其用作字段的類型。這使得代碼自文檔化並減少了出錯的機會。在使用 Spring Data JPA 持久化枚舉時,請指定其存儲方式,這是一個好習慣。優先使用 @Enumerated(EnumType.STRING),這樣數據庫中包含的是可讀的名稱,並且您不會因常量重新排序而受影響。同時,確保列類型/長度適合枚舉名稱(如果需要,設置 length 或 columnDefinition)。
// 存儲為可讀名稱;確保列能容納它們(例如,length = 32)。
@Column(name = "STATUS", length = 32)
@Enumerated(EnumType.STRING)
private CompanyStatus status;
public enum CompanyStatus {
/** 活躍公司 */ ACTIVE,
/** 暫時暫停 */ SUSPENDED,
/** 正式解散 */ DISSOLVED,
/** 合併到其他組織 */ MERGED;
}
如果您現有的列存儲數字代碼(例如 1–4)且必須保持為數字,不要使用 EnumType.ORDINAL(它寫入的是基於 0 的序號,與 1–4 不匹配)。使用 AttributeConverter<CompanyStatus, Integer> 將顯式代碼映射到枚舉值:
@Converter(autoApply = false)
public class CompanyStatusConverter implements AttributeConverter<CompanyStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(CompanyStatus v) {
if (v == null) return null;
return switch (v) {
case ACTIVE -> 1;
case SUSPENDED -> 2;
case DISSOLVED -> 3;
case MERGED -> 4;
};
}
@Override
public CompanyStatus convertToEntityAttribute(Integer db) {
if (db == null) return null;
return switch (db) {
case 1 -> CompanyStatus.ACTIVE;
case 2 -> CompanyStatus.SUSPENDED;
case 3 -> CompanyStatus.DISSOLVED;
case 4 -> CompanyStatus.MERGED;
default -> throw new IllegalArgumentException("未知 STATUS_CODE: " + db);
};
}
}
// 在列中保持數字 1..4,同時在 Java 中暴露類型安全的枚舉。
@Column(name = "STATUS_CODE")
@Convert(converter = CompanyStatusConverter.class)
private CompanyStatus status;
4 類型的一致性使用
如果一個字段在多個實體中使用,請確保它在各處具有相同的類型。對概念上相同的字段使用不同的類型會導致業務邏輯不明確。例如,以下不好的用法展示了兩個代表布爾標誌但使用不同類型和名稱的字段:
// 對邏輯相同的字段選擇了不好的類型
// A – 自動, M – 手動
@Column(name = "WAY_FLG")
private String wayFlg;
@Column(name = "WAY_FLG")
private Boolean wayFlg;
更好的選擇是對兩個字段都使用 Boolean,或者,如果您需要兩個以上的值,或者這兩個值是帶有領域標籤的(例如,Automatic/Manual),則對兩個字段都使用枚舉。如果它確實是二元的 是/否,使用 Boolean(對於可空列使用包裝類型)即可。否則,為了清晰性和麪向未來,優先使用枚舉。以下是不使用轉換器的一致性映射示例:
// 兩個帶標籤的狀態:為了清晰,優先使用枚舉
public enum WayMode { A, M } // 或 AUTOMATIC, MANUAL
// 在每個涉及 WAY_FLG 的實體中使用相同的映射
@Column(name = "WAY_FLG", length = 1) // 確保長度適合枚舉名稱
@Enumerated(EnumType.STRING)
private WayMode wayFlg;
// 真正的二元情況(例如,活躍/非活躍):
@Column(name = "IS_ACTIVE")
private Boolean active; // 如果列可為 NULL,則使用包裝類型
本文有意省略了關於 Spring Data JPA 中表關係部分,因為這是一個廣泛的主題,值得另寫一篇關於最佳實踐的文章。
5 Lombok 的使用
為了減少樣板源代碼的數量,推薦使用 Lombok 進行代碼生成——但應明智地使用。生成 getter 和 setter 是一個理想的選擇。最好堅持這種做法,並且僅在需要某些預處理時才重寫 getter 和 setter。
對於 JPA,確保存在無參構造函數。使用 Lombok,您可以添加 @NoArgsConstructor(access = AccessLevel.PROTECTED) 來清晰地滿足規範。
警告提示:避免在實體上使用 @Data,因為它生成的 equals/hashCode/toString 可能與 JPA 產生問題(延遲關係、可變標識符)。優先使用針對性的註解(@Getter, @Setter, @NoArgsConstructor),並且如果需要,使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 和排除關聯字段來顯式定義相等性。下文將詳細説明。
此外,Lombok 支持以下常用註解。您可以在其網站上找到完整列表:https://projectlombok.org/
6 重寫 equals 和 hashCode
在數據庫實體中重寫 equals 和 hashCode 時,會出現許多問題。例如,許多應用程序使用從 Object 繼承的標準方法也能正常工作。
上下文:在單個持久化上下文中,Spring Data JPA/Hibernate 已經確保了標識語義(相同的數據庫行 -> 相同的 Java 實例)。通常只有在跨上下文依賴值語義或在哈希集合中使用時,才需要自定義 equals/hashCode。
數據庫實體通常代表現實世界的對象,您可以選擇不同的方式來重寫:
- 基於實體的主鍵(它是不可變的)。細微差別:如果 ID 是數據庫生成的,則在持久化/刷新之前它為 null。需要處理臨時狀態,以免對象在哈希集合中時哈希值發生改變。
- 基於業務鍵(例如,員工的税號/INN),因為它不依賴於數據庫實現。細微差別:如果鍵是唯一、不可變且始終可用的,則效果很好;避免使用可變字段/關聯。
- 基於所有字段。不安全:可變數據、潛在的延遲加載、通過關聯的遞歸以及性能成本,使得這對於 JPA 實體來説很脆弱。
什麼時候應該重寫 equals 和 hashCode?
- 當對象在
Map中用作鍵時。細微差別:當對象位於哈希結構內部時,不要修改被hashCode使用的字段。 - 當使用僅存儲唯一對象的結構時(例如
Set)。細微差別:同樣的注意事項——修改相等性/重要字段會破壞集合的不變性。 - 當需要比較數據庫實體時。細微差別:通常比較標識符就足夠了;如果標識比較符合您的用例,則重寫不是強制性的。
綜上所述,您應該謹慎使用 Lombok 的 @EqualsAndHashCode 和 @Data,因為除非另行配置,否則 Lombok 會為所有字段生成這些方法。
擴展説明:優先使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 並僅標記穩定的標識符/業務鍵;避免在實體上使用 @Data(它生成的 equals/hashCode/toString 可能與延遲關係產生不良交互)。您還可以使用 @EqualsAndHashCode.Exclude / @ToString.Exclude 將關聯從相等性或 toString 中排除。
繼承的細微差別:如果在映射的超類中定義了相等性,請確保規則對所有子類一致,並且與整個層次結構的標識定義方式相匹配。
A) 業務鍵相等性(當鍵唯一且不可變時安全)
public class Employee {
private String taxId; // 自然鍵:唯一且不可變
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false; // 這裏保持簡單
Employee other = (Employee) o;
return taxId != null && taxId.equals(other.taxId);
}
@Override
public int hashCode() {
return (taxId == null) ? 0 : taxId.hashCode();
}
}
B) 基於 ID 的相等性(處理臨時狀態;避免哈希變化)
public class Order {
private Long id; // 數據庫生成
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Order other = (Order) o;
// 臨時實體 (id == null) 除了自身外,不等於任何東西
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
// 返回常量,避免在後續分配 ID 後重新計算哈希值
return getClass().hashCode();
}
}
C) Lombok 模式(顯式包含;避免全字段默認)
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Customer {
@EqualsAndHashCode.Include
private String externalId; // 穩定的業務鍵
// 排除關聯和可變細節
// @EqualsAndHashCode.Exclude private List<Order> orders;
}
7 開發 DTO
DTO(數據傳輸對象) 是專門設計的對象,用於向客户端呈現數據,因為將原始數據庫實體直接發送給客户端被認為是一種不良實踐。有些團隊確實會在內部邊界傳遞實體,但對於公開/面向客户端的 API,優先使用 DTO 以避免泄露持久化細節。
創建各種 DTO 會增加開發和維護時間。如果使用像 ModelMapper 這樣的庫,對象映射還會帶來內存開銷。
DTO 的另一個特性是通過傳輸更少的數據量來減少網絡傳輸的數據量,並通過請求更少的字段來降低 DBMS 的負載。最重要的是,只有當您確實選擇了更少的列時(使用構造函數表達式、Spring Data JPA 投影或僅返回所需字段的本機查詢),您才能真正減少數據庫負載。獲取完整實體然後進行映射不會減少選擇的列數,這是顯而易見的。
設計 DTO 有不同的方式:
- 使用類(對象)。對於外部 API(序列化、驗證、文檔),類或 Java record 通常更清晰。
- 使用接口。接口適用於 Spring Data 基於接口的投影(只讀、僅有 getter 的視圖),而不適用於寫入模型。
將實體對象轉換為 DTO 有不同的方式:
- 最優方法是將數據從數據庫直接投影到所需的 DTO 中。這既避免了額外的映射工作,又確保選擇了更少的列。
- 您也可以使用像 ModelMapper 這樣的庫。優先考慮 MapStruct(編譯時代碼生成,運行時更快,映射明確)。
- 您也可以編寫自己的對象轉換器。手寫映射器提供了完全的控制,但增加了維護需求。
開發 DTO 的良好實踐:
- 優先為每個用例設計特定用途的 DTO(例如,Summary/Detail/ListItem;CreateRequest 與 Response)。
- 避免使用一個與實體綁定的巨型 DTO,這會導致過度獲取和緊耦合。
8 Spring Data JPA 總結性最佳實踐
- 使用 JPA 註解開發實體
-
實體將字段映射到列;關係、可嵌入對象和
@Transient字段很常見(不總是 1:1)。- 最低要求:
@Entity+ 主鍵(@Id/@EmbeddedId)+ 無參構造函數(public/protected)。 - 僅在使用
@Table覆蓋默認值(表、模式、約束)時使用。 - 優先使用顯式的
@Entity(name="…")以將 JPQL 與類名解耦,使得 JPQL 在類重命名時保持穩定。
- 最低要求:
- 避免在 JPQL 中使用字符串拼接,使用參數。
- JPQL 實體名稱(
@Entity(name))和物理表名稱(@Table(name))是獨立的。 - 避免魔法數字/字面量
- 根據值域範圍和可空性選擇類型;如果列可為 NULL,使用包裝類型(
Integer,Boolean)。 - 貨幣/精度計算 -> 使用具有適當精度/小數位數的
BigDecimal。 - 用枚舉替換數字代碼。使用
@Enumerated(EnumType.STRING)持久化,並確保列長度適合名稱。 - 遺留的數字代碼列:使用
AttributeConverter<Enum, Integer>。不要使用EnumType.ORDINAL。 - 類型的一致性使用
- 對相同的概念性列在所有地方使用相同的 Java 類型。
- 二元標誌 ->
Boolean(包裝類型)。領域標籤化或未來可擴展的標誌 -> 一致地使用枚舉。 - 一致地映射枚舉(
@Enumerated(EnumType.STRING),@Column(length=…));避免對同一列混合使用String/Boolean/枚舉。 - Lombok 的使用
- 使用 Lombok 處理樣板代碼:
@Getter,@Setter,@NoArgsConstructor(access = PROTECTED)用於 JPA。 - 避免在實體上使用
@Data(生成的equals/hashCode/toString可能與延遲關係和標識符衝突)。 - 僅當需要前/後處理時才重寫訪問器。
- 重寫 equals 和 hashCode
- 僅當您需要跨上下文的值語義或在哈希集合中使用時才重寫。
- 業務鍵策略:比較唯一、不可變的鍵。
- 基於 ID 的策略:將臨時(
id == null)實體視為不相等;使用穩定/恆定的hashCode()以避免持久化後重新計算哈希。 - 避免全字段相等性;排除關聯以防止延遲加載/遞歸。
- 使用 Lombok 時,優先使用
@EqualsAndHashCode(onlyExplicitlyIncluded = true)並顯式包含穩定的標識符;對關係使用@EqualsAndHashCode.Exclude/@ToString.Exclude。 - 在層次結構(映射的超類與子類)中保持相等性規則的一致性。
- 開發 DTO
- 不要向客户端暴露實體,即使您使用
@JsonIgnore註解返回它們;設計特定用途的 DTO(Summary/Detail/ListItem;Create/Update/Response)。 - 通過選擇更少的列來減少數據庫負載:直接投影到 DTO(使用構造函數表達式),利用基於接口的投影,或使用僅返回必要字段的本機查詢。
- 映射完整實體不會減少選擇的列數。
- 優先使用 MapStruct(編譯時、快速、明確)而不是 ModelMapper;手寫映射器以更高的維護成本提供控制。
最後
希望您覺得這篇文章有幫助。如果您對 Spring Data JPA 感興趣,請閲讀下一篇文章:"Spring Data JPA 最佳實踐:存儲庫設計指南"
【注】本文譯自:Spring Data JPA Best Practices: Entity Design Guide