
昨天結尾說今天談那個唯一會被動到的型別。
一個時間值寫進資料庫,欄位裡不會記下它是以哪個時區為準。同一段程式在台北的機器上跑,跟在時區設成 UTC 的主機上跑,寫進去的值差 8 小時,而資料表看不出任何差別。這件事要等第一個跨時區的使用者出現才會被發現,那時歷史資料已經整批寫進去了。
框架的作法是資料庫只用一個基準,換算也只在一個地方做。
本篇說明:
資料庫存本地時間,在單一時區的部署下看不出問題,但「本地」可能是應用主機的時區,也可能是資料庫主機的時區,兩者平常一致,不一致的時候沒有任何跡象。
框架把每一個時間點都以 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 值改壞,所以不換。
要換算哪些訊息型別是逐一列出來的,新增一個帶資料的訊息型別不會自動納入。所以另有一支測試掃出所有帶 DataSet、DataTable 或查詢條件的訊息型別,每一個實際跑一次,確認回應有換成使用者時區、查詢條件有換成 UTC,漏接的型別會讓這支測試失敗。
新增一列時,日期欄預設填今天,這個「今天」也有時區的問題。如果取伺服器的今天,而伺服器時區設成 UTC,台北的使用者在早上八點以前開單,預設日期會是前一天。
框架依使用者時區算今天:新增一列時日期欄的預設值,以及運算式裡的今天,用的是同一段程式,時區由呼叫端把 session 上的時區傳進去。這段程式在伺服端與用戶端都會跑,兩側必須算出同一個今天,否則同一張單在兩邊會是不同日期。日曆日不做換算,沒有機制會把這個差異補回來。
自寫 SQL 漏了宣告日曆日欄位時,那一欄會被當成時間點,收到回應時照樣換算。實測資料庫裡是 2026-01-01 的一欄:
| 誰讀 | 換算後的值 |
|---|---|
| 台北的使用者 | 2026-01-01 08:00 |
| 紐約的使用者 | 2025-12-31 19:00 |
台北在 UTC 以東,換算是加 8 小時,日期沒變,只多出時分;紐約在 UTC 以西,換算是減,日期退成前一天。所以同一個錯,在只有台灣使用者的部署裡看起來只是多了時分,要等 UTC 以西的使用者出現,才會變成錯的日期。
沒有 Connector 的用戶端得自己換:收到的時間點換成使用者時區,查詢條件換成 UTC。欄位型別跟著 payload 一起送達,日曆日那一欄的 type 是 Date,分得出哪一欄不必換。
案例是單一時區部署,示範帳號那一列的時區填的是 Asia/Taipei。
應用自己的兩個時間欄位都是日曆日,所以 Connector 在應用的表單上挑不出要換算的欄位。被換算動到、畫面上又看得到的,只有稽核規則那張表單的建立時間:清單不列這一欄,單筆畫面上有,而且是唯讀,只顯示日期。從畫面新增一筆規則時,這一欄由伺服端在存檔時填入。
資料庫裡的時間點一律存 UTC,欄位的預設值也用回傳 UTC 的寫法。換算只在用戶端的 Connector 做:收到回應時,依欄位上的標記把時間點換成 session 上的時區;送出時只把查詢條件換成 UTC。存檔送上去的時間點伺服端不採用,新增的列填存檔當下的 UTC,修改的列用資料庫原值。
自寫 SQL 漏了宣告日曆日欄位時,台北的使用者只看到多出來的時分,UTC 以西的使用者才會看到前一天。所以日曆日欄位要由寫 SQL 的人宣告。換算有沒有涵蓋每一種訊息型別,則由測試確認。
這一章的三篇問的是同一件事:一個值代表什麼,看值本身看不出來。同一個十進位數字可以是單價也可以是金額,同一個時間值可以是日曆日也可以是時間點,存成 UTC 的時間點也看不出該換成哪個時區顯示。三篇的作法也相同:把語意宣告在值的旁邊,每一條會碰到這個值的路徑都照那個宣告處理。代價也相同:宣告漏了不會有人報錯,因為漏掉之後剩下的值仍然是合法的。
明天回到案例,把這些機制逐項歸位:哪些落在定義裡,哪些落在框架裡,哪些得由應用自己寫。
本系列同步發表於 HackMD,完整目錄