22 April 2014

過去一個月來,我們嘗試轉換部份專案到 Java 8,用 lambda 用的很爽,我也寫了人生中第一個應用 Paralell Stream 的程式碼 (快十倍,哈!)。除了 lambda 之外,另一個大的改變是 JSR 310 java.time,我曾經寫過一篇介紹,也在新的程式碼中開始廣泛使用。將原來使用到舊 Date 的地方,全部轉成 Local* 系列的 class:

class Account {
  String name;
  LocalDate birthday;           //生日,包含年
  LocalDateTime createTime;     //帳號建立時間
  LocalTime notificationTime;   //每日新聞的通知時段
}

上面就是一個全部換新的 java.time 的例子。但是一直有個問題困擾著我們,就是 LocalDateTime 傳給前端時 (網頁或手機) 它需要先轉換成 Epoch Milli Seconds 再傳出去:

long epoch = localDateTime.atZone(ZoneId.systemDefault())
    .toInstant().toEpochMilli();

這程式碼不僅醜,而且還產生了無謂的中介物件,也寫死了系統時區,這意謂著程式如果部署到不同時區的主機上,結果將會不一樣。我們對這個寫法將信將疑,不過仍然繼續用著,畢竟沒有出錯過。

後來我跟同事討論到,如果我做一個留言版,上面記著留言時間:

class Comment {
  String content;
  LocalDateTime commentTime;
}

如果剛好是在日光節約時間調整的前後,各留言了一筆。那麼 commentTime 豈不是大亂?例如 美國洛杉磯於 2013 年 3 月 10 日 2 時開始日光節約,那麼當下拿到的 LocalDateTime.now() 會是 2013-03-10T03:00,指向3點。而過了一小時後,再取得一次 now(),結果仍然是 2013-03-10T03:00,也是3點。留言版上的時間顯示和排序都會因為重疊而錯誤。

生活在台灣一直沒有日光節約的概念,所以一直很幸運沒有遇到問題,但程式應該要到處都可以執行才對,而不是換了個環境就出了 bug。這個例子裡,很明顯我搞錯 LocalDateTime 的用法了。

我錯在混肴了機器觀點的時間和人觀點的時間。上面例子裡的 Account.createTime 和 Comment.commentTime 兩者都是由 系統自行生成的,用戶並沒有介入該時間的建立。這樣子的時間點,要用機器觀點的 Instant,而不是人的觀點的 LocalDateTime。修正後程式為:

//修正後:
class Account {
  Instant createTime;
}
class Comment {
  Instant commentTime;
}

//將建立的時間轉到 UI 前端時,變得很簡單:
long epoch = instant.toEpochMilli();

//與 JDBC java.sql.Timestamp 的切換也很容易:
Instant createTime = resultSet.getTimestamp("createTime").toInstant();
java.sql.Timestamp timestamp = Timestamp.from(createTime);

Instant 是絕對時間,沒有時區的影響,所以不會再發生留言時間經過日光節約前後的錯亂。而轉到 UI 前端時也沒有繁鎖的轉換程式碼。之前 LocalDateTime 轉 epoch milli seconds 很難寫其實是自己搞錯了。

LocalDateTime 與 Instant,盡管兩者的精準度都在 milli seconds 以上,感覺可以在應用程式面互換著使用,但其實不同的觀點會決定何時該使用哪一個:

何時使用 Instant

如果時間點沒有人為的介入,例如常見的建立時間、最後修改時間,或是到期時間 (通常用戶只輸入有效期間,而不是直接輸入到期日) 等等,這時你的 Model 應該要使用 Instant。通常這類的時間點在 UI 顯示時都是以相對時間顯示居多,例如上一次登入時間是三天前、或是還剩五小時就到期。

一般來說這類時間點使用上著重在時間點的前後關係 (例如用在排序),實際的日期並不重要。

何時使用 LocalDateTime

如果時間點是用戶輸入的,例如辦一個活動,鍵入了活動的日期與開始的小時,這就是很強烈的徵兆,該活動的時間應該要使用 LocalDateTime。你可以想像這樣的時間點在 UI 上,通常會直接顯示日期和時間,例如 2月20日下午3點 活動開始,很少人會顯示成還剩 17 天後活動才開始這樣。(當然也有故意營造氣氛時用倒數時間)。

一般來說如果 UI 的表單上有 DatePicker 之類的元件,那個資料就要用 LocalDate 或是 LocalDateTime,而不是 Instant。

小結

LocalDate 只有日期,可用在像是生日之類多半是用戶直接輸入的時間點。而 LocalTime 只有時間,用在像是設定鬧鐘的時間上,這也是用戶直接輸入的居多。這兩者在使用的情境上都很直覺,都需要人為介入,而且精準度都有限,所以大概不會弄錯。不過 Instant 和 LocalDateTime 的精準度類似,兩者也都沒有時區的資訊,像我就糊理糊塗的搞錯了方向,把機器觀點和人的觀點弄混了。這一篇雖然是討論 Java 8 的 JSR 310,不過使用 JodaTime 也會有類似的情形要判斷。希望這篇能讓各位不要再重蹈我的覆轍。