原文出處: About Rolandz

對很多應用來說,時間和日期的概念都是必須的。像生日,租賃期,事件的時間戳和商店營業時長,等等,都是基于時間和日期的;然而,Java卻沒有好的API來處理它們。在Java SE 8中,添加了一個新包:java.time,它提供了結構良好的API來處理時間和日期。

歷史

在Java剛剛發布,也就是版本1.0的時候,對時間和日期僅有的支持就是java.util.Date類。大多數開發者對它的第一印象就是,它根本不代表一個“日期”。實際上,它只是簡單的表示一個,從1970-01-01Z開始計時的,精確到毫秒的瞬時點。由于標準的toString()方法,按照JVM的默認時區輸出時間和日期,有些開發人員把它誤認為是時區敏感的。

在升級Java到1.1期間,Date類被認為是無法修復的。由于這個原因,java.util.Calendar類被添加了進來。悲劇的是,Calendar類并不比java.util.Date好多少。它們面臨的部分問題是:

  • 可變性。像時間和日期這樣的類應該是不可變的。
  • 偏移性。Date中的年份是從1900開始的,而月份都是從0開始的。
  • 命名。Date不是“日期”,而Calendar也不真實“日歷”。
  • 格式化。格式化只對Date有用,Calendar則不行。另外,它也不是線程安全的。

大約在2001年,Joda-Time項目開始了。它的目的很簡單,就是給Java提供一個高質量的時間和日期類庫。盡管被耽擱了一段時間,它的1.0版還是被發布。很快,它就成為了廣泛使用和流行的類庫。隨著時間的推移,有越來越多的需求,要在JDK中擁有一個像Joda-Time的這樣類庫。在來自巴西的Michael Nascimento Santos的幫助下,官方為JDK開發新的時間/日期API的進程:JSR-310,啟動了。

綜述

新的API:java.time,由5個包組成:

大多數開發者只會用到基礎和format包,也可能會用到temporal包。因此,盡管有68個新的公開類型,大多數開發者,大概,將只會用到其中的三分之一。

日期

在新的API中,LocalDate是其中最重要的類之一。它是表示日期的不可變類型,不包含時間和時區。

“本地”,這個術語,我們對它的熟悉來自于Joda-Time。它原本出自ISO-8061的時間和日期標準,它和時區無關。實際上,本地日期只是日期的描述,例如“2014年4月5日”。特定的本地時間,因你在地球上的不同位置,開始于不同的時間線。所以,澳大利亞的本地時間開始的比倫敦早10小時,比舊金山早18小時。

LocalDate被設計成,它的所有方法,都是常用方法:

1
2
3
4
5
6
7
LocalDate date = LocalDate.of(2014, Month.JUNE, 10);
int year = date.getYear(); // 2014
Month month = date.getMonth(); // 6月
int dom = date.getDayOfMonth(); // 10
DayOfWeek dow = date.getDayOfWeek(); // 星期二
int len = date.lengthOfMonth(); // 30 (6月份的天數)
boolean leap = date.isLeapYear(); // false (不是閏年)

在上面的例子中,我們看到日期使用工廠方法(所有的構造方法都是私有的)創建。然后用它查詢了部分基本信息。注意:枚舉類型,MonthDayOfWeek,被設計用來增強代碼的可讀性和可靠性。

在下面的例子中,我們來看看如何操作LocalDate的實例。由于它是不可變類型,每次操作都會產生一個新的實例,而原有實例不收任何影響。

1
2
3
4
LocalDate date = LocalDate.of(2014, Month.JUNE, 10);
date = date.withYear(2015); // 2015-06-10
date = date.plusMonths(2); // 2015-08-10
date = date.minusDays(1); // 2015-08-09

