業務應用程式的日期時間與時區 ── 從 DateTime 的陷阱到 UTC 儲存原則、測試設計

· · C#, .NET, .NET Framework, Windows, 時區, 日期時間處理, 測試, 維運, 技術諮詢

「伺服器搬到雲端的 VM 之後,報表上的時間全部差了 9 個小時」「只有配發到海外據點的裝置,日報上的日期會變成前一天」「深夜的彙總批次,好像有一天執行了兩次」。跟日期時間與時區有關的諮詢,往往都是以「環境一改變」這種形式冒出來的。程式碼明明一行都沒改。

「我們是純日本國內用的應用程式,跟時區沒關係」──雖然常常這樣想,但實際上大多數的諮詢案例,恰恰就是發生在這種純國內用的應用程式上。雲端的 VM 或容器經常以 UTC 設定發放,海外的函式庫或 SaaS 的 Web API 也會回傳 UTC 或帶偏移量的時間戳記。就算應用程式自己以為只活在日本時間裡,邊界的另一端早就是 UTC 的世界了。如果在交換值的時候,沒有明確標示「這個時間是以什麼為基準」,就會在伺服器搬遷或上雲的那一天,一次爆發為 9 小時的落差。

本文以 .NET 的業務應用程式為前提,完整整理 DateTime 的 Kind 與隱式轉換陷阱、與 DateTimeOffset 的取捨、「儲存與通訊用 UTC 或帶偏移量,只有顯示才轉本地時間」的原則、TimeZoneInfo 與夏令時間、和資料庫的邊界,一直到用 TimeProvider 做測試設計。也會把設計審查時每次都會確認的項目,整理成檢查清單的形式。

1. 先講結論

  • 日期時間事故的根本原因幾乎都只有一個:「這個時間是以什麼為基準」這項資訊沒有被保留下來的值,跨越了邊界(資料庫、API、檔案)。發病的時間點不是改程式碼的那天,而是環境改變的那天。
  • DateTime 帶有一個叫 Kind(Utc / Local / Unspecified) 的屬性,預設值是 Unspecified。ToLocalTime 會把 Unspecified 當作 UTCToUniversalTime 則會當作本地時間,這種不對稱的隱式解讀,正是「差 9 小時」的典型來源。1
  • 新程式碼的預設選擇是 DateTimeOffset。官方指南也明確寫著「應考慮把它當作應用程式開發的預設日期時間型別」。2
  • 原則是「儲存與通訊用 UTC 或帶偏移量,只有顯示才轉本地時間」。轉成字串時,用符合 ISO 8601 的往返格式 “o” 寫出,再用 DateTimeStyles.RoundtripKind 讀回。3
  • 時區轉換用 TimeZoneInfo 進行。.NET 6 以後,IANA ID(Asia/Tokyo)和 Windows ID(Tokyo Standard Time)兩種都能用,也有互相轉換的 API4 不過 Windows 上解析 IANA ID 依賴 ICU,在較舊的 Windows Server 或不可變全球化組態下會失敗(第 4 章)。5
  • 就算日本沒有夏令時間,只要經過海外據點的裝置、與海外 SaaS 的連動、UTC 設定的伺服器,就會踩到 DST 的「不存在的時間・模糊的時間」。6
  • 不要在程式碼裡直接寫 DateTime.Now,改成注入 TimeProvider(.NET 8 標準;舊環境用 Microsoft.Bcl.TimeProvider),讓時間可以在測試中自由推移。7

2. DateTime 的 Kind ── 三個值的意義與隱式轉換的事故

DateTime 除了日期時間的值(Ticks)之外,還帶有一個叫 Kind 的屬性。這個屬性只有 3 個值,預設是 Unspecified。1

Kind 意義 主要來源
Utc 以 UTC 為基準的時間 DateTime.UtcNowToUniversalTime() 的結果
Local 以執行機器的本地時區為基準 DateTime.NowToLocalTime() 的結果
Unspecified 基準不明(預設) new DateTime(...)DateTime.Parse(多數情況)、從資料庫讀出的值

重點在於,一般寫程式碼幾乎都會滿是 Unspecified。用建構子做出來的值、從字串解析出來的值、從資料庫讀出來的值,預設全部都是「基準不明」。這件事本身不是問題。問題在於,轉換方法會對這個 Unspecified 的值悄悄假設一個基準1

