讀古今文學網 > Java 8實戰 > 第12章 新的日期和時間API >

第12章 新的日期和時間API

本章內容

  • 為什麼在Java 8中需要引入新的日期和時間庫

  • 同時為人和機器表示日期和時間

  • 定義時間的度量

  • 操縱、格式化以及解析日期

  • 處理不同的時區和曆法

Java的API提供了很多有用的組件,能幫助你構建複雜的應用。不過,Java API也不總是完美的。我們相信大多數有經驗的程序員都會贊同Java 8之前的庫對日期和時間的支持就非常不理想。然而,你也不用太擔心:Java 8中引入全新的日期和時間API就是要解決這一問題。

在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,即中歐時間(Central Europe Time)。但這並不表示Date類在任何方面支持時區。

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

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

最後,DateCalendar類都是可以變的。能把2014年3月18日修改成4月18日意味著什麼呢?這種設計會將你拖入維護的噩夢,接下來的一章,我們會討論函數式編程,你在該章中會瞭解到更多的細節。

所有這些缺陷和不一致導致用戶們轉投第三方的日期和時間庫,比如Joda-Time。為了解決這些問題,Oracle決定在原生的Java API中提供高質量的日期和時間支持。所以,你會看到Java 8在java.time包中整合了很多Joda-Time的特性。

這一章中,我們會一起探索新的日期和時間API所提供的新特性。我們從最基本的用例入手,比如創建同時適合人與機器的日期和時間,逐漸轉入到日期和時間API更高級的一些應用,比如操縱、解析、打印輸出日期-時間對象,使用不同的時區和年歷。

12.1 LocalDateLocalTimeInstantDuration以及Period

讓我們從探索如何創建簡單的日期和時間間隔入手。java.time包中提供了很多新的類可以幫你解決問題,它們是LocalDateLocalTimeInstantDurationPeriod

12.1.1 使用LocalDateLocalTime

開始使用新的日期和時間API時,你最先碰到的可能是LocalDate類。該類的實例是一個不可變對象,它只提供了簡單的日期,並不含當天的時間信息。另外,它也不附帶任何與時區相關的信息。

你可以通過靜態工廠方法of創建一個LocalDate實例。LocalDate實例提供了多種方法來讀取常用的值,比如年份、月份、星期幾等,如下所示。

代碼清單12-1 創建一個LocalDate對象並讀取其值

LocalDate date = LocalDate.of(2014, 3, 18);    ←─2014-03-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)

  

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

LocalDate today = LocalDate.now;

  

本章剩餘的部分會探討所有日期-時間類,這些類都提供了類似的工廠方法。你還可以通過傳遞一個TemporalField參數給get方法拿到同樣的信息。TemporalField是一個接口,它定義了如何訪問temporal對像某個字段的值。ChronoField枚舉實現了這一接口,所以你可以很方便地使用get方法得到枚舉元素的值,如下所示。

代碼清單12-2 使用TemporalField讀取LocalDate的值

int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronoField.DAY_OF_MONTH);

  

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

代碼清單12-3 創建LocalTime並讀取其值

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

  

LocalDateLocalTime都可以通過解析代表它們的字符串創建。使用靜態方法parse,你可以實現這一目的:

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

  

你可以向parse方法傳遞一個DateTimeFormatter。該類的實例定義了如何格式化一個日期或者時間對象。正如我們之前所介紹的,它是替換老版java.util.DateFormat的推薦替代品。我們會在12.2節展開介紹怎樣使用DateTimeFormatter。同時,也請注意,一旦傳遞的字符串參數無法被解析為合法的LocalDateLocalTime對象,這兩個parse方法都會拋出一個繼承自RuntimeExceptionDateTimeParseException異常。

12.1.2 合併日期和時間

這個復合類名叫LocalDateTime,是LocalDateLocalTime的合體。它同時表示了日期和時間,但不帶有時區信息,你可以直接創建,也可以通過合併日期和時間對像構造,如下所示。

代碼清單12-4 直接創建LocalDateTime對象,或者通過合併日期和時間的方式創建

