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 上找到。