Image

事發緣由

咱們內部套件中有個方法,會將各個參數組合為 QueryString 去打指定的 Api。就是這麼稀鬆平常的場景,神奇的事情就發生了。

同樣的套件、同樣的語法,在團隊中兩個人的電腦上安裝執行,卻是一個成功一個失敗。

原來該方法的參數中,包含一欄型別為 DateTime 的資料,並且會把該欄位的值拿來 ToString() 再做為參數傳遞給目標 Api。

而呼叫失敗的人就是在這個 DateTime.ToString() 的過程中產生了中文字,使得目標 Api 接到參數後,無法將中文字轉換回 DateTime 而發生了錯誤。

問題就浮現了:同一行 DateTime.ToString() 在不同電腦執行的結果竟然不一樣?!

為了讓我們更快了解狀況,現在就簡單地使用 Linqpad 進行測試:

void Main()
{
    DateTime.Now.ToString().Dump();
}

首先,在我的 Windows 時間設定中,完整時間的格式為 09:40:07 ,也就是 24 小時制。

現在讓我們先執行上面這段語法看看:

// 2021/10/04 21:00:00

接著讓我們變更時間格式看看。

以我的 Win10 為例,在 Windows 工具列,也就是畫面的右下角右鍵,選擇 調整日期時間 → 日期時間格式設定 → 變更資料格式,將時間格式變更為 上午 09:40:07

重新啟動 Linqpad 再執行如下:

// 2021/10/04 下午 09:00:00

可以看到 下午 兩個字就蹦出來了!

之所以會有這樣的差異,是因為 DateTime.ToString() 預設轉換的目標格式會是抓取目前執行緒的文化特性

文化特性

在 CSharp 中有個專門負責處理文化特性、地區設定等在地化處理的類別:CultureInfo。舉凡國家地區的資訊、時間日期的格式、字串的排序方式等等,都是它的工作。

要取得當前的文化特性,我們可以使用 CultureInfo.CurrentCulture

CultureInfo.CurrentCulture.Dump(); // zh-TW

可以看到我們目前的文化特性是 zh-TW,這個格式叫做 IETF 語言標籤,其中 zh 是指語言,TW 則是地區。例如說 en-US 就是「英語,美國」。

補充:關於語言標籤,有需要查詢的朋友可以翻看看 i18n 的 RFC 3066 表
此外,我們平常用的語言標籤還會有一些子標籤之類的,例如 zh-Hant-HK,可以參見 Language tags in HTML and XML (w3.org)

補充:除了 CurrentCulture 以外,CultureInfo 還有另一組文化特性的設定:CurrentUICulture。從名稱上可以看得出來,前者是用來預設系統的文化設定,例如數值、排序等等;而加上 UI 的後者,則是用來預設使用者介面要顯示什麼語言。

當然,大多數時候這兩個文化特性會是一樣的,不過如果想要國際化,弄個使用者介面的多國語言版本,就需要特別注意一下囉!

接著讓我們來看看裡面包了哪些重要東西吧:

Image

DateTimeFormat

DateTimeFormat 是用來放跟 DateTime 相關的設定值的,其中包含了該文化的日期時間的顯示格式。

我們同樣用 Linqpad 看一下裡面有些什麼:

CultureInfo.CurrentCulture.DateTimeFormat.Dump();

Image

可以看到裡面包含了一年有哪些月份和名稱、一週有哪些日子和名稱,以及一堆時間格式。

那一堆時間格式主要是對照 標準日期和時間格式字串 的。

例如 ShortDatePattern 就是對應到文件中的簡短日期模式("d"),也就是說,當我們 ToString 的時候,如果使用 “d” 的話,就會根據文化特性顯示該文化的「簡短日期」以此類推。

關於這部分的欄位內容,可以參照 Vito 大大的 使用文化特性 這篇文章,裡面的整理已經相當詳細,供各位參考。

補充:我們常常會看到 ToString("yyyy-MM-dd HH:mm:ss") 這種用法,但要小心這邊的 -: 其實是用來標示日期和時間的分隔符號,並不是真正的 -:

所以 : 在 format 的時候,會被替換為 TimeSeparator
/ 同樣也會使用 DateSeparator

也就是說,如果你使用的文化特性 TimeSeparator# 的話,HH:mm:ss21:07:00 出來的結果將會是 21#07#00。如果發現 ToString 出來的結果怪怪的,可以優先檢查文化特性的這兩個分隔符號有沒有問題。

相關的說明請參見 Date and time separator specifiers 這一小節。

現在讓我們用 Thread.CurrentThread.CurrentCulture 來更改當前執行緒的時間,並用完整日期時間模式("F")來測試看看:

var date = new DateTime(2006, 1, 2, 3, 4, 5);

Thread.CurrentThread.CurrentCulture = new CultureInfo("zh-tw");
date.ToString("F").Dump(); // 2006年1月2日 03:04:05

Thread.CurrentThread.CurrentCulture = new CultureInfo("zh-cn");
date.ToString("F").Dump(); // 2006年1月2日 3:04:05

