程式處理時間的原則
花了點時間讀 LUXON.js 的文件,其作者先前是 moment.js 的開發者,覺得受到啟發,筆記一下在程式中處理時間重要觀念,另外搭配一些自己查到的補充資料。
觀念
- 不論人類怎麼依據歷史政治文化居住地點改變時間的表示方式,世界上的每個地方同一時間所經過的秒數都是相同的,儘管時鐘顯 示的時間不同。時區(timezone)、時差(time offset) 都只是人類觀點的產物。
- 時差 (offset) 是指所在地和 UTC 的時間差。像台北就是 +8 。代表台北比 UTC 快了 8 小時。
- 時區 (time zone) 通常用 IANA string表示,帶表該地點的時間和 UTC 之間差異的規則,例如
Asia/Taiepi
或是America/NewYork
,同樣時區與 UTC 的時間差在一年中並不一定總是固定的,可能會因為日光節約時間而改變,例如America/NewYork
在 3~11月 會把時間撥快1hr -4,其他月份是-5。- 不同的 IANA 可能會有相同的 time offset,這很直觀,想想那些在同一個經線上的國家。
- 就算一個 IANA 現在沒有日光節約時間(DST, daylight saving time),也不代表該時區以前沒有
- 固定時差 (fixed-offset timezone) ,我們可以指定一個固定的時差,例如 UTC+7,代表這個時間永遠比 UTC 快七小時。
- GMT (Greenwich Mean Time) 格林威治標準時間是 1972 年以前的稱呼,現在大家都用 UTC (Universal Time Coordinated)。
- 常常會聽到MST(山區標準時間), CST(中部標準時間), EST(東岸標準時間),這些是特定地點的時差代稱,不同國家的人會有自己習慣的稱呼,甚至還可能會有相同名稱,這些邏輯留給格式化去顯示就好,不要拿來運算。
- ISO8601 是目前最通用和全世界的人溝通時間的標準,盡量多使用
- 如果沒有時區符號,就代表是當地時間,有offset的話就是指定time-offset時區的時間
- 可以只表示日期 (
2022-11-27
) - 可以表示日期時間 (
2022-11-27T21:00:00
),但因為沒有時區可能會造成誤會 - 用 Z 表示 UTC +0 時區的時間,Z是Zero的意思 (
2022-11-27T21:00:00Z
) - 用 正負 hh:mm 表示時差(
2022-11-27T21:00:00+08:00
) - 其他細節請參閱 Wiki ISO8601
開發原則
- 盡量讓 Server 使用 UTC 時間,讓 Server 的程式以 UTC 的角度來存取、處理時間。把時間想成是一個數字 epoch milliseconds 就好。
- Docker Container 預設的時間就是 UTC 。
- 如果可以,在資料庫中盡量以 UTC 存取時間,如果不行的話輸出成 ISO8601 時也要盡量帶 TimeOffset 資訊。
- 跨系統間的溝通使用 ISO8601 ,使用 Z 或是 Offset 都可以,因為不管哪種格式,都能夠被解讀成全域時間線的某個時間點。
- 時區只作為格式化使用,一般來說你完全不需要處理時區。
- 讓 Client 端根據時區在顯示時格式化,因為 Client Side 才有時區資訊。
以 js 的 datetime library Luxon 為例
這些問題都是獨立於語言之外的,理論上是可以擴展到不同語言的。
在本地時區處理時間
- 建立一個現在時間的物件,並用本地時區顯示 (一般來說,預設建立的時間物件都會用本地時區的視角顯示 時間)
import { DateTime } from luxon
const local = DateTime.local();
const now = DateTime.now();
console.log(local.toString()) // 2022-11-27T17:27:02.394+08:00
console.log(now.toString()) // 2022-11-27T17:27:02.395+08:00
// 為求簡潔, 以下省略 console.log()
- 建立一個指定時間的物件,並用本地時區顯示
const localTime = DateTime.local(2022, 11, 25, 15, 15, 15);
// 2022-11-25T15:15:15.000+08:00
- 從 ISO8601 剖析,並用本機時區顯示
- 3-1. 不帶 time-offset
DateTime.fromISO("2017-05-15T09:10:23");
// 沒有指定時區的時候會視為系統時區的時間
// 2017-05-15T09:10:23.000+08:00
- 3-2 帶 Z (UTC) => 轉成本地時區時間
DateTime.fromISO("2017-05-15T09:10:23Z");
// 有指定 Z(UTC+0) 時區會把相同的時間點轉成 local +8時區
// 2017-05-15T17:10:23.000+08:00
- 3-3 帶完整 time-offset
DateTime.fromISO("2017-05-15T09:10:23+07:00");
// 有指定 (+07:00) 時區會把相同的時間點轉成 local +8時區
// 2017-05-15T10:10:23.000+08:00
- 輸出成 ISO 8601
const localTime = DateTime.local(2022, 11, 25, 15, 15, 15);
// 2022-11-25T15:15:15.000+08:00
同一個時間物件在不同時區顯示
把本地時間的物件轉成 UTC 時區的物件
const localTime = DateTime.local(2022, 11, 25, 15, 15, 15);
const utc = localTime.toUTC(); //2022-11-25T07:15:15.000Z
把本地時間的物件轉成其他時區的物件
const localTime = DateTime.local(2022, 11, 25, 15, 15, 15);
const sameTickInOtherZone = localTime.setZone("America/Los_Angeles");
// sameTickInOtherZone: 2022-11-24T23:15:15.000-08:00
時間不變強制改變時區(盡量少用)
強制在 local 的時間不變的情況下,把時區變成另一個時區。通常會需要這樣寫是因為 legacy code 或是錯誤資料的關係。如果你發現你很常需要手動轉換時區代表一定有更基本的事情做錯了。
const local = DateTime.local(2022, 11, 27, 18, 0, 0);
const rezoned = local.setZone("America/Los_Angeles", { keepLocalTime: true });
console.log(local.toString()); // 2022-11-27T18:00:00.000+08:00;
console.log(rezoned.toString()); // 2022-11-27T18:00:00.000-08:00;
console.log(local.valueOf() === rezoned.valueOf()); // false