【Joda-Time 與 JSR310 】(4)使用 JDK8 日期時間 API


Joda-Time 的創建者 Stephen Colebourne 參與了 JSR310,也就是 Java 標準的日期與時間 API 規格之制訂,預計在 JDK8 中一併釋出,為什麼 Stephen Colebourne 不直接將 Joda-Time 放入 Java 標準呢?在他的 Why JSR-310 isn’t Joda-Time 中做了解釋,最主要的是 Stephen Colebourne 認為 Joda-Time 有一些設計上欠周詳的缺點:

  • 人類與機器的時間軸
  • 可抽換的年曆設計
  • Nulls
  • 內部實作

以下逐一來探討,並看看 JSR310 中會怎麼改正 …

避免 Nulls

Joda-Time 中有些 API 接受 null,視 API 而定,可能將 null 視為 1970 年 1 月 1 日,或是者是視為 0。null 引發的問題可以參考 補救 null 的策略;JSR310 的 API 不接受 null

清楚區隔人類與機器時間概念

人類與機器對時間的觀點截然不同。對機器來說,時間就是不斷增加的數字,以 Java 來說,就是 January 1, 1970, 00:00:00 GMT(實際上是 UTC)經過的毫秒數;對人類來說,對時間的概念有年曆,有年、月、日、時、分、秒,還加上了時區等概念。

在 Joda-Time 中,DateTime 實作了 ReadableInstantReadableInstant 是機器對時間的概念,然而 DateTime 卻是人類對時間的概念,Stephen Colebourne 認為應該將兩種概念予以分離。

在 JSR310 中,特意讓機器與人類對時間概念的界線變得分明。JSR310 的套件命名從 java.time 開始。對於機器相關的時間概念,JSR310 設計了 finalInstant,代表著從 Java epoch(1970 年 1 月 1 日)之後的某個時間點,精確度則可至奈秒(nanosecond)等級。為了避免時間定義上的模糊,JSR310 定義了自己的時間度量(Time-scale) ,可以在 Instant 的 API 文件查詢得知其如何定義時間。

對於人類的時間概念,像是日期與時間,JSR310 有 LocalDateTimeLocalDateLocalTime 等類別來定義,這些類別基於 ISO-8601 年曆系統,是不具時區的日期與時間代表(看 Local 字眼也知道是這樣)。年、月、日的概念,則分別有 YearYearMonthMonthDay 等類別,可分別代表如 2007 年、2007-12、12-03 這樣的概念。

對於時間的量,Joda-Time 有 Duration 的概念,JSR310 中也有,以類別 Duration 來定義,用來表示時間方面的量,精度設定可以達奈秒等級,而秒的最大值可以是 long 型態可保存之值。Joda-Time 有 Period 的概念,JSR310 也有,以類別 Period 定義,用來表示日期方面的量,像是 2 年、3 個月、4 天等。

可以發表,Joda-Time 中的一些概念,經過調整後,依舊可對應至 JSR310,程式碼使用上也類似,來看看實際的程式碼範例。底下是 Joda-Time 中要取得兩個日期間經過幾年的程式碼:

Years years = Years.yearsBetween(
DateTime.parse("1975-05-26"), DateTime.now());
System.out.printf("你今年的歲數為:%d%n", years.getYears());

改成 JSR310 的話,長得也蠻類似的:

Period period = Period.between(LocalDate.parse("1975-05-26"), LocalDate.now());
System.out.printf("你今年的歲數為:%d%n", period.getYears());

Joda-Time 中以建構 LocalDate 來表示本地時間:

LocalDate javaTwoDate = new LocalDate(2013, 8, 2);
System.out.printf("Taiwan Java Developer Day is %s.%n", javaTwoDate);

JSR310 中常見到工廠方法建立相關實例:

System.out.printf("Taiwan Java Developer Day is %s.%n", LocalDate.of(2013, 8, 2));

Joda-Time 中對日期進行運算的例子是這樣的:

LocalDate birthDate = new LocalDate(1975, 5, 26);
System.out.println(birthDate
                    .plusDays(5)
                    .plusMonths(6)
                    .plusWeeks(3).toString("E MM/dd/yyyy"));

透過 Joda-Time 中 Period 類別上的 static 方法,搭配 import static,可以達到更進一步的可讀性:

LocalDate birthDate = new LocalDate(1975, 5, 26);
System.out.println(birthDate
                    .plus(days(5))
                    .plus(months(6))
                    .plus(weeks(3)).toString("E MM/dd/yyyy"));

這是因為 LocalDateplus 方法接受 ReadablePeriod 實例,操作後傳回 LocalDate,因而可以流暢地持續操作。

在 JSR310 中,則可以寫成這樣:

LocalDate birthDate = LocalDate.of(1975, 5, 26);
      System.out.println(birthDate
                    .plus(5, DAYS)
                    .plus(6, MONTHS)
                    .plus(3, WEEKS).format(ofPattern("E MM/dd/yyyy")));

JSR310 中,UTC 偏移量與時區的概念是分開的。OffsetDateTime 單純代表 UTC 偏移量,使用 ISO-8601;ZonedDateTime 是代表加入了時區規則的類別。舉例來說,如果有個機器時間觀點的 Instant 實例,你可以用它來分別取得 UTC 偏移量或者是某時區的時間:

