實作 Rails 的時間與時區,好亂啊,通通把它變成 UTC

紅寶鐵軌客
Join to follow...
Follow/Unfollow Writer: 紅寶鐵軌客
By following, you’ll receive notifications when this author publishes new articles.
Don't wait! Sign up to follow this writer.
WriterShelf is a privacy-oriented writing platform. Unleash the power of your voice. It's free!
Sign up. Join WriterShelf now! Already a member. Login to WriterShelf.
寫程式中、折磨中、享受中 ......
2.15K   0  
·
2017/11/06
·
10 mins read


時間處理,或是一段時間的資料收集,可能是很多網路應用上必定要有的功能,但是寫程式時,尤其是當面對多時區時,真的要頭腦清晰,在 Rails 中,有很多與時間相關的設定與使用方式,我也真的常會搞錯,所以這篇主要是自己做參考用的記錄用,但是應該也會對其他苦主有幫助。

在 Rails 中,預設時區是設定在 config/application.rb 中,預設時區的設定為 config.time_zone,以台北為例,預設時區為台北的話,就加一行 config.time_zone = “Taipei”,基本上,這是設定程式(不是資料庫)的基礎參考時區,這是一個很重要的設定與了解,我剛開始用 Rails 時,並沒有設定這,結果好像也相安無事,沒設定,就是一切以 UTC 為主,就是 timezone offset +0,但是這樣使用可能有很大的問題,一旦你以後加上預設時區,你就慘了,因為,在 Rails 中,資料庫裡面的時間欄位,datetime,一般都是儲存 UTC 的時間,也就是說,它會根據你的程式預設時區來重新計算時間,例如,預設時區為台北 (UTC+8) 時區,這時存取資料庫,Rails 便會幫你自動轉換時區,也就是拿出來時 +8,存回去時 -8,你可以在 Log 中看到類似以下的換算:

irb(main):001:0> Blog.where( "created_at > ? and created_at <= ?", Time.now.beginning_of_day, Time.now.end_of_day )
=> SELECT * FROM "blogs" WHERE (created_at > '2017-03-30 16:00:00' and created_at <= '2017-03-31 15:59:59')

看起來簡單,這拿出來與存回去,在多時區開發中,常搞得我頭腦不清,如果想要更改資料庫裡面的時間,也不是不行,在 app/config/application.rb 更改如下,但是,非常不建議,相信我,讓資料庫裡面的時間保持在 UTC 是最好的方式,除非,你的程式應用確定就是單一時區!

config.time_zone = "Taipei"
config.active_record.default_timezone = :local

用 UTC 有很大的方便性,我後還就發現一個好方法,就是:

通通轉成 UTC

很笨,但是真的很好用,反正電腦會算的,我們就用電腦,以下是用 Rails Console 的測試:

irb(main):001:0> Time.now
=> 2017-11-06 18:16:35 +0800
irb(main):002:0> Time.now.utc
=> 2017-11-06 10:16:43 UTC

可以看到,不同的時區,用個 .utc 就可以簡單的轉成 UTC,所以,不管使用者身在哪裡,他們的時區改變,在處理時都轉成 UTC,這對一般資料查詢是多此一舉,但是對自動執行的後台批次程式有很大的幫助,分享在這,希望對大家有幫助,我是留個紀錄,不要忘記了。

 時間比時間,日期比日期!

記住,當比較日期與時間時,時間比時間,日期比日期,千萬不要亂比,很容易錯的,而且錯誤都會很難找,久久才會出來一次,例如:

t1 = Time.new(2017, 9, 30, 18, 20, 0) # 日期➕時間
=> 2017-09-30 18:20:00 +0800
start_date
=> Fri, 01 Sep 2017 # 日期
t1 > start_date.end_of_month
=> true # end_of_month 沒有時間
t1 > start_date.end_of_month.end_of_day
=> false
t1 > start_date.end_of_month.end_of_day.utc
=> false

這是用 Rails Console 驗證的,可以看到第 05 行,當日期比上時間時,完全不對了!

 讓讀者看到正確的時間!

一般網頁幾乎都是顯示 server 的時區時間,大部分的應用也沒太大的問題,但是如果你要能讓讀者看到的時間是「轉換」成他的瀏覽器設定的時間,有一個 Gem 就很好用,local_time 

basecamp/local_time — Rails engine for cache-friendly, client-side local time - basecamp/local_time
GitHub

照著裝好後,使用起來非常簡單,如下,就會自動調整顯時的時間到讀者的時區了:

<%# using local_time gem %>
<%= local_time(@blog.updated_at, "%m/%d/%Y %H:%M") %>

