新的日期和时间API

日期时间

一. 背景

Java API提供了很多有用的组件,对于日期和时间上的支持如在Java 1.0时提供了java.util.Date类,这个类无法表示日期,只能以毫秒的精度表示时间。而且在设计上有很多缺陷,比如年份从1900开始,月份从0开始,又比如toString方法会返回时区,但Date类并不支持时区。

在Java 1.1时就因此废弃了Date类的许多方法,并引入了java.util.Calendar类。但Calendar也有设计上的缺陷,导致写出的代码非常容易出错,比如月份依旧从0开始,并且同时存在Date类和Calendar类也让一些开发者困惑,二者具有各自的特性,比如Date类的DateFormat方法。

这些问题导致开发者转投第三方的日期和时间库,比如Joda-Time。Java 8时Oracle希望在Java API中提供高质量的日期和时间支持,比如在java.time包中整合了Joda-Time的一些特性。

二. LocalDate、LocalTime、Instant、Duration、Period

2.1 LocalDate和LocalTime

LocalDate是一个final类,实现了Temporal, TemporalAdjuster, ChronoLocalDate, Serializable这四个接口,可以通过静态工厂of来创建实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LocalDate date = LocalDate.of(2018,4,16);//2018-04-01
int year = date.getYear();//2018
Month month = date.getMonth();//APRIL
int day = date.getDayOfMonth();//16
DayOfWeek dow = date.getDayOfWeek();//MONDAY
int len = date.lengthOfMonth();//30
boolean leap = date.isLeapYear();//false

//今天
LocalDate today = LocalDate.now();//2019-10-01
//get(TemporalField field)获取时间信息,TemporalField接口定义了如何访问temporal对象某个字段的值,ChronoField枚举实现了接口
int yearFromField = today.get(ChronoField.YEAR);//2019
int monthFromField = today.get(ChronoField.MONTH_OF_YEAR);//10
int dayFromField = today.get(ChronoField.DAY_OF_MONTH);//1

LocalTime也是一个final类,实现了 Temporal , TemporalAdjuster , Comparable<LocalTime> , Serializable 这四个接口。

1
2
3
4
5
6
7
8
9
//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

//解析字符串构建实例
LocalDate date1 = LocalDate.parse("2019-06-06");
LocalTime time1 = LocalTime.parse("14:46:21");

2.2 合并日期和时间

LocalDateTime是LocalDate和LocalTime的复合类,也是一个final类,实现了 Temporal , TemporalAdjuster , ChronoLocalDateTime<LocalDate> , Serializable 这四个接口。

1
2
3
4
5
6
7
8
LocalDateTime dt1 = LocalDateTime.of(2014,Month.MARCH,18,13,45,20);//2014-03-18T13:45:20
LocalDateTime dt2 = LocalDateTime.of(date,time);//2018-04-01T13:45:20
LocalDateTime dt3 = date.atTime(15,47,22);//2018-04-01T15:47:22
LocalDateTime dt4 = date.atTime(time);//2018-04-01T13:45:20
LocalDateTime dt5 = time.atDate(date);//2018-04-01T13:45:20
//由复合类拆回LocalDate和LocalTime
LocalDate date2 = dt1.toLocalDate();
LocalTime time2 = dt1.toLocalTime();

2.3 机器的日期和时间格式

Instant类描述了计算机对时间的建模,表示一个持续时间段上某个点的单一大整型数,也是一个final类,实现了Temporal , TemporalAdjuster , Comparable<Instant> , Serializable 这四个接口。

1
2
3
4
Instant i1 = Instant.ofEpochSecond(3);//1970-01-01T00:00:03Z
Instant i2 = Instant.ofEpochSecond(3,0);//第二个参数用来调整纳秒,i1到i4应该几乎是相同的。
Instant i3 = Instant.ofEpochSecond(2,1_000_000_000);
Instant i4 = Instant.ofEpochSecond(4,-1_000_000_000);

Instant的now()方法可以帮你获取当前时刻的时间戳,但Instant是为机器使用而设计,包含的是由秒及纳秒构成的数字,所以它无法处理为开发者设计的时间单位。

