Hibernate @TimeZoneStorage 註解指南
1. 概述
當使用 Hibernate 建立持久層並使用時間戳欄位時,我們通常還需要處理時區詳細資訊。從 Java 8 開始,用時區表示時間戳的最常見方法是使用OffsetDateTime和ZonedDateTime類別。然而,將它們儲存在我們的資料庫中是一個挑戰,因為根據 JPA 規範它們不是有效的屬性類型。
Hibernate 6 引入了@TimeZoneStorage註解來解決上述挑戰。此註釋提供了靈活的選項,用於配置時區資訊在我們的資料庫中儲存和檢索的方式。
在本教程中,我們將探討 Hibernate 的@TimeZoneStorage註解及其各種儲存策略。我們將透過實際範例並研究每種策略的行為,使我們能夠選擇最適合我們特定需求的策略。
2. 應用程式設定
在探索 Hibernate 中的@TimeZoneStorage註解之前,讓我們先設定一個簡單的應用程序,我們將在本教程中使用該應用程式。
2.1.依賴關係
讓我們先將Hibernate 依賴項新增到專案的pom.xml檔案中:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.6.0.Final</version>
</dependency>
這種依賴關係為我們提供了核心 Hibernate ORM 功能,包括我們在本教程中討論的@TimeZoneStorage註解。
2.2.定義實體類別和儲存庫層
現在,讓我們定義我們的實體類別:
@Entity
@Table(name = "astronomical_observations")
class AstronomicalObservation {
@Id
private UUID id;
private String celestialObjectName;
private ZonedDateTime observationStartTime;
private OffsetDateTime peakVisibilityTime;
private ZonedDateTime nextExpectedAppearance;
private OffsetDateTime lastRecordedSighting;
// standard setters and getters
}
在我們的演示中,我們將戴上天文學極客的帽子。 AstronomicalObservation類別是我們範例中的中心實體,我們將使用它來了解@TimeZoneStorage註釋在接下來的部分中如何運作。
定義好實體類別後,我們來建立其對應的儲存庫介面:
@Repository
interface AstronomicalObservationRepository extends JpaRepository<AstronomicalObservation, UUID> {
}
我們的AstronomicalObservationRepository介面擴展了JpaRepository並允許我們與資料庫進行互動。
2.3.啟用 SQL 日誌記錄
為了更好地理解@TimeZoneStorage在底層的工作原理,讓我們透過將相應的配置新增至application.yml檔案來在應用程式中啟用 SQL 日誌記錄:
logging:
level:
org:
hibernate:
SQL: DEBUG
orm:
results: DEBUG
jdbc:
bind: TRACE
type:
descriptor:
sql:
BasicBinder: TRACE
透過此設置,我們將能夠看到 Hibernate 為我們的AstronomicalObservation實體產生的確切 SQL。
需要注意的是,上述配置僅用於我們的實際演示,並不適合生產使用。
3. @TimeZoneStorage策略
現在我們已經設定了應用程序,讓我們來看看使用@TimeZoneStorage註釋時可用的不同儲存策略。
3.1. NATIVE
在看NATIVE策略之前,我們先來談談**TIMESTAMP WITH TIME ZONE資料型態。它是一種 SQL 標準資料類型,能夠儲存時間戳記和時區資訊。然而,並非所有資料庫供應商都支援它**。 PostgreSQL 和 Oracle 是支援它的熱門資料庫。
讓我們用@TimeZoneStorage註解我們的observationStartTime欄位並使用NATIVE策略:
@TimeZoneStorage(TimeZoneStorageType.NATIVE)
private ZonedDateTime observationStartTime;
當我們使用NATIVE策略時,Hibernate 將我們的ZonedDateTime或OffsetDateTime值直接儲存在TIMESTAMP WITH TIME ZONE類型的欄位中。讓我們看看實際效果:
AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setObservationStartTime(ZonedDateTime.now());
astronomicalObservationRepository.save(observation);
讓我們來看看執行上述程式碼保存新的AstronomicalObservation物件時產生的日誌:
`org.hibernate.SQL : insert into astronomical_observations (id, celestial_object_name, observation_start_time) values (?, ?, ?)
org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [ffc2f72d-bcfe-38bc-80af-288d9fcb9bb0]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP_WITH_TIMEZONE) <- [2024-09-18T17:52:46.759673+05:30[Asia/Kolkata]]`
從日誌中可以明顯看出,我們的ZonedDateTime值直接對應到TIMESTAMP_WITH_TIMEZONE列,保留時區資訊。
如果我們的資料庫支援這種資料類型,那麼建議使用NATIVE策略來儲存帶有 timezone 的時間戳記。
3.2. COLUMN
COLUMN策略將時間戳記和時區偏移儲存在單獨的表格列中。時區偏移量儲存在類型為INTEGER的欄位中。
讓我們在AstronomicalObservation實體類別的peakVisibilityTime屬性上使用此策略:
@TimeZoneStorage(TimeZoneStorageType.COLUMN)
@TimeZoneColumn(name = "peak_visibility_time_offset")
private OffsetDateTime peakVisibilityTime;
@Column(name = "peak_visibility_time_offset", insertable = false, updatable = false)
private Integer peakVisibilityTimeOffset;
我們也宣告一個新的peakVisibilityTimeOffset屬性並使用@TimeZoneColumn註解告訴Hibernate使用它來儲存時區偏移。然後,我們將insertable和updatable屬性設為false ,這對於防止映射衝突是必要的,因為 Hibernate 透過@TimeZoneColumn註解來管理它。
如果我們不使用@TimeZoneColumn註釋,Hibernate 會假設時區偏移列名稱以_tz為後綴。在我們的範例中,它將是peak_visibility_time_tz 。
現在,讓我們看看當我們使用COLUMN策略來保存AstronomicalObservation實體時會發生什麼:
AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setPeakVisibilityTime(OffsetDateTime.now());
astronomicalObservationRepository.save(observation);
讓我們來分析一下執行上述指令時所產生的日誌:
`org.hibernate.SQL : insert into astronomical_observations (id, celestial_object_name, peak_visibility_time, peak_visibility_time_offset) values (?, ?, ?, ?)
org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [82d0a618-dd11-4354-8c99-ef2d2603cacf]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP_UTC) <- [2024-09-18T12:37:43.441296Z]
org.hibernate.orm.jdbc.bind : binding parameter (4:INTEGER) <- [+05:30]`
我們可以看到 Hibernate 將沒有時區的時間戳記儲存在peak_visibility_time欄位中,並將時區偏移儲存在peak_visibility_time_offset欄位中。
如果我們的資料庫不支援TIMESTAMP WITH TIME ZONE資料類型,建議使用COLUMN策略。此外,我們需要確保用於儲存時區偏移量的列存在於我們的表模式中。
3.3. NORMALIZE
接下來,我們將看一下NORMALIZE策略。當我們使用此策略時,Hibernate 將時間戳記標準化為我們應用程式的本地時區,並儲存不帶時區資訊的時間戳記值。當我們從資料庫中取得記錄時,Hibernate 將我們的本地時區新增到時間戳記值中。
讓我們仔細看看這種行為。首先,讓我們用@TimeZoneStorage註解nextExpectedAppearance屬性並指定NORMALIZE策略:
@TimeZoneStorage(TimeZoneStorageType.NORMALIZE)
private ZonedDateTime nextExpectedAppearance;
現在,讓我們保存一個AstronomicalObservation實體並分析 SQL 日誌以了解發生了什麼:
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")); // UTC+05:30
AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setNextExpectedAppearance(ZonedDateTime.of(1999, 12, 25, 18, 0, 0, 0, ZoneId.of("UTC+8")));
astronomicalObservationRepository.save(observation);
我們首先將應用程式的預設時區設定為Asia/Kolkata (UTC+05:30)。然後,我們建立一個新的AstronomicalObservation實體,並將其nextExpectedAppearance設定為時區為UTC+8的ZonedDateTime 。最後,我們將實體保存在資料庫中。
在執行上面的程式碼並分析日誌之前,我們需要為 Hibernate 的ResourceRegistryStandardImpl類別添加一些額外的日誌記錄到我們的application.yaml檔案中:
logging:
level:
org:
hibernate:
resource:
jdbc:
internal:
ResourceRegistryStandardImpl: TRACE
新增上述配置後,我們將執行程式碼並看到以下日誌:
`org.hibernate.SQL : insert into astronomical_observations (id, celestial_object_name, next_expected_appearance) values (?, ?, ?)
org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [938bafb9-20a7-42f0-b865-dfaca7c088f5]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP) <- [1999-12-25T18:00+08:00[UTC+08:00]]
ohrjiResourceRegistryStandardImpl : Releasing statement [HikariProxyPreparedStatement@971578330 wrapping prep1: insert into astronomical_observations (id, celestial_object_name, next_expected_appearance) values (?, ?, ?) {1: UUID '938bafb9-20a7-42f0-b865-dfaca7c088f5', 2: 'test-planet', 3: TIMESTAMP '1999-12-25 15:30:00'}]`
我們可以看到我們的時間戳1999-12-25T18:00+08:00已標準化為我們應用程式的本地時區Asia/Kolkata並儲存為1999-12-25 15:30:00 。 Hibernate 透過減去 2.5 小時從時間戳中刪除時區信息,這是原始時區 ( UTC+8 ) 和應用程式本地時區 ( UTC+5:30 ) 之間的差異,導致儲存的時間為15:30 。
現在,讓我們從資料庫中取得已儲存的實體:
astronomicalObservationRepository.findById(observation.getId()).orElseThrow();
當我們執行上面的 fetch 操作時,我們會看到以下日誌:
`org.hibernate.SQL : select ao1_0.id, ao1_0.celestial_object_name, ao1_0.next_expected_appearance from astronomical_observations ao1_0 where ao1_0.id=?
org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [938bafb9-20a7-42f0-b865-dfaca7c088f5]
org.hibernate.orm.results : Extracted JDBC value [1] - [test-planet]
org.hibernate.orm.results : Extracted JDBC value [2] - [1999-12-25T15:30+05:30[Asia/Kolkata]]`
Hibernate 重建ZonedDateTime值並新增我們應用程式的本地時區+05:30 。正如我們所看到的,該值不在我們儲存的UTC+8時區中。
當我們的應用程式在多個時區運行時,我們需要小心使用此策略。例如,當在負載平衡器後面運行應用程式的多個實例時,我們需要確保實例具有相同的預設時區以避免不一致。
3.4. NORMALIZE_UTC
NORMALIZE_UTC策略與我們在上一節探討的NORMALIZE策略類似。唯一的區別是,它始終將時間戳標準化為 UTC ,而不是使用應用程式的本地時區。
讓我們看看這個策略是如何運作的。我們將在AstronomicalObservation類別的lastRecordingSighting屬性上指定它:
@TimeZoneStorage(TimeZoneStorageType.NORMALIZE_UTC)
private OffsetDateTime lastRecordedSighting;
現在,讓我們儲存一個AstronomicalObservation實體,並將其lastRecordedSighting屬性設定為具有UTC+8偏移量的OffsetDateTime :
AstronomicalObservation observation = new AstronomicalObservation();
observation.setId(UUID.randomUUID());
observation.setCelestialObjectName("test-planet");
observation.setLastRecordedSighting(OffsetDateTime.of(1999, 12, 25, 18, 0, 0, 0, ZoneOffset.ofHours(8)));
astronomicalObservationRepository.save(observation);
執行程式碼後,讓我們看看產生的日誌:
`org.hibernate.SQL : insert into astronomical_observations (id,celestial_object_name,last_recorded_sighting) values (?,?,?)
org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [c843a9db-45c7-44c7-a2de-f5f0c8947449]
org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [test-planet]
org.hibernate.orm.jdbc.bind : binding parameter (3:TIMESTAMP_UTC) <- [1999-12-25T18:00+08:00]
ohrjiResourceRegistryStandardImpl : Releasing statement [HikariProxyPreparedStatement@1938138927 wrapping prep1: insert into astronomical_observations (id,celestial_object_name,last_recorded_sighting) values (?,?,?) {1: UUID 'c843a9db-45c7-44c7-a2de-f5f0c8947449', 2: 'test-planet', 3: TIMESTAMP WITH TIME ZONE '1999-12-25 10:00:00+00'}]`
從日誌中,我們可以看到 Hibernate 將我們的OffsetDateTime 1999-12-25T18:00+08:00規範化為 UTC 的1999-12-25 10:00:00+00在將其儲存到資料庫之前減去八小時。
為了確保當我們從資料庫檢索時間戳值時,本地時區偏移量不會添加到時間戳記值中,讓我們看一下獲取先前保存的物件時產生的日誌:
`org.hibernate.SQL : select ao1_0.id,ao1_0.celestial_object_name,ao1_0.last_recorded_sighting from astronomical_observations ao1_0 where ao1_0.id=?
org.hibernate.orm.jdbc.bind : binding parameter (1:UUID) <- [9fd6cc61-ab7e-490b-aeca-954505f52603]
org.hibernate.orm.results : Extracted JDBC value [1] - [test-planet]
org.hibernate.orm.results : Extracted JDBC value [2] - [1999-12-25T10:00Z]`
雖然我們失去了UTC+8的原始時區訊息,但OffsetDateTime仍然代表同一時刻。
3.5. AUTO
AUTO策略讓 Hibernate 根據我們的資料庫選擇合適的策略。
如果我們的資料庫支援TIMESTAMP WITH TIME ZONE資料類型,Hibernate 將使用NATIVE策略。否則,它將使用COLUMN策略。
在大多數情況下,我們會了解正在使用的資料庫,因此明確使用適當的策略而不是依賴AUTO策略通常是一個好主意。
3.6. DEFAULT
DEFAULT策略很像AUTO策略。它讓 Hibernate 根據我們使用的資料庫選擇適當的策略。
如果我們的資料庫支援TIMESTAMP WITH TIME ZONE資料類型,Hibernate 將使用NATIVE策略。否則,它將使用NORMALIZE_UTC策略。
同樣,當我們知道我們正在使用什麼資料庫時,明確地使用適當的策略通常是一個好主意。
4. 結論
在本文中,我們探索了使用 Hibernate 的@TimeZoneStorage註解在資料庫中保留帶有時區詳細資訊的時間戳記。
我們研究了在OffsetDateTime和ZonedDateTime欄位上使用@TimeZoneStorage註解時可用的各種儲存策略。我們透過分析每個策略產生的 SQL 日誌語句來了解每個策略的行為。
與往常一樣,本文中使用的所有程式碼範例都可以在 GitHub 上找到。