Instant now = Instant.now();
OffsetDateTime offsetDateTime = now.atOffset(ZoneOffset.UTC);
ZonedDateTime zonedDateTime = now.atZone(ZoneId.of("Asia/Taipei"));

類似地,如果有個人類時間概念的 LocalDateLocalTime,也可以在分別補齊欄位資訊後,分別取得 UTC 偏移量或者是某時區的時間:

LocalDate nowDate = LocalDate.now();
LocalTime nowTime = LocalTime.now();

OffsetDateTime offsetDateTime = OffsetDateTime.of(nowDate, nowTime, ZoneOffset.UTC);
ZonedDateTime zonedDateTime = ZonedDateTime.of(nowDate, nowTime, ZoneId.of("Asia/Taipei"));

改善內部實作彈性

Joda-Time 有些實作上缺乏彈性或是複雜。舉例而言,如果你仔細察看過 Joda-Time 的 API,可以發現有些操作在各類別重複了,像是 plus 方法,你可以在 DateTimePeriod 上分別發現 plus 名稱的方法,分別傳回 DateTimePeriod 實例,這類 API 上的操作直接定義在類別,將來要擴充時會比較沒有彈性。

JSR310 將 API 上的操作抽取出來獨立定義,放置在 java.time.temporal 套件之中,其中 TemporalAccessor 定義了唯讀用的時間物件(像是日期、時間、偏移量等)讀取操作,Temporal 是 TemporalAccessor 子介面,增加了對時間的處理操作,像是 plusminuswith 等方法,方才你看過的 JSR310 相關類別,幾乎都有實作 Temporal 介面,像是 …

  • Instant
  • LocalDateLocalDateTimeLocalTime
  • OffsetDateTimeOffsetTime
  • YearYearMonth
  • ZonedDateTime

有趣的是,MonthDay 是唯讀的,也就是僅實作了 TemporalAccessor 介面,為什麼呢?在 MonthDay 的 API 文件 有說明,因為有閏年問題,在缺少「年」的資訊下,如果 MonthDay 可進行 plus 操作,那麼 2 月 28 日加一天會是 2 月 29 日或是 3 月 1 日就無法定義了…

來看看 Temporal 介面定義的幾個操作:

  • plus(TemporalAmount amount)
  • plus(long amountToAdd, TemporalUnit unit)
  • minus(TemporalAmount amount)
  • minus(long amountToSubtract, TemporalUnit unit)

操作時必須有時間的量,這是由 TemporalAmount 定義,實際上方才看過 JSR310 中的 DurationPeriod 類別,都實作了 TemporalAmount;如果不使用 TemporalAmount 實例,那也可以指定數字配合時間單位,也就是 TemporalUnit 列舉的單位:

【Joda-Time 與 JSR310 】(4)使用 JDK8 日期時間 API

如果只是想調整某個日期或時間欄位,可以使用 Temporalwith 方法,像是 with(TemporalField field, long newValue)TemporalField 列舉了一些欄位:

【Joda-Time 與 JSR310 】(4)使用 JDK8 日期時間 API

如果你需要更複雜的調整,可以使用 Twith(TemporalAdjuster adjuster),細節可參考 TemporalAdjuster 的 API 文件。

單一年曆系統設計

內部實作除了上述問題之外,也有年曆系統複雜及容易引發誤用的問題,Stephen Colebourne 以下列程式碼為例,month 結果可能是 1 ~ 12,但也有可能是 1 ~ 13:

int month = dateTime.getMonthOfDay();

如果 dateTime 參考的 DateTime 實例中,實際上若採用了科普特曆(Coptic calendar)的 CopticChronology 實例,傳回值就有可能是 1 ~ 13,如果你一直想著用 1 ~ 12 的結果去進行後續運算,就有可能出錯,因為你沒有去確定過使用的是不是 ISO 年歷系統。

JSR310 採單一年曆系統設計,也就是說,事實上 java.time 套件中的類別在需要採行年曆系統時,其實都是採用單一的 ISO-8601 年曆系統;那麼,如果需要其他年曆系統呢?你不能像 Joda-Time 中進行抽換,而需要明確採行 java.time.chrono 中的相關類別,JapaneseChronologyThaiBuddhistChronology
等實作了 Chronology 介面的類別,可以作為使用的起點。

總結

簡單來說,使用 JDK 現有的 DateCalendar 等既存的日期時間 API,容易出錯、痛苦且麻煩,日期時間在處理時的複雜度,也遠超過平常人們的想像,在處理時間之前,得想想現在想處理的是機器上的時間概念,還是人類對時間的概念,在 Java 這塊的話,最好是選用個 Joda-Time 或 JSR310,處理上會比較容易。

不單只是 Java 會面臨【Joda-Time 與 JSR310 】系列中談到的問題,其他語言生態系在處理日期時間時,也會遇到類似問題,以下是一些剛好我有看過的替代程式庫參考:

  • Date4j:對 java.util.Date 的簡單替代方案
  • Arrow:Python 中更好的日期與時間處理程式庫
  • Moment.js:JavaScript 中的日期程式庫
  • Noda-Time:.NET 陣營對 Joda-Time 的複刻

以下是這系列在準備過程中,一些可以參考的文件來源: