Skip to main content

一天24小時?談DateTime處理

· 12 min read
Mech Tsai

概述

DateTime的處理,在開發應用程式時很常見,但身為開發者一陣子,總有那麼幾次會栽在DateTime的處理問題上。就日期來說,年要潤與不潤,月是大月小月,這雖然都有一定規則可循,但其中的複雜度在開發應用程式時有時程壓力下,要能夠處理得好也不是太容易的事。

本文會以Web Application為範例,簡述幾個DateTime處理上可能遇到的問題,及處理方式。

問題

一天24小時

再簡單不過不是?但是在現在電腦系統中廣泛使用的格里曆,處理小時與日期轉換時是否可以將一天當作24小時來處理?

舉個例子來說,開發一個部落格系統,在「最新文章」的區塊要在畫面上顯示過去一天新發表的文章。大概會像這樣

  1. 由前端向後端發起請求
  2. 後端收到請求時以server的時間為準取得當下時間Now作為lte(less than or equal)
  3. 拿lte減去24hr作為gte (greater than or equal)

以Javascript前後端,接上MongoDB寫起來大概是這樣

frontend.js
fetch('http://backend.com/latest-posts')
.then(function(response) {
return response.json();
})
.then(function(data) {
$('target-pre').html(data);
});
backend.js
async function (req, res) {
const endTime = new Date()
const starTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000)
const posts = await Post.find({
createdAt: {
$gte: `${startTime.getFullYear()}-${startTime.getMonth()}-${startTime.getDate()} 00:00:00`
$lte: `${endTime.getFullYear()}-${endTime.getMonth()}-${endTime.getDate()} 00:00:00`
}
})
res.json(posts)
}

寫過一陣子Web Application的工程師大多能做得出來。過程中也做了些安全上的保護:

  1. endTime是由Server決定的,所以不會受到前端使用者本地時間的影響,所以這隻程式不需擔心使用者電腦校時有沒有問題
  2. 接著拿endTime減去24hr取得gte,去跟資料庫拿出這個區間的資料回給前端顯示在畫面上。所有往資料庫的輸入都是同一台機器,也就是後端決定的,也沒有被XSS或SQL inject等安全疑慮

但其實這隻程式在很多國家,一年會壞個兩次

在有實行日光節約的國家(e.g. 美國),每年有個兩天並不是24小時。

例如以2021年來說,在美國東部標準時間(EST)為例子

  • Mar 14, 2021 01:59:59 的下一秒鐘是 03:00:00,日光節約結束
  • Nov 7, 2021 02:59:59 的下一秒鐘是 02:00:00,日光節約開始

於是在2021年的EST Timezone,Mar 14只有23小時,而Nov 7卻有25個小時。只要你的程式被執行的當下,往前算24小時會經過這個被切換的小時的話,被從資料庫抓出來的資料就會多或少一個小時的資料。

DMY與YMD

不同國家對於日期格式的使用有不同的使用習慣,1/3是指Mar 1st,還是Jan 3rd真的要處理的對也不容易。

下面這張Wiki借來的圖,這些國家使用的日期格式大概分類如下:

  • 青色:DMY
  • 黃色:YMD
  • 綠色:混用

DD

要能正確格式化這些日期格式,不只是單位換算上,就連顯示格式都可能因地區不同有不同的作法。

所以要怎麼辦?

問題我們明白了,但是要怎麼辦?有沒有一個準則或做法是能讓我們善加利用別人造好的輪子,但是在我們自己寫的應用程式中卻又能正確操作DateTime的處理?

讓我們來看一下下面這兩個日期時間的表示,他們是相等的嗎?

  • 2021-10-13 10:00:00 (UTC+8)
  • 2021-10-13 02:00:00 (UTC+0)

作為字串看起來當然不同,但其實是指相同的時間,他們則應該要是相等的。相信看到這裡應該有些頭緒,其實處理日期時間的問題並不那麼複雜,我們只要將其分開成兩個問題看待:

  • 怎麼存DateTime
    • 存成絕對格式,例如UTC Timezone的timestamp
  • 怎麼顯示DateTime
    • 盡可能推遲轉換時間,在接近使用者的呈現層才作序列化(Serialize)

若是商業邏輯在處理過程中需要對DateTime進行運算,先把它轉換成具有處理這些複雜問題能力的物件再進行;在要傳遞或顯示出來之前,將DateTime物件序列化(Seralize)成可被對方讀懂的格式

能夠處理這些問題的DateTime物件在各個語言中都存在,例如:

序列化與反序列化

而在序列化的格式中,最具權威性的標準就是ISO8601,也因為它是一系列的日期格式標準,幾乎能在所有的語言與平台中被正確的處理。在ISO8601中,帶日期時間與時區的表示方法長這樣

