1. 程式人生 > >新的日期時間API

新的日期時間API

本文參考書籍《Java 8實戰》,陸明剛、勞佳  譯,如有侵權,請聯絡刪除!

在Java 1.0中,對日期和時間的支援只能依賴java.util.Date類。正如類名所表達的,這個類無法表示日期,只能以毫秒的精度表示時間。更糟糕的是它的易用性,由於某些原因未知的設計決策,這個類的易用性被深深地損害了,比如:年份的起始選擇是1900年,月份的起始從0開始。這意味著,如果你想要用Date表示Java 8的釋出日期,即2014年3月18日,需要建立下面這樣的Date例項:

    Date date = new Date(114, 2, 18);

它的列印輸出效果為:

    Tue Mar 18 00:00:00 CET 2014

看起來不那麼直觀。此外,Date類的toString方法返回的字串也容易誤導人。以我們的例子而言,它的返回值中甚至還包含了JVM的預設時區CET,即中歐時間(CentralEurope Time)。但這並不表示Date類在任何方面支援時區。

隨著Java 1.0退出歷史舞臺, Date類的種種問題和限制幾乎一掃而光,但很明顯,這些歷史舊賬如果不犧牲前向相容性是無法解決的。所以,在Java 1.1中, Date類中的很多方法被廢棄了,取而代之的是java.util.Calendar類。很不幸, Calendar類也有類似的問題和設計缺陷,導致使用這些方法寫出的程式碼非常容易出錯。比如,月份依舊是從0開始計算(不過,至少Calendar類拿掉了由1900年開始計算年份這一設計)。更糟的是,同時存在Date和Calendar這兩個類,也增加了程式設計師的困惑。到底該使用哪一個類呢?此外,有的特性只在某一個類有提供,比如用於以語言無關方式格式化和解析日期或時間的DateFormat方法就只在Date類裡有。

DateFormat方法也有它自己的問題。比如,它不是執行緒安全的。這意味著兩個執行緒如果嘗試使用同一個formatter解析日期,你可能會得到無法預期的結果。

最後, Date和Calendar類都是可以變的。能把2014年3月18日修改成4月18日意味著什麼呢?這種設計會將你拖入維護的噩夢。

所有這些缺陷和不一致導致使用者們轉投第三方的日期和時間庫,比如Joda-Time。為了解決這些問題, Oracle決定在原生的Java API中提供高質量的日期和時間支援。所以,你會看到Java 8在java.time包中整合了很多Joda-Time的特性。讓我們從探索如何建立簡單的日期和時間間隔入手。java.time包中提供了很多新的類可以幫你解決問題,它們是LocalDate、 LocalTime、 Instant、 Duration和Period。

LocalDate 和 LocalTime

LocalDate類的例項是一個不可變物件,它只提供了簡單的日期,並不含當天的時間資訊。另外,它也不附帶任何與時區相關的資訊。可以通過靜態工廠方法of建立一個LocalDate例項。 LocalDate例項提供了多種方法來讀取常用的值,比如年份、月份、星期幾等,如下所示:

        LocalDate date = LocalDate.of(2014, 3, 18);
        int year = date.getYear(); // 2014
        Month month = date.getMonth(); // MARCH
        int day = date.getDayOfMonth(); // 18
        DayOfWeek dow = date.getDayOfWeek(); // TUESDAY
        int len = date.lengthOfMonth(); // 31 (days in March)
        boolean leap = date.isLeapYear(); // false (not a leap year,不是閏年)

還可以通過傳遞一個TemporalField引數給get方法拿到同樣的資訊。 TemporalField是一個介面,它定義瞭如何訪問temporal物件個欄位的值。 ChronoField列舉實現了這一介面,所以你可以很方便地使用get方法得到列舉元素的值,如下所示:

        int y = date.get(ChronoField.YEAR);
        int m = date.get(ChronoField.MONTH_OF_YEAR);
        int d = date.get(ChronoField.DAY_OF_MONTH);

可以使用工廠方法從系統時鐘中獲取當前的日期:

    LocalDate today = LocalDate.now();

類似地,一天中的時間,比如13:45:20,可以使用LocalTime類表示。可以使用of過載的兩個工廠方法建立LocalTime的例項。第一個過載函式接收小時和分鐘,第二個過載函式同時還接收秒。同LocalDate一樣, LocalTime類也提供了一些getter方法訪問這些變數的值,如下所示:

        LocalTime time = LocalTime.of(13, 45, 20); // 13:45:20
        int hour = time.getHour(); // 13
        int minute = time.getMinute(); // 45
        int second = time.getSecond(); // 20

LocalDate和LocalTime都可以通過解析代表它們的字串建立。使用靜態方法parse,可以實現這一目的:

    LocalDate date = LocalDate.parse("2014-03-18");
    LocalTime time = LocalTime.parse("13:45:20")

可以向parse方法傳遞一個DateTimeFormatter(第二個引數),該類的例項定義瞭如何格式化一個日期或者時間物件,它是替換老版java.util.DateFormat的推薦替代品。後面會詳細介紹DateTimeFormatter。一旦傳遞的字串引數無法被解析為合法的LocalDate或LocalTime物件,這兩個parse方法都會丟擲一個繼承自RuntimeException的DateTimeParseException異常。

LocalDateTime

LocalDateTime,是LocalDate和LocalTime的合體。它同時表示了日期和時間,但不帶有時區資訊,可以直接建立,也可以通過合併日期和時間物件構造,如下所示:

        LocalDateTime dt1 = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45, 20); // 2014-03-18 13:45:20
        LocalDateTime dt2 = LocalDateTime.of(date, time);
        LocalDateTime dt3 = date.atTime(13, 45, 20);
        LocalDateTime dt4 = date.atTime(time);
        LocalDateTime dt5 = time.atDate(date);

通過LocalDate和LocalTime的atTime或者atDate方法,向LocalDate傳遞一個時間物件,或者向LocalTime傳遞一個日期物件的方式,可以建立一個LocalDateTime物件。也可以使用LocalDateTime的toLocalDate或者toLocalTime方法,從LocalDateTime中提取LocalDate或者LocalTime:

    LocalDate date1 = dt1.toLocalDate();
    LocalTime time1 = dt1.toLocalTime();

Instant

作為人,我們習慣於以星期幾、幾號、幾點、幾分這樣的方式理解日期和時間,這種方式對於計算機而言並不容易理解。從計算機的角度來看,建模時間最自然的格式是表示一個持續時間段上某個點的單一大整型數。這也是新的java.time.Instant類對時間建模的方式,它是以Unix元年時間(傳統的設定為UTC時區1970年1月1日午夜時分)開始所經歷的秒數進行計算。

可以通過向Instant類的靜態工廠方法ofEpochSecond傳遞一個代表秒數的值建立一個該類的例項,例如:

    Instant instant = Instant.ofEpochSecond(44 * 365 * 86400);

靜態工廠方法ofEpochSecond還有一個增強的過載版本,它接收第二個以納秒為單位的引數值,對傳入作為秒數的引數進行調整。過載的版本會調整納秒引數,確保儲存的納秒分片在0到999 999999之間。這意味著下面這些對ofEpochSecond工廠方法的呼叫會返回幾乎同樣的Instant物件:

    Instant.ofEpochSecond(3); 
    Instant.ofEpochSecond(3, 0);
    Instant.ofEpochSecond(2, 1000000000); // 2秒之後再加上100萬納秒(1秒)
    Instant.ofEpochSecond(4, -1000000000); // 4秒之前的100萬納秒(1秒)

Instant類也支援靜態工廠方法now,它能夠獲取當前時刻的時間戳:

    Instant now = Instant.now();

