3

一文告诉你Java日期时间API到底有多烂

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzI0MTUwOTgyOQ%3D%3D&%3Bmid=2247492093&%3Bidx=1&%3Bsn=64e2c1b863682a75f11f27507db3c7de
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
vAZzI3.png!mobile

前言

你好,我是A哥(YourBatman)。

好看的代码,千篇一律!难看的代码,卧槽卧槽~其实没有什么代码是“史上最烂”的,要有也只有“史上更烂”。

日期是商业逻辑计算的一个 关键部分 ,任何企业的程序都需要正确的处理日期时间问题,否则很可能带来事故和损失。为此本系列仅着眼于这一个点就写了好几篇文章,目的是帮助你系统化的搞定所有问题/难题。

平时我们都热衷于吐槽同事的代码有多烂,今天我们就来玩点狠的:吐槽吐槽JDK,看看它的日期时间API设计得到底有多烂。

说明:本文指的日期时间API是Date/Calendar系列,而非Java 8新的API。毕竟一般我们称后者为JSR 310日期时间,请注意区分哈

本文提纲

bAVFn2a.png!mobile

版本约定

  • JDK:8

正文

诚然,Java的API绝大多数设计得都是非常优秀且成功的,否则Java也不可能成为编程语言界的常青藤,并且还常年霸榜。 但是 ,JDK也有失手的地方,存在设计得非常烂的API,先来了解下。

最烂API投票

谈到对Java API不满意程度的调研,最出名的当属2010年国外一个大佬Tiago Fernandez发起的一个很有意思的投票,投票结果的数据统计图表如下:

v636BfM.png!mobile

对横向标题栏的各个单词解释一下,从左到右依次为:

iAbiArM.png!mobile

计算最终得分的公式为:

Score = (I can live with) + (Painful * 2) + (Crappy * 3) + (Hellish * 4)

按照此公式,计算出各API的得分,画成直方图直观的展示出来:

IvyYfuy.png!mobile

好,排名出来了。从最烂 -> 最好的名次依次为:

  1. EJB 2.x,简直“遥遥领先”

  2. Date/Time/Calendar,今天的猪脚

  3. XML/DOM

  4. AWT/Swing

  5. ...

烂归烂,想一想什么样的烂API对你的产生影响会是最大的呢?答: 很常用却很烂的 。倘若一个API设计得很烂但你很少用或者几乎不用接触,你也不会对它产生很大厌恶感。打个比方,一堆屎本身很臭,但若你并不需要走到它身旁也就闻不到,自然就不会觉得它有多碍眼了。

回到这个统计结果来,EJB 2.x的API设计得最烂这个结果无可厚非,但站在时间维度的现在(2021年)回头来看,是可以完全忽略它了,毕竟现在的我们绝无可能再接触到它,再烂又有何干呢?

EJB 2.x这个老古董,相信在看文章的绝大部分同学都没见过甚至没听过它吧,A哥2015年入行,一上来Spring 4.x嘎嘎就是干,从未接触过EJB。

说明:这个统计是2010年做的,那会EJB2.x的使用量还比较大,因此上了“榜首”

XML/DOM设计得也不好,但已完全被第三库(如dom4j)取代,后者成为了事实的标准;AWT/Swing是市场的抉择,你用Java开发界面才会用到,否则不会接触,属于正常。

最后再看“屈居”第二名的Date/Time/Calendar日期时间API,它就不得了了。毕竟此API有个很大的特点:哪怕到了现在(2021年)依旧非常常用。所以,它设计得烂带来的实际影响是蛮大的。

下面就来具体了解下它有哪些坑爹的设计和槽点,一起不吐不快。

日期时间API的七宗罪

JrEjYb7.png!mobile

罪状一:Date同时表示日期和时间

java.util.Date被设计为日期 + 时间的结合体。也就是说如果只需要日期,或者只需要单纯的时间,用Date是做不到的。

@Test
public void test1() {
System.out.println(new Date());
}

输出:
Fri Jan 22 00:25:06 CST 2021

这就导致语义非常的不清晰,比如说:

/**
* 是否是假期
*/

private static boolean isHoliday(Date date){
return ...;
}

判断某一天是否是假期,只和日期有关,和具体时间没有关系。如果代码这样写语义只能靠注释解释,方法本身无法达到自描述的效果,也无法通过强类型去约束,因此容易出错。