// 2014-03-18T13:45:20
LocalDateTime dt1 = LocalDateTime.of(2014, Month.MARCH, 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);

  

注意,通過它們各自的atTime或者atDate方法,向LocalDate傳遞一個時間對象,或者向LocalTime傳遞一個日期對象的方式,你可以創建一個LocalDateTime對象。你也可以使用toLocalDate或者toLocalTime方法,從LocalDateTime中提取LocalDate或者LocalTime組件:

LocalDate date1 = dt1.toLocalDate;    ←─2014-03-18
LocalTime time1 = dt1.toLocalTime;    ←─13:45:20

  

12.1.3 機器的日期和時間格式

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

你可以通過向靜態工廠方法ofEpochSecond傳遞一個代表秒數的值創建一個該類的實例。靜態工廠方法ofEpochSecond還有一個增強的重載版本,它接收第二個以納秒為單位的參數值,對傳入作為秒數的參數進行調整。重載的版本會調整納秒參數,確保保存的納秒分片在0到999 999 999之間。這意味著下面這些對ofEpochSecond工廠方法的調用會返回幾乎同樣的Instant對像:

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

  

正如你已經在LocalDate及其他為便於閱讀而設計的日期-時間類中所看到的那樣,Instant類也支持靜態工廠方法now,它能夠幫你獲取當前時刻的時間戳。我們想要特別強調一點,Instant的設計初衷是為了便於機器使用。它包含的是由秒及納秒所構成的數字。所以,它無法處理那些我們非常容易理解的時間單位。比如下面這段語句:

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

  

它會拋出下面這樣的異常:

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

  

但是你可以通過DurationPeriod類使用Instant,接下來我們會對這部分內容進行介紹。

12.1.4 定義DurationPeriod

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

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

  

由於LocalDateTimeInstant是為不同的目的而設計的,一個是為了便於人閱讀使用,另一個是為了便於機器處理,所以你不能將二者混用。如果你試圖在這兩類對像之間創建duration,會觸發一個DateTimeException異常。此外,由於Duration類主要用於以秒和納秒衡量時間的長短,你不能僅向between方法傳遞一個LocalDate對像做參數。

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

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

  

最後,DurationPeriod類都提供了很多非常方便的工廠類,直接創建對應的實例;換句話說,就像下面這段代碼那樣,不再是只能以兩個temporal對象的差值的方式來定義它們的對象。

代碼清單12-5 創建DurationPeriod對像

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);

  

Duration類和Period類共享了很多相似的方法,參見表12-1所示。

表12-1 日期-時間類中表示時間間隔的通用方法

方法名

是否是靜態方法

方法描述

between

創建兩個時間點之間的interval

from

由一個臨時時間點創建interval

of

由它的組成部分創建interval的實例

parse

由字符串創建interval的實例

addTo

創建該interval的副本,並將其疊加到某個指定的temporal對像

get

讀取該interval的狀態

isNegative

檢查該interval是否為負值,不包含零

isZero

檢查該interval的時長是否為零

minus

通過減去一定的時間創建該interval的副本

multipliedBy

將interval的值乘以某個標量創建該interval的副本

negated

以忽略某個時長的方式創建該interval的副本

plus

以增加某個指定的時長的方式創建該interval的副本

subtractFrom

從指定的temporal對像中減去該interval

截至目前,我們介紹的這些日期-時間對象都是不可修改的,這是為了更好地支持函數式編程,確保線程安全,保持領域模式一致性而做出的重大設計決定。當然,新的日期和時間API也提供了一些便利的方法來創建這些對象的可變版本。比如,你可能希望在已有的LocalDate實例上增加3天。我們在下一節中會針對這一主題進行介紹。除此之外,我們還會介紹如何依據指定的模式,比如dd/MM/yyyy,創建日期-時間格式器,以及如何使用這種格式器解析和輸出日期。

12.2 操縱、解析和格式化日期

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