稍微要注意一下就是它的格式設定跟 strftime 相容性很高,很相同,但是不是完全,我是覺得很夠用了。

 要用 DateTime() 或是 Time()?

Rails 就是 Ruby 寫的,Ruby 有兩個關於日期與時間的 class,如果你會問到這個問題,代表你已經被搞昏了,別灰心,不只你,每個人都是,我想最主要大家的問題就是,我也不想知道那麼多,就告訴我,建議我,我應該要用 DateTime 還是 Time 就好了,照 Ruby Doc 的說法,你要用 Time!

So when should you use DateTime in Ruby and when should you use Time?

Almost certainly you'll want to use Time since your app is probably dealing with current dates and times. However, if you need to deal with dates and times in a historical context you'll want to use DateTime to avoid making the same mistakes as UNESCO. If you also have to deal with timezones then best of luck - just bear in mind that you'll probably be dealing with local solar times, since it wasn't until the 19th century that the introduction of the railways necessitated the need for Standard Time and eventually timezones.

詳細有關這兩個的差異性,主要是

  • DateTime 有著很棒的不同歷史日曆換算支援,一個很酷的例子就是關於莎士比亞跟唐吉訶德的作者去逝日期都是 1616年4月23日,但是實際上是差了十天,只因為一個是英國曆,一個是西班牙曆,說真的,這太厲害了,只是,你除非寫的是與歐洲歷史應用有關,我想信你不會在乎。
  • Time 有很棒的日光節約支援,與 ActiveSupport::TimeZone 合作良好,DateTime 就陽春多了。

如果想要知道更多,這篇在 Stack Overflow 上的討論,值得細看:

Difference between DateTime and Time in Ruby — What's the difference between DateTime and Time classes in Ruby and what factors would cause me to choose one or the other?
Stack Overflow

以上這些都還算是時區的小菜,真正麻煩的是當您的應用網站要支援多時區,那就是頭痛的開始,不過也還好啦,只要知道以下的一些重點,你可以做到的!

讓使用者自訂時區

ActiveSupport::TimeZone vs. TZInfo::Timezone

很重要的一件事就是:Rails 中的 ActiveSupport::TimeZone 跟 Ruby 中的 TZInfo::Timezone 是不一樣的,但也不能說是都不一樣,ActiveSupport::TimeZone 是把使用標準 IANA 的 TZInfo::Timezone 包起來,為什麼要包起來呢?因為 IANA 中的 timezone 實在真的太多了,以我現在使用的版本來說:

  • ActiveSupport::TimeZone.all.count => 150
  • TZInfo::Timezone.all.count => 589

Rails 為了要讓使用者好選擇,所以把 Ruby 中的 TZInfo::Timezone 包裝起來,只選了其中比較常用到的,但是這也馬上就會讓你的應用在使用開發上面臨選擇,先簡單的說怎麼選擇:

  • 你想要從瀏覽器得到時區嗎?那就建議要用 TZInfo::Timezone
  • 你要讓你的使用者自己選擇時區嗎?那就可以用 ActiveSupport::TimeZone

根據你的需求,這樣的選擇會讓你未來的程式開發方便多了,為什麼呢?主要是因為,這兩個選擇出來的時區字串,並不直接相容! 以 Osaka 為例:

  • ActiveSupport::TimeZone.find_tzinfo('Osaka') => #<TZInfo::DataTimezone: Asia/Tokyo>
  • TZInfo::Timezone.get('Osaka') =>TZInfo::InvalidTimezoneIdentifier
  • TZInfo::Timezone.get('Asia/Tokyo') => #<TZInfo::DataTimezone: Asia/Tokyo>

ActiveSupport::TimeZone 的時區命名都是很簡短的,如:Osaka,Taipei 等,TZInfo::Timezone 就是用 IANA 標準命名,如:Asia/Tokyo,Asia/Taipei ,當你讓使用者自己選擇時區時,如果用 TZInfo::Timezone,那使用者就會有 589 個時區選擇,這也太多了吧,所以用 ActiveSupport::TimeZone 會是一個很好的方向,特別是大部分有時區的需求的應用,都是讓使用者的設定中自選,所以,如果一開始,就是讓使用者選擇 ActiveSupport 中的 TimeZone,這樣,整個程式就可以都用 ActiveSupport::TimeZone 了,這樣就不用擔心 Rails ActiveSupport::TimeZone 跟 Ruby 中 TZInfo::Timezone 的不相容問題了。

從瀏覽器取得時區

如果你的客戶就是說:「我要瀏覽器取得時區」,那就恭喜你了,這真的麻煩多了,標準的 javascript 時區取得方式很簡單:

Intl.DateTimeFormat().resolvedOptions().timeZone = "Asia/Taipei"