说明:本文所有例子不考虑时区问题,下同

罪状二:坑爹的年月日

@Test
public void test2() {
Date date = new Date();
System.out.println("当前日期时间:" + date);
System.out.println("年份:" + date.getYear());
System.out.println("月份:" + date.getMonth());
}

输出:
当前日期时间:Fri Jan 22 00:25:16 CST 2021
年份:121
月份:0

what?年份是121年,这什么鬼?月份返回0,这又是什么鬼?

无奈,看看这两个方法的Javadoc:

vU73EjR.png!mobilerINRbiI.png!mobile

尼玛,原来 2021 - 1900 = 121是这么来的。那么问题来了,为何是1900这个数字呢?

月份,竟然从0开始,这是学的谁呢?简直打破了我认为的只有index索引值才是从0开始的认知啊,这种做法非常的不符合人类思维有木有。

索引值从0开始就算了,毕竟那是给计算机看的无所谓,但是你这月份主要是给人看的呀

罪状三:Date是可变的

oh my god,也就是说我把一个Date日期时间对象传给你,你竟然还能给我改掉,真是太没安全感可言了。

@Test
public void test() {
Date currDate = new Date();
System.out.println("当前日期是①:" + currDate);
boolean holiday = isHoliday(currDate);
System.out.println("是否是假期:" + holiday);

System.out.println("当前日期是②:" + currDate);
}

/**
* 是否是假期
*/

private static boolean isHoliday(Date date) {
// 架设等于这一天才是假期,否则不是
Date holiday = new Date(2021 - 1900, 10 - 1, 1);

if (date.getTime() == holiday.getTime()) {
return true;
} else {
// 模拟写代码时不注意,使坏
date.setTime(holiday.getTime());
return true;
}
}

输出:
当前日期是①:Fri Jan 22 00:41:59 CST 2021
是否是假期:true
当前日期是②:Fri Oct 01 00:00:00 CST 2021

我就像让你帮我判断下遮天是否是假期,然后你竟然连我的日期都给我改了?过分了啊。这是多么可怕的事,存在重大安全隐患有木有。

针对这种case,一般来说我们函数内部操作的参数只能是 副本 :要么调用者传进来的就是副本,要么内部自己生成一个副本。

在本利中提高程序健壮性只需在isHoliday首行加入这句代码即可:

private static boolean isHoliday(Date date) {
date = (Date) date.clone();
...
}

再次运行程序,输出:

当前日期是①:Fri Jan 22 00:44:10 CST 2021
是否是假期:true
当前日期是②:Fri Jan 22 00:44:10 CST 2021

bingo。

但是呢,Date作为高频使用的API,并不能要求每个程序员都有这种安全意识,毕竟即使百密也会有一疏。所以说,把Date设计为一个可变的类是非常糟糕的设计。

罪状四:无法理喻的java.sql.Date

来,看看java.util.Date类的继承结构:

BzURnaA.png!mobile

它的三个子类均处于java.sql包内。且先不谈这种垮包继承的合理性问题,直接看下面这个使用例子:

@Test
public void test3() {
// 竟然还没有空构造器
// java.util.Date date = new java.sql.Date();
java.util.Date date = new java.sql.Date(System.currentTimeMillis());

// 按到当前的时分秒
System.out.println(date.getHours());
System.out.println(date.getMinutes());
System.out.println(date.getSeconds());
}

运行程序,暴雷了:

java.lang.IllegalArgumentException
at java.sql.Date.getHours(Date.java:187)
at com.yourbatman.formatter.DateTester.test3(DateTester.java:65)
...

what?又是一打破认知的结果啊,第一句getHours()就报错啦。走进java.sql.Date的方法源码进去一看,握草重写了父类方法:

rmymqeu.png!mobile

还有这么重写父类方法的?还有王法吗?这也算是JDK能干出来的事?赤裸裸的违背里氏替换原则等众多设计原则,子类能力竟然比父类小,使用起来简直让人云里雾里。

java.util.Date的三个子类均位于java.sql包内,他们三是通过Javadoc描述来进行分工的:

  • java.sql.Date:只表示日期

  • java.sql.Time:只表示时间

  • java.sql.Timestamp:表示日期 + 时间

