ADR-002: JSON 欄位處理策略
ADR 編號: 002 狀態: 已接受 (Accepted) 決策日期: 2025-12-22 決策者: Development Team 取代: 無 被取代: 無
摘要
使用 @Lob + @Convert(converter = JsonConverters.XXXConverter.class) 儲存 JSON 欄位,確保跨資料庫相容性(特別是開發環境的 H2 資料庫)。
⚠️ 重要提醒:不要使用
@JdbcTypeCode(SqlTypes.LONGVARCHAR),它對List<String>等基本型別集合無法正確序列化。
背景 (Context)
問題陳述
許多 Entity 需要儲存結構化的列表數據,例如:
- Customer: 地址列表、喜好標籤、重要日期、聯絡人
- Product: 商品圖片 URL 列表
- Order: 作品照片、簽收照片
- OrderStatusHistory: 狀態變更的元數據
這些數據具有以下特性:
- 從屬性: 不需要單獨查詢或 JOIN
- 列表型: 一對多的關係,但數量有限(< 100 筆)
- 靈活性: 結構可能隨需求變化
我們需要選擇合適的方式儲存這些數據。
限制條件
- 多資料庫支援: 需同時支援 H2(開發)、MySQL、PostgreSQL、Oracle、SQL Server
- H2 限制: H2 資料庫不支援原生 JSON 類型
- 性能要求: 讀取/寫入性能需滿足一般業務需求
- 查詢需求: 不需要在 WHERE 子句中過濾 JSON 內容
假設前提
- JSON 欄位的數據量不大(< 10KB)
- 不需要在資料庫層級查詢 JSON 內容
- 應用層使用 Jackson 進行序列化/反序列化
考量的方案 (Options Considered)
方案 A: @JdbcTypeCode(SqlTypes.JSON)
說明:
使用 JPA 3.1 的 @JdbcTypeCode(SqlTypes.JSON) 註解,利用資料庫的原生 JSON 類型。
@JdbcTypeCode(SqlTypes.JSON)
@Column
private List<Address> addresses;
優點:
- ✅ 利用資料庫原生 JSON 功能(MySQL/PostgreSQL)
- ✅ 支援 JSON 查詢語法(如 PostgreSQL 的 jsonb)
- ✅ 語意清晰(明確表示這是 JSON 欄位)
缺點:
- ❌ H2 不支援 JSON 類型(開發環境無法使用)
- ❌ 跨資料庫移植困難
- ❌ Oracle 部分版本不支援
評分: 3/5
方案 B: @JdbcTypeCode(SqlTypes.LONGVARCHAR) (❌ 不建議)
說明:
使用 SqlTypes.LONGVARCHAR,Hibernate 會根據資料庫類型選擇適當的文字類型(TEXT、CLOB、VARCHAR)。
@JdbcTypeCode(SqlTypes.LONGVARCHAR)
@Column
private List<Address> addresses;
優點:
- ✅ 跨資料庫相容性(H2、MySQL、PostgreSQL、Oracle、SQL Server)
- ✅ Hibernate 自動選擇適當的資料庫類型
缺點:
- ❌ 對
List<String>等基本型別集合無法正確序列化(執行時拋出Unknown unwrap conversion錯誤) - ❌ 序列化行為不明確,依賴 Hibernate 內部實作
- ❌ 無法使用資料庫的 JSON 查詢功能
- ❌ 資料庫層級無 JSON 驗證
評分: 2/5
⚠️ 實測問題:
@JdbcTypeCode(SqlTypes.LONGVARCHAR)用於List<String>時會拋出:Unknown unwrap conversion requested: java.util.List<java.lang.String> to java.lang.String
方案 B2: @Lob + @Convert (✅ 選擇)
說明:
使用 @Lob 指定大文字欄位,配合自訂 AttributeConverter 處理 JSON 序列化。
@Lob
@Convert(converter = JsonConverters.StringListConverter.class)
private List<String> preferences = new ArrayList<>();
@Lob
@Convert(converter = JsonConverters.AddressListConverter.class)
private List<Address> addresses = new ArrayList<>();
優點:
- ✅ 跨資料庫相容性(H2、MySQL、PostgreSQL、Oracle、SQL Server)
- ✅ 明確的序列化控制:由 Converter 處理,行為可預測
- ✅ 支援所有集合類型:
List<String>、List<Object>、Map<String, Object>皆可 - ✅ H2 開發環境正常運作
- ✅ 使用 Jackson 序列化,與 REST API 一致
缺點:
- ❌ 需要為每種類型建立 Converter
- ❌ 無法使用資料庫的 JSON 查詢功能
評分: 5/5
方案 C: @OneToMany 獨立 Entity
說明:
建立獨立的 Entity(如 CustomerAddress),使用 @OneToMany 關聯。
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private List<CustomerAddress> addresses;
優點:
- ✅ 可以獨立查詢、過濾
- ✅ 支援複雜的關聯查詢
- ✅ 符合傳統 ORM 設計
缺點:
- ❌ 需要額外的表和 JOIN
- ❌ 查詢性能較差
- ❌ Schema 變更時需要 migration
- ❌ 對於簡單列表過度設計
評分: 3/5
方案 D: @Embedded + @ElementCollection
說明:
使用 @Embeddable 和 @ElementCollection,JPA 會建立獨立的關聯表。
@ElementCollection
@CollectionTable(name = "customer_address")
private List<Address> addresses;
優點:
- ✅ JPA 標準做法
- ✅ 支援查詢
缺點:
- ❌ 會建立額外的表(類似 @OneToMany)
- ❌ 查詢需要 JOIN
- ❌ 對於簡單列表過度設計
評分: 3/5
決策 (Decision)
選擇方案: B2 - @Lob + @Convert
核心理由:
- 跨資料庫相容性: 支援所有目標資料庫,特別是 H2 開發環境
- 明確的序列化控制: Converter 明確定義序列化邏輯,行為可預測
- 支援所有集合類型: 包括
List<String>、List<Object>、Map<String, Object> - 簡單性: 不需要額外的表或 JOIN,單表查詢
- 靈活性: 結構變更不需要 migration
⚠️ 為什麼不選擇 @JdbcTypeCode(SqlTypes.LONGVARCHAR): 雖然該方案在某些情況下可行,但實測發現對
List<String>等基本型別集合會拋出序列化錯誤。 為確保一致性和可預測性,統一使用@Lob+@Convert方案。
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- ✅ 跨資料庫相容性: H2、MySQL、PostgreSQL、Oracle、SQL Server 全部支援
- ✅ 開發體驗: H2 開發環境正常運作,不需要為開發環境特別處理
- ✅ 性能: 單表查詢,無 JOIN 開銷
- ✅ 靈活性: JSON 結構變更不需要 migration
我們放棄什麼 (Losses)
- ❌ 資料庫 JSON 功能: 無法使用 PostgreSQL 的 jsonb 查詢語法
- ❌ 資料庫驗證: 無法在資料庫層級驗證 JSON 結構
風險與緩解措施 (Risks & Mitigations)
| 風險 | 嚴重性 | 機率 | 緩解措施 |
|---|---|---|---|
| JSON 格式錯誤導致反序列化失敗 | 中 | 低 | 應用層使用 Bean Validation、Jackson 自動處理 |
| 無法在 SQL 中查詢 JSON 內容 | 低 | 低 | 設計上這些欄位不需要在 SQL 中查詢 |
| 大量數據時性能問題 | 中 | 低 | 限制 JSON 數據量(< 100 項)、監控性能 |
影響 (Consequences)
正面影響
- ➕ 開發效率: 開發環境(H2)與生產環境(PostgreSQL/MySQL)行為一致
- ➕ 可移植性: 可輕鬆切換不同資料庫
- ➕ 簡單性: 不需要管理額外的表和關聯
負面影響
- ➖ 查詢限制: 無法在 SQL 中過濾 JSON 內容
- ➖ 驗證位置: 需在應用層驗證 JSON 結構
中性影響
- 🔸 儲存成本: TEXT/CLOB 欄位略大於原生 JSON
- 🔸 資料庫選擇: 未來如需 JSON 查詢,需要重構
實作指南 (Implementation Guidelines)
必須遵守的規則
-
使用
@Lob+@Convert處理 JSON 欄位// ✅ 正確:使用 @Lob + @Convert
@Lob
@Convert(converter = JsonConverters.StringListConverter.class)
private List<String> preferences = new ArrayList<>();
@Lob
@Convert(converter = JsonConverters.AddressListConverter.class)
private List<Address> addresses = new ArrayList<>();// ❌ 錯誤:不要使用 @JdbcTypeCode(對 List<String> 會失敗)
@JdbcTypeCode(SqlTypes.LONGVARCHAR)
@Column
private List<String> preferences = new ArrayList<>(); // 會拋出序列化錯誤! -
使用 JsonConverters 中預定義的 Converter
類型 Converter List<String>JsonConverters.StringListConverterList<Address>JsonConverters.AddressListConverterList<Contact>JsonConverters.ContactListConverterList<ImportantDate>JsonConverters.ImportantDateListConverterMap<String, Object>JsonConverters.MapConverter如需新類型,請在
JsonConverters.java中新增對應的 Converter。 -
JSON POJO 必須是純 POJO(無 JPA 註解)
// ✅ 正確
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Address {
private String type;
private String city;
private String detail;
}
// ❌ 錯誤
@Embeddable // 不要使用
public class Address { ... } -
初始化為空集合(避免 null)
private List<Address> addresses = new ArrayList<>(); -
不要使用
@Embeddable或@Column在 JSON POJO 中@Embeddable是給@Embedded使用的(欄位平攤到父表)- JSON POJO 由 Jackson 序列化,不需要 JPA 註解
建議的最佳實踐
- JSON 欄位的數據量控制在 100 項以內
- 使用 Bean Validation 驗證 JSON 結構(在應用層)
- 提供有意義的預設值(空列表而非 null)
- 記錄 JSON 結構的版本(如需演進)
適用場景
✅ 適合使用 JSON 欄位:
- 列表型數據(地址、標籤、聯絡人)
- 嵌入式對象集合(不需要單獨查詢)
- 靈活的欄位結構(未來可能新增欄位)
- 數量有限(< 100 項)
❌ 不適合使用 JSON 欄位:
- 需要在 WHERE 子句中過濾(如
WHERE address.city = '台北') - 需要 JOIN 查詢
- 大量數據(> 100 項,應使用 @OneToMany)
檢查清單
- 使用
@Lob+@Convert(converter = JsonConverters.XXXConverter.class) - 不要使用
@JdbcTypeCode(SqlTypes.LONGVARCHAR)或@JdbcTypeCode(SqlTypes.JSON) - 使用
JsonConverters中預定義的 Converter,或新增自訂 Converter - JSON POJO 是純 POJO(僅 Lombok 註解)
- 不使用
@Embeddable在 JSON POJO - 初始化為空集合
- 數據量在合理範圍(< 100 項)
- 不需要在 SQL 中查詢 JSON 內容
相關文檔 (References)
內部文檔
- 資料層設計指南 - 第 2 章
應用層原始碼(參考實作 app-server)
以下是參考實作中的相關檔案,供開發者參考:
- Converter 類別:
io.leandev.app.converter.JsonConverters- 所有 JSON Converter 定義 - 實作範例:
io.leandev.app.entity.customer.Customer-List<Address>,List<String>,List<ImportantDate>,List<Contact>io.leandev.app.entity.order.Order-List<String>(productionPhotos, deliveryPhotos)io.leandev.app.entity.order.OrderStatusHistory-Map<String, Object>(metadata)
外部資源
- Hibernate 6.2 - JSON mapping - 說明為何
@JdbcTypeCode不適用於所有類型 - Jackson Documentation
相關 ADR
變更歷史 (Change Log)
| 日期 | 變更內容 | 變更者 |
|---|---|---|
| 2025-12-22 | 初版 | Development Team |
| 2025-12-29 | 修正建議方案:從 @JdbcTypeCode(SqlTypes.LONGVARCHAR) 改為 @Lob + @Convert,因為前者對 List<String> 無法正確序列化 | AI Assistant |
文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-29