《Java 8 in Action》Chapter 12:新的日期和時間API

莊裡程式猿發表於2019-04-10
  • 在Java 1.0中,對日期和時間的支援只能依賴java.util.Date類。同時這個類還有兩個很大的缺點:年份的起始選擇是1900年,月份的起始從0開始。
  • 在Java 1.1中,Date類中的很多方法被廢棄,取而代之的是java.util.Calendar類。然而Calendar類也有類似的問題和設計缺陷,導致使用這些方法寫出的程式碼非常容易出錯。

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

1. 使用LocalDate 和LocalTime

1.1 LocalDate

Java 8提供新的日期和時間API,LocalDate類例項是一個不可變物件,只提供簡單的日期並且不含當天時間資訊。此外也不附帶任何與時區相關的資訊。

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

LocalDate localDate = LocalDate.of(2014, 3, 18);
int year = localDate.getYear();
Month month = localDate.getMonth();
int day = localDate.getDayOfMonth();
DayOfWeek dow = localDate.getDayOfWeek();
int len = localDate.lengthOfMonth();
boolean leap = localDate.isLeapYear();

// 使用工廠方法從系統時鐘中獲取當前的日期
LocalDate today = LocalDate.now();

System.out.println(String.format("year:%s\nmonth:%s\nday:%s\ndow:%s\nlen:%s\nleap:%s", year, month, day, dow, len, leap));
System.out.println(today);

結果:
year:2014
month:MARCH
day:18
dow:TUESDAY
len:31
leap:false
2019-03-27
複製程式碼

Java 8日期-時間類都提供了類似的工廠方法。通過傳遞TemporalField引數給get方法拿到同樣的資訊。TemporalField介面定義瞭如何訪問temporal物件某個欄位的值。ChronoField列舉實現TemporalField介面,可以使用get方法得到列舉元素的值。

int year = localDate.get(ChronoField.YEAR);
int month = localDate.get(ChronoField.MONTH_OF_YEAR);
int day = localDate.get(ChronoField.DAY_OF_MONTH);
複製程式碼

1.2 LocalTime

使用LocalTime類表示時間,可以使用of過載的兩個工廠方法建立LocalTime的例項。

  • 第一個過載函式接收小時和分鐘
  • 第二個過載函式同時還接收秒。

LocalTime類也提供了一些get方法訪問這些變數的值,如下所示。

LocalTime localTime = LocalTime.of(13, 45, 20);
int hour = localTime.getHour();
int minute = localTime.getMinute();
int second = localTime.getSecond();
System.out.println(String.format("hour:%s\nminute:%s\nsecond:%s", hour, minute, second));

列印結果:
hour:13
minute:45
second:20
複製程式碼

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

LocalDate date = LocalDate.parse("2019-03-27");
LocalTime time = LocalTime.parse("20:17:08");
複製程式碼

可以向parse方法傳遞一個DateTimeFormatter。該類的例項定義瞭如何格式化一個日期或者時間物件。用來替換老版java.util.DateFormat。 如果傳遞的字串引數無法被解析為合法的LocalDate或LocalTime物件,這兩個parse方法都會丟擲一個繼承自RuntimeException的DateTimeParseException異常。

2. 合併日期和時間

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

LocalTime time = LocalTime.of(21, 31, 50);
LocalDate date = LocalDate.of(2019, 03, 27);

LocalDateTime dt1 = LocalDateTime.of(2017, Month.NOVEMBER, 07, 22, 31, 51);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(22, 21, 14);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(date);
複製程式碼

建立LocalDateTime物件

  • 直接建立
  • 通過atTime方法向LocalDate傳遞一個時間物件
  • 通過atDate方法向LocalTime傳遞一個時間物件

也可以使用toLocalDate或者toLocalTime方法,從LocalDateTime中提取LocalDate或者LocalTime元件:

LocalDate date1 = dt1.toLocalDate();
LocalTime time1 = dt1.toLocalTime();
複製程式碼

