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

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

前言


你好,我是A哥(YourBatman)。


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


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


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


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


本文提纲


image.png


版本约定


  • JDK:8


正文


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


最烂API投票


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


image.png


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


image.png


计算最终得分的公式为:


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


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


image.png


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


  1. EJB 2.x,简直“遥遥领先”
  2. Date/Time/Calendar,今天的猪脚
  3. XML/DOM
  4. 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是做不到的。


@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:


image.png


image.png


尼玛,原来 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类的继承结构:


image.png


它的三个子类均处于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的方法源码进去一看,握草重写了父类方法:


image.png


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


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


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


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


罪状五:无法处理时区


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


image.png

也就是说它表示的是一个具体时刻(时间戳),这个数值放在全球任何地方都是一模一样的,也就是说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代替。

image.png


image.png


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。


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相比,最主要的特征对比如下:


image.png



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


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


总结


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


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


本文思考题


本文所属专栏:JDK日期时间,后台回复专栏名即可获取全部内容。本文已被https://www.yourbatman.cn收录。


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


  1. 偏移量Z代表什么含义?
  2. ZoneId和ZoneOffset是如何建立对应关系的?
  3. 若某个城市不在ZoneId列表里面,想要获取其UTC偏移量该怎么破?
相关文章
|
18天前
|
Java API Spring
打造未来电商新引擎:揭秘Java可扩展API设计,让支付与物流灵活如丝,引领电商时代潮流!
【8月更文挑战第30天】本文通过电商平台案例,探讨了如何设计可扩展的Java API。首先定义支付和物流服务的接口与抽象类,然后实现具体服务,接着引入工厂模式或依赖注入管理服务实例,最后通过配置实现灵活扩展。这种设计确保了应用架构的灵活性和长期稳定性。
36 3
|
6天前
|
Java API C++
Java 8 Stream Api 中的 peek 操作
本文介绍了Java中`Stream`的`peek`操作,该操作通过`Consumer<T>`函数消费流中的每个元素,但不改变元素类型。文章详细解释了`Consumer<T>`接口及其使用场景,并通过示例代码展示了`peek`操作的应用。此外,还对比了`peek`与`map`的区别,帮助读者更好地理解这两种操作的不同用途。作者为码农小胖哥,原文发布于稀土掘金。
Java 8 Stream Api 中的 peek 操作
|
10天前
|
安全 Java API
【性能与安全的双重飞跃】JDK 22外部函数与内存API:JNI的继任者,引领Java新潮流!
【9月更文挑战第7天】JDK 22外部函数与内存API的发布,标志着Java在性能与安全性方面实现了双重飞跃。作为JNI的继任者,这一新特性不仅简化了Java与本地代码的交互过程,还提升了程序的性能和安全性。我们有理由相信,在外部函数与内存API的引领下,Java将开启一个全新的编程时代,为开发者们带来更加高效、更加安全的编程体验。让我们共同期待Java在未来的辉煌成就!
36 11
|
11天前
|
安全 Java API
【本地与Java无缝对接】JDK 22外部函数和内存API:JNI终结者,性能与安全双提升!
【9月更文挑战第6天】JDK 22的外部函数和内存API无疑是Java编程语言发展史上的一个重要里程碑。它不仅解决了JNI的诸多局限和挑战,还为Java与本地代码的互操作提供了更加高效、安全和简洁的解决方案。随着FFM API的逐渐成熟和完善,我们有理由相信,Java将在更多领域展现出其强大的生命力和竞争力。让我们共同期待Java编程新纪元的到来!
34 11
|
8天前
|
监控 Java 大数据
【Java内存管理新突破】JDK 22:细粒度内存管理API,精准控制每一块内存!
【9月更文挑战第9天】虽然目前JDK 22的确切内容尚未公布,但我们可以根据Java语言的发展趋势和社区的需求,预测细粒度内存管理API可能成为未来Java内存管理领域的新突破。这套API将为开发者提供前所未有的内存控制能力,助力Java应用在更多领域发挥更大作用。我们期待JDK 22的发布,期待Java语言在内存管理领域的持续创新和发展。
|
10天前
|
Java API 数据处理
【Java的SIMD革命】JDK 22向量API:释放硬件潜能,让Java应用性能飙升!
【9月更文挑战第7天】 JDK 22向量API的发布标志着Java编程语言在SIMD技术领域的重大突破。这一新特性不仅释放了现代硬件的潜能,更让Java应用性能实现了飙升。我们有理由相信,在未来的发展中,Java将继续引领编程语言的潮流,为开发者们带来更加高效、更加强大的编程体验。让我们共同期待Java在SIMD技术的推动下开启一个全新的性能提升时代!
|
11天前
|
Java API 开发者
【Java字节码操控新篇章】JDK 22类文件API预览:解锁Java底层的无限可能!
【9月更文挑战第6天】JDK 22的类文件API为Java开发者们打开了一扇通往Java底层世界的大门。通过这个API,我们可以更加深入地理解Java程序的工作原理,实现更加灵活和强大的功能。虽然目前它还处于预览版阶段,但我们已经可以预见其在未来Java开发中的重要地位。让我们共同期待Java字节码操控新篇章的到来!
|
9天前
|
Java API 开发者
【Java字节码的掌控者】JDK 22类文件API:解锁Java深层次的奥秘,赋能开发者无限可能!
【9月更文挑战第8天】JDK 22类文件API的引入,为Java开发者们打开了一扇通往Java字节码操控新世界的大门。通过这个API,我们可以更加深入地理解Java程序的底层行为,实现更加高效、可靠和创新的Java应用。虽然目前它还处于预览版阶段,但我们已经可以预见其在未来Java开发中的重要地位。让我们共同期待Java字节码操控新篇章的到来,并积极探索类文件API带来的无限可能!
|
18天前
|
Java API
Java 8新特性:Lambda表达式与Stream API的深度解析
【7月更文挑战第61天】本文将深入探讨Java 8中的两个重要特性:Lambda表达式和Stream API。我们将首先介绍Lambda表达式的基本概念和语法,然后详细解析Stream API的使用和优势。最后,我们将通过实例代码演示如何结合使用Lambda表达式和Stream API,以提高Java编程的效率和可读性。
|
17天前
|
Java 数据库连接 缓存
Hibernate性能调优:五大秘籍,让应用效能飙升,告别慢如蜗牛的加载,体验丝滑般流畅!
【8月更文挑战第31天】本文深入探讨了提升Hibernate应用性能的五大技巧,包括选择合适的缓存策略、优化查询语句、合理使用Eager与Lazy加载、批量操作与事务管理以及利用索引和数据库优化。通过正确配置多级缓存、分页查询、延迟加载、批量处理及合理创建索引,能够显著提高应用响应速度与吞吐量,改善用户体验。这些技巧需根据具体应用场景灵活调整,以实现最佳性能优化效果。
35 0