统计三月的订单,查出来是四月的
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") // 订单号前缀用
还有一个坑值得单独说:yyyy 和 YYYY 不一样。大写 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() 调用处 | 47 | 0 |
Calendar 使用处 | 23 | 0 |
| static 的 SimpleDateFormat | 11 | 0 |
手动写的 + 1900、- 1 | 9 | 0 |
| 日期相关工具类行数 | 214 行 | 62 行 |
代码量降了七成,更重要的是消除了一整类"编译能过、运行出错误结果"的问题。
下篇预告
这篇先把《Java 8 日期时间 API 全面迁移:LocalDateTime 实战》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。