iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

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

Day 27:Date、DateTime 與 Time 的語意分界

  • 分享至 

  • xImage
  •  

Day 27:Date、DateTime 與 Time 的語意分界

一張單據上的訂單日期、一筆稽核紀錄的事件時間、一份班表上的上班時刻,回答的是不一樣的問題。但進了 DataSet,前兩個都是 DateTime 欄,第三個是字串欄,光看欄位型別分不出來,所以「這一欄是什麼」得由定義層另外交代。

畫面要靠它挑編輯控件,資料庫要靠它挑原生型別,用戶端與伺服器之間傳遞時,也要靠它決定要不要做時區轉換(那是明天的題目)。這幾件事都無法只從「它是 DateTime」判斷。

本篇說明:

  1. 三種時間語意的判別法
  2. 日曆日與時間點在 DataSet 裡同為 DateTime,語意靠什麼保留
  3. 時刻為什麼不用資料庫的原生時刻型別
  4. 未填與越界

一、這個值需不需要知道是哪一天

排班表上的 08:00 跟打卡紀錄上的 08:00 看起來一樣,意思不同:前者每天都適用,後者只代表發生過的那一次。

框架的時間欄位分成三種語意,判別法是問:這個值需不需要知道是哪一天。

語意 回答的問題 FieldDbType 程式裡的型別 時區轉換 例子
日曆日 哪一天 Date DateOnly 不轉換 生日、發票日期、會計期間
時間點 哪一天的幾點 DateTime DateTime 轉換 建立時間、登入時戳、打卡紀錄
時刻 幾點(一日之內) Time TimeOnly 不轉換 班別起訖、營業時間、提醒時刻

宣告方式跟其他欄位一樣,一個欄位一行:

<FormField FieldName="hire_date"  Caption="到職日"   DbType="Date" />
<FormField FieldName="created_at" Caption="建立時間" DbType="DateTime" />
<FormField FieldName="work_start" Caption="上班時刻" DbType="Time" />

DbType 同時決定畫面預設的控件,對應的就是 Day 7 那張欄位對應控件表的第一欄:日曆日與時間點用日期輸入控件,時刻用定寬的時刻輸入控件。欄位若自己指定了控件,就以指定的為準。


二、在 DataSet 裡同為 DateTime 的那兩種

日曆日與時間點進了 DataSet 都是 DateTime 欄位,DateOnly 的值寫不進去。框架的處理方式如下:

日曆日 Date 時間點 DateTime
DataSet 裡的欄位型別 DateTime DateTime
欄位上的標記 Date DateTime
取值時 CDateOnly 取成 DateOnly CDateTime 取成 DateTime

兩者型別相同,要靠欄位上的標記區分:程式判斷時先讀標記,沒有標記才看 CLR 型別,而 DateTime 一律判成時間點。

標記只管判讀,不管寫入:程式直接寫 row["hire_date"] = DateTime.Now,時分秒會一起留在 DataSet 裡,只要日期就自己取 .Date

各種情況下,標記從哪裡來、會不會保留:

情況 標記
依定義建立的欄位 建立時就掛上
從資料庫讀回的表格 框架依 FormSchema 補上
自己寫的 SQL 沒有定義可查,由寫 SQL 的人在查詢上列出日曆日欄位,或事後對表格補標;欄名寫錯會擲例外
經 API 以 JSON 或 MessagePack 送出 另一端會還原

沒有標記的日曆日欄會被當成時間點,而且不會報錯,因為它的值本來就是合法的時間點。會在哪裡出問題,明天再談。


三、時刻為什麼不用資料庫的原生型別

時刻在各處的存放方式:

在哪裡 長什麼樣子
資料庫 定寬 5 碼的字串,例如 "08:30"
DataSet 同一個字串 "08:30"
程式取值 CTimeOnly 轉成 TimeOnly
寫回 DataSet 單筆表單上的時刻控件會把 "8:30" 補成 "08:30";其他方式寫入不一定會補,程式寫入要自己用 CTimeString

TimeOnly 不能直接放進 DataSet:時刻欄是字串,直接設 TimeOnly 不會擲例外,而是存成目前文化的文字(zh-TW 下是「上午8:30」);若把欄位型別改成 TimeOnly,JSON 與 MessagePack 送表格時又不認得它。所以時刻欄的格式定為 "HH:mm" 字串,取值時再轉成 TimeOnly