3. 機器的日期和時間格式

從計算機的角度來看,"2019年03月27日11:20:03"這樣的方式是不容易理解的,計算機更加容易理解建模時間最自然的格式是表示一個持續時間段上某個點的單一大整型數。新的java.time.Instant類對時間建模的方式,基本上它是以Unix元年時間(傳統的設定為UTC時區1970年1月1日午夜時分)開始所經歷的秒數進行計算。

3.1 建立Instant

  • 靜態工廠方法ofEpochSecond傳遞一個代表秒數的值建立一個該類的例項。
  • 靜態工廠方法ofEpochSecond還有一個增強的過載版本,它接收第二個以納秒為單位的引數值,對傳入作為秒數的引數進行調整。過載的版本會調整納秒引數,確保儲存的納秒分片在0到999 999999之間。
Instant.ofEpochSecond(3);
Instant.ofEpochSecond(3, 0);
// 2 秒之後再加上100萬納秒(1秒)
Instant.ofEpochSecond(2, 1_000_000_000);
// 4秒之前的100萬納秒(1秒)
Instant.ofEpochSecond(4, -1_000_000_000);
複製程式碼

3.2 工廠方法now

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

Instant now = Instant.now();
System.out.println(now);

2019-03-27T03:26:39.451Z
複製程式碼

Instant的設計初衷是為了便於機器使用,它包含的是由秒及納秒所構成的數字。因此Instant無法處理那些我們非常容易理解的時間單位。

int day = Instant.now().get(ChronoField.DAY_OF_MONTH);
它會丟擲下面這樣的異常:

Exception in thread "main" java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: DayOfMonth
但是你可以通過Duration和Period類使用Instant,接下來我們會對這部分內容進行介紹。
複製程式碼

4. Duration和Period

4.1 Duration

所有類都實現了Temporal介面,該介面定義如何讀取和操縱為時間建模的物件的值。如果需要建立兩個Temporal物件之間的duration,就需要Duration類的靜態工廠方法between。 可以建立兩個LocalTimes物件、兩個LocalDateTimes物件,或者兩個Instant物件之間的duration:

LocalTime time1 = LocalTime.of(21, 50, 10);
LocalTime time2 = LocalTime.of(22, 50, 10);
LocalDateTime dateTime1 = LocalDateTime.of(2019, 03, 27, 21, 20, 40);
LocalDateTime dateTime2 = LocalDateTime.of(2019, 03, 27, 21, 20, 40);
Instant instant1 = Instant.ofEpochSecond(1000 * 60 * 2);
Instant instant2 = Instant.ofEpochSecond(1000 * 60 * 3);

Duration d1 = Duration.between(time1, time2);
Duration d2 = Duration.between(dateTime1, dateTime2);
Duration d3 = Duration.between(instant1, instant2);
// PT1H 相差1小時
System.out.println("d1:" + d1);
// PT2H 相差2小時
System.out.println("d2:" + d2);
// PT16H40M 相差16小時40分鐘
System.out.println("d3:" + d3);
複製程式碼

LocalDateTime是為了便於人閱讀使用,Instant是為了便於機器處理,所以不能將二者混用。如果在這兩類物件之間建立duration,會觸發一個DateTimeException異常。 此外,由於Duration類主要用於以秒和納秒衡量時間的長短,你不能僅向between方法傳遞一個LocalDate物件做引數。

4.2 Period

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

Period period = Period.between(LocalDate.of(2019, 03, 7), LocalDate.of(2019, 03, 17));
// 相差10天
System.out.println("Period between:" + period);
複製程式碼

Duration和Period類都提供了很多非常方便的工廠類,直接建立對應的例項。

Duration threeMinutes = Duration.ofMinutes(3);
Duration fourMinutes = Duration.of(4, ChronoUnit.MINUTES);