Thread.CurrentThread.CurrentCulture = new CultureInfo("en-us");
date.ToString("F").Dump(); // Monday, January 2, 2006 3:04:05 AM

當然,在 ToString 的時候直接扔文化特性進去也是可以的:

var date = new DateTime(2006, 1, 2, 3, 4, 5);

date.ToString(new CultureInfo("zh-tw")).Dump(); // 2006/1/2 03:04:05
date.ToString(new CultureInfo("zh-cn")).Dump(); // 2006/1/2 3:04:05
date.ToString(new CultureInfo("en-us")).Dump(); // 1/2/2006 3:04:05 AM

而本篇起因的翻船事件,我們可以試著觀察一下 CultureInfo 的預設曆法,也就是 Calendar 的內容:

void Main()
{
	CultureInfo.CurrentCulture.Calendar.Dump();
	CultureInfo.CurrentUICulture.Calendar.Dump();
}

// 完整時間格式: 15:04:05
// MinSupportedDateTime 0001/1/1 上午 12:00:00
// MaxSupportedDateTime 9999/12/31 下午 11:59:59

// 完整時間格式: 下午 03:04:05
// MinSupportedDateTime 0001/1/1 00:00:00
// MaxSupportedDateTime 9999/12/31 23:59:59

可以明確看到格式的變更,兇手就在我們之中!

其實文檔已經明確地說了:使用者可以透過主控台的『地區及語言選項』部分,選擇覆寫與 Windows 目前文化特性相關聯的一些值。 (CultureInfo.Calendar 屬性 (System.Globalization) | Microsoft Docs

當然,我們也可以調整 Calendar 來把曆法換掉。例如說當我們要做西元年轉民國年的時候,就可以把 Calendar 指定為 TaiwanCalendar 來處理。

這部分請參照:mrkt 的程式學習筆記: 基本題 - C# 西元年轉換取得民國年格式字串

有了這些日期時間的格式,我們就可以用它們來處理一些日常的時間處理囉~例如:

NumberFormat

除了日期格式以外,數值也是各個文化常常不同的部份。在 CultureInfo 中的 NumberFormat 就是用來處理數值相關的格式。

我們同樣用 Linqpad 看一下裡面有些什麼:

CultureInfo.CurrentCulture.NumberFormat.Dump();

Image

可以看見裡面包含了 CurrencySymbol 貨幣符號、PercentSymbol 百分比符號、CurrencyNegativePattern 貨幣負數格式 等等數值相關的設定。

現在讓我們使用 ToString("C") 來指定轉換為貨幣格式,並且丟不同的文化特性進去看看吧:

var price = -49.99;

price.ToString("C", new CultureInfo("zh-tw")).Dump(); // -NT$49.99
price.ToString("C", new CultureInfo("zh-cn")).Dump(); // ¥-49.99
price.ToString("C", new CultureInfo("en-us")).Dump(); // ($49.99)

可以看見整個格式都不一樣了呢。

備註:CurrencyNegativePattern 這類的格式只會存數字編號,例如 0 對應到 ($n) 之類的。 各編號對應的格式可以參見 NumberFormatInfo.CurrencyNegativePattern 屬性 (System.Globalization) | Microsoft Docs

InvariantCulture

那麼,當我們想要統一格式的時候呢?例如說我們在世界各地都有服務,這些客戶端的資訊會集中傳回主伺服器。要是直接 ToString 那鐵定是每個地方回來的時間、貨幣和數值格式都不一樣的。該怎麼辦呢?總不能發個字串下去叫大家寫死吧囧?

這時候就可以使用 InvariantCulture 啦!

讓我們看看文檔是怎麼說明的:「取得與文化特性無關的 (不變的) CultureInfo 物件」、「不區分文化特性的文化特性不區分文化特性」(也太饒舌)

也就是說,只要使用約定好一起使用這組不變的文化特性,就可以保證大家的格式都是同一套囉。

使用的時候可以 CultureInfo.InvariantCulture 或是直接 new CultureInfo("") 就可以取得這組不變的文化特性。

var culinfo = CultureInfo.InvariantCulture;

var date = new DateTime(2006, 1, 2, 3, 4, 5);
date.ToString(culinfo).Dump(); // 01/02/2006 03:04:05

var price = -49.99;
price.ToString("C", culinfo).Dump(); // (¤49.99)

不過如果覺得 月/日/年 的格式很鳥的話,其實大家約好挑一組文化特性就好了齁?

後日談

最後交代一下篇頭的問題怎麼解決的:

直接把該套件打開然後把 .ToString() 加上 "yyyy/MM/dd" 打完收工囧。

就是這麼簡單。爽發一篇廢文,筆記筆記。

2022.05.06 補充:

朋友在將 Asp.net Framework 的服務佈到多台 Windows Server 上時,也遇到了時區設定不一致的問題。

經過一番排查,最後決定在 Web.config 中的 System.web 設定 Globalization 指定服務採用的時區來解決這個問題。

這邊也提供相關的資訊給遇到同樣問題的朋友們參考:

參考資料