28 February 2014

繼續 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。好在常用的就只有幾個: LocalDateTimeInstantZoneOffsetClock。轉換的第一步驟是將過去使用 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 (datetimestamp),通常也是不存時區的。這點跟 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.createTimePerson.lastLoginTime 使用 LocalDateTime 做為範例,這是個錯誤的示範,應該用 Instant 比較好。詳情請見 新文章的討論


回響

可以用 Tag <I>、<B>,程式碼請用 <PRE>