上面這些都很簡單,但有時我們需要對日期進行更復雜的修改。java.time包含了對此的處理機制:TemporalAdjuster類。時間修改器背后的設計思想是,提供一個預裝包的、能操縱日期的功能,比如,根據月份的最后一天獲取日期的對象。API提供了一些通用的功能,你可以新增你自己的。修改器的使用很簡單,但使用靜態導入的方式,會讓你更方便:

1
2
3
4
5
6
import static java.time.DayOfWeek.*
import static java.time.temporal.TemporalAdjusters.*
  
LocalDate date = LocalDate.of(2014, Month.JUNE, 10);
date = date.with(lastDayOfMonth());
date = date.with(nextOrSame(WEDNESDAY));

java.time包中,像所有主要的時間/日期類一樣,LocalDate類是固定于單個歷法系統的:ISO-8601標準定義的歷法。
看見修改器的瞬間反應就是,這代碼跟業務邏輯長的差不多啊!這對時間/日期類業務邏輯是很重的。我們最后想看的是多次手動修改日期。如果你的代碼庫里,有一個將會使用很多次的,對日期的通用操作,考慮把它做成修改器,然后告訴你的小組成員,把它當做一個寫好的,測試過的組件,直接拿來用吧。

時間/日期值對象

值得花點時間弄清楚,是什么導致了LocalDate類成為值類型。值類型是這樣一種簡單的數據類型:2個實例,只要內容相同,就該是可以相互替換的,是不是同一個實例,并不重要。String類就是一個標準的值類型的例子,只要字面值一樣,我們就認為它們相等,而不關心它們是不是同一個String對象的不同引用。

大部分時間/日期類都應該是值類型的,java.time開發包印證了這一點。因此,我們沒有理由去用==來判斷2個LocalDate是不是相等,實際上,Javadoc也反對這樣做。

對于值類型,想要更多了解的同學,可以參考我最近的文章,VALJOs:在Java中,它對值類型,定義了嚴格的規則集合,包括不可變性、工廠方法和良好定義的equals()hashCode()toString()compareTo()方法。

不同的歷法系統

java.time包中,像所有主要的時間/日期類一樣,LocalDate是固定于單個歷法系統的:由ISO-8601標準定義。

ISO-8601歷法系統是事實上的世界民用歷法系統,也就是公歷。平年有365天,閏年是366天。閏年的定義是:非世紀年,能被4整除;世紀年能被400整除。為了計算的一致性,公元1年的前一年被當做公元0年,以此類推。

采用這套歷法,第一個影響就是,ISO-8601的日期不必跟GregorianCalendar一致。在GregorianCalendar中,凱撒歷格里高利歷之間有一個轉換日,一般默認在1582年10月15日。那天之前,用凱撒歷:每4年一個閏年,沒有例外。那天之后,用格里高利歷,也就是公歷,擁有稍微復雜點的閏年計算方式。

既然凱撒歷和格里高利歷之間的轉換是個歷史事實,那為什么新的java.time開發包不參照它呢?原因就是,現在使用歷史日期的大部分Java應用程序,都是不正確的,繼續下去,是個錯誤。這是為什么呢?當年,羅馬的梵蒂岡,把歷法從凱撒歷改換成格里高利歷的時候,世界上大部分其他地區并沒有更換歷法。比如大英帝國,包括早期的美國,直到大約200后的1752年9月14日才換歷法,沙俄直到1918年2月14日,而瑞典的歷法轉換更是一團糟。因此,實際上,對1918之前的日期,解釋是相當多的;僅相信擁有單一轉換日的GregorianCalendar,是不靠譜的。所以LocalDate中沒有這種轉換,就是一個合理的選擇了。應用程序需要額外的上下文信息,才能在凱撒歷和格里高利歷間,精確的解釋特定的歷史日期。

第二個影響是,我們需要額外的一組類來幫助處理其他歷法系統。Chronology接口,是其他歷法的主要入口點,它允許通過所屬的語言環境查找對應的歷法系統。Java 8支持額外的4個歷法系統:泰國佛教歷,中華民國歷,日本歷(沿襲中國古代帝位紀年),伊斯蘭歷。如有需要,應用程序也可以實現自己的歷法系統。

