一文告诉你 Java 日期时间 API 到底有多烂
你好,我是 A 哥(YourBatman)。
好看的代码,千篇一律!难看的代码,卧槽卧槽~其实没有什么代码是“史上最烂”的,要有也只有“史上更烂”。
日期是商业逻辑计算的一个关键部分,任何企业的程序都需要正确的处理日期时间问题,否则很可能带来事故和损失。为此本系列仅着眼于这一个点就写了好几篇文章,目的是帮助你系统化的搞定所有问题/难题。
平时我们都热衷于吐槽同事的代码有多烂,今天我们就来玩点狠的:吐槽吐槽 JDK,看看它的日期时间 API 设计得到底有多烂。
说明:本文指的日期时间 API 是 Date/Calendar 系列,而非 Java 8 新的 API。毕竟一般我们称后者为 JSR 310 日期时间,请注意区分哈
本文提纲
版本约定
JDK:8
正文
诚然,Java 的 API 绝大多数设计得都是非常优秀且成功的,否则 Java 也不可能成为编程语言界的常青藤,并且还常年霸榜。但是,JDK 也有失手的地方,存在设计得非常烂的 API,先来了解下。
最烂 API 投票
谈到对 Java API 不满意程度的调研,最出名的当属 2010 年国外一个大佬 Tiago Fernandez 发起的一个很有意思的投票,投票结果的数据统计图表如下:
对横向标题栏的各个单词解释一下,从左到右依次为:
计算最终得分的公式为:
按照此公式,计算出各 API 的得分,画成直方图直观的展示出来:
好,排名出来了。从最烂 -> 最好的名次依次为:
EJB 2.x,简直“遥遥领先”
Date/Time/Calendar,今天的猪脚
XML/DOM
AWT/Swing
...
烂归烂,想一想什么样的烂 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 的七宗罪
罪状一:Date 同时表示日期和时间
java.util.Date 被设计为日期 + 时间的结合体。也就是说如果只需要日期,或者只需要单纯的时间,用 Date 是做不到的。
这就导致语义非常的不清晰,比如说:
判断某一天是否是假期,只和日期有关,和具体时间没有关系。如果代码这样写语义只能靠注释解释,方法本身无法达到自描述的效果,也无法通过强类型去约束,因此容易出错。
说明:本文所有例子不考虑时区问题,下同
罪状二:坑爹的年月日
what?年份是 121 年,这什么鬼?月份返回 0,这又是什么鬼?
无奈,看看这两个方法的 Javadoc:
尼玛,原来 2021 - 1900 = 121 是这么来的。那么问题来了,为何是 1900 这个数字呢?
月份,竟然从 0 开始,这是学的谁呢?简直打破了我认为的只有 index 索引值才是从 0 开始的认知啊,这种做法非常的不符合人类思维有木有。
索引值从 0 开始就算了,毕竟那是给计算机看的无所谓,但是你这月份主要是给人看的呀
罪状三:Date 是可变的
oh my god,也就是说我把一个 Date 日期时间对象传给你,你竟然还能给我改掉,真是太没安全感可言了。
我就像让你帮我判断下遮天是否是假期,然后你竟然连我的日期都给我改了?过分了啊。这是多么可怕的事,存在重大安全隐患有木有。
针对这种 case,一般来说我们函数内部操作的参数只能是副本:要么调用者传进来的就是副本,要么内部自己生成一个副本。
在本利中提高程序健壮性只需在 isHoliday 首行加入这句代码即可:
再次运行程序,输出:
bingo。
但是呢,Date 作为高频使用的 API,并不能要求每个程序员都有这种安全意识,毕竟即使百密也会有一疏。所以说,把 Date 设计为一个可变的类是非常糟糕的设计。
罪状四:无法理喻的 java.sql.Date
来,看看 java.util.Date 类的继承结构:
它的三个子类均处于 java.sql 包内。且先不谈这种垮包继承的合理性问题,直接看下面这个使用例子:
运行程序,暴雷了:
what?又是一打破认知的结果啊,第一句 getHours()就报错啦。走进 java.sql.Date 的方法源码进去一看,握草重写了父类方法:
还有这么重写父类方法的?还有王法吗?这也算是 JDK 能干出来的事?赤裸裸的违背里氏替换原则等众多设计原则,子类能力竟然比父类小,使用起来简直让人云里雾里。
java.util.Date 的三个子类均位于 java.sql 包内,他们三是通过 Javadoc 描述来进行分工的:
java.sql.Date:只表示日期
java.sql.Time:只表示时间
java.sql.Timestamp:表示日期 + 时间
这么一来,似乎可以“理解”java.sql.Date 为何重写父类的 getHours()方法改为抛出 IllegalArgumentException 异常了,毕竟它只能表示日期嘛。但是这种通过继承再阉割的实现手法你们接受得了?反正我是不能的~
罪状五:无法处理时区
因为日期时间的特殊性,不同的国家地区在同一时刻显示的日期时间应该是不一样的,但 Date 做不到,因为它底层代码是这样的:
也就是说它表示的是一个具体时刻(时间戳),这个数值放在全球任何地方都是一模一样的,也就是说 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 代替。
Date 终于单纯了些:只需要展示日期时间而无需再顾及年月日操作、格式化操作等等了。值得注意的是,这些方法只是被标记为过期,并未删除。即便如此,请在实际开发中也一定不要使用它们。
引入了一个 Calendar 似乎分离了职责,但 Calendar 难当大任,设计上依旧存在很多问题。
年月日的处理上似乎可以接受没有问题了。从结果中可以发现,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。
JSR 310 规范的领导者是 Stephen Colebourne,此人也是 Joda-Time 的缔造者。不客气的说 JSR 310 是在 Joda-Time 的基础上建立的,参考了其绝大部分的 API 实现,因此若你之前是 Joda-Time 的重度使用者,现在迁移到 Java 8 原生的 JSR 310 日期时间上来几乎无缝。
即便这样,也并不能说 JSR 310 就完全等于 Joda-Time 的官方版本,还是有些许诧异的,例举如下:
首先当然是包名的差别,org.joda.time -> java.time 标准日期时间包
JSR 310 不接受 null 值,Joda-Time 把 Null 值当 0 处理
JSR 310 所有抛出的异常是 DateTimeException,它是个 RuntimeException,而 Joda-Time 都是 checked exception
简单感受下 JSR 310 API:
JSR 310 的所有对象都是不可变的,所以线程安全。和老的日期时间 API 相比,最主要的特征对比如下:
JSR 310 | Date/Calendar | 说明
-------- | ----- | -----
流畅的 API | 难用的 API | API 设计的好坏最直接影响编程体验,前者大大大大优于后者
实例不可变 | 实例可变 | 对于日期时间实例,设计为可变确实不合理也不安全。都不敢放心的传递给其它函数使用
线程安全 | 线程不安全 | 此特性直接决定了编码方式和健壮性
关于 JSR 310 日期时间更多介绍此处就不展开了,毕竟前面文章啰嗦过好多次了。总之它是 Java 的新一代日期时间 API,设计得非常好,几乎没有缺点可言,可用于 100%替代老的日期时间 API。
如果你到现在 2021 年了还没拥抱它,那么请问你还在等啥呢?
总结
日期时间 API 因为过于常用,因此你可能都觉得它毫不起眼。坦白的说,如果你没有复杂的日期时间需求要处理,如涉及到时区、偏移量、跨时区转换、国际化显示等等,那么可能觉得 Date 也能将就。
如果你不想做个将就的人,如果你想拥有更好的日期时间编程体验,弃用 Date,拥抱 JSR 310 吧。
本文思考题
本文所属专栏:JDK 日期时间,后台回复专栏名即可获取全部内容。本文已被https://www.yourbatman.cn收录。
看完了不一定懂,看懂了不一定会。来,文末 3 个思考题帮你复盘:
偏移量 Z 代表什么含义?
ZoneId 和 ZoneOffset 是如何建立对应关系的?
若某个城市不在 ZoneId 列表里面,想要获取其 UTC 偏移量该怎么破?
推荐阅读
作者简介:A 哥(YourBatman),Spring Framework/Boot 开源贡献者,Java 架构师,爱分享。非常注重基本功修养,底层基础决定上层建筑,才能焕发程序员更强生命力。非常擅长结构化讲述专题,抽丝剥茧颇具深度。这些专题也许可能大概是全网最好或独一份哦,欢迎自取。
版权声明: 本文为 InfoQ 作者【YourBatman】的原创文章。
原文链接:【http://xie.infoq.cn/article/0abe8deddecb0ca9de85fb87b】。文章转载请联系作者。
评论