这么一来,似乎可以“理解”java.sql.Date为何重写父类的getHours()方法改为抛出IllegalArgumentException异常了,毕竟它只能表示日期嘛。但是这种通过继承再阉割的实现手法你们接受得了?反正我是不能的~

罪状五:无法处理时区

因为日期时间的特殊性,不同的国家地区在 同一时刻 显示的日期时间应该是不一样的,但Date做不到,因为它底层代码是这样的:

F7RvMjf.png!mobile

也就是说它表示的是一个具体时刻(时间戳),这个数值放在全球任何地方都是一模一样的,也就是说new Date()和System.currentTimeMillis()没啥两样。

JDK提供了TimeZone表示时区的概念,但它在Date里并无任何体现,只能使用在格式化器上,这种设计着实让我再一次看不懂了。

罪状六:线程不安全的格式化器

关于Date的格式化,站在架构设计的角度来看,首先不得不吐槽的是Date明明属于java.util包,那么它的格式化器DateFormat为毛却跑到java.text里去了呢?这种依赖管理的什么鬼?是不是有点太过于随意了呢?

另外,JDK提供了一个DateFormat的子类实现SimpleDateFormat专门用于格式化日期时间。 但是 它却被设计为了线程不安全的,一个定位为模版组件的API竟然被设计为线程不安全的类,实属瞎整。

就因为这个坑的存在,让多少初中级工程师泪洒职场,算了说多了都是泪。另外,因为线程不安全问题并非必现问题,因此在黑盒/白盒测试、功能测试阶段都可能测不出来,留下潜在风险。

这就是“灵异事件”:测试环境测试得好好的,为何到线上就出问题了呢?

罪状七:Calendar难当大任

从JDK 1.1 开始,Java日期时间API似乎进步了些,引入了Calendar类,并且对职责进行了划分:

  • Calendar类:日期和时间字段之间转换

  • DateFormat类:格式化和解析字符串

  • Date类: 用来承载日期和时间

有了Calendar后,原有Date中的大部分方法均标记为废弃,交由Calendar代替。

7JN3aaY.png!mobile

Date终于单纯了些:只需要展示日期时间而无需再顾及年月日操作、格式化操作等等了。值得注意的是,这些方法只是被标记为过期,并未删除。即便如此,请在实际开发中也 一定不要使用 它们。

引入了一个Calendar似乎分离了职责,但Calendar难当大任,设计上依旧存在很多问题。

@Test
public void test4() {
Calendar calendar = Calendar.getInstance(TimeZone.getDefault());
calendar.set(2021, 10, 1); // -> 依旧是可变的

System.out.println(calendar.get(Calendar.YEAR));
System.out.println(calendar.get(Calendar.MONTH));
System.out.println(calendar.get(Calendar.DAY_OF_MONTH));
}

输出:
2021
10
1

年月日的处理上似乎可以接受没有问题了。从结果中可以发现,Calendar年份的传值不用再减去1900了,这和Date是不一样的,不知道这种行为不一致会不会让有些人抓狂。

说明:Calendar相关的API是由IBM捐过来的,所以和Date不一样貌似也“情有可原”

另外,还有个重点是Calendar依旧是可变的,所以存在不安全因素,参与计算改变值时请使用其副本变量。

总的来说,Calendar在Date的基础上做了改善,但仅限于修修补补, 并未从根本上解决问题 。最重要的是Calendar的API使用起来真的很不方便,而且该类在语义上也完全不符合日期/时间的含义,使用起来更显尴尬。

总之,无论是Date,还是Calendar,还是格式化DateFormat都用着太方便,且存在各式各样的安全隐患、线程安全问题等等,这是API没有设计好的地方。

并不孤单

日期时间API属于基础API,在各个语言中都是必备的。然而不仅仅是Java面临着API设计很烂的处境,有些其它流行语言一样如此,涌现出1个(1堆)三方库比乙方库设计更好的情况,比如:

  • Python:日期时间处理库Arrow

  • JavaScript:日期时间处理库Moment.js

  • .Net:日期时间处理库Joda-Time

所以说,Java它并不孤单(自我安慰一把)

自我救赎:JSR 310

因为原生的Date日期时间体系存在“ 七宗罪 ”,催生了第三方Java日期时间库的诞生,如大名鼎鼎的Joda-Time的流行甚至一度成为标配。

