都什么年代了你还在用 Date
前言
上篇文章搞清楚了时区,这篇文章就主要来谈一谈 Java 中处理日期时间用什么 API 比较好。我本来不准备写这篇文章的,因为我觉得 Java17 都特么出来了,大家对 Java8 提供的时间日期 API 都很熟悉了。但是经过我调研,很多中小公司还在用老版本的 Date
来处理时间日期,视 Java8 提供的时间日期 API 于无物,所以还是想来推荐一下新一代的时间日期 API,希望对大家有帮助。
传统的 Date
老版本的 Date
相信大家都很熟悉了,这里就简单介绍几个点
可观不可触的时区
对于老版本的 Date
、SimpleDateFormat
相信大家都很熟悉,值得注意的是,你是无法直接设置 Date
的时区信息的,但与之矛盾的是我们在代码中从数据库读取一个带时区的时间,例如:2021-11-01 13:50:47.138494+00
,它却能够自动解析成当前服务器所在时区的时间封装在 Date
对象中。其实这是它的一个成员变量 private transient BaseCalendar.Date cdate
去做的事情,由于 Unix 时间戳是和时区无关的,所以在从数据库读取时它会将数据库带有时区时间转换为 Unix 时间戳,然后在用这个时间戳转换为当前服务器所在时区的时间,并且携带着时区信息。
其实我们可以看一下 Date
构造方法源码,他就是获取的当前 Unix 时间戳。
public Date() {
this(System.currentTimeMillis());
}
复杂的时区转换计算
如果我们需要显示一个 Date
对象在不同时区的时间,那么我们需要通过 SimpleDateFormat
来实现
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = new Date();//默认北京时区的时间
System.out.println("北京时区:" + sdf.getTimeZone());
System.out.println("北京时区时间:" + sdf.format(date));
sdf.setTimeZone(TimeZone.getTimeZone(ZoneId.of("Asia/Jakarta")));
System.out.println("雅加达时区:" + sdf.getTimeZone());
System.out.println("雅加达时区时间:" + sdf.format(date));
如果你想得到的不是字符串而是转换为另一个时区的 Date
对象,那么你还需要再从字符串反转到 Date
......这无疑是非常麻烦的
复杂时间间隔计算
要说 Date
最难受的地方之一就是两个日期的时间差计算,之前 JDK 并没有提供直接的 API 来计算两个 Date
的间隔。通常我们是把 Date
转成时间戳之后进行操作
Date date1 = new Date();
TimeUnit.SECONDS.sleep(10);
Date date2 = new Date();
long time1 = date1.getTime();
long time2 = date2.getTime();
System.out.println("间隔秒数:" + (time2 - time1) / 1000);
System.out.println("间隔小时数:" + (time2 - time1) / (1000 * 3600));
//...
是不是很麻烦?如果你并不觉得麻烦,那么你只是没有见过更好的方式。我们来看看新版 API 怎么来做(后面会详细介绍)
Duration duration = Duration.between(time2, time1);
long days = duration.toDays();//获取天数间隔
long hours = duration.toHours();//获取小时间隔
//...
这样是不是很简单!
数据库到底要不要存储时区信息
在谈新版的时间 API 之前,我们先要搞清楚一个问题,那就是你真的有必要把时区信息存储到数据库吗?其实我觉得对于绝大多数的公司应该都是不需要的,下我以我们公司印度尼西亚业务为例来分析这个问题。
存储时区
我们目前是先在代码中获取当前服务器(我们服务器在亚马逊 UTC-3
)所在时区的 Date
对象,在存入数据库时,在数据库层面将其转换为 UTC+8
时区的时间存储到数据库中,例如 2021-10-24 15:47:47.138494-03
存到数据库中是 2021-10-25 02:47:47.138494+08
,然后在前端页面可以直接调用 API 根据带时区的时间计算出前端设备当地时区的时间。
xxx//假如设备在印度尼西亚,那么前端 API 获得的时间就是 2021-10-25 01:47:47.138494
不过不是很明白,我们既然存时区,那么应该也要存印度尼西亚的时区啊......毕竟我们的业务在印尼,也不在北京......
不存储时区
正常来说服务器所在地一般是唯一的,即使有 100
个服务实例来做负载均衡,总不可能出现一半服务器在美国,一半在中国吧?那么代码中的时间日期操作都是默认使用服务器所在地时区(UTC-3
),既然如此那么我们数据库就可以不存储时区信息,只存储一个时间描述例如 2021-10-24 13:50:47.138494
,它本身没有时区,但是我们都知道它的默认时区就是 UTC-3
。
这样在前端代码中只需要调用 API 的时候带上带上该时间的所属时区 UTC-3
即可算出前端设备当地时区的时间。
xxx//假如设备在印度尼西亚,那么前端 API 获得的时间就是 2021-10-25 01:47:47.138494
而且这个不带时区的时间不会受数据库时区的影响,无论你把数据库设置成哪个时区,它都不会变化。这种方式就是整个业务里我们把所有时区都干掉,只留一个服务器时区,当有任何涉及时区的业务功能时,只需要把源时间
换算成服务器所在时区的时间即可。
总结
不存储时区还有一种情况就是有些程序员喜欢存时间戳,不得不吐槽一下我觉得这是最 low 的方式之一(也许你能说出它仅有的个别优点,但我不接受反驳)。虽然时间戳是和时区无关的,但是它的可读性真的太差了......而且代码中也不好进行操作
其实对比一下就能发现数据库存不存储时区信息,对于前后端的操作区别不大的。而且我觉得绝大多数业务场景,不存储时区相对更简单!
Java8 的时间日期 API
LocalDate、LocalTime、LocalDateTime
看源码是一个好习惯,看源码注释更是一个好习惯。这三个类的注释说的很清楚,首先这些类是线程安全的,对于它的任何操作都会产生一个新的实例,这和 String
类是一样的。其次它不存储或表示时区。相反,它是对日期时间的描述。
值得注意的是,它不存储时区,但不代表它没有时区,细品这句话!通过一张图来理解三者的关系
下面以 LocalDateTime
为例简单介绍下用法
LocalDateTime time1 = LocalDateTime.now();
LocalDateTime time2 = LocalDateTime.now();
time1.isAfter(time2); time1.isBefore(time2);//比较时间
time1.plusDays(1L); time1.minusHours(1L);//加减时间日期
LocalDateTime.parse("2021-11-19T15:16:17");//解析时间
LocalDateTime.of(2019, 11, 30, 15, 16, 17);//指定日期时间
LocalDateTime.now(ZoneId.of("Asia/Jakarta"));//其他时区相对此服务器时区的时间
//...
如果你可以不在数据库中存储时区信息的话,那么请使用这个类。如果你一定要存储,那么也请使用下面的 OffsetDateTime
而不要使用 Date
。
OffsetDateTime
带有时区偏移量的日期时间类,相当于 OffsetDateTime = LocalDateTime + ZoneOffset
大多数情况下我们使用带有偏移量的日期时间已经能够满足需求。
ZonedDateTime
真正带有完整时区信息的日期时间类
Duration、Period
这两个类是代表一段时间或者说是两个时间的间隔,以 Duration
为例,试想在 Java8 之前你有一个业务要表示一个令牌的有效期为 7
天,那么通常的做法可能是存储令牌的创建时间,然后在代码中用系统当前时间减去令牌创建时间和 7
天做比较,例如
long duration = System.currentTimeMillis() - token.getCreateTime().getTime();
if (duration < 7 * 24 * 60 * 60 * 1000) { //令牌合法}
但是在使用 Duration 之后,
Duration duration = Duration.between(token.getCreateTime(), LocalDateTime.now());
boolean negative = duration.minus(effective).isNegative();//是否过期
其次 Duration
在 SpringBoot 项目中,配置也很方便
token:
effective-time: 7d # d:天 , h:小时 , m:分钟 , s:秒
在实体类中,可以使用 @ConfigurationProperties
或者 @Value
将它直接映射成 Duration
对象,当然这依赖于 SpringBoot
中提供的丰富的类型转换器。下一篇文章会介绍
TemporalAdjuster 和 TemporalAdjusters
第一眼看到这两个类是不是想起了熟悉的 Collection
和 Collections
,与之类似这两个类是时间矫正器接口和时间矫正器的工具类。新版日期时间类几乎都实现了 TemporalAdjuster
,以便于针对所有日期时间都可以对其进行计算得到另外一个时间,例如
LocalDateTime.now().with(TemporalAdjusters.firstDayOfMonth());//当月第一天
LocalDateTime.now().with(TemporalAdjusters.firstDayOfNextMonth());//下个月第一天
LocalDateTime.now().with(TemporalAdjusters.dayOfWeekInMonth(2,DayOfWeek.MONDAY));//第N个星期几
LocalDateTime.now().with(TemporalAdjusters.next(DayOfWeek.MONDAY));//下个星期几
//...
有这么丰富的 API ,你还需要写一堆日期时间的工具类吗?
Date 和 LocalDateTime 互转
不可否认存在一种现象就是你的项目一直用的都是 Date
,而 leader
又不愿意花费时间精力去升级,或者老的业务限制的情况,那么某些场景下你可以使用 Java8 提供的 Instant
将两者互转来简化一些业务代码操作
LocalDateTime.ofInstant(new Date().toInstant(), ZoneId.systemDefault());//Date 转 LocalDateTime
Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());// LocalDateTime 转 Date
总结
Java8 的时间日期工具还有很多用法,这里就不一一介绍了,总之 Date
有的功能 LocalDateTime
都有,Date
没有的功能,LocalDateTime
还有很多。使用新版的日期时间,几乎是不存在原来的 DateUtil
的。所以还需要我告诉你选谁吗~~
结语
人们总是对于自己熟悉的东西持有倾向,对于不熟悉的新事物往往会抵触,曾经我也不止一次的抵触我亲爱的架构师让我们更换新的技术组件,但后来我都爱上了这些新的技术。
抵触新技术不是一个优秀程序员该有,这会阻碍你的成长。新技术的出现往往是弥补老技术的缺陷,没有哪个组织会花费人力物力出一个废物组件......所以每当有新技术组件出现时,请尝试它,也许会有意想不到的收获!