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

如果你已经有一个LocalDate对象,想要创建它的一个修改版,最直接也最简单的方法是使用withAttribute方法。withAttribute方法会创建对象的一个副本,并按照需要修改它的属性。注意,下面的这段代码中所有的方法都返回一个修改了属性的对象。它们都不会修改原来的对象!

代码清单 12-6 以比较直观的方式操纵LocalDate的属性

  1. LocalDate date1 = LocalDate.of(2017, 9, 21); ←---- 2017-09-21
  2. LocalDate date2 = date1.withYear(2011); ←---- 2011-09-21
  3. LocalDate date3 = date2.withDayOfMonth(25); ←---- 2011-09-21
  4. LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 2); ←---- 2011-02-25

采用更通用的with方法能达到同样的目的,它接受的第一个参数是一个TemporalField对象,格式类似代码清单12-6的最后一行。最后这一行中使用的with方法和代码清单12-2中的get方法有些类似。它们都声明于Temporal接口,所有的日期和时间API类都实现这两个方法,它们定义了单点的时间,比如LocalDateLocalTimeLocalDateTime以及Instant。更确切地说,使用getwith方法,可以将Temporal对象值的读取和修改1区分开。如果Temporal对象不支持请求访问的字段,它就会抛出一个UnsupportedTemporalTypeException异常,比如试图访问Instant对象的ChronoField.MONTH_OF_YEAR字段,或者LocalDate对象的ChronoField.NANO_OF_SECOND字段时都会抛出这样的异常。

1请注意,使用with方法并不会直接修改现有的Temporal对象,它会创建现有对象的副本并更新对应的字段。这一过程也被称作函数式更新(更多内容请参见第19章)。

它甚至能以声明的方式操纵LocalDate对象。比如,你可以像下面这段代码那样加上或者减去一段时间。

代码清单 12-7 以相对方式修改LocalDate对象的属性

  1. LocalDate date1 = LocalDate.of(2017, 9, 21); ←---- 2017-09-21
  2. LocalDate date2 = date1.plusWeeks(1); ←---- 2017-09-28
  3. LocalDate date3 = date2.minusYears(6); ←---- 2011-09-28
  4. LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS); ←---- 2012-03-28

与刚才介绍的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变量的值是什么?

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

答案:2016-09-08

正如刚才看到的,你可以通过绝对方式和相对方式操纵日期。你甚至还可以在一个语句中连接多个操作,因为每个动作都会创建一个新的LocalDate对象,后续的方法调用可以操纵前一方法创建的对象。这段代码的最后一行不会产生任何能看到的效果,因为它像前面的那些操作一样,会创建一个新的LocalDate实例,不过我们并没有将这个新创建的值赋给任何的变量。

12.2.1 使用TemporalAdjuster

截至目前,你所看到的所有日期操作都是相对比较直接的。有的时候,你需要进行一些更加复杂的操作,比如,将日期调整到下个周日、下个工作日,或者是本月的最后一天。这时,你可以使用重载版本的with方法,向其传递一个提供了更多定制化选择的TemporalAdjuster对象,更加灵活地处理日期。对于最常见的用例,日期和时间API已经提供了大量预定义的TemporalAdjuster。你可以通过TemporalAdjusters类的静态工厂方法访问它们,如下所示。

代码清单 12-8 使用预定义的TemporalAdjuster

  1. import static java.time.temporal.TemporalAdjusters.*;
  2. LocalDate date1 = LocalDate.of(2014, 3, 18); ←---- 2014-03-18
  3. LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); ←---- 2014-03-23
  4. LocalDate date3 = date2.with(lastDayOfMonth()); ←---- 2014-03-31

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

表 12-3 TemporalAdjusters类中的工厂方法

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

正如我们看到的,使用TemporalAdjuster可以进行更加复杂的日期操作,而且这些方法的名称也非常直观,方法名基本就是问题陈述。此外,即使你没有找到符合要求的预定义的TemporalAdjuster,创建你自己的TemporalAdjuster也并非难事。实际上,TemporalAdjuster接口只声明了单一的一个方法(这使得它成为了一个函数式接口),定义如下。

代码清单 12-9 TemporalAdjuster接口

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

这意味着TemporalAdjuster接口的实现需要定义如何将一个Temporal对象转换为另一个Temporal对象。你可以把它看成一个UnaryOperator。花几分钟时间完成测验12.2,练习一下到目前为止所学习的东西,请实现你自己的TemporalAdjuster

测验12.2:实现一个定制的TemporalAdjuster

请设计一个NextWorkingDay类,该类实现了TemporalAdjuster接口,能够计算明天的日期,同时过滤掉周六和周日这些节假日。格式如下所示:

  1. date = date.with(new NextWorkingDay());

如果当天的星期数介于周一至周五之间,就将日期向后移动一天;如果当天是周六或者周日,则返回下一个周一。