每個歷法系統都有自己的日期類,有ThaiBuddhistDateMinguoDateJapaneseDateHijrahDate。它們應在對本地化有嚴重需求的應用中使用,比如為日本政府開發的系統。它們4個都繼承了另外一個接口,ChronoLocalDate,可以讓代碼在不知道歷法系統的情況下,去操作它們。雖然如此,但還是希望少用這個接口。

理解為什么少用ChronoLocalDate,對正確的使用整個java.time開發包很關鍵。真實情況是,當我們檢視當前的應用,盡量以歷法無關的方式來操作日期的大部分代碼,都有問題。例如,你不能假定一年有12個月,而開發者都是這樣認為的,并且增加12個月,他們就認為是增加了一年。你不能認為所有的月份都有相同的天數,比如,科普特人的歷法,包括12個30天的月份,還有一個月僅有5天,或者6天。你也不能認為,下一年的年份就比現在的年份大了1年,比如日本歷,在天皇換代時,會重新紀年,此時,還在那一年的年中(你甚至不能認為在同一個月的兩天,是屬于同一年的)。

在一個大型的系統中,唯一的以歷法無關的方式開發的方法是:形成嚴格的代碼審查制度,對日期和時間相關的每行代碼都要做雙重檢查,以防偏向ISO歷法系統。因此,推薦的做法是,在系統中,全部使用LocalDate,包括存儲,操作和解釋業務規則。僅有的,使用ChronoLocalDate的時候是,本地化的輸入/輸出,典型的做法是使用用戶配置中首選的歷法;即使如此,大多數應用并不需要那樣的本地化級別。

如需更全面的了解,查看ChronoLocalDate的Javadoc

時間

日期之后,下一個考慮的概念就是本地時間,LocalTime。典型的例子就是便利店的營業時間,例如從07:00到23:00(早上7點到晚上11點)。可能,在這個時間段營業的便利店,遍布整個美利堅,但是這個時間是本地化的,跟時區無關。

LocalTime是值類型,且跟日期和時區沒有關聯。當我們對時間進行加減操作時,以午夜基準,24小時一個周期。因此,20:00加上6小時,結果就是02:00。

LocalTime的用法跟LocalDate相似:

1
2
3
4
5
LocalTime time = LocalTime.of(20, 30);
int hour = date.getHour(); // 20
int minute = date.getMinute(); // 30
time = time.withSecond(6); // 20:30:06
time = time.plusMinutes(3); // 20:33:06

修改器機制同樣適用于LocalTime,只是對它的復雜操作比較少。

時間和日期組合

下一個要考察的是LocalDateTime類。這個值類型只是LocalDateLocalTime的簡單組合。它表示一個跟時區無關的日期和時間。

LocalDateTime可以直接創建,或者組合時間和日期:

1
2
3
4
LocalDateTime dt1 = LocalDateTime.of(2014, Month.JUNE, 10, 20, 30);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(20, 30);
LocalDateTime dt4 = date.atTime(time);

第三和第四行使用atTime()方法,平滑地構造一個LocalDateTime實例。大部分的時間和日期類都有“at“方法:以這樣的方式,把當前對象和其他對象組合,生成更復雜的對象。

LocalDateTime的其他方法跟LocalDateLocalTime相似。這種相似的方法模式非常有利于API的學習。下面總結了用到的方法前綴:

  • of: 靜態工廠方法,從組成部分中創建實例
  • from: 靜態工廠方法,嘗試從相似對象中提取實例。from()方法沒有of()方法類型安全
  • now: 靜態工廠方法,用當前時間創建實例
  • parse: 靜態工廠方法,總字符串解析得到對象實例
  • get: 獲取時間日期對象的部分狀態
  • is: 檢查關于時間日期對象的描述是否正確
  • with: 返回一個部分狀態改變了的時間日期對象拷貝
  • plus: 返回一個時間增加了的、時間日期對象拷貝
  • minus: 返回一個時間減少了的、時間日期對象拷貝
  • to: 把當前時間日期對象轉換成另外一個,可能會損失部分狀態
  • at: 用當前時間日期對象組合另外一個,創建一個更大或更復雜的時間日期對象
  • format: 提供格式化時間日期對象的能力

