Administrator
发布于 2019-03-14 / 3095 阅读
27

Java 8 日期时间 API 全面迁移:LocalDateTime 实战

统计三月的订单,查出来是四月的

3 月中旬接了个老项目的改造任务,其中一块是给运营加个按月导出订单的功能。我写了个 SQL 拼时间的工具类,自测的时候点了三月,导出来的数据全是四月的。

当时那段代码:

public static Date monthStart(int year, int month) {
    Calendar cal = Calendar.getInstance();
    cal.set(year, month, 1, 0, 0, 0);
    return cal.getTime();
}

// 调用方传的是页面上选的 3
Date start = monthStart(2019, 3);

问题在于 Calendar 的月份是从 0 开始数的,Calendar.MARCH 的值是 2。我传 3 进去,得到的是四月。

这已经是我在这个项目里因为日期 API 栽的第三次了。前两次分别是:Date.getYear() 返回 119(要加 1900),以及同事把 SimpleDateFormat 定义成 static 导致线上时间串了(那次写了一篇单独的笔记)。主管看我改得费劲,干脆批了两天时间,让我把这个模块的时间处理全部迁到 Java 8 的 java.time

Date 和 Calendar 到底烂在哪

迁移之前我列了一遍旧 API 的问题,确认迁移是值得的:

  • Date 可变setTime() 能改掉对象内部状态,把它作为参数传进方法、或者作为字段返回给调用方,都有被人改掉的风险。所有 Date 字段都得防御性拷贝,没人这么写。
  • Calendar 月份从 0 开始,星期从周日开始,年份要手动处理 1900 偏移。这种"设计上的小聪明"是纯纯的 bug 来源。
  • Date 和 Calendar 职责混乱。叫 Date 的类里存的是时间戳(精确到毫秒的瞬间),但 getHours() 这些方法又依赖系统默认时区,导致它既不是纯瞬间也不是纯日历。
  • 格式化类线程不安全SimpleDateFormat 有共享的 Calendar 字段。
  • 没有表示"一段时间"和"只有日期"的类型。想表达"2019-03-14"这种纯日期,只能用 Date 或者字符串,前者带时分秒,后者要手动解析。

最要命的是这些坑全都编译期不报错,跑起来也能出结果,只是结果是错的。

迁移对照表

我把项目里 47 处 Date/Calendar 的用法归类,整理了这张对照表,改造时基本是机械替换:

旧写法新写法
new Date()LocalDateTime.now()Instant.now()
new Date(millis)Instant.ofEpochMilli(millis)
date.getTime()instant.toEpochMilli()
只关心日期(生日、账单日)LocalDate
只关心时间(每天几点跑批)LocalTime
带时区的完整时间(跨境业务)ZonedDateTime
cal.add(Calendar.DAY_OF_MONTH, -7)localDate.minusDays(7)
两个时间点之差的毫秒数Duration.between(a, b).toMillis()
两个日期相差几天Period.between(d1, d2).getDays()ChronoUnit.DAYS.between(d1, d2)

改造后的工具类,语义清晰了很多,月份就是 3:

public static LocalDateTime monthStart(int year, int month) {
    return LocalDate.of(year, month, 1).atStartOfDay();   // month 就是 1~12
}

public static void main(String[] args) {
    LocalDateTime start = monthStart(2019, 3);
    LocalDateTime end   = start.plusMonths(1);            // 左闭右开,避免处理 31 号
    System.out.println(start + " ~ " + end);
}

输出:

2019-03-01T00:00 ~ 2019-04-01T00:00

注意查询条件要写成 >= start AND < end 这种左闭右开的形式。以前用 BETWEEN 加"月末最后一天 23:59:59",每个月天数不一样,而且漏掉了 23:59:59.500 这种毫秒级的数据。

DateTimeFormatter 可以放心定义成 static

这是迁移带来的最大收益之一。DateTimeFormatter 是不可变对象,内部没有可变的共享状态,官方文档明确写了线程安全:

// 可以,也应该这么写
private static final DateTimeFormatter FORMATTER =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public String format(LocalDateTime time) {
    return time.format(FORMATTER);
}