代碼清單12-6 以比較直觀的方式操縱LocalDate的屬性

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對象,格式類似代碼清單12-6的最後一行。最後這一行中使用的with方法和代碼清單12-2中的get方法有些類似。它們都聲明於Temporal接口,所有的日期和時間API類都實現這兩個方法,它們定義了單點的時間,比如LocalDateLocalTimeLocalDateTime以及Instant。更確切地說,使用getwith方法,我們可以將Temporal對像值的讀取和修改區分開。如果Temporal對像不支持請求訪問的字段,它會拋出一個UnsupportedTemporalTypeException異常,比如試圖訪問Instant對象的ChronoField.MONTH_OF_YEAR字段,或者LocalDate對象的ChronoField.NANO_OF_SECOND字段時都會拋出這樣的異常。

它甚至能以聲明的方式操縱LocalDate對象。比如,你可以像下面這段代碼那樣加上或者減去一段時間。

代碼清單12-7 以相對方式修改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

  

與我們剛才介紹的getwith方法類似,代碼清單12-7中最後一行使用的plus方法也是通用方法,它和minus方法都聲明於Temporal接口中。通過這些方法,對TemporalUnit對像加上或者減去一個數字,我們能非常方便地將Temporal對像前溯或者回滾至某個時間段,通過ChronoUnit枚舉我們可以非常方便地實現TemporalUnit接口。

大概你已經猜到,像LocalDateLocalTimeLocalDateTime以及Instant這樣表示時間點的日期-時間類提供了大量通用的方法,表12-2對這些通用的方法進行了總結。

表12-2 表示時間點的日期-時間類的通用方法

方法名

是否是靜態方法

描述

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對像為模板,對某些狀態進行修改創建該對象的副本

你可以嘗試一下測驗12.1,檢查一下到目前為止你都掌握了哪些操縱日期的技能。

測驗12.1 操縱LocalDate對像

經過下面這些操作,date變量的值是什麼?

LocalDate date = LocalDate.of(2014, 3, 18);
date = date.with(ChronoField.MONTH_OF_YEAR, 9);
date = date.plusYears(2).minusDays(10);
date.withYear(2011);

  

答案:2016-09-08

正如我們剛才看到的,你可以通過絕對的方式,也能以相對的方式操縱日期。你甚至還可以在一個語句中連接多個操作,因為每個動作都會創建一個新的LocalDate對象,後續的方法調用可以操縱前一方法創建的對象。這段代碼的最後一句不會產生任何我們能看到的效果,因為它像前面的那些操作一樣,會創建一個新的LocalDate實例,不過我們並沒有將這個新創建的值賦給任何的變量。

12.2.1 使用TemporalAdjuster

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

代碼清單12-8 使用預定義的TemporalAdjuster

import static java.time.temporal.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

  

表12-3提供了TemporalAdjuster中包含的工廠方法列表。

表12-3 TemporalAdjuster類中的工廠方法

方法名

描述

dayOfWeekInMonth

創建一個新的日期,它的值為同一個月中每一周的第幾天

firstDayOfMonth

創建一個新的日期,它的值為當月的第一天

firstDayOfNextMonth

創建一個新的日期,它的值為下月的第一天

firstDayOfNextYear

創建一個新的日期,它的值為明年的第一天

firstDayOfYear

創建一個新的日期,它的值為當年的第一天

firstInMonth

創建一個新的日期,它的值為同一個月中,第一個符合星期幾要求的值

lastDayOfMonth

創建一個新的日期,它的值為下月的最後一天

lastDayOfNextMonth

創建一個新的日期,它的值為下月的最後一天

lastDayOfNextYear

創建一個新的日期,它的值為明年的最後一天

lastDayOfYear

創建一個新的日期,它的值為今年的最後一天

lastInMonth

創建一個新的日期,它的值為同一個月中,最後一個符合星期幾要求的值

next/previous

創建一個新的日期,並將其值設定為日期調整後或者調整前,第一個符合指定星期幾要求的日期

nextOrSame/previousOrSame

創建一個新的日期,並將其值設定為日期調整後或者調整前,第一個符合指定星期幾要求的日期,如果該日期已經符合要求,直接返回該對像

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

代碼清單12-9 TemporalAdjuster接口

@FunctionalInterface
public interface TemporalAdjuster {
    Temporal adjustInto(Temporal temporal);
}

  