時間點

在處理時間和日期的時候,我們通常會想到年,月,日,時,分,秒。然而,這只是時間的一個模型,是面向人類的。第二種通用模型是面向機器的,或者說是連續的。在此模型中,時間線中的一個點表示為一個很大的數。這有利于計算機處理。在UNIX中,這個數從1970年開始,以秒為的單位;同樣的,在Java中,也是從1970年開始,但以毫秒為單位。

java.time包通過值類型Instant提供機器視圖。Instant表示時間線上的一點,而不需要任何上下文信息,例如,時區。概念上講,它只是簡單的表示自1970年1月1日0時0分0秒(UTC)開始的秒數。因為java.time包是基于納秒計算的,所以Instant的精度可以達到納秒級。

1
2
3
4
Instant start = Instant.now();
// perform some calculation
Instant end = Instant.now();
assert end.isAfter(start);

Instant典型的用法是,當你需要記錄事件的發生時間,而不需要記錄任何有關時區信息時,存儲和比較時間戳。它額很多有趣的地方在于,你不能對它做什么,而不是你能做什么。例如,下面的幾行代碼會拋出異常:

1
2
instant.get(ChronoField.MONTH_OF_YEAR);
instant.plus(6, ChronoUnit.YEARS);

拋出這些異常是因為,Instant只包含秒數和納秒數,不提供處理人類意義上的時間單位。如果確實有需要,你需要額外提供時區信息。

時區

時區的概念由大英帝國開始采用。鐵路的發明和通訊工具的改進,突然意味著人們的活動范圍,大到跟太陽時的改變有很大的關系。在此之前,每個城鎮和村莊,都通過太陽和日晷規定自己的時間。

下面的英國布魯斯托交易所的時鐘照片,顯示了時區導致的最初混亂的一個例子。紅色的指針顯示格林威治時間,而黑色指針顯示布魯斯托時間,它們相差10分鐘。

在技術的推動下,標準的時區系統慢慢演進,最終替代了老舊的本地太陽法計時。然而,關鍵的事實是,時區也是政治的產物。它們被用來顯示對一個地區的政治控制,例如,最近的克里米亞時改為莫斯科時。一旦和政治掛鉤,相關的規則常常不合邏輯。

時區規則,由一個發布IANA時區數據庫的國際組織搜集和匯總。這些數據,包含地球上每個地區的標識和時區的歷史變化。標識的格式類似”歐洲/倫敦“,或者”美洲/紐約“。

java.time之前,我們用TimeZone表示時區,而現在,用ZoneId。它們有2個主要的不同。第一,ZoneId是不可變的,它里面保存時區縮寫的靜態變量也是不可變的;第二,實際的規則集在ZoneRules里,不在ZoneId中,通過getRules()方法可以獲得。

時區的常見情況,是從UTC/格林威治開始的一個固定偏移。我們通常在說時差的時候,會遇到它,例如,我們說紐約比倫敦晚5個小時。ZoneId的子類,ZoneOffset,代表了這種從倫敦格林威治零度子午線開始的時間偏移。

作為一個開發者,如果不用去處理時區和它帶來的復雜性,將會是非常棒的。java.time開發包盡最大努力的幫助你那樣做。只要有可能,盡量使用LocalDateLocalTimeLocalDateInstant。當你不能回避時區時,ZonedDateTime可以滿足你的需求。

