跳至主要内容

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: 狀態變更的元數據

這些數據具有以下特性:

  1. 從屬性: 不需要單獨查詢或 JOIN
  2. 列表型: 一對多的關係,但數量有限(< 100 筆)
  3. 靈活性: 結構可能隨需求變化

我們需要選擇合適的方式儲存這些數據。

限制條件

  • 多資料庫支援: 需同時支援 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

核心理由:

  1. 跨資料庫相容性: 支援所有目標資料庫,特別是 H2 開發環境
  2. 明確的序列化控制: Converter 明確定義序列化邏輯,行為可預測
  3. 支援所有集合類型: 包括 List<String>List<Object>Map<String, Object>
  4. 簡單性: 不需要額外的表或 JOIN,單表查詢
  5. 靈活性: 結構變更不需要 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)

必須遵守的規則

  1. 使用 @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<>(); // 會拋出序列化錯誤!
  2. 使用 JsonConverters 中預定義的 Converter

    類型Converter
    List<String>JsonConverters.StringListConverter
    List<Address>JsonConverters.AddressListConverter
    List<Contact>JsonConverters.ContactListConverter
    List<ImportantDate>JsonConverters.ImportantDateListConverter
    Map<String, Object>JsonConverters.MapConverter

    如需新類型,請在 JsonConverters.java 中新增對應的 Converter。

  3. JSON POJO 必須是純 POJO(無 JPA 註解)

    // ✅ 正確
    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    public class Address {
    private String type;
    private String city;
    private String detail;
    }

    // ❌ 錯誤
    @Embeddable // 不要使用
    public class Address { ... }
  4. 初始化為空集合(避免 null)

    private List<Address> addresses = new ArrayList<>();
  5. 不要使用 @Embeddable@Column 在 JSON POJO 中

    • @Embeddable 是給 @Embedded 使用的(欄位平攤到父表)
    • JSON POJO 由 Jackson 序列化,不需要 JPA 註解

建議的最佳實踐

  1. JSON 欄位的數據量控制在 100 項以內
  2. 使用 Bean Validation 驗證 JSON 結構(在應用層)
  3. 提供有意義的預設值(空列表而非 null)
  4. 記錄 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)

內部文檔

應用層原始碼(參考實作 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)

外部資源

相關 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