這意味著TemporalAdjuster接口的實現需要定義如何將一個Temporal對像轉換為另一個Temporal對象。你可以把它看成一個UnaryOperator<Temporal>。花幾分鐘時間完成測驗12.2,練習一下我們到目前為止所學習的東西,請實現你自己的TemporalAdjuster

測驗12.2 實現一個定制的TemporalAdjuster

請設計一個NextWorkingDay類,該類實現了TemporalAdjuster接口,能夠計算明天的日期,同時過濾掉週六和週日這些節假日。格式如下所示:

date = date.with(new NextWorkingDay);

  

如果當天的星期介於週一至週五之間,日期向後移動一天;如果當天是週六或者週日,則返回下一個週一。

答案:下面是參考的NextWorkingDay類的實現。

public class NextWorkingDay implements TemporalAdjuster {
    @Override
    public Temporal adjustInto(Temporal temporal) {
        DayOfWeek dow =
                DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));    ←─讀取當前日期
        int dayToAdd = 1;    ←─正常情況,增加1天
        if (dow == DayOfWeek.FRIDAY) dayToAdd = 3;    ←─如果當天是週五,增加3天
        else if (dow == DayOfWeek.SATURDAY) dayToAdd = 2;    ←─如果當天是週六,增加2天
        return temporal.plus(dayToAdd, ChronoUnit.DAYS);    ←─增加恰當的天數後,返回修改的日期
    }
}

  

TemporalAdjuster通常情況下將日期往後順延一天,如果當天是週六或者週日,則依據情況分別將日期順延3天或者2天。注意,由於TemporalAdjuster是一個函數式接口,你只能以Lambda表達式的方式向該adjuster接口傳遞行為:

date = date.with(temporal -> {
        DayOfWeek dow =
                DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
    int dayToAdd = 1;
    if (dow == DayOfWeek.FRIDAY) dayToAdd = 3;
    else if (dow == DayOfWeek.SATURDAY) dayToAdd = 2;
    return temporal.plus(dayToAdd, ChronoUnit.DAYS);
});

  

你大概會希望在你代碼的多個地方使用同樣的方式去操作日期,為了達到這一目的,我們建議你像我們的示例那樣將它的邏輯封裝到一個類中。對於你經常使用的操作,都應該採用類似的方式,進行封裝。最終,你會創建自己的類庫,讓你和你的團隊能輕鬆地實現代碼復用。

如果你想要使用Lambda表達式定義TemporalAdjuster對象,推薦使用TemporalAdjusters類的靜態工廠方法ofDateAdjuster,它接受一個UnaryOperator<LocalDate>類型的參數,代碼如下:

TemporalAdjuster nextWorkingDay = TemporalAdjusters.ofDateAdjuster(
    temporal -> {
        DayOfWeek dow =
            DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
        int dayToAdd = 1;
        if (dow == DayOfWeek.FRIDAY) dayToAdd = 3;
        if (dow == DayOfWeek.SATURDAY) dayToAdd = 2;
        return temporal.plus(dayToAdd, ChronoUnit.DAYS);
    });
    
date = date.with(nextWorkingDay);

  

你可能希望對你的日期時間對像進行的另外一個通用操作是,依據你的業務領域以不同的格式打印輸出這些日期和時間對象。類似地,你可能也需要將那些格式的字符串轉換為實際的日期對象。接下來的一節,我們會演示新的日期和時間API提供那些機制是如何完成這些任務的。

12.2.2 打印輸出及解析日期-時間對像

處理日期和時間對像時,格式化以及解析日期-時間對象是另一個非常重要的功能。新的java.time.format包就是特別為這個目的而設計的。這個包中,最重要的類是 DateTimeFormatter。創建格式器最簡單的方法是通過它的靜態工廠方法以及常量。像BASIC_ISO_DATEISO_LOCAL_DATE這樣的常量是DateTimeFormatter類的預定義實例。所有的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類還支持一個靜態工廠方法,它可以按照某個特定的模式創建格式器,代碼清單如下。

代碼清單12-10 按照某個模式創建DateTimeFormatter

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

  