Period tenDay = Period.ofDays(10);
Period threeWeeks = Period.ofWeeks(3);
Period twoYearsSixMonthsOneDay = Period.of(2, 6, 1);
Duration類和Period類共享了很多相似的方法,有興趣的可以參考官網的文件。
複製程式碼

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

5. 操縱、解析和格式化日期

如果已經有一個LocalDate物件,想要建立它的一個修改版,最直接也最簡單的方法是使用withAttribute方法。withAttribute方法會建立物件的一個副本,並按照需要修改它的屬性。

// 這段程式碼中所有的方法都返回一個修改了屬性的物件。它們都不會修改原來的物件!
LocalDate date1 = LocalDate.of(2017, 12, 15);
LocalDate date2 = date1.withYear(2019);
LocalDate date3 = date2.withDayOfMonth(25);
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 9);
複製程式碼

它們都宣告於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, 10, 19);
LocalDate date2 = date1.plusWeeks(1);
LocalDate date3 = date2.minusYears(3);
LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS);
複製程式碼

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

6. 使用TemporalAdjuster

有時需要進行一些更加複雜的操作,比如,將日期調整到下個週日、下個工作日,或者是本月的最後一天。可以使用過載版本的with方法,向其傳遞一個提供了更多定製化選擇的TemporalAdjuster物件,更加靈活地處理日期。

// 對於最常見的用例,日期和時間API已經提供了大量預定義的TemporalAdjuster。可以通過TemporalAdjuster類的靜態工廠方法訪問。
LocalDate date1 = LocalDate.of(2013, 12, 11);
LocalDate date2 = date1.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
LocalDate date3 = date2.with(TemporalAdjusters.lastDayOfMonth());
複製程式碼

使用TemporalAdjuster可以進行更加複雜的日期操作,方法的名稱很直觀。如果沒有找到符合預期的預定義的TemporalAdjuster,可以建立自定義的TemporalAdjuster。TemporalAdjuster介面只宣告一個方法(即函式式介面)。實現該介面需要定義如何將一個Temporal物件轉換為另一個Temporal物件,可以把它看成一個UnaryOperator。

@FunctionalInterface
public interface TemporalAdjuster {
    Temporal adjustInto(Temporal temporal);
}
複製程式碼

7. 列印輸出及解析日期-時間物件

新的java.time.format包就是特別為格式化以及解析日期-時間物件而設計的。其中最重要的類是DateTimeFormatter。建立格式器最簡單的方法是通過它的靜態工廠方法以及常量。所有的DateTimeFormatter例項都能用於以一定的格式建立代表特定日期或時間的字串。

LocalDate date = LocalDate.of(2013, 10, 11);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE);
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE);

20131011
2013-10-11
複製程式碼

通過解析代表日期或時間的字串重新建立該日期物件,也可以使用工廠方法parse重新建立。

