owned this note
owned this note
Published
Linked with GitHub
# Entity Framework 中 DateTime 時區問題與解決方案
[](https://hackmd.io/JcSAuAenQJKzLKN3J45MkQ)
雖然許多專案僅在台灣環境中運行,不需要考慮時區問題,但隨著雲端環境的普及,而很多雲端時區都是定在國際標準時間(UTC +0 時區),所以也開始需要注意這個問題。
我一直知道 `DateTime` 的 UTC 格式 可能存在陷阱,因此在處理時區問題時,我通常會盡量使用 `DateTimeOffset`。由於這幾天遇到了一個相關的情境,所以就稍微查資料,並記錄一下。
有同事向我反映,他的專案已經和前端約定使用 UTC 的時間,但在將從資料庫取得的 `DateTime` 資料傳給前端時,發現時間少了 8 個小時。為了解決這個問題,他使用 `ToString()` 方法將時間格式化為 `yyyy-MM-ddTHH:mm:ssZ`。
我當時疑惑地問他,為什麼要在時間字串的末尾加上 `Z`。他回應說這樣時間才不會少 8 小時。我去查一下,根據 Wiki 上的「[ISO 8601](https://zh.wikipedia.org/zh-tw/ISO_8601) 」說明,`Z` 表示 UTC +0 時區。
本來想要幫他優化這部分處理,認為應該要在 `JsonSerializerOptions.Converters`,裡變更 `DateTime` 型別的處理。但後來想想,使用 `DateTime` 做 UTC +0 的專案肯定不少,像知名框架 [ABP.IO](https://abp.io/),就是使用 `DateTime` 型別,ASP.NET Core 應該不至於在處理格式時,沒注意到這點。上網查一下,`DateTime` 如果是 UTC 格式 的話,會有 `Z` 結尾沒錯,就做以下測試:
```csharp
DateTime localTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Local);
DateTime utcTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Utc);
DateTime unspecifiedTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Unspecified);
Console.WriteLine("Local:" + localTime.ToString("O"));
Console.WriteLine("UTC:" + utcTime.ToString("O"));
Console.WriteLine("Unspecified:" + unspecifiedTime.ToString("O"));
```
產生結果如下:
```
Local:2024-08-14T08:00:00.0000000+08:00
UTC:2024-08-14T08:00:00.0000000Z
Unspecified:2024-08-14T08:00:00.0000000
```
再對比我同事的這句話,感覺破案了。
> 但在將***從資料庫取得***的 `DateTime` 資料傳給前端時,發現時間少了 8 小時。
## DateTime 的時區格式問題
`DateTime` 這個型別有一個 `Kind` 屬性,用於表示時間的來源,共有以下列舉值:
| 值 | 屬性名稱 | 說明 |
| --- | --- | --- |
| 0 | Unspecified | 未指定 |
| 1 | Utc | Coordinated Universal Time (UTC) |
| 2 | Local | 本地時間 |
而不清楚 `Kind` 格式的情況下,使用 `ToLocalTime()` 或 `ToUniversalTime()` 來切換時間,產生來的時間就會不如預期。
以下是測試程式碼:
```csharp
DateTime utcNow = DateTime.UtcNow;
DateTime now = DateTime.Now;
Print("原始時間:");
PrintNow("Local", now);
PrintNow("Utc", utcNow);
Console.WriteLine();
Print("切換 Kind 為 Local");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Local));
Console.WriteLine();
Print("切換 Kind 為 Utc:");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Utc));
Console.WriteLine();
Print("切換 Kind 為 Unspecified:");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Unspecified));
void Print(string str) {
Console.WriteLine(str);
}
void PrintNow(string title, DateTime dateTime) {
Print($"{title}:{dateTime:O}, Kind:{dateTime.Kind}");
}
void PrintTime(DateTime dateTime) {
Print($"Original:{dateTime:O}, Kind:{dateTime.Kind}");
DateTime local = dateTime.ToLocalTime();
Print($"Local:{local:O}, Kind:{local.Kind}");
DateTime utc = dateTime.ToUniversalTime();
Print($"Utc:{utc:O}, Kind:{utc.Kind}");
}
```
產生結果如下:
```
原始時間:
Local:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8421977Z, Kind:Utc
切換 Kind 為 Local
Original:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Local:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8422172Z, Kind:Utc
切換 Kind 為 Utc:
Original:2024-08-15T10:35:48.8422172Z, Kind:Utc
Local:2024-08-15T18:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T10:35:48.8422172Z, Kind:Utc
切換 Kind 為 Unspecified:
Original:2024-08-15T10:35:48.8422172, Kind:Unspecified
Local:2024-08-15T18:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8422172Z, Kind:Utc
```
從結果可以看到:
* 當 `Kind` 為 `Local` 時,呼叫 `ToLocalTime()` 不會改變時間。
* 當 `Kind` 為 `Utc` 時,呼叫 `ToUniversalTime()` 也不會改變時間。
* 當 `Kind` 為 `Unspecified` 時,由於無法確定時間的類型,呼叫 `ToLocalTime()` 時,系統會假設原本是 UTC 時間,並轉換為本地時間,因而增加時區偏移。相反地,呼叫 `ToUniversalTime()` 時,系統會假設原本是本地時間,並減去時區偏移。
也因此,ABP.IO 在使用 `DateTime` 時,有定義 `IClock` 介面,來將的`Kind` 進行修正,來避免預期外問題,以下節錄他的 `Clock` 程式碼,藉由比對設定的 `Kind` 與要標準化的時間的 `Kind`,來決定轉換結果,更具體的說明可參考官方文件「[Timing](https://abp.io/docs/latest/framework/infrastructure/timing)」。
```csharp
public virtual DateTime Normalize(DateTime dateTime)
{
if (Kind == DateTimeKind.Unspecified || Kind == dateTime.Kind)
{
return dateTime;
}
if (Kind == DateTimeKind.Local && dateTime.Kind == DateTimeKind.Utc)
{
return dateTime.ToLocalTime();
}
if (Kind == DateTimeKind.Utc && dateTime.Kind == DateTimeKind.Local)
{
return dateTime.ToUniversalTime();
}
return DateTime.SpecifyKind(dateTime, Kind);
}
```
## Entity Framework 使用 DateTime 的時區問題
如果資料表欄位使用 `datetime`、`datetime2` 等不包含時區的資料庫類型,在儲存資料時,由於這些型別無法儲存時區資訊,因此存進資料庫的時間並不包含時區資訊。但是,當 Entity Framework 將資料取出並對應到 `DateTime` 型別時,由於無法確定時間的 `Kind`,這時的 `Kind` 會是 `Unspecified`。因此,回傳給前端的時間值末尾不會包含 `Z`。
此時,正確的處理方式不是在回傳值時補上 `Z`,而是在從資料庫取出資料時,將 `DateTime` 型別的 `Kind` 轉換為 `Utc`。雖然 `DateTime` 在進行值比較時不會考慮 `Kind`,但在程式內的 `DateTime.Kind` 有多種可能的情況下,呼叫 `ToLocalTime()` 或 `ToUniversalTime()` 時,可能會導致預期外的結果。
### 解決方案
如果有在使用 Code First 的話,就會知道這時候是 `ValueConverter` 出馬的時候了。使用 Fluent API 在 `OnModelCreating()` 中定義 Entity 結構時,可以透過 `HasConversion()` 來處理資料寫入和讀取時的轉換。常見的用途包括 Enum、Enum Object 和時間的時區處理。詳細資訊可參考 Microsoft 的文件「[值轉換](https://learn.microsoft.com/zh-tw/ef/core/modeling/value-conversions?tabs=data-annotations)」,這篇先針對此問題來說明。
可以藉由 `HasConversion()` 來進行以下處理:
* 在資料寫入時,若 `DateTime` 的 `Kind` 不是 `Utc`,則呼叫 `ToUniversalTime()` 進行轉換。
* 在取出資料時,將 `DateTime` 的 `Kind` 設定為 `Utc`。
具體程式碼如下:
```csharp
modelBuilder.Entity<Test>(entity =>
{
entity.Property(x => x.TestDateTime)
.HasConversion(
v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc)
);
});
```
也可以定義一個 `UtcDateTimeValueConverter` 類別來重複使用,具體程式碼如下:
```csharp
public class UtcDateTimeValueConverter : ValueConverter<DateTime, DateTime> {
public UtcDateTimeValueConverter()
: base(v => ToDb(v), v => FromDb(v)) {
}
private static DateTime ToDb(DateTime dateTime) {
return dateTime.Kind == DateTimeKind.Utc ? dateTime : dateTime.ToUniversalTime();
}
private static DateTime FromDb(DateTime dateTime) {
return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
}
}
```
使用 `UtcDateTimeValueConverter` 進行轉換:
```csharp
modelBuilder.Entity<Test>(entity => {
entity.Property(x => x.TestDateTime)
.HasConversion<UtcDateTimeValueConverter>();
});
```
如果不想要每一個屬性都個別設定,可以用使用以下方式統一處理:
```csharp
foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) {
foreach (IMutableProperty property in entityType.GetProperties()) {
if (property.ClrType == typeof(DateTime) || property.ClrType == typeof(DateTime?)) {
property.SetValueConverter(typeof(UtcDateTimeValueConverter));
}
}
}
```
使用 Code First,DbContext 內容可以隨意定義,可以使用以上的作法。但如果是使用反向工程來產生 Entity 和 DbContext 的話,通常 DbContext 應該會包含以下程式碼:
```csharp
public partial class MyDbContext : DbContext {
// 省略中...
protected override void OnModelCreating(ModelBuilder modelBuilder) {
// 省略 Entity 定義
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
```
此時,可以寫一個 Partial 類別來增加自定義設定,需注意 Namespace 必須與反向工程產生的 `MyDbContext` 的 Namespace 一致:
```csharp
public partial class MyDbContext {
partial void OnModelCreatingPartial(ModelBuilder modelBuilder) {
foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) {
foreach (IMutableProperty property in entityType.GetProperties()) {
if (property.ClrType == typeof(DateTime) || property.ClrType == typeof(DateTime?)) {
property.SetValueConverter(typeof(UtcDateTimeValueConverter));
}
}
}
}
}
```
當然不寫 Partial 類別,而是另寫一個 DbContext 去繼承,然後程式使用自定義的 DbContext,我也不反對阿。
而在 .NET 6,又有一個更簡單的設定方式,`ConfigureConventions()`,詳請可參考 Microsoft 的 [文件](https://learn.microsoft.com/zh-tw/dotnet/api/microsoft.entityframeworkcore.dbcontext.configureconventions?view=efcore-6.0):
```csharp
public partial class MyDbContext {
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
ArgumentNullException.ThrowIfNull(configurationBuilder);
configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeValueConverter>();
}
```
由於 `ConfigureConventions()` 會在 `OnModelCreating()` 前執行,所以可用來定義預設值和設定慣例,如果想要覆蓋設定的部分,則適合定義在 `OnModelCreatingPartial()` 裡。
###### tags: `.NET` `Entity Framework` `C#`