這段代碼中,LocalDateformate方法使用指定的模式生成了一個代表該日期的字符串。緊接著,靜態的parse方法使用同樣的格式器解析了剛才生成的字符串,並重建了該日期對象。ofPattern方法也提供了一個重載的版本,使用它你可以創建某個Locale的格式器,代碼清單如下所示。

代碼清單12-11 創建一個本地化的DateTimeFormatter

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);

  

最後,如果你還需要更加細粒度的控制,DateTimeFormatterBuilder類還提供了更複雜的格式器,你可以選擇恰當的方法,一步一步地構造自己的格式器。另外,它還提供了非常強大的解析功能,比如區分大小寫的解析、柔性解析(允許解析器使用啟髮式的機制去解析輸入,不精確地匹配指定的模式)、填充,以及在格式器中指定可選節。比如,你可以通過DateTimeFormatterBuilder自己編程實現我們在代碼清單12-11中使用的italianFormatter,代碼清單如下。

代碼清單12-12 構造一個DateTimeFormatter

DateTimeFormatter italianFormatter = new DateTimeFormatterBuilder
        .appendText(ChronoField.DAY_OF_MONTH)
        .appendLiteral(". ")
        .appendText(ChronoField.MONTH_OF_YEAR)
        .appendLiteral(" ")
        .appendText(ChronoField.YEAR)
        .parseCaseInsensitive
        .toFormatter(Locale.ITALIAN);

  

目前為止,你已經學習了如何創建、操縱、格式化以及解析時間點和時間段,但是你還不瞭解如何處理日期和時間之間的微妙關係。比如,你可能需要處理不同的時區,或者由於不同的曆法系統帶來的差異。接下來的一節,我們會探究如何使用新的日期和時間API解決這些問題。

12.3 處理不同的時區和曆法

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

時區是按照一定的規則將區域劃分成的標準時間相同的區間。在ZoneRules這個類中包含了40個這樣的實例。你可以簡單地通過調用ZoneIdgetRules得到指定時區的規則。每個特定的ZoneId對象都由一個地區ID標識,比如:

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

  

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

ZoneId zoneId = TimeZone.getDefault.toZoneId;

  

一旦得到一個ZoneId對象,你就可以將它與LocalDateLocalDateTime或者是Instant對像整合起來,構造為一個ZonedDateTime實例,它代表了相對於指定時區的時間點,代碼清單如下所示。

代碼清單12-13 為時間點添加時區信息

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);

  

圖12-1對ZonedDateTime的組成部分進行了說明,相信能夠幫助你理解LocaleDateLocalTimeLocalDateTime以及ZoneId之間的差異。

圖 12-1 理解ZonedDateTime

通過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);

  

12.3.1 利用和UTC/格林尼治時間的固定偏差計算時區

另一種比較通用的表達時區的方式是利用當前時區和UTC/格林尼治的固定偏差。比如,基於這個理論,你可以說“紐約落後於倫敦5小時”。這種情況下,你可以使用ZoneOffset類,它是ZoneId的一個子類,表示的是當前時間和倫敦格林尼治子午線時間的差異:

ZoneOffset newYorkOffset = ZoneOffset.of("-05:00");

  

“-05:00”的偏差實際上對應的是美國東部標準時間。注意,使用這種方式定義的ZoneOffset並未考慮任何日光時的影響,所以在大多數情況下,不推薦使用。由於ZoneOffset也是ZoneId,所以你可以像代碼清單12-13那樣使用它。你甚至還可以創建這樣的OffsetDateTime,它使用ISO-8601的曆法系統,以相對於UTC/格林尼治時間的偏差方式表示日期時間。

LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
OffsetDateTime dateTimeInNewYork = OffsetDateTime.of(date, newYorkOffset);

  

新版的日期和時間API還提供了另一個高級特性,即對非ISO曆法系統(non-ISO calendaring)的支持。

12.3.2 使用別的日曆系統

