当前位置: 首页 > 科技观察

这篇文章告诉你JavaDateTimeAPI有多烂

时间:2023-03-19 19:09:34 科技观察

前言大家好,我是A哥(YourBatman)。好看的代码都一样!丑陋的代码,其实没有“史上最烂”的代码,只有“史上最烂”。日期是业务逻辑计算的关键部分。任何业务程序都需要正确处理日期和时间问题,否则很可能发生意外和损失。为此,本系列专门针对这一点撰写了多篇文章,旨在帮助大家系统地解决所有问题/难点。通常我们热衷于抱怨我们同事的代码有多糟糕。今天我们来玩点狠的:吐槽JDK,看看它的日期时间API设计有多烂。?注意:本文所指的日期时间API是Date/Calendar系列,并不是Java8的新API,毕竟我们一般称后者为JSR310日期时间,请注意区分。Java的大部分API都设计得非常好,很成功,否则Java也不可能成为编程语言界的常春藤盟校,常年霸榜。然而,JDK也有它的错误。有些API设计得很糟糕。让我们先来认识一下他们。WorstAPIPoll说到对JavaAPI的不满度调查,最著名的是外国大亨TiagoFernandez在2010年发起的一项有趣的民意调查。民意调查结果的统计图表如下:我来解释一下,从从左到右:最终得分的计算公式为:Score=(Icanlivewith)+(Painful*2)+(Crappy*3)+(Hellish*4)根据这个公式,计算出每个API的得分,得分为绘制成直方图直观展示:OK,排名出来了。从最差->最好的排名分别是:EJB2.x,简直“遥遥领先”Date/Time/Calendar,今天的猪脚XML/DOMAWT/Swing……烂到烂,想想有哪些烂API有对你影响最大?答:很常见但不好。如果一个API设计得很糟糕,但你很少使用或几乎不接触它,你不会觉得很不喜欢它。比如一堆狗屎,本身就很臭,但是如果你不用去拿,闻不到,你也不会觉得它碍眼。回到这个统计结果,EJB2.x的API设计最差无可厚非,但是在时间维度上回看现在(2021年),完全可以忽略不计,毕竟那是万万不能的对我们现在再接触它,再坏又有什么意义呢?EJB2.x是一个古老的古董。相信大部分看过文章的同学都没有见过,甚至没有听说过。A哥2015年入行。.x嘎嘎干,没接触过EJB。?说明:这个统计是2010年做的,当时EJB2.x的使用量还比较大,所以在“toplist”上?XML/DOM设计的不好,但是已经被第三个库完全替代了(如dom4j)反而后者成为了事实上的标准;AWT/Swing是市场选择,只有用Java开发界面才会用到,否则接触不到,很正常。最后再看Date/Time/Calendar日期时间API,排名第二,不可思议。毕竟,这个API有一个很大的特点:即使是现在(2021年),它仍然非常常用。因此,其不良设计的实际影响是相当大的。下面我们就来详细了解一下它的外挂设计和槽点,一起来聊聊吧。日期时间API七大罪1:Date同时表示日期和时间java.util.Date被设计为日期+时间的组合。也就是说,如果你只需要一个日期,或者只需要一个简单的时间,用Date是不行的。@Testpublicvoidtest1(){System.out.println(newDate());}输出:FriJan2200:25:06CST2021这导致语义非常不明确,例如:/***Isitaholiday*/privatestaticbooleanisHoliday(Datedate){return...;}判断某一天是否为节假日只与日期有关,与具体时间无关。如果代码这样写,语义只能通过注释来解释,方法本身无法达到自我描述的效果,也无法被强类型约束,所以容易出错。?说明:本文所有例子均未考虑时区问题,下同?罪名2:作弊日期@Testpublicvoidtest2(){Datedate=newDate();System.out.println("当前日期时间:"+date);System.out.println("年份:"+date.getYear());System.out.println("Month:"+date.getMonth());}输出:当前日期和时间:FriJan2200:25:16CST2021Year:121Month:0what?年份是121,这是什么鬼?月份返回0,这是什么鬼?无奈,看看这两个方法的Javadoc:尼玛,原来2021-1900=121是这么来的。那么问题来了,为什么是1900这个数字呢?月份其实是从0开始的,这个跟谁学的?刚好打破了我的认知,只有索引索引值是从0开始的。这种做法很不符合人的思维。?索引值从0开始无所谓,毕竟给电脑看无所谓,但你的月主要是给人看的。时间对象传给你了,你还可以给我改。真的很没有安全感。@Testpublicvoidtest(){DatecurrDate=newDate();System.out.println("当前日期是①:"+currDate);booleanholiday=isHoliday(currDate);System.out.println("是否是节假日:"+holiday);System.out.println("当前日期是②:"+currDate);}/***是不是假期*/privatestaticbooleanisHoliday(Datedate){//设置等于这一天才算假期,否则不是Dateholiday=newDate(2021-1900,10-1,1);if(date.getTime()==holiday.getTime()){returntrue;}else{//模拟写代码时不注意,makingbaddate.setTime(holiday.getTime());returntrue;}}输出:当前日期是①:FriJan2200:41:59CST2021是不是假期:true当前日期是②:FriOct0100:00:00CST2021我要你帮我判断是不是放假,然后你还给我改日期?这太多了。这是多么可怕的事情,而且还不存在重大安全隐患。对于这种情况,一般来说我们函数内部操作的参数只能是副本:要么调用者传入一个副本,要么内部生成一个副本。为了提高程序的健壮性,只需在isHoliday的第一行添加这行代码:privatestaticbooleanisHoliday(Datedate){date=(Date)date.clone();...}再次运行程序,输出:当前日期是①:FriJan2200:44:10CST2021是假期:true当前日期是②:FriJan2200:44:10CST2021bingo。但是Date作为一个经常使用的API,并不需要每个程序员都具备这种安全意识。因此,将Date设计为可变类是一个非常糟糕的设计。罪状四:不合理的java.sql.Date我们来看看java.util.Date类的继承结构:它的三个子类都在java.sql包中。先不说这种破包继承的合理性,只看下面的使用例子:@Testpublicvoidtest3(){//还没有空构造函数//java.util.Datedate=newjava.sql.Date();java.util.Datedate=newjava.sql.Date(System.currentTimeMillis());//按到当前时分秒System.out.println(date.getHours());System.out.println(date.getMinutes());System.out.println(date.getSeconds());}运行程序,出现雷雨:java.lang.IllegalArgumentExceptionatjava.sql.Date.getHours(Date.java:187)atcom.yourbatman.formatter.DateTester。test3(DateTester.java:65)...什么?又是打破认知的结果,第一句getHours()报错。进入java.sql.Date的方法源码看一看。Grass重写父类方法:还有这种重写父类方法的方法吗?有什么王者方法吗?这也是JDK可以做到的?Naked违背了里氏代换原则等诸多设计原则,子类的能力实际上比父类要小,使用起来比较混乱。java.util.Date的三个子类都位于java.sql包中,其中三个按Javadoc描述划分:java.sql.Date:只表示日期java.sql.Time:只表示时间java.sql。Timestamp:表示日期+时间这样看来,似乎可以“理解”为什么java.sql.Date重写了父类的getHours()方法会抛出IllegalArgumentException异常。毕竟,它只能代表日期。但是这种传承阉割的方式你能接受吗?反正我不行~罪恶5:无法处理时区由于日期和时间的特殊性,不同国家和地区同一时间显示的日期和时间应该是不一样的。但是Date做不到,因为它的底层代码是这样的:也就是说,它代表一个特定的时刻(时间戳),这个值在世界任何地方都是完全一样的,也就是说newDate()和System.currentTimeMillis()没有什么不同。JDK提供了TimeZone的概念来表示时区,但是在Date中没有体现,只能用在formatter上,这个设计真的让我又看不懂了。Sin6:Thread-unsafeformatter关于Date的格式化,从架构设计的角度来说,首先要吐槽的是Date明明是属于java.util包的,所以它的formatterDateFormat太恶心了。这个依赖管理怎么样?是不是有点太随意了?此外,JDK还提供了一个DateFormat子类,它实现了用于格式化日期时间的SimpleDateFormat。但它被设计成线程不安全的。一个定位为模板组件的API,被设计成线程不安全的类,实在是太蠢了。就因为这个坑的存在,让多少初中级工程师在职场掉下了眼泪。另外,由于线程不安全的问题不一定是问题,在黑盒/白盒测试和功能测试阶段也可能检测不到,留下隐患。?这是一个“灵异事件”:测试环境测试的很好,为什么上线就出问题了??罪恶7:日历很难成为一项大任务。从JDK1.1开始,Java日期时间API似乎有所改进,引入了Calendar类,并划分了职责:Calendar类:日期和时间字段之间的转换DateFormat类:格式化和解析字符串Date类:仅用于携带日期和时间有了Calendar,原来Date中的大部分方法都被标记为废弃,被Calendar取代。Date终于更简单了:只需要显示日期和时间就可以了,不用操心年月日操作,格式化操作等等。值得注意的是,这些方法只是被标记为过期,并没有被移除。尽管如此,请不要在实际开发中使用它们。日历的引入,看似是职责分离,但是日历做起来很难,设计上还有很多问题。@Testpublicvoidtest4(){Calendarcalendar=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));}output:2021101年月日处理好像没问题。从结果可以发现Calendaryear的传值不需要减去1900,这一点和Date不同。我想知道这种不一致的行为是否会让一些人发疯。?解释:Calendar相关的API是IBM捐赠的,与Date不同似乎“情有可原”。更改值时使用其复制变量。总的来说,Calendar在Date的基础上做了改进,但也仅限于修修补补,并没有从根本上解决问题。最重要的是CalendarAPI用起来实在是太不方便了,而且这个类的语义完全不符合日期/时间的意思,用起来就更别扭了。总之,不管是Date,Calendar,还是DateFormat,用起来太方便,存在各种安全隐患,线程安全问题等等,这就是API设计不好的地方。不孤单。日期和时间API是每种语言都必需的基本API。然而,不仅Java面临着API设计糟糕的境地,其他一些流行的语言也是如此。出现了一个(一堆)第三方库,设计比乙方的库更好,比如:Python:日期时间处理库ArrowJavaScript:日期时间处理库Moment.js.Net:日期时间处理库Joda-Time所以,Java并不孤单(自我安慰)自我救赎:JSR310有“七大罪”,因为原生的Date日期时间系统,催生了第三方Java日期时间的诞生库,例如著名的Joda-Time的流行,甚至一度成为标准。对于Java来说,这么重要的API模块怎么可能被第三方库霸占。开发者想简单的处理一个日期时间,还得导入第三方库,使用起来太不方便了。当时Java如日中天,于是开始了“收编”Joda-Time的旅程。2013年9月,划时代的Java8大版本正式发布,带来了很多新特性,其中最引人注目的就是新的日期和时间API:JSR310。JSR310规范的领导者是StephenColebourne,他也是Joda-Time的创造者。说白了,JSR310是在Joda-Time的基础上构建的,参考了它的大部分API实现,所以如果你之前是Joda-Time的重度用户,现在就迁移到Java8原生的JSR310日期吧时机几乎无缝地出现了。即便如此,也不能说JSR310完全等同于正式版的Joda-Time。还是有点意外。下面举例如下:首先当然是包名的区别,org.joda.time->java.time标准日期时间包JSR310不接受空值,而Joda-Time将Null值视为0。JSR310抛出的所有异常都是DateTimeException,这是一个RuntimeException,而Joda-Time都是checkedexception。简单体验JSR310API:@Testpublicvoidtest5(){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()));}所有JSR310对象都是不可变的,因此线程安全。与旧的日期时间API相比,主要特点如下:更多关于JSR310日期时间的介绍这里就不展开了,毕竟之前的文章已经啰嗦了很多遍了。简而言之,它是新一代的Java日期和时间API。它的设计非常好,几乎没有缺点。它可用于100%替换旧的日期和时间API。如果你到2021年还没有拥抱它,你还在等什么?综上所述,日期和时间API太普通了,你可能会觉得它不起眼。坦白说,如果你没有复杂的日期和时间需要处理,比如时区、偏移量、跨时区转换、国际显示等,那么你可能认为Date可以做到。如果你不想做一个随便的人,如果你想有更好的日期和时间编程体验,抛弃Date,拥抱JSR310。看完本文的思考题,你可能看不懂,你可能不懂就不懂。在这里,文末的3个问题将帮助大家复习一下:offsetZ是什么意思?ZoneId和ZoneOffset是如何建立对应关系的呢?如果某个城市不在ZoneId列表中,如何获取其UTC偏移量??