答案:下面是参考的NextWorkingDay类的实现。

  1. public class NextWorkingDay implements TemporalAdjuster {
  2. @Override
  3. public Temporal adjustInto(Temporal temporal) {
  4. DayOfWeek dow =
  5. DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK)); ←—— 读取当前日期
  6. int dayToAdd = 1; ←—— 正常情况,增加一天
  7. if (dow == DayOfWeek.FRIDAY) dayToAdd = 3; ←—— 如果当天是周五,增加三天
  8. else if (dow == DayOfWeek.SATURDAY) dayToAdd = 2; ←—— 如果当天是周六,增加两天
  9. return temporal.plus(dayToAdd, ChronoUnit.DAYS); ←—— 增加恰当的天数后,返回修改的日期
  10. }
  11. }

TemporalAdjuster通常情况下将日期往后顺延一天,如果当天是周五或者周六,则依据情况分别将日期顺延三天或者两天。注意,由于TemporalAdjuster是一个函数式接口,因此你只能以Lambda表达式的方式向该adjuster接口传递行为:

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

你大概会希望在你代码的多个地方使用同样的方式去操作日期,为了达到这一目的,建议你像示例那样将它的逻辑封装到一个类中。对于经常使用的操作,都应该采用类似的方式进行封装。最终,你会创建自己的类库,让你和你的团队能轻松地实现代码复用。

如果你想要使用Lambda表达式定义TemporalAdjuster对象,那么推荐使用TemporalAdjuster类的静态工厂方法ofDateAdjuster,它接受一个UnaryOperator 类型的参数,代码如下:

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

你可能希望对你的日期和时间对象进行的另外一个通用操作是,依据你的业务领域以不同的格式打印输出这些日期和时间对象。类似地,你可能也需要将那些格式的字符串转换为实际的日期对象。接下来的一节会演示新的日期和时间API提供的那些机制是如何完成这些任务的。

12.2.2 打印输出及解析日期–时间对象

处理日期和时间对象时,格式化以及解析日期–时间对象是另一个非常重要的功能。新的java.time.format包就是特别为这个目的而设计的。这个包中,最重要的类是DateTimeFormatter。创建格式器最简单的方法是通过它的静态工厂方法以及常量。像BASIC_ISO_DATEISO_LOCAL_DATE这样的常量是DateTimeFormatter类的预定义实例。所有的DateTimeFormatter实例都能用于以一定的格式创建代表特定日期或时间的字符串。比如,下面这个例子使用两个不同的格式器生成了字符串:

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

你也可以通过解析代表日期或时间的字符串重新创建该日期对象。所有的日期和时间API都提供了表示时间点或者时间段的工厂方法,你可以使用工厂方法parse达到重创该日期对象的目的:

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

和老的java.util.DateFormat相比较,所有的DateTimeFormatter实例都是线程安全的。所以,你能够以单例模式创建格式器实例,就像DateTimeFormatter所定义的那些常量,并能在多个线程间共享这些实例。DateTimeFormatter类还支持一个静态工厂方法,它可以按照某个特定的模式创建格式器,代码清单如下。

代码清单 12-10 按照某个模式创建DateTimeFormatter

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

这段代码中,LocalDateformate方法使用指定的模式生成了一个代表该日期的字符串。紧接着,静态的parse方法使用同样的格式器解析了刚才生成的字符串,并重建了该日期对象。ofPattern方法也提供了一个重载的版本,使用它你可以创建某个Locale的格式器,代码清单如下。

代码清单 12-11 创建一个本地化的DateTimeFormatter

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

最后,如果你还需要更加细粒度的控制,DateTimeFormatterBuilder类还提供了更复杂的格式器,你可以选择恰当的方法,一步一步地构造自己的格式器。另外,它还提供了非常强大的解析功能,比如区分大小写的解析、柔性解析(允许解析器使用启发式的机制去解析输入,不精确地匹配指定的模式)、填充,以及在格式器中指定可选节。比如,你可以通过DateTimeFormatterBuilder自己编程实现代码清单12-11中使用的italianFormatter,代码清单如下。

代码清单 12-12 构造一个DateTimeFormatter

  1. DateTimeFormatter italianFormatter = new DateTimeFormatterBuilder()
  2. .appendText(ChronoField.DAY_OF_MONTH)
  3. .appendLiteral(". ")
  4. .appendText(ChronoField.MONTH_OF_YEAR)
  5. .appendLiteral(" ")
  6. .appendText(ChronoField.YEAR)
  7. .parseCaseInsensitive()
  8. .toFormatter(Locale.ITALIAN);

目前为止,你已经学习了如何创建、操纵、格式化以及解析时间点和时间段,但是还不了解如何处理日期和时间之间的微妙关系。比如,你可能需要处理不同的时区,或者由于不同的历法系统带来的差异。接下来的一节会探究如何使用新的日期和时间API解决这些问题。