特別強調一點, Instant的設計初衷是為了便於機器使用。它包含的是由秒及納秒所構成的數字。所以,它無法處理那些我們非常容易理解的時間單位。比如下面這段語句:

    int day = Instant.now().get(ChronoField.DAY_OF_MONTH);

它會丟擲下面這樣的異常:

    java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: DayOfMonth

Duration 和 Period

目前為止,我們看到的所有類都實現了Temporal介面。Temporal介面定義瞭如何讀取和操縱為時間建模的物件的值。之前的介紹中,我們已經瞭解了建立Temporal例項的幾種方法,很自然地我們會想到,我們需要建立兩個Temporal物件之間的duration。 Duration類的靜態工廠方法between就是為這個目的而設計的。你可以建立兩個LocalTime物件、兩個LocalDateTime物件,或者兩個Instant物件之間的duration,如下所示:

    Duration d1 = Duration.between(time1, time2);
    Duration d1 = Duration.between(dateTime1, dateTime2);
    Duration d2 = Duration.between(instant1, instant2);

如果需要以年、月或者日的方式對多個時間單位建模,可以使用Period類。使用該類的工廠方法between,你可以使用得到兩個LocalDate之間的時長,如下所示:

    Period tenDays = Period.between(LocalDate.of(2014, 3, 8), LocalDate.of(2014, 3, 18));

Duration和Period類都提供了很多非常方便的工廠類,直接建立對應的例項;換句話說,就像下面這段程式碼那樣,不再是隻能以兩個temporal物件的差值的方式來定義它們的物件:

    Duration threeMinutes = Duration.ofMinutes(3);
    Duration threeMinutes = Duration.of(3, ChronoUnit.MINUTES);
    Period tenDays = Period.ofDays(10);
    Period threeWeeks = Period.ofWeeks(3);
    Period twoYearsSixMonthsOneDay = Period.of(2, 6, 1);

操縱、解析和格式化日期

如果你已經有一個LocalDate物件,想要建立它的一個修改版,最直接也最簡單的方法是使用withAttribute方法。withAttribute方法會建立物件的一個副本,並按照需要修改它的屬性。注意,下面的這段程式碼中所有的方法都返回一個修改了屬性的物件,它們都不會修改原來的物件(它們都是不可變物件)!

    LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18
    LocalDate date2 = date1.withYear(2011); // 2011-03-18
    LocalDate date3 = date2.withDayOfMonth(25); // 2011-03-25
    LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 9); // 2011-09-25

採用更通用的with方法能達到同樣的目的,它接受的第一個引數是一個TemporalField物件,第二個引數是修改後的新值,格式類似上面的程式碼清單的最後一行。最後這一行中使用的with方法和前面例子中使用的get方法有些類似,它們都聲明於Temporal介面,所有的日期和時間API類都實現這兩個方法,它們定義了單點的時間,比如LocalDate、 LocalTime、 LocalDateTime以及Instant。使用get和with方法,我們可以將Temporal物件值的讀取和修改區分開。如果Temporal物件不支援請求訪問的欄位,它會丟擲一個UnsupportedTemporalTypeException異常,比如 試 圖 訪 問Instant 對 象 的ChronoField.MONTH_OF_YEAR 字 段 , 或 者LocalDate 對 象 的ChronoField.NANO_OF_SECOND欄位時都會丟擲這樣的異常。

我們甚至能以宣告的方式操縱LocalDate物件。比如,可以像下面這段程式碼那樣加上或者減去一段時間:

    LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18
    LocalDate date2 = date1.plusWeeks(1); // 2014-03-25
    LocalDate date3 = date2.minusYears(3); // 2011-03-25
    LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS); // 2011-09-25

與get和with方法類似,上面程式碼清單中最後一行使用的plus方法也是通用方法,它和minus方法都聲明於Temporal介面中。通過這些方法,對TemporalUnit物件加上或者減去一個數字,我們能非常方便地將Temporal物件前溯或者回滾至某個時間段,通過ChronoUnit列舉我們可以非常方便地實現TemporalUnit介面。