ISO-8601日曆系統是世界文明日曆系統的事實標準。但是,Java 8中另外還提供了4種其他的日曆系統。這些日曆系統中的每一個都有一個對應的日誌類,分別是ThaiBuddhistDateMinguoDateJapaneseDate以及HijrahDate。所有這些類以及LocalDate都實現了ChronoLocalDate接口,能夠對公歷的日期進行建模。利用LocalDate對象,你可以創建這些類的實例。更通用地說,使用它們提供的靜態工廠方法,你可以創建任何一個Temporal對象的實例,如下所示:

LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
JapaneseDate japaneseDate = JapaneseDate.from(date);

  

或者,你還可以為某個Locale顯式地創建日曆系統,接著創建該Locale對應的日期的實例。新的日期和時間API中,Chronology接口建模了一個日曆系統,使用它的靜態工廠方法ofLocale,可以得到它的一個實例,代碼如下:

Chronology japaneseChronology = Chronology.ofLocale(Locale.JAPAN);
ChronoLocalDate now = japaneseChronology.dateNow;

  

日期及時間API的設計者建議我們使用LocalDate,盡量避免使用ChronoLocalDate,原因是開發者在他們的代碼中可能會做一些假設,而這些假設在不同的日曆系統中,有可能不成立。比如,有人可能會做這樣的假設,即一個月天數不會超過31天,一年包括12個月,或者一年中包含的月份數目是固定的。由於這些原因,我們建議你盡量在你的應用中使用LocalDate,包括存儲、操作、業務規則的解讀;不過如果你需要將程序的輸入或者輸出本地化,這時你應該使用ChronoLocalDate類。

伊斯蘭教日曆

在Java 8新添加的幾種日曆類型中,HijrahDate(伊斯蘭教日曆)是最複雜一個,因為它會發生各種變化。Hijrah日曆系統構建於農曆月份繼承之上。Java 8提供了多種方法判斷一個月份,比如新月,在世界的哪些地方可見,或者說它只能首先可見於沙特阿拉伯。withVariant方法可以用於選擇期望的變化。為了支持HijrahDate這一標準,Java 8中還包括了烏姆庫拉(Umm Al-Qura)變量。

下面這段代碼作為一個例子說明了如何在ISO日曆中計算當前伊斯蘭年中齋月的起始和終止日期:

HijrahDate ramadanDate =
    HijrahDate.now.with(ChronoField.DAY_OF_MONTH, 1)
                    .with(ChronoField.MONTH_OF_YEAR, 9);    ←─取得當前的Hijrah日期,緊接著對其進行修正,得到齋月的第一天,即第9個月

System.out.println("Ramadan starts on " +
                   IsoChronology.INSTANCE.date(ramadanDate) +    ←─IsoChronology.INSTANCE是IsoChronology類的一個靜態實例
                   " and ends on " +
                   IsoChronology.INSTANCE.date(    ←─齋月始於2014-06-28,止於2014-07-27
                       ramadanDate.with(
                           TemporalAdjusters.lastDayOfMonth)));

  

12.4 小結

這一章中,你應該掌握下面這些內容。

  • Java 8之前老版的java.util.Date類以及其他用於建模日期時間的類有很多不一致及設計上的缺陷,包括易變性以及糟糕的偏移值、默認值和命名。

  • 新版的日期和時間API中,日期-時間對象是不可變的。

  • 新的API提供了兩種不同的時間表示方式,有效地區分了運行時人和機器的不同需求。

  • 你可以用絕對或者相對的方式操縱日期和時間,操作的結果總是返回一個新的實例,老的日期時間對像不會發生變化。

  • TemporalAdjuster讓你能夠用更精細的方式操縱日期,不再局限於一次只能改變它的一個值,並且你還可按照需求定義自己的日期轉換器。

  • 你現在可以按照特定的格式需求,定義自己的格式器,打印輸出或者解析日期-時間對象。這些格式器可以通過模板創建,也可以自己編程創建,並且它們都是線程安全的。

  • 你可以用相對於某個地區/位置的方式,或者以與UTC/格林尼治時間的絕對偏差的方式表示時區,並將其應用到日期-時間對像上,對其進行本地化。

  • 你現在可以使用不同於ISO-8601標準系統的其他日曆系統了。