public LocalDateTime parse(String text) {
    return LocalDateTime.parse(text, FORMATTER);
}

我拿之前那个多线程测试跑了一遍:200 个线程同时对同一个 formatter 做 1000 次 format,结果完全一致,零异常。同样的测试用在 SimpleDateFormat 上会出现 7 种不同的错误结果。

另外几个常用的格式化器是预定义好的,不用自己拼 pattern:

DateTimeFormatter.ISO_LOCAL_DATE_TIME      // 2019-03-14T21:45:00
DateTimeFormatter.ISO_LOCAL_DATE           // 2019-03-14
DateTimeFormatter.ofPattern("yyyyMMdd")    // 订单号前缀用

还有一个坑值得单独说:yyyyYYYY 不一样。大写 YYYY 是"基于周的年",跨年的那一周会把年份算到下一年。2019 年 12 月 30 号(周一)用 YYYY-MM-dd 格式化出来是 2020-12-30。我们代码里没人这么写,但第三方依赖里见过一次。

时区是个单独的话题

LocalDateTime 这个名字里的 "Local" 指的是"没有时区信息",它只表示"2019-03-14 21:45:00"这一串数字,不对应时间轴上的某个点。要想表示真正的瞬间,得用 Instant 或者 ZonedDateTime

// LocalDateTime 转毫秒时间戳:必须指定时区,否则不知道是哪个时区的 21:45
long millis = LocalDateTime.now()
        .atZone(ZoneId.systemDefault())
        .toInstant()
        .toEpochMilli();

// 反过来
LocalDateTime ldt = LocalDateTime.ofInstant(
        Instant.ofEpochMilli(millis), ZoneId.systemDefault());

时区字符串我吃过一次亏,一开始写的是 ZoneId.of("GMT+8"),结果夏令时相关的计算和同事的 Asia/Shanghai 对不上。查了之后知道应该用 IANA 的地区名:中国境内一律用 Asia/Shanghai,它包含了历史时区变更的信息,GMT+8 只是一个固定偏移。虽然中国现在不用夏令时,但历史数据里(1986 到 1991 年)是有的。

还有个我以前不知道的事:MySQL 的 datetime 类型同样不带时区,而 timestamp 存的是 UTC,读写时会按连接会话的时区转换。我们项目之前的规矩是"存 UTC,显示的时候转",但老表里混用了两种类型,迁移的时候我加了一行断言,测试环境就暴露出 3 处差 8 小时的问题。

和 MySQL、Jackson 打交道

MyBatis

我们用的 MyBatis 3.5.0 直接支持 JSR-310 类型,不需要额外配 typeHandler,实体类里写 LocalDateTime 就能和 datetime 字段互相映射。前提是 JDBC 驱动要 5.1.37 以上(我们用的 mysql-connector-java 5.1.47,JDBC 4.2 的 setObject 才支持这些新类型)。低于这个版本会报:

org.apache.ibatis.type.TypeException: Error setting non null for parameter #1 with JdbcType null

Jackson 序列化

这个坑我踩得最狠。改完实体类之后,接口返回的 JSON 变了:

// 改之前
{"createTime": "2019-03-14 21:45:00"}
// 改之后
{"createTime": [2019, 3, 14, 21, 45]}

前端同事直接来找我。原因是 Jackson 对 JSR-310 类型默认序列化成数组(WRITE_DATES_AS_TIMESTAMPS 默认为 true)。Spring Boot 2.1 里已经集成了 jackson-datatype-jsr310,只要在配置里关掉就行:

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Shanghai
    serialization:
      write-dates-as-timestamps: false

如果还有个别接口需要特殊格式,可以在字段上单独标注:

@JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
private LocalDate billDate;

清理成果

两天改完,统计了一下:

项目改之前改之后
new Date() 调用处470
Calendar 使用处230
static 的 SimpleDateFormat110
手动写的 + 1900- 190
日期相关工具类行数214 行62 行

代码量降了七成,更重要的是消除了一整类"编译能过、运行出错误结果"的问题。

下篇预告

这篇先把《Java 8 日期时间 API 全面迁移:LocalDateTime 实战》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考