1
int day = Instant.now().get(ChronoField.DAY_OF_MONTH);//java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: DayOfMonth

2.4 定义Duration或Period

所有类都实现了接口Temporal,Temporal定义了如何读取和操纵为时间建模的对象的值。

Duration类是为两个Temporal对象间duration而设计的,可以通过between方法接收两个相同类型参数进行实例化。也是一个final类,实现了 TemporalAmount , Comparable<Duration> , Serializable 这三个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
LocalDate date = LocalDate.of(2018,4,1);//2018-04-01
LocalTime time1 = LocalTime.parse("14:46:21");
LocalTime time2 = LocalTime.parse("15:46:21");
LocalDateTime dt1 = time1.atDate(date);//2018-04-01T15:47:22
LocalDateTime dt2 = time2.atDate(date);//2018-04-01T15:47:22
Instant it1 = Instant.ofEpochSecond(3);
Instant it2 = Instant.ofEpochSecond(4);
Duration d1 = Duration.between(time1,time2);
Duration d2 = Duration.between(dt1,dt2);
Duration d3 = Duration.between(it1,it2);

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

Period类可以对年月日的方式对多个时间单位建模。也是一个final类,实现了ChronoPeriod, Serializable这两个接口。

1
2
3
4
5
Period tenDays = Period.between(LocalDate.of(2014,3,8),
LocalDate.of(2014,3,18));
Period tenDays1 = Period.ofDays(10);
Period threeWeeks = Period.ofWeeks(3);
Period twoYearsSixMonthsOneDay = Period.of(2,6,1);

下列表格记录日期—时间类中表示时间间隔的通用方法。

方法名 是否是静态方法 方法描述
between 创建两个时间点之间的interval
from 由一个临时时间点创建interval
of 由它的组成部分创建interval的实例
parse 由字符串创建interval的实例
addTo 创建该interval的副本,并将其叠加到某个指定的temporal对象
get 读取该interval的状态
isNegative 检查该interval是否为负值,不包含零
isZero 检查该interval的时长是否为零
minus 通过减去一定的时间创建该interval的副本
multipliedBy 将interval的值乘以某个标量创建该interval的副本
nagated 以忽略某个时长的方式创建该interval的副本
plus 以增加某个时长的方式创建该interval的副本
subtractFrom 从指定的temporal对象中减去该interval

上述所有介绍到的日期时间对象都是不可修改的,为了更好地支持函数式编程,确保线程安全,保持领域模式一致性而做出的重大设计决定。为了应对一些需要变换的场景,日期时间API也额外提供了一些方法来创建可变版本。

三. 操纵、解析和格式化日期

通过方法withAttribute方法来创建LocalDate的修改版本,方法会创建一个对象的副本,并按照需要去修改它的属性。

1
2
3
4
LocalDate ld1 = LocalDate.of(2014,3,18);//2014-03-18
LocalDate ld2 = ld1.withYear(2018);//2018-03-18
LocalDate ld3 = ld2.withDayOfMonth(25);//2018-03-25
LocalDate ld4 = ld3.with(ChronoField.MONTH_OF_YEAR,9);//2018-09-25

也可以使用重载方法接收一个TemporalField对象。这些方法都声明于Temporal接口,所有的日期时间API类都实现了这些方法,使用get和with方法我们可以将Temporal对象值得读取和修改区分开。若Temporal对象不支持请求访问的字段,它会抛出一个UnsupportedTemporalTypeException异常。

我们能以声明的方式操纵LocalDate对象,通过plus和minus方法对TemporalUnit对象增加或减少一个数字从而前溯或回滚至某个时间段。

1
2
3
4
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

下列表格记录时间点的日期—时间类的通用方法。

方法名 是否是静态方法 方法描述
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对象为模板,对某些状态进行修改创建该对象的副本

3.1 TemporalAdjuster

有时我们需要调整特定需求的时间间隔,如调整到下个周日、下个工作日、本月的最后一天等等。这时我们可以使用with的重载版本,接收一个TemporalAdjuster对象,可以更加灵活的处理日期。

1
2
3
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

下列表格记录TemporalAdjuster中包含的工厂方法。