呼叫 Kind=Utc Kind=Local Kind=Unspecified
ToUniversalTime() 原樣返回 轉成 UTC 當作本地時間轉成 UTC
ToLocalTime() 轉成本地時間 原樣返回 當作 UTC轉成本地時間

重點是,同一個 Unspecified,會依呼叫的方法不同,被解讀成「本地」或「UTC」,這種不對稱才是關鍵。寫成程式碼會是這樣:

// 從資料庫讀出的值。很多路徑下 Kind 會是 Unspecified
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);

// 若執行機器是 JST(UTC+9):
Console.WriteLine(fromDb.ToLocalTime());      // 18:00 ── 被當作 UTC,+9 小時
Console.WriteLine(fromDb.ToUniversalTime());  // 00:00 ── 被當作本地時間,-9 小時

事故就是這樣發生的。資料庫裡以日本時間儲存的 09:00(Unspecified),如果在顯示前「為求保險」呼叫 ToLocalTime(),就會被當作 UTC 而變成 18:00。反過來,如果路徑中某處重複插入了「儲存前先轉成 UTC」的轉換,9 小時就會被扣掉兩次。更麻煩的是,這個行為取決於執行機器的時區設定。開發機(JST)差 9 小時,UTC 設定的伺服器則差 0 小時,於是就會出現「我這邊重現不出來」的回報。開頭提到的「伺服器搬遷後差了 9 小時」,大多就是這種結構。

2.1 與 DateTimeOffset 的差異與取捨

DateTimeOffset 除了日期時間之外,還永遠帶著與 UTC 的偏移量(例如 +09:00),所以光看這個值本身,就能唯一確定它是世界上的哪一個瞬間。在記錄日誌、交易時間、系統事件記錄這類「記錄時間點」的用途中,官方指南明確建議把 DateTimeOffset 當作預設的日期時間型別來考慮2 因為根本沒有依賴 Kind 隱式解讀的空間,本文討論的大部分事故在結構上就不會發生。

不過它並非萬能。要注意 DateTimeOffset 擁有的是偏移量,並不是時區+09:00 究竟是日本還是韓國,光看這個值分不出來,也沒有夏令時間的調整規則。2 如果想重現「當地掛鐘顯示的時間」,就必須搭配後面會提到的 TimeZoneInfo。整理成表格如下:

型別 擁有的資訊 適合的用途 補充
DateTimeOffset 日期時間 + UTC 偏移量 記錄發生時間、日誌、API 邊界 新程式碼的預設選擇2
DateTime(採用 Kind=Utc) 僅日期時間 內部計算、與既有資產相容 Kind 的管理全部要自己來
DateOnly / TimeOnly 僅日期/僅時間 業務日期、營業時間、截止時間 .NET Framework 無法使用2
TimeSpan 時間區間 經過時間、兩個時間點的差  
TimeZoneInfo 時區的定義(含調整規則) 轉換、判斷夏令時間 第 4 章

把既有的 DateTime 資產全部改寫成 DateTimeOffset,往往不太現實,所以我們在改造案件中常採用的折衷方案是「內部與儲存統一用 Kind=Utc 的 DateTime,邊界(API、序列化)才用 DateTimeOffset 或 “o” 格式字串」。無論選哪一種,下一章的原則都是基礎。

3. 原則 ── 儲存與通訊用 UTC 或帶偏移量,只有顯示才轉本地時間

日期時間處理的設計原則,可以整理成 3 行:

  1. 發生時間用 DateTime.UtcNowDateTimeOffset.UtcNow 取得,並以 UTC(或帶偏移量)的形式傳遞下去
  2. 跨越邊界(資料庫、API、檔案、登錄檔)時,把格式與基準明確寫進規格裡
  3. 轉成本地時間,只在要顯示在畫面或報表前的那一次

用本地時間儲存的設計,等於讓「值的意義」依賴伺服器的作業系統設定這個外部狀態。只要還在日本設定的地端伺服器上跑,問題就不會顯現,但搬到雲端 VM、在海外地區建置 DR、開發環境與正式環境的設定差異──任何一項都會改變這個意義。用 UTC 儲存的話,值的意義在任何環境下都相同。顯示用的轉換,則依使用者端的設定(或使用者主檔裡的據點時區)進行,所以同一份資料在東京和柏林看起來,都會是各自正確的壁掛時鐘時間。