只可惜,IE 不支援,Android webview 也不支援,所以就看你要不要支援他們了,一般都不會直接使用這個,而是用:

我是用 jsTimeZoneDetect, 這是他的官網連結:pellepim / jsTimezoneDetectjsTimeZoneDetect 會回覆 IANA 標準時區,所以,跟 TZInfo::Timezone 剛好配成一對,這也是為什麼我會建議要用 TZInfo::Timezone 了,但是,網路上很多人都建議,不要‘這麼’相信瀏覽器,因為有很大的可能,瀏覽器上的時區設定是錯誤的,或是,根本沒設定,所以,就算是從瀏覽器取得了時區,實務上,你還是要讓使用者可以自選,最簡單的 Rails helper 就是以下這行:

time_zone_select( :timezone, TZInfo::Timezone.all.sort, :model => TZInfo::Timezone, default: xxx )

多棒啊,原來,Rails 還是可以指定使用 TZInfo!只是,最大的缺點就是,使用者會有 589 個時區選擇,你當然可以將 IANA 透過 ActiveSupport::TimeZone 轉換,但是,真的很煩啊,從瀏覽器取得時區一點都不好玩,有太多的變數了,基本上,都是用‘猜’的,瀏覽器 - Ruby - Rails - OS 四者間,真的有太多變數了。既然,你的客戶要從瀏覽器取得時區,那,就讓他的客戶有 589 個時區選擇吧! 哈哈哈哈哈!

Rails 會自動轉換使用者輸入的時間

Rails 為了讓你簡單使用,如果你沒有指定時區時,它就會以你「設定」的 server 時區為準,如果你的 ActiveRecord 是跟我一樣,設定為 UTC ,那當存入 DB 時,就會自動存成 UTC 時區,我們上面已經提過,在 Rails 中:

  1. 預設 server 時區的地方是設定在 config/application.rb 中,預設時區的設定為 config.time_zone,以台北為例,預設時區為台北的話,就加一行 config.time_zone = “Taipei”
  2. 預設 ActiveRecord 時區的地方是設定也是在 config/application.rb 中,如果要設定 server 的時區,就: config.active_record.default_timezone = :local,如果要設成 UTC,就改成 config.active_record.default_timezone = :utc

實務上,如果你的應用是要支援多時區,很好的方式就是,兩個都設成 UTC,這樣就不用擔心轉來轉去的問題,因為是要支援多時區,所以你的 server 時間也不屬於那個時區了,這是一個很簡單的方法,非常建議。

如果你要支援多時區,但是也要設定 server 的時區,這時,你就要注意 Rails 中的時區轉換了,最常發生的應用就是,當使用者在時間欄位中輸入一個時間,這個使用者設定中,也有自己的時區時,這時,你就要:

  1. 轉換他輸入的時間到他自己的時區,
  2. 要存到 DB 時,一定要,再,轉換到「你的 server 時區」,

為什麼要轉換到「 server 時區」?就是前段提的,當存入 DB 時,Rails 會自動存成 UTC 時區,當沒有指定時區時,所有的沒標明時區的時間,就會被認定為 server 時區,這特別是在我們用 form 中的 datetime_select helper,轉換的方式可以像這樣:

# convert with server time 
# 1st convert to UTC
v_at = ActiveSupport::TimeZone.new(x_timezone).local_to_utc( Time.new(x_year.to_i,
  x_month.to_i, x_day.to_i, x_hour.to_i,x_minutes.to_i,0) )
# 2nd convert to server timezone,
v_at = v_at.in_time_zone(Rails.application.config.time_zone)

x_timezone 就是使用者的時區,Rails.application.config.time_zone 可以幫你直接讀出你在 config 中設定的時區。

附贈一個彩蛋,知道怎麼讀取 SQL 中的兩個時間欄位嗎?

 

就這樣,好像也沒我之前說的麻煩,如果您已經看到這裡,代表你也正在開發多時區的應用,就先預祝您開發愉快了。


WriterShelf™ is a unique multiple pen name blogging and forum platform. Protect relationships and your privacy. Take your writing in new directions. ** Join WriterShelf**
WriterShelf™ is an open writing platform. The views, information and opinions in this article are those of the author.


Article info

Categories:
Tags:
Date:
Published: 2017/11/06 - Updated: 2020/02/16
Total: 2815 words


Share this article:
About the Author

很久以前就是個「寫程式的」,其實,什麼程式都不熟⋯⋯
就,這會一點點,那會一點點⋯⋯




Join the discussion now!
Don't wait! Sign up to join the discussion.
WriterShelf is a privacy-oriented writing platform. Unleash the power of your voice. It's free!
Sign up. Join WriterShelf now! Already a member. Login to WriterShelf.