方法名 方法描述
dayOfWeekInMonth 创建一个新的日期,它的值为同一个月中每一周的第几天
firstDayOfMonth 创建一个新的日期,它的值为当月的第一天
firstDayOfNextMonth 创建一个新的日期,它的值为下月的第一天
firstDayOfNextYear 创建一个新的日期,它的值为明年的第一天
firstDayOfYear 创建一个新的日期,它的值为当年的第一天
firstInMonth 创建一个新的日期,它的值为同一个月中,第一个符合星期几要求的值
lastDayOfMonth 创建一个新的日期,它的值为当月的最后一天
lastDayOfNextMonth 创建一个新的日期,它的值为下月的最后一天
lastDayOfNextYear 创建一个新的日期,它的值为明年的最后一天
lastDayOfYear 创建一个新的日期,它的值为今年的最后一天
lastInMonth 创建一个新的日期,它的值为同一个月中,最后一个符合星期几要求的值
next/previous 创建一个新的日期,并将其值设定为日期调整后或者调整前,第一个符合指定星期几要求的日期
nextOfSame/previousOrSame 创建一个新的日期,并将其值设定为日期调整后或者调整前,第一个符合指定星期几要求的日期,如果该日期已符合要求,直接返回该对象

即使上述方法不能满足你的需求,还可以自定义自己的TemporalAdjuster。TemporalAdjuster是一个函数式接口,可以看作一个 UnaryOperator<Temporal>

1
2
3
4
@FunctionalInterface
public interface TemporalAdjuster {
Temporal adjustInto(Temporal temporal);
}

3.1.1 自定义TemporalAdjuster

可以实现类NextWorkingDay,并实现adjustInto方法。也可以直接通过方法参数化直接通过Lambda表达式来实现。推荐使用TemporalAdjusters的静态工厂方法ofDateAdjuster来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
    public static void main(String[] args) {
//通过类实现
LocalDate ta1 = date1.with(new NextWorkingDay());//2014-03-19
//通过Lambda表达式实现
LocalDate ta2 = date1.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);
});//2014-03-19
//推荐使用工厂方法ofDateAdjuster
TemporalAdjuster nextWorkingDay = TemporalAdjusters.ofDateAdjuster(
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);
}
);//2014-03-19
}

/**
* 计算明天的日期,同时过滤掉周六和周日这些节假日
*/
public class NextWorkingDay implements TemporalAdjuster {
@Override
public Temporal adjustInto(Temporal temporal) {
//读取当前日期
DayOfWeek dow = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
//正常情况加1天
int dayToAdd = 1;
//周五加3天
if(dow == DayOfWeek.FRIDAY) dayToAdd = 3;
//周六加2天
else if(dow == DayOfWeek.SATURDAY) dayToAdd = 2;
//返回修改的日期
return temporal.plus(dayToAdd, ChronoUnit.DAYS);
}
}

3.2 打印输出及解析日期—时间对象

java.time.format为格式化和解析日期—时间对象而设计,最重要的类是DateTimeFormatter。可以利用DateTimeFormatter生成各种常用格式的日期—时间字符串,也可以解析字符串创建日期对象。相比老版本的DateFormat,所有DateTimeFormatter实例都是线程安全的。

1
2
3
4
5
String s1 = date1.format(DateTimeFormatter.BASIC_ISO_DATE);//20140318
String s2 = date1.format(DateTimeFormatter.ISO_LOCAL_DATE);//2014-03-18

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

通过ofPattern指定日期—时间格式,当然可以根据需求自定义想要的格式,如果需要更加细粒度的控制,DateTimeFormatterBuilder类还提供了更复杂的格式器,可以区分大小写的解析、柔性解析(允许解析器使用启发式的机制去解析输入,不精确地匹配指定的模式)、填充,以及在格式器中指定可选节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//指定模式创建DateTimeFormatter
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
String formattedDate = date1.format(formatter);//18/03/2014
LocalDate fromFormattedDate = LocalDate.parse(formattedDate,formatter);