3.1 跨越邊界的字串表示用 ISO 8601/”o” 格式

用字串跨越邊界時(JSON、CSV、日誌、設定檔),要用符合 ISO 8601 的往返格式 “o”。”o” 會把 DateTime 的 Kind、DateTimeOffset 的偏移量保留在字串裡,只要指定 DateTimeStyles.RoundtripKind 解析,就能還原成原本的值。3

using System.Globalization;

// 寫出:2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);

// 讀回:偏移量會被保留下來還原
var restored = DateTimeOffset.Parse(
    s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);

請務必搭配使用 CultureInfo.InvariantCulture。如果保持文化特性預設值,在使用和曆之類非格里曆文化特性的裝置上,年份的表示方式就會跟著改變。反過來絕對不能做的,是用像 "yyyy/MM/dd HH:mm" 這種不帶基準資訊的格式儲存或通訊。收到這個字串的一方,只能猜測「這是以什麼為基準」,而猜測在環境改變時就會猜錯。順帶一提,System.Text.Json 預設的日期時間表示法也屬於 ISO 8601 系統,所以在 JSON 邊界上,直接沿用預設值是安全的做法。

「跨越邊界的資料要在規格裡明確標示格式」這個想法,不只限於日期時間。文字編碼與換行字元也有完全相同的討論,寫在「Windows 的文字編碼與換行字元」裡。邊界上的隱式假設,種類不同,壞掉的方式卻是一樣的。

3.2 區分時間戳記與「業務日期」

還有一個用來解開開頭提到的「只有海外據點的日報日期會變成前一天」的區別。時間戳記(世界上唯一確定的時間點)和業務日期(像「7 月 3 日的日報」這種標籤)是不同的東西。如果把業務日期以「深夜 0 點的 DateTime」持有,再經過 UTC 轉換,UTC+9 的 7 月 3 日 00:00,就會變成 UTC 的 7 月 2 日 15:00,一旦切出日期部分,就會偏移到前一天。這就是典型的發生機制。

業務日期應該用 DateOnly(在 .NET Framework 上則用 yyyy-MM-dd 格式的字串或年月日的值)來持有,並且在規格裡決定「用哪個時區來切日期」。只要規格上寫清楚「日報的日期以據點的當地時間為基準」「結算以總公司的 JST 為基準」,實作只需要把 UtcNow 轉換到對應的時區,再切出日期即可。如果規格上沒寫,那就等於實作者的機器設定變成了規格本身。

4. 時區轉換 ── TimeZoneInfo 與 ID 體系

時區轉換是用 TimeZoneInfoConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime 來進行的。要注意的是,這些 API 會檢查 DateTime 的 Kind 與轉換來源時區是否一致。舉例來說,如果把 Kind=Utc 的值當作「轉換來源是東京」傳進去,就會得到 ArgumentException8 也就是說,Kind 管理不夠嚴謹的程式碼,連時區轉換 API 都無法正常呼叫。第 2 章的內容,直接連結到這裡。

4.1 Windows 的時區 ID 與 IANA ID

指定時區用的 ID,有兩套體系。

  Windows ID IANA ID
範例(日本) Tokyo Standard Time Asia/Tokyo
範例(德國) W. Europe Standard Time Europe/Berlin
管理單位 Windows(登錄檔) IANA tz database
主要使用場景 Windows API、.NET(Framework) Linux、海外 SaaS、Web API、其他語言

.NET Framework 時代只能用 Windows ID,因此出現過「Web API 傳來 Asia/Tokyo,卻無法傳給 FindSystemTimeZoneById」這種需要轉換表的問題。.NET 6 以後,TimeZoneInfo.FindSystemTimeZoneById 兩種 ID 都能接受,系統上沒有的那一種 ID 會自動轉換後解析。如果想明確轉換,還新增了 TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId4

// .NET 6+:IANA ID 可以直接使用(用 Windows ID "Tokyo Standard Time" 結果相同)
var tokyo  = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo));   // 東京的壁掛時鐘時間
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin));  // 柏林的壁掛時鐘時間

// ID 體系互相轉換(.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
    Console.WriteLine(ianaId);  // Asia/Tokyo

