繼續 Java 8 的旅程,這一次是 JSR 310 - java.time。 過去 Java 一直都有接納 open source 成功函式庫的慣例,但大部份都搞砸了。但這一次不一樣啦,java.time 是源自於 Joda-Time,由同一位開發者 Stephen Colebourne 設計。他從 Joda-Time 的經驗記起教訓。去蕪存菁,重新打造。結果我們看到的是近乎完美的 API,終於可以擺脫過去醜惡的 java.util.Date 和 Calender 。
網路上有許多 java.time 的簡介,也寫的比較清楚,大家可以先讀一些。時間不夠的可以看一下 java.time package 的 javadoc,有個大概的認識。本文不做基本介紹,而是討論實際運用上的技巧。
java.time.* API
首先來看 java.time 提供的新 API,它引進相當多的 class。好在常用的就只有幾個: LocalDateTime、Instant、ZoneOffset、Clock。轉換的第一步驟是將過去使用 Date
物件的地方,都換成 LocalDate*
,下面是一個常見的帳戶 entity:
public class Person {
String name;
LocalDate birthday;
LocalDateTime createTime;
LocalDateTime lastLoginTime;
//你幾歲
int age(LocalDate refDate) {
return Period.between(birthday, refDate).getYears();
}
//生日到了沒?
boolean isBirthday(MonthDay refDay) {
return MonthDay.from(birthday).equals(refDay);
}
//距上次登入已經過了幾天
long lastLoginDaySince(LocalDateTime refTime) {
return Duration.between(lastLoginTime, refTime).toDays();
}
}
//測試
@Test
public void variousDate() {
LocalDate birthday = LocalDate.of(1985, 3, 16);
LocalDateTime createTime = LocalDateTime.of(2014, 2, 3, 5, 0);
LocalDateTime lastLoginTime = LocalDateTime.of(2014, 2, 7, 19, 11);
Person person = new Person("Ingram",
birthday, createTime, lastLoginTime);
LocalDate today = LocalDate.of(2014, 2, 28);
assertEquals(28, person.age(today));
assertTrue(person.isBirthday(MonthDay.of(3, 16)));
LocalDateTime now = LocalDateTime.of(today, LocalTime.of(14, 51));
assertEquals(20, person.lastLoginDaySince(now));
}
帳戶這個物件上有生日,建立時間、以及最後登入時間,過去這些欄位都是 Date
物件,現在生日轉用 LocalDate
,因為只需要包留年月日,不需要時間。另外兩個時間則是轉用 LocalDateTime
,包含日期與時間。Local 開頭的物件都是不包含時區的,我們在設計物件模型時應盡可能不要包含時區的概念。有時區的時間 (Zoned
開頭的) 只適合用在 UI 層,顯示給用戶時使用。
新的 API 真正的威力在於計算時間間距與比較。上面的範例示範如何使用 Period 計算年紀、利用 MonthDay 判斷是否生日到了、而 Duration 可計算兩個時間相差的天數。你可以發現程式碼非常直覺好讀,你不用擔心常見的 index 差一的錯誤,也不用擔心潤年、時區換算的問題。
我一開始搞不大清楚 Period
(期間) 和 Duration
(長短) 這兩個間距的差別。後來才知道 Period
專指日期,也就是只用 LocalDate
去算,精準度只到 "天";而 Duration
專門算時間差,是用 LocalDateTime
去算,它可以算到 nanoseconds 的差別,但是最高單位只到 "天",沒有月和年。
JDBC 的支援
ok,現在 person 這個 entity 已經轉用 java.time,計算時間的邏輯非常的方便,那麼怎麼存到資料庫呢? JDBC 4.2 增加了對 java.time 的支援,不過不是在 sql type 上增加,而是在 java.sql.Date, java.sql.Timestamp 上提供轉換:
//利用 Spring JdbcTemplate 的 Dao
class PersonDao {
@Autowired
JdbcTemplate jdbcTemplate;
Person create(String name, LocalDate birthday) {
LocalDateTime now = LocalDateTime.now(clock);
Person person = new Person(name, birthday, now, now);
String sql = " INSERT INTO Person "
+ " (name, birthday, createTime, lastLoginTime) "
+ " VALUES (?,?,?,?) ";
//使用 valueOf() 轉換
jdbcTemplate.update(sql,
person.getName(),
java.sql.Date.valueOf(person.getBirthday()),
java.sql.Timestamp.valueOf(person.getCreateTime()),
java.sql.Timestamp.valueOf(person.getLastLoginTime()));
return person;
}
List<Person> listByName(String targetName) {
String sql = " SELECT * FROM Person WHERE name = ? ";
return jdbcTemplate.query(sql, (ResultSet rs, int rowNum) -> {
String name = rs.getString("name");
// sql.Date 和 sql.Timestamp 上都有 toLocal*() 的 method
// 可供轉換
LocalDate birthday =
rs.getDate("birthday").toLocalDate();
LocalDateTime createTime =
rs.getTimestamp("createTime").toLocalDateTime();
LocalDateTime lastLoginTime =
rs.getTimestamp("lastLoginTime").toLocalDateTime();
return new Person(name, birthday, createTime, lastLoginTime);
}, targetName);
}
}
上面的範例是簡單的新增和查詢。程式碼有點長,但重點只在 java.sql.Timestamp 的 valueOf()
以及 toLocalDateTime()
這兩個轉換 method 上。操作上並不難,只是有點繁鎖而已,我覺得很可惜啦,因為其實 API 可以設計的更簡單些。Spring 4.0 說它們支援 Java 8 了,但是 JdbcTemplate 傳參數時還是不能直接吃 LocalDate,要先用 valueOf 轉換後才行。而 ResultSet 上也沒有 getLocalDate(string) 這樣方便的 method,要自己再轉一次才成。
值得注意的是,資料庫的 schema 定義的 sql data type (date
和 timestamp
),通常也是不存時區的。這點跟 entity 裡只寫 LocalDate 一樣的意思,時區只在 UI 層有意義。物件模型以及資料庫裡時間的欄位都應避免包含時區。
java.util.Date 互轉 LocalDateTime
Java 已經發展快二十年了,所有的程式都是用 java.util.Date
。雖然新程式可以用新 API,但難免會需要和舊的 API 接軌。但我很意外,這兩個日期的轉換比想像中的複雜:
LocalDateTime newDate = LocalDateTime.now();
//要先取得 system ZoneId
ZoneId sysZoneId = ZoneId.systemDefault();
//將 LocalDateTime 轉回 java.util.Date
Date legacyDate = Date.from(newDate.atZone(sysZoneId).toInstant());
//將 java.util.Date 轉成 LocalDateTime
Instant instant = legacyDate.toInstant();
LocalDateTime coverted = LocalDateTime.ofInstant(instant, sysZoneId);
哇,有夠麻煩。兩種日期的轉換中間都要先產生一個 Instant
物件,再補上時區的轉換。呼~~ 這整死人了。注意 zoneId
這裡都是用系統預設的,通常這不會有什麼問題,但如果你的 legacyDate
裡面帶有不同於系統的時區,轉成 LocalDateTime
時就要另外處理。
更多轉換的參考資料: StackOverflow
Unit Test and Dependency Injection
好,談實務就不能不談到單元測試, 時間 相關的邏輯並不好測,因為每一次程式在執行時,時間都一直在變,換句話說它就是個 side effect。一個容易測試的程式的通常是接近函數式 (functional) -- 輸入給它 A 就會回傳 B,無論何時呼叫,呼叫幾次, 結果都該一樣。為了達到這個目標,在運用時間的 API 時必須遵守幾個規矩,而新的 java.time 也提供工具協助。
我們回頭看上面的 Person 的例子,如果你仔細看的話,每個和時間運算的 method,參考時間都是由參數傳入,而不是在 method 內生成,這樣的設計是為了測試的考量。
class Person {
LocalDate birthday;
//方法一:參考日期由外面傳入
int age(LocalDate refDate) {
return Period.between(birthday, refDate).getYears();
}
//方法二:參考日期由內部生成,雖然好用,但這讓測試變的很困難
int age2() {
return Period.between(birthday, LocalDate.now()).getYears();
}
}
上面的範例比較了兩種寫法。age2()
API 比較簡單,但每次呼叫的可能會有不同的結果,而且,你也很難創造一些特殊的狀況來測試。比方說你需要 65 歲以上的帳戶來驗證老人津貼的邏輯。測試的範圍少,不週全,bug 就會多。因此在物件模型的設計上,參考日期應該設計成由外面傳入,而不是內部產生。
但總不能一直讓外面傳吧?最終最外層的那個物件還是得自行建立參考日期。這時就得靠 Dependency Injection (依賴注入) 來幫忙了。通常系統裡的 service 或是 dao 等等元件都會交由 IoC container 來管理,它們都會靠 container 將依賴的元件全部串在一起。而這一次,我們要注入的是 時鐘 java.time.Clock
Clock 這個物件掌管了整個 java.time 裡時間的流動。如果我們可以將時鐘抽換掉,也就代表我們可以控制時間的變化,它不再是不可駕馭的 side effect 了。來看一個測試期間抽換時鐘的例子吧:
class PersonDao {
//預設已經準備好一個系統的時鐘。
Clock clock = Clock.systemDefaultZone();
//這是給測試用的 setter,讓測試可以抽換
setClock(Clock clock) {
this.clock = clock;
}
Person create(String name, LocalDate birthday) {
//運用時鐘產生時間,產生的時間將依據 clock 的特性而改變
LocalDateTime now = LocalDateTime.now(clock);
Person person = new Person(name, birthday, now, now);
jdbcTemplate.update(sql, ....);
return person;
}
}
//Dao 的測試
class PersonDaoTest {
@Test
public void create() {
//建立一個特別的時鐘,時間固定在 2014/1/1 05:18
ZoneId zondId = ZoneId.systemDefault();
Instant instant = ZonedDateTime.of(2014, 1, 1, 5, 18, 0, 0, zondId)
.toInstant();
Clock fixedClock = Clock.fixed(instant, zondId);
//注入特製的時鐘
personDao.setClock(fixedClock);
//測試的主程式
Person created = personDao.create("Ingram",
LocalDate.of(1987, 1, 1));
//帳戶建立的時間要等於時鐘的設定
assertEquals(LocalDateTime.of(2014, 1, 1, 5, 18), person.getCreateTime());
}
}
上面的範例就是剛才 JDBC 用的 Dao,我們要測試 Person 建立的時間要等於現在。為此,我們做了一個固定時間在 2014/1/1 5:18 分的時鐘,注入給 PersonDao 使用。Dao 在取得現在時間時,使用 LocalDateTime.now(clock)
,這樣產生的時間就會依據時鐘的設定。最後我們就能 assert 帳戶的 createTime
是該日期了。
這個例子過份的簡單,所以大概只會覺得這樣寫很麻煩,但其實邏輯一複雜起來,能夠控制時間的流動對測試的幫助很大的。
Clock 這物件是 immutable、thread safe,所以可放心的共用。
中華民國曆
Surprise! Java 8 內建中華民國曆!來看怎麼轉換民國吧:
//延續 Person 的例子,birthday 是 LocalDate
MinguoDate minguoBirthday = MinguoDate.from(birthday);
int minguoYear = minguoBirthday.get(ChronoField.YEAR);
得到的就是出生在民國幾年,如果民國前的話就會是負值。盡管換算民國年只是個加減的操作,但能有 type safe 的物件明確表示民國的曆法還是不錯的,它能減少程式的 bug。
MinguoChronogy 是個相當簡單的客制曆法,如果要自行建立曆法的話可以參考它的原始碼。
美麗的 Design Pattern
java.time 的 API 可說是歷經千錘百鍊,我可以說它的設計已經成為經典,足以和 java.util.Collection 並列,它是個我們可以仿效的典範:
- 全部是 Immutable - Immutable 物件不論在多複雜的環境都能正常的運作,高效率,不會有 thread contention,而且 bug free。
- 命名規則統一 - 它的 class 很多,method 繁雜,但是不會難學,因為它的命名有規則、有系統。
.of
是建構、.at
是沿伸、.with
是修改、.to
是轉換。這簡短的介系詞的運用也值得我們學習。 - side effect 封裝在
Clock
裡,讓用 API 的人可以修改,增加可測試性。 - Design for extension: java.time 的 API 設立了很多 concrete class,而且每個都不能繼承。但是它額外提供 Temporal 的界面供 framework 擴充。這是一個同時滿足物件安全性和擴充性的設計,難得難得。
小結
Java 8 的旅程繼續前進,到了這一站,我把原本系統依賴的 Joda-Time 給整個移除,換成全新設計的 java.time。現代化的 Java 將依賴更少,更加的優雅。
2014/04/22 更新
本文中的範例 Person.createTime
與 Person.lastLoginTime
使用 LocalDateTime
做為範例,這是個錯誤的示範,應該用 Instant
比較好。詳情請見 新文章的討論。