//本地化DateTimeFormatter
DateTimeFormatter formatterCN = DateTimeFormatter.ofPattern("yyyy年 MMMM dd日", Locale.CHINA);
String formattedCNDate = date1.format(formatterCN);//2014年 三月 18日
LocalDate fromFormattedCNDate = LocalDate.parse(formattedCNDate,formatterCN);


//自定义DateTimeFormatter
DateTimeFormatter CNFormatter = new DateTimeFormatterBuilder()
.appendText(ChronoField.YEAR)
.appendLiteral("年 ")
.appendText(ChronoField.MONTH_OF_YEAR)
.appendLiteral(" ")
.appendText(ChronoField.DAY_OF_MONTH)
.appendLiteral("日 ")
.toFormatter(Locale.CHINA);
String stringCNFormatter = date1.format(CNFormatter);//2014年 三月 18日

3.3 处理不同的时区和历法

时区的处理是新版API新增的重要功能,java.time.ZoneId类替代了java.util.TimeZone,其设计目标就是让你无需操心时区处理的繁琐。

1
2
3
4
5
6
7
8
9
10
11
12
//地区Id标识:{区域}/{城市}
ZoneId romeZone = ZoneId.of("Europe/Rome");

//通过方法toZoneId将旧的时区对象转换为ZoneId
ZoneId zoneId = TimeZone.getDefault().toZoneId();

//有了ZoneId就可以和日期—时间对象结合成ZonedDateTime实例,代表了对应时区的时间点
ZonedDateTime zdt1 = date1.atStartOfDay(romeZone);//2014-03-18T00:00+01:00[Europe/Rome]
LocalDateTime dateTime = date1.atTime(18,13,45);
ZonedDateTime zdt2 = dateTime.atZone(romeZone);//2014-03-18T18:13:45+01:00[Europe/Rome]
Instant instant = Instant.now();
ZonedDateTime zdt3 = instant.atZone(romeZone);//2019-10-01T03:37:34.201+01:00[Europe/Rome]

下图对ZonedDateTime的组成部分进行了说明,有助于理解ZonedDateTime和LocalDate、LocalTime、LocalDateTime之间的差异。

理解ZonedDateTime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//通过ZoneId将Instant转为LocalDateTime
LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant,romeZone);//2019-12-04T03:52:42.845
System.out.println("timeFromInstant " + timeFromInstant);

//表示此时间和伦敦格林尼治子午线时间的差异
ZoneOffset newYorkOffSet = ZoneOffset.of("-05:00");
//但上述方式并未考虑任何夏令时的影响,因此不推荐。

//ZoneOffset是ZoneId的子类,可以通过其创建OffsetDateTime
OffsetDateTime dateTimeInNewYork = OffsetDateTime.of(dateTime,newYorkOffSet);//2014-03-18T18:13:45-05:00
System.out.println("dateTimeInNewYork " + dateTimeInNewYork);

//可以直接生成Instant
Instant fromOffsetLocalDateTime = dateTimeInNewYork.toInstant();
//LocalDateTime则需要ZoneOffset参数才能生成Instant
Instant fromLocalDateTime = dateTime.toInstant(newYorkOffSet);

除了ISO历法日历系统,Java 8还提供了4种其他日历系统分别对应一个类:ThaiBuddhisDate、MinguoDate、JapaneseDate、HijranDate。所有这些类以及LocalDate都实现了ChronoLocalDate接口,能够对公历的日期进行建模。

1
2
3
4
5
6
//根据LocalDate(Temporal对象)创建实例
JapaneseDate japaneseDate = JapaneseDate.from(date1);

//可以为某个Locale显式的创建日历系统
Chronology japaneseChronology = Chronology.ofLocale(Locale.JAPAN);
ChronoLocalDate now = japaneseChronology.dateNow();//2019-10-01

API设计者建议避免使用ChronoLocalDate,原因是开发者可能会在代码中做一些假设,而这些假设在不同的日历系统中可能会不成立。比如假设一个月天数不会超过31天,一年12个月,或一年中月数是固定的,因为这些假设请使用LocalDate,包括存储、操作和业务规则的解读。如果是本地化的输出或输入场景,可以尝试使用ChronoLocalDate。


参考:

🔗 《Java8实战》