demoshop

demo, trying to be the best_

廣大的 .NET 開發者一定都用過 DateTime ,取得現在的時間就很自然的使用 DateTime.Now,看似美好的日子竟然會因為雲端的普及而開始受到迫害,雲端平台的服務因為是全球性質因此時區通常都定在國際標準時間 UTC +0(以下稱為 Universal Time) ,所以為了時區的正確性,我們開始改變了時間的寫法,由 DateTime.Now 換成了 DateTime.UtcNow ,並且保持一個開發原則,進資料庫儲存的都是 Universal Time 顯示時再調整為適合的本地時區(以下稱為 Local Time 並且使用台灣時區 UTC+8)顯示,在這個原則限制之下世界終於恢復了平靜,但真的是這樣嗎?…

情境說明

網頁上有一個日期欄位與時間欄位讓使用者自行選擇,選擇完畢的時間與日期要存進資料庫,使用者在網頁上選擇了 2021-04-25 21:00:00 ,我們預期存入資料庫的時間是 2021-04-25 13:00:00 ,顯示的時候再將時間轉換為 Local Time 即可,於是我們的程式就大概是這樣。

var dt=new DateTime(2021,4,20);
var ti=new TimeSpan(21,0,0);

//使用者選的預期台灣時間
var nd=(dt+ti);

//存入資料庫的 UTC 時間
nd.ToUniversalTime();
台灣時區開發環境的執行結果  

然後我們開開心心的把程式上傳到雲端上,時間卻錯亂了😱

雲端的開發環境執行結果

上雲以後使用者選擇的本地時間看起來是正常,但是 ToUniverslTime() 取得的時間竟然沒有-8導致存入資料庫的時間與預期的本地時間整整多了八小時,這還得了…到底發生了什麼事!

DateTime 的原罪

DateTime 有一個 Kind 屬性可以記錄這個時間是 UTC , Local, Unspecified 看似可以解決我們的問題,但其實有些問題,做一個簡單的示範

//取得本地時間(台灣)
var saveNow = DateTime.Now;
//output: 2021-04-25 17:26:56, Kind = Local

//取得國際標準時間 Kind= UTC
var saveUtcNow = DateTime.UtcNow;
//output: 2021-04-25 09:26:56, Kind = Utc

DateTime myDt;


//把 本地時間的 Kind 從 Local 轉為 UTC"
myDt = DateTime.SpecifyKind(saveNow, DateTimeKind.Utc);

myDt.ToLocalTime();
//output: 2021-04-26 01:26:56, Kind = Local
myDt.ToUniversalTime();
//output: 2021-04-25 17:26:56, Kind = Utc

上面的程式碼 UTC 時間是拿來比對用的,所以我們從 Local Time 來講,系統抓出的本地時間是 2021-04-2517:26:56 將 Kind 自行調整為 Local 後再次使用 ToLocalTime() 取得的時間卻變成 2021-04-26 01:26:56 ,也就是和真的本地時間多了八小時,使用 ToUniversalTime() 時間卻是實際上的本地時間,這是為什麼呢?🤔

我們以為的 Local Time,在手動改變 Kind 值後就搖身一變的變成了 Universal Time 所以當你使用 DateTime內建方法要轉回 Local Time ,系統知道你目前 OS 的時區是 UTC+8 就很貼心的再幫你加上 8小時,那你想 -8 使用的 ToUniversalTime() 就完全沒變,因為你告訴程式你這時間本來就是 UTC 的,最可怕的是 Local Time 是執行程式定義的!本案例就是因為上傳到雲端的 Universal Time 環境後,在程式執行時 Local Time 同等於 Universal Time 導致預期的 -8 沒有作用,更不用說很多時候的 DateTime Kind 是 Unspecified。

官方範例完整程式碼可參考此連結: 點擊觀看

DateTimeOffset 的救贖