LocalDate date2 = LocalDate.parse("20141007", DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date3 = LocalDate.parse("2014-10-07", DateTimeFormatter.ISO_LOCAL_DATE);
複製程式碼

DateTimeFormatter例項是執行緒安全的,老的java.util.DateFormat執行緒不安全。單例模式建立格式器例項,在多個執行緒間共享例項是沒有問題的。也可以通過ofPattern靜態工廠方法,按照某個特定的模式建立格式器。

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
String formattedDateStr = date.format(formatter);
LocalDate date1 = LocalDate.parse(formattedDateStr, formatter);
複製程式碼

ofPattern方法也提供了一個過載的版本,可以傳入Locale建立格式器。

DateTimeFormatter italianFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
LocalDate date = LocalDate.of(2015, 11, 14);
String formattedDate = date.format(italianFormatter);
LocalDate date1 = LocalDate.parse(formattedDate, italianFormatter);
複製程式碼

DateTimeFormatterBuilder類還提供了更復雜的格式器,以提供更加細粒度的控制。同時也提供非常強大的解析功能,比如區分大小寫的解析、柔性解析、填充,以及在格式器中指定可選節等等。

通過DateTimeFormatterBuilder自定義格式器

DateTimeFormatter italianFormatter = new DateTimeFormatterBuilder()
                .appendText(ChronoField.DAY_OF_MONTH)
                .appendLiteral(". ")
                .appendText(ChronoField.MONTH_OF_YEAR)
                .appendLiteral(" ")
                .appendText(ChronoField.YEAR)
                .parseCaseInsensitive()
                .toFormatter(Locale.ITALIAN);
LocalDate now = LocalDate.now();
String s = now.format(italianFormatter);
複製程式碼

8. 處理不同的時區和曆法

新版日期和時間API新增加的重要功能是時區的處理。新的java.time.ZoneId類替代老版java.util.TimeZone。跟其他日期和時間類一樣,ZoneId類也是無法修改的。是按照一定的規則將區域劃分成的標準時間相同的區間。在ZoneRules這個類中包含了40個時區例項,可以通過呼叫ZoneId的getRules()得到指定時區的規則,每個特定的ZoneId物件都由一個地區ID標識。

ZoneId shanghaiZone = ZoneId.of("Asia/Shanghai");
複製程式碼

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

ZoneId zoneId = TimeZone.getDefault().toZoneId();
複製程式碼

ZoneId物件可以與LocalDate、LocalDateTime或者是Instant物件整合構造為成ZonedDateTime例項,它代表了相對於指定時區的時間點。

LocalDate date = LocalDate.of(2019, 03, 27);
ZonedDateTime zdt1 = date.atStartOfDay(shanghaiZone);

LocalDateTime dateTime = LocalDateTime.of(2015, 12, 21, 11, 11, 11);
ZonedDateTime zdt2 = dateTime.atZone(shanghaiZone);

Instant instant = Instant.now();
ZonedDateTime zdt3 = instant.atZone(shanghaiZone);
通過ZoneId,你還可以將LocalDateTime轉換為Instant:

LocalDateTime dateTime = LocalDateTime.of(2016, 10, 14, 15, 35);
Instant instantFromDateTime = dateTime.toInstant(shanghaiZone);
你也可以通過反向的方式得到LocalDateTime物件:

Instant instant = Instant.now();
LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, shanghaiZone);
複製程式碼

另一種比較通用的表達時區的方式是利用當前時區和UTC/格林尼治的固定偏差。使用ZoneId的一個子類ZoneOffset,表示的是當前時間和倫敦格林尼治子午線時間的差異:

ZoneOffset newYorkOffset = ZoneOffset.of("-05:00");
複製程式碼

9. 總結

  • Java 8之前老版的java.util.Date類以及其他用於建模日期時間的類有很多不一致及設計上的缺陷,包括易變性以及糟糕的偏移值、預設值和命名。
  • 新版的日期和時間API中,日期-時間物件是不可變的。
  • 新的API提供了兩種不同的時間表示方式,有效地區分了執行時人和機器的不同需求。
  • 你可以用絕對或者相對的方式操縱日期和時間,操作的結果總是返回一個新的例項,老的日期時間物件不會發生變化。
  • TemporalAdjuster讓你能夠用更精細的方式操縱日期,不再侷限於一次只能改變它的一個值,並且你還可按照需求定義自己的日期轉換器。
  • 你現在可以按照特定的格式需求,定義自己的格式器,列印輸出或者解析日期時間物件。這些格式器可以通過模板建立,也可以自己程式設計建立,並且它們都是執行緒安全的。
  • 你可以用相對於某個地區/位置的方式,或者以與UTC/格林尼治時間的絕對偏差的方式表示時區,並將其應用到日期時間物件上,對其進行本地化。

Tips

本文同步發表在公眾號,歡迎大家關注!? 後續筆記歡迎關注獲取第一時間更新!

《Java 8 in Action》Chapter 12:新的日期和時間API

相關文章