程式裡用 TimeOnly,因為它表示的就是一天之內的時刻:可以直接比較先後、取出時與分,兩個 TimeOnly 相減還能算出時距,例如下班時刻減上班時刻就是工時,跨過午夜的夜班也算得出來。TimeSpan 表示的是一段長度,可以超過一天,也可以是負數,用它存上班時刻,型別擋不住超過一天的值。

資料庫不用原生的時間型別,是因為五家不一致:SQL Server 與 PostgreSQL 的 TIME 是一天之內的時刻,MySQL 的 TIME 是一段長度,值域到正負 838 小時,Oracle 則沒有 TIME;而前三家的驅動讀回來都是 TimeSpan。所以五家一律用字串:

Date DateTime Time
SQL Server date datetime2(7) nchar(5)
PostgreSQL date timestamp char(5)
MySQL DATE DATETIME(6) CHAR(5)
SQLite DATE DATETIME VARCHAR(5)
Oracle DATE TIMESTAMP(6) VARCHAR2(5)

字串的好處是直接下 SELECT 就看得懂,而且每筆都補零時,字典序就是時間順序,WHERE work_start BETWEEN '08:00' AND '17:00' 直接成立;沒補零的 "8:30" 會排在 "17:00" 後面,這個條件撈不到它。代價是資料庫只會把這一欄報成長度 5 的字串,框架比對結構時得先把定義裡的時刻也當成長度 5 的字串,否則每次升級都會判定有差異。


四、未填與越界

三種語意的取值方法寫法一致,方法名就是回傳型別,單參數多載一律回 nullable(三支都在 ValueUtilities.Temporal):

DateOnly? day     = ValueUtilities.CDateOnly(row["hire_date"]);
DateTime? instant = ValueUtilities.CDateTime(row["created_at"]);
TimeOnly? start   = ValueUtilities.CTimeOnly(row["work_start"]);

回 nullable 是因為時刻找不到能代表「未填」的值:default(TimeOnly)00:00,而 00:00 本身就可能是上班時刻,用它代表未填,沒填的欄位會被當成 00:00,也不會報錯。日期雖然可以拿 0001-01-01 代表未填,但這個值很容易被印到報表上、被當成資料,所以日期的兩支也一樣回 nullable。需要非空的值時,用雙參數多載自己指定替代值。儲存時也一樣,時刻沒填存的是空字串,不是 "00:00"

取值時也會檢查格式與範圍:CTimeOnly("8:30") 可以接受並當成 08:30"25:00" 與 30 小時的 TimeSpan 都回空值。字串欄位與 TimeSpan 都裝得下超過一天的值,所以範圍由取值方法來擋。


回到 Northwind

案例的 FormSchema 裡,應用自己的時間欄位只有兩個,而且是同一種:訂單的 order_date 與員工的 hire_date,兩份定義上都寫著 DbType="Date"

案例的 TableSchema 裡有一批時間點欄位,一個都不在應用自己的表上:session 的寫入與失效時間、API 金鑰的到期時間、定義與快取通知的更新時間、稽核那幾張表各自的事件時間,以及框架其他幾張表的寫入時間。全部是框架自己記錄的資料。

原因是兩種資料的用途不同。訂單日期拿來結帳、分期間、比對月結,幾點下的單在業務上用不到;session 什麼時候失效、哪一秒登入失敗,少了時分秒就沒有意義。用第一節的判別法來看,應用的表要的是日曆日,框架的表要的是時間點。


小結

日曆日與時間點在 DataSet 裡同為 DateTime,靠欄位上的標記區分,沒有標記的欄位會被當成時間點,而且不會報錯。框架建立與讀回的表格都會標上,自寫的 SQL 則要自己宣告日曆日欄位。

時刻在資料庫與 DataSet 裡都是定寬的字串,不會被誤認成時間點,程式取值時再轉成 TimeOnly。代價是資料庫只認得它是長度 5 的字串,框架比對結構時要另外處理。

明天談這三種型別裡唯一會被動到的那一種,也就是時間點的時區轉換。日曆日與時刻不做轉換,轉了只會出錯,例如生日退到前一天、八點上班變成下午四點。前提是先分得出一欄是哪一種。


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


上一篇
Day 26:數值語意、多幣別與計量單位
系列文
ERP 架構師筆記:定義驅動的框架設計27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言