对于Java来说,如此重要的API模块岂能被第三方库给占据,开发者本就想简单的处理个日期时间还得导入第三方库,使用也太不方便了吧。当时的Java如日中天,因此就开启了“收编”Joda-Time之旅。

2013年9月份,具有划时代意义的Java 8大版本正式发布,该版本带来了非常多的新特性,其中最引入瞩目之一便是全新的日期时间API:JSR 310。

3eimae2.png!mobile

JSR 310规范的领导者是Stephen Colebourne,此人也是Joda-Time的缔造者。不客气的说JSR 310是在Joda-Time的基础上建立的,参考了其绝大部分的API实现,因此若你之前是Joda-Time的重度使用者,现在迁移到Java 8原生的JSR 310日期时间上来几乎无缝。

即便这样,也并不能说JSR 310就完全等于Joda-Time的官方版本,还是有些许诧异的,例举如下:

  1. 首先当然是包名的差别,org.joda.time -> java.time标准日期时间包

  2. JSR 310不接受null值,Joda-Time把Null值当0处理

  3. JSR 310所有抛出的异常是DateTimeException,它是个RuntimeException,而Joda-Time都是checked exception

简单感受下JSR 310 API:

@Test
public void test5() {
System.out.println(LocalDate.now(ZoneId.systemDefault()));
System.out.println(LocalTime.now(ZoneId.systemDefault()));
System.out.println(LocalDateTime.now(ZoneId.systemDefault()));

System.out.println(OffsetTime.now(ZoneId.systemDefault()));
System.out.println(OffsetDateTime.now(ZoneId.systemDefault()));
System.out.println(ZonedDateTime.now(ZoneId.systemDefault()));

System.out.println(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()));
System.out.println(DateTimeFormatter.ISO_LOCAL_TIME.format(LocalTime.now()));
System.out.println(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()));
}

JSR 310的所有对象都是 不可变 的,所以线程安全。和老的日期时间API相比, 最主要 的特征对比如下:

 JSR 310 Date/Calendar 说明 流畅的API 难用的API API设计的好坏最直接影响编程体验,前者大大大大优于后者 实例不可变 实例可变 对于日期时间实例,设计为可变确实不合理也不安全。都不敢放心的传递给其它函数使用 线程安全 线程不安全 此特性直接决定了编码方式和健壮性

关于JSR 310日期时间更多介绍此处就不展开了,毕竟前面文章啰嗦过好多次了。总之它是Java的新一代日期时间API,设计得非常好, 几乎没有缺点可言 ,可用于100%替代老的日期时间API。

如果你到现在2021年了还没拥抱它,那么请问你还在等啥呢?

总结

日期时间API因为过于常用,因此你可能都觉得它毫不起眼。坦白的说,如果你没有复杂的日期时间需求要处理,如涉及到时区、偏移量、跨时区转换、国际化显示等等,那么可能觉得Date也能将就。

如果你不想做个将就的人,如果你想拥有更好的日期时间编程体验,弃用Date,拥抱JSR 310吧。

本文思考题

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. 偏移量Z代表什么含义?

  2. ZoneId和ZoneOffset是如何建立对应关系的?

  3. 若某个城市不在ZoneId列表里面,想要获取其UTC偏移量该怎么破?

推荐阅读

GMT UTC CST ISO 夏令时 时间戳,都是些什么鬼?

全网最全!彻底弄透Java处理GMT/UTC日期时间

LocalDateTime、OffsetDateTime、ZonedDateTime互转,这一篇绝对喂饱你

关注我

分享、成长,拒绝浅藏辄止。关注【BAT的乌托邦】回复关键字 专栏 有Spring技术栈、中间件等小而美的纯原创专栏。本文已被 https://www.yourbatman.cn 收录。

本文所属专栏: JDK日期时间 ,公号后台回复专栏名即可获取全部内容。

A哥(YourBatman):Spring Framework/Boot开源贡献者,Java架构师。 非常注重基本功修养 ,相信底层基础决定上层建筑,坚实基础才能焕发程序员更强生命力。文章特点为以小而美专栏形式重构知识体系,抽丝剥茧,致力于做人人能看懂的最好的专栏系列。可加我好友( fsx1056342982 )共勉哦!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK