iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 28

Day 28:DateTime 的時區轉換

  • 分享至 

  • xImage
  •  

Day 28:DateTime 的時區轉換

昨天結尾說今天談那個唯一會被動到的型別。

一個時間值寫進資料庫,欄位裡不會記下它是以哪個時區為準。同一段程式在台北的機器上跑,跟在時區設成 UTC 的主機上跑,寫進去的值差 8 小時,而資料表看不出任何差別。這件事要等第一個跨時區的使用者出現才會被發現,那時歷史資料已經整批寫進去了。

框架的作法是資料庫只用一個基準,換算也只在一個地方做。

本篇說明:

  1. 資料庫存的是 UTC,而且存在不帶時區的欄位裡
  2. 換算集中在用戶端的 Connector,存檔的時間點由伺服端決定
  3. 哪些值會被動到,判斷依據是什麼
  4. 漏宣告日曆日欄位,以及沒有 Connector 的用戶端

一、存進去的那個值,以誰為準

資料庫存本地時間,在單一時區的部署下看不出問題,但「本地」可能是應用主機的時區,也可能是資料庫主機的時區,兩者平常一致,不一致的時候沒有任何跡象。

框架把每一個時間點都以 UTC 存進不帶時區的欄位,也就是昨天那張五家對照表裡 DateTime 那一欄的型別。PostgreSQL 的 timestamptz 會依伺服器的時區設定隱式換算,框架不用它,時區換算不交給資料庫。

SQL 語句沒有指定某一欄的值時,寫進去的是欄位的預設值,例如呼叫端自寫的 INSERT 省略了該欄,或新增欄位時回填既有列。所以各家的預設值也一律用回傳 UTC 的寫法:

Provider 時間點欄位的 DEFAULT
SQL Server getutcdate()
PostgreSQL (NOW() AT TIME ZONE 'UTC')
MySQL (UTC_TIMESTAMP(6))
Oracle SYS_EXTRACT_UTC(SYSTIMESTAMP)
SQLite CURRENT_TIMESTAMP(本來就是 UTC)

MySQL 那一列的外層括號是 8.0.13 起的語法要求:除了 CURRENT_TIMESTAMP,其他運算式當預設值都要用括號包起來。


二、換算只發生在一個地方

換算如果散在各處,桌面端做一次、瀏覽器端做一次、報表匯出再做一次,其中一處多做或少做,症狀是畫面上的時間不對,不會有例外。

框架把換算集中在用戶端的 Connector,伺服端不做換算,直接讀寫 UTC。Connector 兩個方向換算的東西不一樣:

方向 換算什麼
收到回應 時間點由 UTC 換成使用者時區
送出請求 只換查詢條件,由使用者時區換成 UTC;存檔的 DataSet 不換

存檔送上去的時間點,伺服端不採用:新增的列填入存檔當下的 UTC,有預設值運算式的欄位由伺服端重算;修改與刪除的列從資料庫讀回原值。所以讀進來再存回去,時間值不會變。時區設錯時,存進資料庫的值不受影響,錯的是畫面上的顯示與查詢條件。

另一種作法是由伺服端依 session 記的時區,把送上來的時間點轉回 UTC 再存,框架沒有採用。這樣時區會有兩個來源,顯示時看用戶端,寫回時看伺服端;兩者不一致時,使用者看到 09:00、沒有動它,存進去的卻是別的時間點。表單上要直接編輯 DateTime 欄位,得由 BO 在伺服端自己換成 UTC。

換算用的時區是 session 上的時區,登入時從使用者那一列讀出來,沒填才退回部署層的預設值,不取裝置的時區:使用者帶著筆電移動,同一筆資料顯示的時間與查詢條件的基準,不該跟著裝置改變。


三、哪些值會被動到

Connector 拿到的只有一包 payload,沒有 FormSchema 可查,所以判斷依據是昨天那個欄位上的標記,它跟著資料一起送過來。

判斷的是這個值是不是時間點。時刻在 DataSet 裡是字串,不在判斷範圍內。

載體 語意由什麼表達 會不會被換算
回應裡 DataSet 的儲存格 欄位上的標記 DateTime 會,Date 不會
請求裡查詢條件的值 值自己的 CLR 型別 DateTime 會,DateOnly 不會
每個請求都帶的參數袋 沒有 不會