實務上的建議是,據點主檔或設定檔裡保存的時區 ID,統一用 IANA ID。海外 SaaS、Linux 容器、其他語言之間都能共通使用的是 IANA ID,而 .NET 這一端只要是 6 以後,原則上可以直接接收。如果還殘留跑在 .NET Framework 上的部分,就只在那個邊界轉成 Windows ID。另外,FindSystemTimeZoneById 找不到對應 ID 時會丟出 TimeZoneNotFoundException,請在登錄 ID 的畫面或啟動時的驗證中,儘早把這種情況擋下來。

還有一個重要的前提。Windows 上解析 IANA ID 依賴 ICU 函式庫。在 NLS 模式或全球化不可變模式(InvariantGlobalization=true)下運作的應用程式,無法解析 IANA ID,TryConvertIanaIdToWindowsId 也會失敗。5 另外,如果在作業系統沒有內建 ICU 的較舊環境(例如 Windows Server 2019、Windows 10 1809 之前)上執行 .NET 6,除非額外附帶應用程式本地 ICU,否則同樣會踩到這個限制(.NET 7 以後,已改成在這些作業系統上也會使用 ICU9)。也就是說,「開發機(Windows 11)上 Asia/Tokyo 可以正常運作,但只有客戶端的 Server 2019 會丟出 TimeZoneNotFoundException」這種情況確實會發生。若採用 IANA ID 作為主檔標準,請務必做到以下 3 點:(1) 確認實際執行環境的作業系統與 .NET 版本能正常解析 IANA ID;(2) 不要為了縮小容器體積之類的目的,輕易啟用 InvariantGlobalization;(3) 在啟動時的驗證中加入備援機制,用 TryConvertIanaIdToWindowsId 轉成 Windows ID 後重試,作為保險。

5. 夏令時間(DST)── 純日本國內用也會踩到的場景

因為日本目前沒有夏令時間,常常會認為「DST 跟我們沒關係」。但只要符合以下任何一項,就一定會踩到:

  • 海外據點或出差人員的裝置上執行這個應用程式(裝置的本地時區有夏令時間)
  • 海外的 SaaS/Web API 連動,接收以當地時間為基準的時間戳記或排程
  • 海外地區的伺服器・VM 執行彙總或批次作業
  • 有依海外據點當地時間結算的彙總(例如「各據點 0 點結算」)

在有夏令時間的時區裡,切換日會出現兩種異常的時間。不存在的時間(春天,時鐘往前跳的那段時間會被跳過;以德國來說是 3 月底的 02:00~03:00),以及模糊的時間(秋天,時鐘往後調而出現兩次的那段時間)。在 .NET 中,可以用 TimeZoneInfo.IsInvalidTime / IsAmbiguousTime 判斷。6ConvertTimeToUtc 之類的轉換 API,一旦傳入不存在的時間就會丟出 ArgumentException,模糊的時間則會解讀成標準時8

var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");

// 2026-03-29 是德國夏令時間的開始日。當地時間 02:00~03:00 不存在
var t = new DateTime(2026, 3, 29, 2, 30, 0);   // Kind = Unspecified

Console.WriteLine(berlin.IsInvalidTime(t));    // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin) 會得到 ArgumentException

如果系統裡有「接收當地時間字串,轉成 UTC 後儲存」這種輸入路徑,這個例外就會潛伏成一年只會發生一天的 bug。實際可行的做法是,在輸入驗證階段確認 IsInvalidTime,並在規格裡明確寫下「模糊的時間視為標準時處理」。

5.1 定期執行與 DST ── 執行兩次・不執行的問題

定期執行是另一個經典的中彈點。每天在當地時間 02:30 執行的工作,在夏令時間開始日該時間點不存在,在結束日則存在兩次。依排程器實作的不同,行為會分成「被跳過」「執行兩次」「延後 1 小時執行」,因此以「每天只執行一次」為前提寫成的彙總處理,就會發生重複彙總或漏算。對策是以下 3 項的組合:

  • 把排程基準改成 UTC(或沒有夏令時間的時區)。如果批次不要求以當地時間啟動,光是這樣就能消除問題
  • 讓處理具備冪等性。加入「該日的彙總若已存在則跳過」這種已執行標記,就算執行兩次也不會壞掉
  • 彙總的鍵用業務日期持有(第 3.2 節)。不要從啟動時間反推日期

工作排程器上定期執行的設計(防止重複啟動、失敗時的切分排查),寫在「工作排程器的任務不執行、以 0x1 結束」;常駐服務端自行持有計時器的設計,則寫在「Windows 服務的建立與維運」。不管採用哪種方式,DST 與排程之間的關係,都需要同樣寫進規格裡。

6. 與資料庫的邊界 ── SQL Server/SQLite/ORM

在所有邊界中,事故最多的是資料庫。因為資料庫的日期時間型別,大多不保留「基準是什麼」這項資訊,一旦儲存,Kind 或偏移量的資訊就會消失

6.1 SQL Server 的日期時間型別

型別 範圍・精度 基準資訊 新採用時的判斷
datetime 1753 年起,約 1/300 秒精度 避免使用(官方明確標示新工作不建議使用)10
datetime2 0001 年起,最高 100 奈秒精度 ◎ 以 UTC 儲存的欄位首選10
datetimeoffset 相當於 datetime2 + 偏移量 保留偏移量 ○ 需要還原當地時間的欄位10

datetime 是捨入粒度較粗、範圍也較窄的舊型別,官方文件明確寫著「新工作應避免使用,改用 datetime2 / datetimeoffset 等」。10 既有結構裡的 datetime 不需要勉強遷移,但在新表格裡沒有理由選它。

要用 UTC 儲存到 datetime2,還是改用 datetimeoffset,取決於「之後是否需要還原輸入當下的偏移量」。如果稽核或法規要求保留「使用者當地時間是幾點」,就用 datetimeoffset;如果只需要確定時間點,UTC 的 datetime2 就足夠。不過 datetimeoffset 擁有的也只是偏移量,並非時區(調整規則)本身,這點跟 DateTimeOffset 一樣。如果連時區都需要,就另外用一個欄位保存 IANA ID。

6.2 SQLite 沒有日期時間型別

SQLite 本身沒有日期時間的儲存型別,Microsoft.Data.Sqlite 會把 DateTime / DateTimeOffsetTEXT 形式儲存11 TEXT 的 ISO 8601 系格式,只要「格式與時區統一」,字串排序就等於時間排序,但反過來說,一旦 UTC 和本地時間混進同一欄,排序與範圍搜尋就會悄悄壞掉。使用 SQLite 時,只能靠應用程式端的規約,固定「這一欄是 UTC,格式是這樣」。連線與交易等 SQLite 的實務內容,整理在「C# 在業務應用程式中使用 SQLite」。

6.3 EF Core/Dapper 的注意事項 ── 讀出時 Kind 會消失

從不保留基準資訊的型別(datetime2、SQLite 的 TEXT 等)讀出 DateTime,理所當然 Kind 會變成 Unspecified。「儲存時明明統一用 UTC,但讀出來的值某處又呼叫了 ToUniversalTime(),結果變成重複轉換」這種事故,就是這樣產生的。對策是在邊界還原 Kind。用 EF Core 的話,可以透過值轉換器一次性宣告。

// EF Core:在模型端一次性宣告「這一欄是 UTC」
modelBuilder.Entity<Order>()
    .Property(o => o.CreatedAtUtc)
    .HasConversion(
        // 寫入:把非 UTC 的值(例如混入的 DateTime.Now)也在邊界正規化為 UTC。
        // 要注意 Unspecified 會被當作本地時間轉換
        v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
        // 讀出:還原 Kind
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc));

寫入端的正規化終究只是最後一道保險。因為 Unspecified 的轉換取決於執行機器的時區設定,把 DateTime.Now 或 Unspecified 的值送進儲存路徑的程式碼本身,一旦發現就該直接修正。請理解成保險與規約的雙重防護。如果是 Dapper 或原始的 ADO.NET,可以在映射完成後統一加一層轉換,集中呼叫 DateTime.SpecifyKind。同樣有效的是命名規約,在欄位名、屬性名裡直接標明基準CreatedAtUtcupdated_at_utc),光是這樣,審查時「對這個值呼叫 ToUniversalTime 很奇怪」就更容易被察覺,因為名字比文件更常被人看到。

7. 時間同步與測試 ── w32time 與 TimeProvider

7.1 懷疑「機器的時鐘是準的」這個前提

到目前為止的討論,都建立在「機器本身的時鐘是準確的」這個前提上,而負責校準這個時鐘的,是 Windows Time 服務(w32time)。w32time 會透過 NTP 與網路上的時間源同步,在 Active Directory 環境下,則會沿著網域階層同步。這是 Kerberos 驗證等對時間差敏感機制的基礎。12

這對應用程式設計有兩點含意。第一,不要把客戶端 PC 的時鐘當作業務邏輯的依據。同步中斷的裝置,時間輕易就會差個幾分鐘,所以事件順序或結算時間的判斷,應該用伺服器端的時間進行,客戶端時間只當參考資訊。第二,遇到「時間好像不對」的問題回報時,先用 w32tm /query /status 確認裝置的同步狀態,比開始追查應用程式的 bug 更快,1 分鐘就能先做初步的切分排查。

7.2 停止在程式碼裡直接寫 DateTime.Now ── TimeProvider

阻礙日期時間處理測試的最大原因,是散落在程式碼各處、直接寫死的 DateTime.Now。就算想測試「月底結算的判斷」「跨年連號重置」「夏令時間切換日的排程」,只要目前時間無法固定,就得等到那一天才能驗證。

.NET 8 開始內建了標準的時間抽象概念 TimeProvider。從 GetUtcNow() / GetLocalNow() / LocalTimeZone 到計時器的產生,都能用單一抽象概念替換。就算是 .NET Framework 4.6.2 以後與 .NET Standard 2.0,也能透過 NuGet 的 Microsoft.Bcl.TimeProvider 使用同一個型別,所以就算是舊資產也能導入。測試用的實作 FakeTimeProvider,則由 Microsoft.Extensions.TimeProvider.Testing 套件提供。7

public sealed class DailyReportService
{
    private readonly TimeProvider _clock;
    private readonly TimeZoneInfo _siteTimeZone;

    public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
    {
        _clock = clock;
        _siteTimeZone = siteTimeZone;
    }

    // 業務日期是明確以「用據點的時區切日期」實作的(第 3.2 節)
    public string GetReportDateKey()
    {
        var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
        return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
    }
}

正式環境傳入 TimeProvider.System,測試則用 FakeTimeProvider 自由固定或推進時間。