下表對這些通用的方法進行了總結。

方法名 是否是靜態方法 描述
from 依據傳入的 Temporal 物件建立物件例項
now 依據系統時鐘建立 Temporal 物件
of 由 Temporal 物件的某個部分建立該物件的例項
parse 由字串建立 Temporal 物件的例項
atOffset 將 Temporal 物件和某個時區偏移相結合
atZone 將 Temporal 物件和某個時區相結合
format 使用某個指定的格式器將Temporal 物件轉換為字串(Instant 類不提供該方法)
get 讀取 Temporal 物件的某一部分的值
minus 建立 Temporal 物件的一個副本,通過將當前 Temporal 物件的值減去一定的時長 建立該副本
plus 建立 Temporal 物件的一個副本,通過將當前 Temporal 物件的值加上一定的時長 建立該副本
with 以該 Temporal 物件為模板,對某些狀態進行修改建立該物件的副本

TemporalAdjuster

截至目前,我們所看到的所有日期操作都是相對比較直接的。有的時候,需要進行一些更加複雜的操作,比如,將日期調整到下個週日、下個工作日,或者是本月的最後一天。這時,你可以使用過載版本的with方法,向其傳遞一個提供了更多定製化選擇的TemporalAdjuster物件,更 加 靈 活 地 處 理 日 期 。 對 於 最 常 見 的 用 例 , 日 期 和 時 間 API 已 經 提 供 了 大 量 預 定 義 TemporalAdjuster。可以通過TemporalAdjusters類的靜態工廠方法訪問它們,如下所示:

    LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18
    LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); // 2014-03-23,注意靜態匯入
    LocalDate date3 = date2.with(lastDayOfMonth()); // 2014-03-31

下表提供了TemporalAdjusters中包含的工廠方法列表:

方法名 描述
dayOfWeekInMonth 建立一個新的日期,它的值為同一個月中每一週的第幾天
firstDayOfMonth 建立一個新的日期,它的值為當月的第一天
firstDayOfNextMonth 建立一個新的日期,它的值為下月的第一天
firstDayOfNextYear 建立一個新的日期,它的值為明年的第一天
firstDayOfYear 建立一個新的日期,它的值為當年的第一天
firstInMonth 建立一個新的日期,它的值為同一個月中,第一個符合星期幾要求的值
lastDayOfMonth 建立一個新的日期,它的值為當月的最後一天
lastDayOfNextMonth 建立一個新的日期,它的值為下月的最後一天
lastDayOfNextYear 建立一個新的日期,它的值為明年的最後一天
lastDayOfYear 建立一個新的日期,它的值為今年的最後一天
lastInMonth 建立一個新的日期,它的值為同一個月中,最後一個符合星期幾要求的值
next/previous 建立一個新的日期,並將其值設定為日期調整後或者調整前,第一個符合指定星期幾要求的日期
nextOrSame/previousOrSame 建立一個新的日期,並將其值設定為日期調整後或者調整前,第一個符合指定星期幾要求的日期,如果該日期已經符合要求,直接返回該物件

可見,使用TemporalAdjuster我們可以進行更加複雜的日期操作,而且這些方法的名稱也非常直觀,方法名基本就是問題陳述。此外,即使你沒有找到符合你要求的預定義的TemporalAdjuster,建立你自己的TemporalAdjuster也並非難事。實際上,TemporalAdjuster介面只聲明瞭單一的一個方法(這使得它成為了一個函式式介面),定義如下:

@FunctionalInterface
public interface TemporalAdjuster {

    Temporal adjustInto(Temporal temporal);

}

這意味著TemporalAdjuster介面的實現需要定義如何將一個Temporal物件轉換為另一個Temporal物件,可以把它看成一個UnaryOperator<Temporal>,具體如何實現可以參考TemporalAdjusters原始碼。

DateTimeFormatter