ZoneDateTime負責處理面向人類的(臺歷和掛鐘上看到的)時間和面向機器的時間(始終連續增長的秒數)之間的轉換。因此,你可以通過本地時間或時間點來創建ZoneDateTime實例:

1
2
3
4
5
6
7
ZoneId zone = ZoneId.of("Europe/Paris");
  
LocalDate date = LocalDate.of(2014, Month.JUNE, 10);
ZonedDateTime zdt1 = date.atStartOfDay(zone);
  
Instant instant = Instant.now();
ZonedDateTime zdt2 = instant.atZone(zone);

最惱人的時區問題之一就是夏令時。在夏令時中,從格林威治的偏移每年要調整兩次(也許更多);典型的做法是,春天調快時間,秋天再調回來。夏令時開始時,我們都需要手動調整家里的掛鐘時間。這些調整,在java.time包中,叫偏移過渡。春天時,跟本地時間相比,缺了一段時間;相反,秋天時,有的時間會出現兩次。

ZonedDateTime在它的工廠方法和控制方法中處理了這些。例如,在夏令時切換的那天,增加一天會增加邏輯上的一天:可能多于24小時,也可能少于24小時。同樣的,方法atStartOfDay()之所以這樣命名,是因為你不能假定它的處理結果就一定是午夜零點,夏令時開始的那天,一天是從午夜1點開始的。

下面是關于夏令時的最后一個小提示。如果你想證明,在夏令時結束那天的重疊時段,你有考慮過什么情況會發生,你可以用這兩個專門處理重疊時段的方法之一:

1
2
zdt = zdt.withEarlierOffsetAtOverlap();
zdt = zdt.withLaterOffsetAtOverlap();

處于時間重疊時段時,使用這兩個方法之一,你可以得到調整之前或調整之后的時間。在其他情況下,這兩個方法是無效的。

時間長度

到目前為止,我們討論的時間/日期類以多種不同的方式表示時間線上的一個點。java.time還為時間長度額外提供了兩個值類型。

Duration表示以秒和納秒為基準的時長。例如,“23.6秒”。

Period表示以年、月、日衡量的時長。例如,“3年2個月零6天”。

它們可以作為參數,傳給主要的時間/日期類的增加或減少時間的方法:

1
2
3
Period sixMonths = Period.ofMonths(6);
LocalDate date = LocalDate.now();
LocalDate future = date.plus(sixMonths);

解析和格式化

java.time.format包是專門用來格式化輸出時間/日期的。這個包圍繞DateTimeFormatter類和它的輔助創建類DateTimeFormatterBuilder展開。

靜態方法加上DateTimeFormatter中的常量,是最通用的創建格式化器的方式。包括:

  • 常用ISO格式常量,如ISO_LOCAL_DATE
  • 字母模式,如ofPattern(“dd/MM/uuuu”)
  • 本地化樣式,如ofLocalizedDate(FormatStyle.MEDIUM)

很典型的,一旦有了格式化器,你可以把它傳遞給主要的時間/日期類的相關方法:

1
2
3
DateTimeFormatter f = DateTimeFormatter.ofPattern("dd/MM/uuuu");
LocalDate date = LocalDate.parse("24/06/2014", f);
String str = date.format(f);

這把你從格式化器自己的格式化和解析方法中隔離開來。

如果你想控制格式化的語言環境,調用格式化器的withLocale(Locale)方法。相似的方式可以允許你控制格式化的歷法系統、時區、十進制數和解析度。

如果你需要更多的控制權,查看DateTimeFormatterBuilder類吧,它允許你一步一步的構造更復雜的格式化器。它還提供大小寫不敏感的解析,松散的解析,字符填充和可選的格式。

總結

Java 8中的java.time是一個新的、復雜的時間/日期API。它把Joda-Time中的設計思想和實現推向了更高的層次,讓開發人員把java.util.DateCalendar拋在了身后。是時候重新享受時間/日期編程的樂趣了。