[Fact]
public void 即使跨年業務日期也能正確切換()
{
    // 固定在 JST 的除夕 23:30 開始
    var clock = new FakeTimeProvider(
        new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
    var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
    var svc = new DailyReportService(clock, tokyo);

    Assert.Equal("2026-12-31", svc.GetReportDateKey());

    clock.Advance(TimeSpan.FromHours(1));   // 瞬間重現跨年
    Assert.Equal("2027-01-01", svc.GetReportDateKey());
}

FakeTimeProvider 除了能固定或手動推進時間,還能替換本地時區(SetLocalTimeZone),所以「德國設定的裝置上日期變成前一天」這類 bug,也能在 CI 上的日本機器重現。如果是連 NuGet 都很難加的 .NET Framework 舊案件,就算只用一個只有 DateTimeOffset UtcNow { get; } 的自訂 IClock 介面,效果也是一樣的。重要的不是抽象概念有多豪華,而是把「目前時間」當成可以注入的相依項目來處理

依經驗,測試案例中至少該放進去的時間點有這 5 個:年末年初(跨年)、月底(31 日・30 日・2 月)、閏年的 2 月 29 日、目標時區的夏令時間切換日(春・秋)、深夜 0 點前後(業務日期的邊界)。這些都是「只在那一天」發作的 bug 經典藏身處,只要有 FakeTimeProvider,全部都能在幾毫秒內測完。

8. 檢查清單與判斷表

日期時間處理,就跟 UUID 一樣,是很容易因為「反正它一直都動得好好的」而被放著不管的基礎元件(跟「UUID 不會碰撞嗎」裡寫的結構非常相似)。把新設計與審查時要看的項目固定下來,就能減少因人而異的差異。

新設計時的檢查清單:

  • 發生時間的取得,是否統一用 DateTimeOffset.UtcNow / TimeProvider.GetUtcNow()
  • 儲存・通訊的基準(UTC 或帶偏移量)與格式(”o” / ISO 8601),是否已明確寫進規格書
  • 資料庫的欄位型別與基準是否已決定(SQL Server 的話是 datetime2(UTC) 或 datetimeoffset。欄位名裡直接標明 Utc
  • 是否區分時間戳記與業務日期,並決定了切日期用的時區
  • 時區 ID 的管理體系(建議用 IANA ID)與不正確 ID 時的錯誤處理是否已決定
  • 定期執行的 DST 方針(以 UTC 為基準排程 + 冪等化)是否已決定
  • TimeProvider / IClock 是否可注入,是否有涵蓋時間邊界的測試案例

審查時用 grep 找出來要懷疑的程式碼:

找到後要懷疑的程式碼 可能發生什麼 修正方式
DateTime.Now 伺服器搬遷後會偏移。無法測試 UtcNow + 顯示時才轉換。注入 TimeProvider
ToLocalTime() / ToUniversalTime() Unspecified 的隱式解讀(第 2 章) 在邊界確定 Kind,只在顯示前才轉換
DateTime.Parse(s)(未指定 styles) 依執行環境的文化特性・時區而不同 ParseExact + InvariantCulture + RoundtripKind
ToString("yyyy/MM/dd HH:mm") 儲存・通訊 基準資訊消失 “o” 格式 + InvariantCulture
直接把 new DateTime(...) 用於比較・儲存 混入 Unspecified SpecifyKind 或改用 DateTimeOffset
SQL Server 新增 datetime 欄位 精度・範圍・未來擴充性的問題 datetime2 / datetimeoffset10

這份檢查清單裡的大部分項目,如果是新開發專案,第一天就能決定好。反過來說,上線後才修正,就要變成「推估已儲存資料的基準再進行遷移」這種考古工作(哪個期間的資料是用哪個基準寫進去的),成本會差一個量級。

9. 結語

日期時間與時區的事故,症狀看起來五花八門——「差 9 小時」「日期變成前一天」「批次執行了兩次」——但原因始終一致:沒有基準資訊的值跨越了邊界。對策也始終一致。取得用 UTC,儲存與通訊用 UTC 或帶偏移量 + ISO 8601(”o”),只有顯示才轉本地時間。在邊界上,把格式與基準明確寫進規格,並在資料庫的欄位名裡直接標明基準。時區用 TimeZoneInfo 與 IANA ID 處理,DST 的不存在時間・模糊時間,則靠輸入驗證與冪等的定期執行來接住。最後注入 TimeProvider,讓跨年與夏令時間切換日都能在測試中重現──做到這裡,就能從「環境一變就被叫去救火的人」,變成「環境變之前就先指出問題的人」。

本公司承接伺服器搬遷、上雲所導致的時間偏差原因調查、日期時間處理相關的設計審查,以及海外據點對應(時區・DST 對應)的改造支援。就算是已經有已儲存資料的基準混雜在一起的情況,也能協助盤點與規劃遷移計畫,如果不確定該怎麼判斷,歡迎與我們聯絡。

相關文章

相關諮詢領域

合同會社小村軟體處理業務應用程式的日期時間・時區處理設計審查、伺服器搬遷或上雲所導致的時間偏差・日期偏差原因調查,以及海外據點對應與舊資產的日期時間處理改造。

參考連結

  1. Microsoft Learn, DateTime.Kind Property。說明 Kind 的預設值為 Unspecified,以及 Kind 的值對 ToLocalTime / ToUniversalTime 轉換結果的影響(Unspecified 在 ToLocalTime 中被當作 UTC,在 ToUniversalTime 中被當作本地時間)。  2 3

  2. Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo。內容涵蓋應考慮把 DateTimeOffset 當作應用程式開發的預設日期時間型別、DateTimeOffset 只擁有偏移量而不綁定時區、DateOnly / TimeOnly 在 .NET Framework 上無法使用等說明。  2 3 4 5

  3. Microsoft Learn, Standard date and time format strings。說明往返格式 “o” 符合 ISO 8601,會把 DateTime 的 Kind、DateTimeOffset 的偏移量保留在字串裡,並可用 DateTimeStyles.RoundtripKind 解析回原值。  2

  4. Microsoft Learn, What’s new in .NET 6。說明 .NET 6 中 TimeZoneInfo.FindSystemTimeZoneById 能接受 IANA/Windows 兩種時區 ID 並自動轉換,以及新增了 TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId。  2

  5. Microsoft Learn, .NET globalization and ICU。說明 Windows 上解析 IANA 時區 ID 與互相轉換 API 依賴 ICU,NLS 模式・全球化不可變模式下無法使用,以及沒有 ICU 的作業系統可透過應用程式本地 ICU 應對。  2

  6. Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method。說明夏令時間切換時產生的「不存在的時間」的定義與判斷方式,以及對應的 IsAmbiguousTime(模糊時間的判斷)。  2

  7. Microsoft Learn, What is TimeProvider?。說明 TimeProvider 從 .NET 8 起標準內建,.NET Framework 4.6.2+/.NET Standard 2.0 可透過 Microsoft.Bcl.TimeProvider 套件使用,涵蓋 GetUtcNow/GetLocalNow/LocalTimeZone/計時器產生的抽象概念,以及測試用的 FakeTimeProvider 由 Microsoft.Extensions.TimeProvider.Testing 套件提供。  2

  8. Microsoft Learn, TimeZoneInfo.ConvertTime Method。說明會要求 DateTime.Kind 與轉換來源時區一致,不一致會得到 ArgumentException,模糊的時間會被解讀為標準時,傳入不存在的時間會得到 ArgumentException。  2

  9. Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019。說明 .NET 7 以後,在沒有內建 ICU 的 Windows Server 2019 等環境上也會改用 ICU 函式庫,在此之前則需要手動配置應用程式本地 ICU。 

  10. Microsoft Learn, datetime (Transact-SQL)。說明新工作應避免使用 datetime,改用 time/date/datetime2/datetimeoffset,以及 datetime2/datetimeoffset 精度較高、datetimeoffset 支援時區偏移量。  2 3 4 5

  11. Microsoft Learn, Data types (Microsoft.Data.Sqlite)。說明 SQLite 只有 4 種基本型別,Microsoft.Data.Sqlite 會把 DateTime/DateTimeOffset 以 TEXT 形式儲存。 

  12. Microsoft Learn, Windows Time Service (W32Time)。說明 Windows Time 服務透過 NTP 同步網路上電腦的時間,Active Directory 網域下的同步階層,以及 Kerberos 驗證對時間同步的依賴。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

伺服器搬遷後時間差了 9 個小時,原因是什麼?
根本原因在於,「這個時間是以什麼為基準」這項資訊沒有被保留下來的值,跨越了資料庫、API、檔案等邊界。.NET 的 DateTime 預設 Kind 為 Unspecified,ToLocalTime() 會把它當作 UTC 處理並 +9 小時,ToUniversalTime() 則會把它當作本地時間並 -9 小時,而且是悄悄進行轉換。這個行為又取決於執行機器的時區設定,所以在日本時間的開發機上看不出問題,一搬到 UTC 設定的雲端 VM 那天就會一次爆發出來。
DateTime 和 DateTimeOffset 該用哪一個?
新程式碼的預設選擇是 DateTimeOffset。因為它永遠帶著與 UTC 的偏移量,光看這個值本身就能唯一確定它是世界上的哪一個瞬間,結構上就不會因為 Kind 的隱式解讀而出事。官方指南也明確建議把它當作應用程式開發的預設日期時間型別來考慮。不過偏移量本身並不是時區,如果需要夏令時間的調整規則,還是要搭配 TimeZoneInfo。如果既有的 DateTime 資產很多,一個實際可行的折衷方案是:內部與儲存統一用 Kind=Utc 的 DateTime,只在邊界改用 DateTimeOffset。
只在日本國內使用的應用程式,也需要處理夏令時間(DST)嗎?
只要符合以下任何一項,就需要處理:程式會在海外據點或出差人員的裝置上執行、會和海外的 SaaS 或 Web API 連動、批次作業會在海外地區的伺服器上執行。有夏令時間的時區,在切換日會出現「不存在的時間」與「模糊的時間」,TimeZoneInfo 的轉換 API 一旦傳入不存在的時間就會丟出 ArgumentException。在當地時間 02:30 執行的定期作業,可能在夏令時間開始日被跳過、在結束日執行兩次,因此以 UTC 為基準排程並設計成冪等處理,才是對策。
日期時間處理的測試該怎麼寫?
不要在程式碼裡直接寫 DateTime.Now,改成注入 .NET 8 標準的 TimeProvider,讓目前時間可以被替換。就算是 .NET Framework 4.6.2 以後的版本,也能透過 Microsoft.Bcl.TimeProvider 套件使用同一個型別。測試中可以用 FakeTimeProvider 固定或推進時間,也能替換本地時區,因此跨年或夏令時間切換日的 bug 都能在 CI 上重現。至少該測試的時間點有 5 個:年末年初、月底、閏年的 2 月 29 日、夏令時間切換日、深夜 0 點前後。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