處理日期和時間物件時,格式化以及解析日期時間物件是另一個非常重要的功能。java.time.format.DateTimeFormatter類就是為這個目的而設計的。DateTimeFormatter是一個可以替代DateFormat的日期時間格式化器。可以通過它的靜態工廠方法或預定義常量得到DateTimeFormatter的例項。下面的這個例子中,我們使用了兩個不同的格式器生成了字串:

    LocalDate date = LocalDate.of(2014, 3, 18);
    String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); // 20140318
    String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2014-03-18

你也可以通過解析代表日期或時間的字串重新建立該日期物件。所有的日期和時間API都提供了表示時間點或者時間段的工廠方法,你可以使用工廠方法parse達到重創該日期物件的目的:

    LocalDate date1 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE);
    LocalDate date2 = LocalDate.parse("2014-03-18", DateTimeFormatter.ISO_LOCAL_DATE);

和java.util.DateFormat相比較,DateTimeFormatter例項都是執行緒安全的。所以,可以以單例模式建立DateTimeFormatter的例項。 DateTimeFormatter類還支援一個靜態工廠方法,它可以按照某個特定的模式建立DateTimeFormatter例項,程式碼清單如下:

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
    LocalDate date1 = LocalDate.of(2014, 3, 18);
    String formattedDate = date1.format(formatter); // 13/03/2014
    LocalDate date2 = LocalDate.parse(formattedDate, formatter);

ofPattern方法也提供了一個過載的版本,使用它你可以建立某個Locale的格式器,程式碼清單如下所示:

    DateTimeFormatter italianFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
    LocalDate date1 = LocalDate.of(2014, 3, 18);
    String formattedDate = date.format(italianFormatter); // 18. marzo 2014
    LocalDate date2 = LocalDate.parse(formattedDate, italianFormatter);

時區

前面我們看到的日期和時間的種類都不包含時區資訊。時區的處理是新版日期和時間API新增加的重要功能,使用新版日期和時間API時區的處理被極大地簡化了。新的java.time.ZoneId類是老版java.util.TimeZone的替代品。它的設計目標就是要讓你無需為時區處理的複雜和繁瑣而操心,比如處理日光時(Daylight Saving Time, DST)這種問題。跟其他日期和時間類一樣, ZoneId類也是無法修改的。

時區是按照一定的規則將區域劃分成的標準時間相同的區間。在ZoneRules這個類中包含了40個這樣的例項。你可以簡單地通過呼叫ZoneId的getRules()得到指定時區的規則。每個特定的ZoneId物件都由一個地區ID標識,比如:

    ZoneId romeZone = ZoneId.of("Europe/Rome");

地區ID都為“{區域}/{城市}”的格式,這些地區集合的設定都由英特網編號分配機構(IANA)的時區資料庫提供。可以通過Java 8的新方法toZoneId將一個老的時區物件轉換為ZoneId:

    ZoneId zoneId = TimeZone.getDefault().toZoneId();

一旦得到一個ZoneId物件,就可以將它與LocalDate、 LocalDateTime或者是Instant物件整合起來,構造一個ZonedDateTime例項,它代表了相對於指定時區的時間點,程式碼清單如下所示:

    LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
    ZonedDateTime zdt1 = date.atStartOfDay(romeZone);
    LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
    ZonedDateTime zdt2 = dateTime.atZone(romeZone);
    Instant instant = Instant.now();
    ZonedDateTime zdt3 = instant.atZone(romeZone);

下圖對ZonedDateTime的組成部分進行了說明,相信能夠幫助你理解LocaleDate、LocalTime、 LocalDateTime以及ZoneId之間的差異。

通過ZoneId,你還可以將LocalDateTime轉換為Instant:

    LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
    Instant instantFromDateTime = dateTime.toInstant(romeZone);

你也可以通過反向的方式得到LocalDateTime物件:

    Instant instant = Instant.now();
    LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, romeZone);