前言
Java 中日期、时间相关的类相当的多,并且分不同的版本提供了不同的实现,包括 Date 、 Calendar 、 LocalDateTime 、 ZoneDateTime 、 OffsetDateTime 等等。针对这些时间类型又通过 SimpleDateFormat 和 DateTimeFormatter 实现不同的日期与字符串之间的格式化和解析。
为了应对各种各样的日期解析,我们通常会封装类似于 DateUtils 的工具类,专门用来处理日期字符串的解析,同时为了兼容不同格式的日期字符串,又需要预先枚举出可能用到的日期格式。这种传统的 DateUtils 通常会面临性能与兼容性的两难问题。
而本文要介绍的,是一个截然不同的日期解析工具 dateparser ,它可以智能地解析几百上千种任意格式的日期字符串,更为难得的是它的性能同样非常出色。
DateUtils 的两难问题
一个比较典型的日期解析函数类似这样(这是 commons-lang3 在其 DateUtils 中提供的函数):
1  | public static Date parseDate(final String str, final String... parsePatterns) {  | 
这种日期解析函数的内部逻辑,往往是根据一批 DATE_FORMAT 轮番尝试,通过异常重试的方式试出来唯一匹配的格式。这种简单粗暴的方式,事实上存在着一个两难问题。
首先,我们很难穷举出全部可能出现的日期格式,年月日的分隔符、排列次序、时分秒、是否有毫秒、时区处理、PM 与 AM 的支持等等,罗列出来的话不计其数。
其次,异常重试的方式存在一些性能损耗,据我粗略测算,在 MBP 硬件环境中异常中断大概需要消耗 2 微秒,而一次日期解析可能消耗 0.75 微秒。如果提供的 parsePatterns 数量很多,则解析一个日期字符串的循环重试的最终耗时甚至会超过 Redis 读写操作。
那么,有没有办法既可以支持无数多个不规则的日期字符串,同时也没有性能问题的技术方案呢?
dateparser
这就是解决日期字符串解析的灵丹妙药,它是一个高性能且非常智能的 datetime 字符串解析工具。
为了实现高性能与可扩展性,它并没有采用 SimpleDateFormat 或 DateTimeFormatter ,而是正则表达式。通过预定义的正则表达式来自动地捕捉不同格式的日期片段,它可以自动抽取出字符串中存在的 year , month , day , hour , minute , second , zone 等熟悉。
这些预定义的正则表达式片段包括:
(?<week>%s)\W* 可以将 Monday 解析为 week
?(?<year>\d{4})$ 可以将 2019 解析为 year
^(?<year>\d{4})(?<month>\d{2})$ 可以抽取出 201909 内部的 year 和 month
?(?<hour>\d{1,2}) o’clock\W* 可以将 12 o’clock 解析为 hour
更多规则参见 DateParserBuilder.java
如此多的正则表达式,会不会也存在性能隐患呢?如果使用的是 java.util.regex 包来进行循环匹配,随着规则增加,确实会有性能问题。
但是 dateparser 使用 retree 将预定义的一大批正则表达式合并为一颗树,也就是正则匹配树。它可以非常快速地对一大批正则表达式执行并行匹配,内部结构可以理解为字典树,但是树中的节点并不是字母,而是正则匹配节点。
安装 Maven 依赖
可以通过此 maven 坐标引入依赖
1  | <dependency>  | 
基础使用
dateparser 提供了一个 DateParserUtils 工具类,可以直接使用它将字符串解析为 Date 、 Calendar 、 LocalDateTime 、 OffsetDateTime 等:
1  | Date date = DateParserUtils.parseDate("Mon Jan 02 15:04:05 -0700 2006");  | 
需要注意的是,它会根据字符串中标明的 TimeZon e 或 ZoneOffset 自动进行偏移量转换。
创建新 DateParser 实例
由于 DateParser 不是线程安全的,同时 parse 操作通常非常快速(1us),因此 DateParserUtils 内部直接维护了一个 DateParser 单例,然后通过 synchronized 进行并发控制。
如果你想在多线程中高频率、并发地使用它,就应该为不同的线程创建不同的 DateParser 实例:
1  | DateParser parser = DateParser.newBuilder().build();  | 
DateParser 实例相当笨重一些,所以你应该尽量多的复用它以提高性能。
MM/dd 与 dd/MM 的优先级
多数情况下, dateparser 可以按照规则自动地识别出字符串内部的 month 与 day 片段。
但是对于 MM/dd/yy 和 dd/MM/yy ,有时候它就难以区分了。因为世界上多数国家会使用 dd/MM/yy 作为日期的格式,但是也有少数国家会特立独行地使用 MM/dd/yy 作为日期格式,最典型的就是美帝国主义。
因此当 dateparser 遇到类似于 7.8.2019 这样的日期时,它就很难判断到底是 7 月 8 日还是 8 月 7 日。
为解决这个难题, dateparser 内部增加了一个名为 preferMonthFirst 的选项,用于辅助解决这个问题:
1  | DateParserUtils.preferMonthFirst(true);  | 
默认情况下,如果无法甄别月与日,则视为月在后。如果你指定了 preferMonthFirst 为 true ,则试为月在前。
自定义 Parser
你可以使用 DateParserBuilder 构建自己的日期解析器,通过此 builder ,你可以自定义新的解析规则。
例如,如果你想支持 【2019】 这样的 year 字符串,可以这样做:
1  | DateParser parser = DateParser.newBuilder().addRule("【(?<year>\\d{4})】").build();  | 
需要注意的是,正则表达式 【(?<year>\\d{4})】 里面的 year 非常重要,它是 dateparser 内置的捕捉关键词。
你也可以增加更加灵活的解析规则,就像这样:
1  | DateParser parser = DateParser.newBuilder()  | 
这个例子里面,新增了一个捕捉并解析 民国xxx年 的日期规则。
性能对比
首先,在单一日期格式下,对比一下 dateparser 与 SimpleDateFormat 的性能表现:
1  | Benchmark Mode Cnt Score Error Units  | 
可以看到,在日期格式固定且单一的情况下, dateparser 在性能上处于下风,这也在预料之中。
然后,在单一日期格式下,对比一下 dateparser 与 DateTimeFormatter 的性能表现:
1  | Benchmark Mode Cnt Score Error Units  | 
可以看到, DateTimeFormatter 的性能表现确实比 S impleDateFormat 更加出色一些。同时 dateparser 的设计初衷是为了应对不规则日期格式,因此在固定格式匹配上存在劣势并不意外。
如果我们将日期格式增加为 16 种时,性能表现就不一样了:
1  | Benchmark Mode Cnt Score Error Units  | 
如果换算一下,无论日期格式是一种还是 16 中, dateparser 的性能始终维持在 1.5us ,说明它在算法上是非常稳定的,面对不同的场景不会有什么性能损失
支持的日期格式(部分)
以下为 dateparser 在单元测试中完成测试解析的日期格式样例,具体可以参考源代码。同时需要注意的是,这个列表只是一个子集:
1  | May 8, 2009 5:57:51 PM  |