在問題發生的時候我就覺得 .NET 一定有內建的方法可以解,.NET 針對時間和曆法支援程度那麼高沒道理沒有官方解決方案,所以沒多久就搜尋到了 DateTimeOffset這個結構,它可以儲存 Local Time 和 Universal Time 的時間差異,所以對於時區轉換的議題可以更精準,且可以轉回 DateTime 所以你不需要改太多的程式,就可以完美修正了。

DateTimeOffset 早在 .NET Framework 2.0 就出現,它的出現就是要解決 DateTime 在時區轉換與比對的時候的問題,如果程式需要考慮到時區轉換時強烈建議使用,官方還有寫文章來詳細的描述你該怎麼選「 Choose between DateTime, DateTimeOffset, TimeSpan, and TimeZoneInfo」現在讓我們直接寫程式看一下 DateTimeOffset的差異。

var dt=new DateTime(2021,4,20);
var ti=new TimeSpan(21,0,0);

//使用者選的預期台灣時間
var nd=(dt+ti);

//使用 DateTimeOffset 並且明確表明我們時區是 +8
var offset=new DateTimeOffset(nd,new TimeSpan(8,0,0));

//存入資料庫的 UTC 時間
offset.ToUniversalTime();
: 使 用 者 的 預 期 台 』 
• 2021 / 4 / 20 下 午 093 : 00 
使 用 DateTimeOffset 
• 2021 / 4 / 20 下 午 09 : : 00 + : 
: 存 入 資 料 的 UTC 時 
• 2021 / 4 / 20 下 午 013 : 00 + 200
在台灣的開發環境測試結果  

本地開發環境沒問題,那在 Universal Time 時區的環境呢?

在UTC+0的環境也沒問題

看執行結果就可以明確瞭解,使用 DateTimeOffset 後時間的資訊就包含了與 UTC 的差異,所以你會看到時間後面帶了+08:00 既然有了這資訊,要回到 UTC+0 就有依據了,所以轉來轉去都不會出事,其實 DateTimeOffset 使用上是很簡單的,只是底層的觀念很多,時區本來就是麻煩的事情,好在 .NET 早就幫我們處理好了。

同場加映

更聰明的時區差異取得方式

前面的程式碼範例使用了 new TimeSpan(8,0,0) 的方式加上了時區,但如果網站確實是多國語系的,哪可能這樣寫死,所以你可以利用這樣的方式來處理

var offnd = new DateTimeOffset(nd, TimeZoneInfo.FindSystemTimeZoneById("Taipei Standard Time").BaseUtcOffset);

其中的 Taipei Standard Time 只要帶入使用者的時區就可以完美的解決了,建議將其寫成擴充方法方便以後的使用(DateTimeOffset 有提供 DateTime 屬性,可以讓你取回 DateTime),在 MVC 或 Razor Pages 的應用上還可以使用 Display Template 讓網站可以輕鬆自動轉換。

到底該 Now 還是 UtcNow ?

時區轉換不是問題了,那額外來各新議題,我們到底應該用 Now 還是 UtcNow 呢? 直接反組譯看看👀    

public static DateTime Now
{
	get
	{
		DateTime utc = UtcNow;
		bool isAmbiguousLocalDst = false;
		long offset = TimeZoneInfo.GetDateTimeNowUtcOffsetFromUtc(utc, out isAmbiguousLocalDst).Ticks;
		long tick = utc.Ticks + offset;
		if (tick > DateTime.MaxTicks)
		{
			return new DateTime(DateTime.MaxTicks, DateTimeKind.Local);
		}
		if (tick < DateTime.MinTicks)
		{
			return new DateTime(DateTime.MinTicks, DateTimeKind.Local);
		}
		return new DateTime(tick, DateTimeKind.Local, isAmbiguousLocalDst);
	}
}

Now其實就是先取得 UtcNow再轉成 Local Time 所以你覺得我們該用什麼呢?

回應討論