2021-10-13T10:00:00+08:00
2021-10-13T02:00:00+00:00

或如果你是UTC時區,你也可以直接將時區改成Z

2021-10-13T02:00:00Z

而在轉換方面,如果在Javascript內,Date()原生的toISOString()幾乎幫你包辦了這件事。更方便的是,在反序列化時,將字串讀入成為DateTime物件,直接將toISOString()的輸出字串拿回來給new Date()進行反序列化回物件,你其實會拿到一樣的DateTime物件

例如下面這個Demo,同樣的DateTime物件now被序列化成ISOString作為顯示之用;而再度使用Date將他們反序列化回DateTime物件,程式認為他們是相等的:

Live Editor
function DisplayDateTime() {
  const [dt, setDt] = useState(new Date())
  const [isoString, setIsoString] = useState('')

  useEffect(() => {
    const timer = setInterval(() => {
      const now = new Date()

      setDt(now)
      setIsoString(now.toISOString())
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  return (
    <>
      <p>
        ISO String: {isoString}
      </p>
      <p>
        DT as timestamp: {dt.getTime()}
      </p>
      <p>
        Unseralize still equal: {
          (new Date(isoString)).getTime() == dt.getTime()
          ? 'yes' : 'no'
          }
      </p>
    </>
  );
}
Result
Loading...

仔細觀察你會發現,這個序列化出來的ISO8601字串是以Z結尾,也就是說他其實是UTC 0的時區在表示你當下的local time,也就是說它其實是帶時區資訊的,瀏覽器處理時是會讀到作業系統所設定的時區。好好掌握這個特性,要在瀏覽器依照使用者的時區來顯示時間,其實是非常方便的。

另外要注意的是,並不是所有的序列化格式,甚至是ISO8601本身,都能夠正確地保留DateTime物件所包含了資訊。在ISO8601的例子中是因為其實ISO8601對於其格式中能保留的訊息精確度是可以選擇的,例如你可以只拿它來表示某個日期,而不是表示到分秒

所以在將DateTime物件序列化成ISO8601時,也得注意是否將訊息的精確度給捨棄了。

而其他的序列化方式,例如在Javascript的toUTCString(),因為他序列化出來的格式沒辦法儲存到毫秒,所以要是你拿它做上面的例子,你會發現反序列化回來的結果會因為秒以下的資訊被捨棄,導致最終反序列化回來的結果不是相等的。

Live Editor
function DisplayDateTime() {
  const [dt, setDt] = useState(new Date())
  const [utcString, setUtcString] = useState('')

  useEffect(() => {
    const timer = setInterval(() => {
      const now = new Date()

      setDt(now)
      setUtcString(now.toUTCString())
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  return (
    <>
      <p>
        UTC String: {utcString}
      </p>
      <p>
        DT to timestamp: {dt.getTime()} <br/>
        UTC String Unseralize Timestamp: {(new Date(utcString)).getTime()}
      </p>
      <p>
        Unseralize still equal: {
          (new Date(utcString)).getTime() == dt.getTime()
          ? 'yes' : 'no'
          }
      </p>
    </>
  );
}
Result
Loading...

兩種情境

在繼續討論前,有必要提一下SSoT (Single Source of Truth)的概念。資料在不同的process與不同的機器間傳遞,過程中因為格式轉換、傳輸延遲、避免被篡改的安全考量、甚至是錯誤的實作(Bug),都有可能導致同一份資料在不同的時間或地方而有不同的值

舉例來說,前端的14:00,在後端因為時區不同或是主機的時間校正有問題有可能是13:01

SSoT的概念指的是:到底哪一份資料是最具權威性的?在資料有差異的時候可以以它為準?

要能夠正確的處理DateTime,識別哪一份資料是SSoT是最重要的一個步驟

舉例來說,以3-tier的Web Application為例子,三個最主要產生資料落差的SSoT候選人就是那三個tier。

  1. Frontend
  2. Backend
  3. Database

在不同的情境下,SSoT可能會在不同的地方,以下就建立與查詢的情境進行討論

建立的情境

在建立資料的情境,例如說前端按下submit,後端收到請求後往資料庫建立資料,這筆資料的Create Time的SSoT會是以誰為基準?

前端可以先排除,畢竟前端的立場是『送出請求』,加上來自前端的資料可能被篡改並不可信,並沒辦法作為SSoT

後端以接收到請求的時間為準,看起來是個選項

查詢的情境

使用者在前端送出"查詢的條件",被查詢的資料則是以資料庫內的為準

也就是說,查詢的SSoT來自前端;回應的資料SSoT是資料庫

Frontend

new Date()
new Date('2021-01-01')

Creation & Query

Scenarios

  • Created At
  • Received At
  • Query between