查詢條件沒有欄位可以掛標記,所以語意由值自己的型別決定(這兩句建的是 FilterCondition):

FilterCondition.Equal("invoice_date", someDateOnly);   // 日曆日,不動
FilterCondition.Equal("created_at", someDateTime);     // 時間點,送出時換成 UTC

該用日曆日卻傳了 DateTime,不會報錯,只是條件值跟著位移,查到的列就不對。

參數袋裡是無型別的值,分辨不出時間點與日曆日,一律換算會把呼叫端刻意放進去的 UTC 值改壞,所以不換。

要換算哪些訊息型別是逐一列出來的,新增一個帶資料的訊息型別不會自動納入。所以另有一支測試掃出所有帶 DataSetDataTable 或查詢條件的訊息型別,每一個實際跑一次,確認回應有換成使用者時區、查詢條件有換成 UTC,漏接的型別會讓這支測試失敗。

今天是誰的今天

新增一列時,日期欄預設填今天,這個「今天」也有時區的問題。如果取伺服器的今天,而伺服器時區設成 UTC,台北的使用者在早上八點以前開單,預設日期會是前一天。

框架依使用者時區算今天:新增一列時日期欄的預設值,以及運算式裡的今天,用的是同一段程式,時區由呼叫端把 session 上的時區傳進去。這段程式在伺服端與用戶端都會跑,兩側必須算出同一個今天,否則同一張單在兩邊會是不同日期。日曆日不做換算,沒有機制會把這個差異補回來。


四、漏掉的時候會怎麼壞

自寫 SQL 漏了宣告日曆日欄位時,那一欄會被當成時間點,收到回應時照樣換算。實測資料庫裡是 2026-01-01 的一欄:

誰讀 換算後的值
台北的使用者 2026-01-01 08:00
紐約的使用者 2025-12-31 19:00

台北在 UTC 以東,換算是加 8 小時,日期沒變,只多出時分;紐約在 UTC 以西,換算是減,日期退成前一天。所以同一個錯,在只有台灣使用者的部署裡看起來只是多了時分,要等 UTC 以西的使用者出現,才會變成錯的日期。

沒有 Connector 的用戶端得自己換:收到的時間點換成使用者時區,查詢條件換成 UTC。欄位型別跟著 payload 一起送達,日曆日那一欄的 typeDate,分得出哪一欄不必換。


回到 Northwind

案例是單一時區部署,示範帳號那一列的時區填的是 Asia/Taipei

應用自己的兩個時間欄位都是日曆日,所以 Connector 在應用的表單上挑不出要換算的欄位。被換算動到、畫面上又看得到的,只有稽核規則那張表單的建立時間:清單不列這一欄,單筆畫面上有,而且是唯讀,只顯示日期。從畫面新增一筆規則時,這一欄由伺服端在存檔時填入。


小結

資料庫裡的時間點一律存 UTC,欄位的預設值也用回傳 UTC 的寫法。換算只在用戶端的 Connector 做:收到回應時,依欄位上的標記把時間點換成 session 上的時區;送出時只把查詢條件換成 UTC。存檔送上去的時間點伺服端不採用,新增的列填存檔當下的 UTC,修改的列用資料庫原值。

自寫 SQL 漏了宣告日曆日欄位時,台北的使用者只看到多出來的時分,UTC 以西的使用者才會看到前一天。所以日曆日欄位要由寫 SQL 的人宣告。換算有沒有涵蓋每一種訊息型別,則由測試確認。

這一章的三篇問的是同一件事:一個值代表什麼,看值本身看不出來。同一個十進位數字可以是單價也可以是金額,同一個時間值可以是日曆日也可以是時間點,存成 UTC 的時間點也看不出該換成哪個時區顯示。三篇的作法也相同:把語意宣告在值的旁邊,每一條會碰到這個值的路徑都照那個宣告處理。代價也相同:宣告漏了不會有人報錯,因為漏掉之後剩下的值仍然是合法的。

明天回到案例,把這些機制逐項歸位:哪些落在定義裡,哪些落在框架裡,哪些得由應用自己寫。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 27:Date、DateTime 與 Time 的語意分界
下一篇
Day 29:定義、框架與應用程式碼各負責哪一段
系列文
ERP 架構師筆記:定義驅動的框架設計30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言