介绍

如果使用得当,正则表达式是匹配各种模式的强大工具。

在这篇文章中,我们将使用java.util.regex包来确定一个给定的String是否包含一个有效的日期。

日期格式概述

我们将定义一个与国际公历有关的有效日期。我们的格式将遵循一般模式。YYYY-MM-DD。

让我们也包括闰年的概念,即包含2月29日这一天的一年。根据公历,除了那些可以被100除以的年份,包括那些可以被400除以的年份,如果该年份的数字可以被4平均除以,我们就称之为闰年。

在所有其他情况下,我们称其为正常年份。

有效日期示例:

  • 2017-12-31
  • 2020-02-29
  • 2400-02-29

无效日期示例:

  • 2017/12/31:不正确的分隔符
  • 2018-1-1:缺少前导零
  • 2018-04-31:四月的天数计算错误
  • 2100-02-29:今年不是闰年,因为值除以 100,所以 2 月限制为 28 天

实施解决方案

由于我们要使用正则表达式匹配日期,让我们首先勾勒出一个接口 DateMatcher,它提供了一个匹配方法:

public interface DateMatcher {
    boolean matches(String date);
}

我们将在下面逐步介绍实现,最终构建完整的解决方案。

匹配广泛的格式

我们将首先创建一个非常简单的原型来处理匹配器的格式约束:

class FormattedDateMatcher implements DateMatcher {

    private static Pattern DATE_PATTERN = Pattern.compile(
      "^\\d{4}-\\d{2}-\\d{2}$");

    @Override
    public boolean matches(String date) {
        return DATE_PATTERN.matcher(date).matches();
    }
}

在这里,我们指定一个有效的日期必须由三组由破折号分隔的整数组成。第一组由四个整数组成,其余两组各有两个整数。

匹配日期:2017-12-31、2018-01-31、0000-00-00、1029-99-72 不匹配日期:2018-01、2018-01-XX、2020/02/29

匹配特定的日期格式

我们的第二个示例接受日期标记范围以及我们的格式约束。为简单起见,我们将兴趣限制在 1900 – 2999 年。

现在我们成功地匹配了我们的一般日期格式,我们需要进一步限制它——以确保日期实际上是正确的:

^((19|2[0-9])[0-9]{2})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$

这里我们介绍了三组需要匹配的整数范围:

  • (19|2[0-9])[0-9]{2} 通过匹配一个以 19 或 2X 开头后跟几个任意数字的数字来覆盖有限的年份范围。
  • 0[1-9]|1[012] 匹配 01-12 范围内的月份数
  • 0[1-9]|[12][0-9]|3[01] 匹配 01-31 范围内的天数

匹配日期:1900-01-01、2205-02-31、2999-12-31 不匹配日期:1899-12-31、2018-05-35、2018-13-05、3000-01-01、2018-01-XX

匹配 2 月 29 日

为了正确匹配闰年,我们必须首先确定遇到闰年的时间,然后确保我们接受 2 月 29 日作为这些年份的有效日期。

由于我们限制范围内的闰年数量足够大,我们应该使用适当的整除规则来过滤它们:

  • 如果一个数的最后两位组成的数能被4整除,则原数能被4整除
  • 如果数字的最后两位是 00,则该数字能被 100 整除

这是一个解决方案:

^((2000|2400|2800|(19|2[0-9])(0[48]|[2468][048]|[13579][26]))-02-29)$

该模式由以下部分组成:

  • 2000|2400|2800 匹配一组闰年,在 1900-2999 的受限范围内,除数为 400
  • 19|20-9) 匹配除数为 4 且没有除数的所有白名单年份组合100 个
  • -02-29 匹配2 月 2 日

匹配二月份的一般日子

除了匹配闰年的 2 月 29 日,我们还需要匹配所有年份中 2 月的所有其他日子(1 - 28):

^(((19|2[0-9])[0-9]{2})-02-(0[1-9]|1[0-9]|2[0-8]))$

匹配日期:2018-02-01、2019-02-13、2020-02-25 不匹配日期:2000-02-30、2400-02-62、2018/02/28

匹配 31 天的月份

1 月、3 月、5 月、7 月、8 月、10 月和 12 月应匹配 1 到 31 天:

^(((19|2[0-9])[0-9]{2})-(0[13578]|10|12)-(0[1-9]|[12][0-9]|3[01]))$

匹配日期:2018-01-31、2021-07-31、2022-08-31 不匹配日期:2018-01-32、2019-03-64、2018/01/31

匹配 30 天的月份

4 月、6 月、9 月和 11 月应匹配 1 到 30 天:

^(((19|2[0-9])[0-9]{2})-(0[469]|11)-(0[1-9]|[12][0-9]|30))$

匹配日期:2018-04-30、2019-06-30、2020-09-30 不匹配日期:2018-04-31、2019-06-31、2018/04/30

公历日期匹配器

现在我们可以将上面的所有模式组合成一个匹配器,以获得一个完整的 GregorianDateMatcher 满足所有约束:

class GregorianDateMatcher implements DateMatcher {

    private static Pattern DATE_PATTERN = Pattern.compile(
      "^((2000|2400|2800|(19|2[0-9])(0[48]|[2468][048]|[13579][26]))-02-29)$" 
      + "|^(((19|2[0-9])[0-9]{2})-02-(0[1-9]|1[0-9]|2[0-8]))$"
      + "|^(((19|2[0-9])[0-9]{2})-(0[13578]|10|12)-(0[1-9]|[12][0-9]|3[01]))$" 
      + "|^(((19|2[0-9])[0-9]{2})-(0[469]|11)-(0[1-9]|[12][0-9]|30))$");

    @Override
    public boolean matches(String date) {
        return DATE_PATTERN.matcher(date).matches();
    }
}

我们使用了一个交替字符"|"来匹配四个分支中的至少一个。因此,2月的有效日期要么与闰年2月29日的第一个分支相匹配,要么与1至28日的任何一天的第二个分支相匹配。其余月份的日期则与第三和第四分支相匹配。

由于我们没有对这个模式进行优化以获得更好的可读性,所以可以自由地对它的长度进行试验。

此刻我们已经满足了所有的约束条件,我们在一开始就介绍了。

性能说明

解析复杂的正则表达式可能会大大影响执行流程的性能。本文的主要目的不是为了学习一种有效的方法来测试一个字符串在所有可能的日期集合中的成员资格。

如果需要一个可靠而快速的方法来验证一个日期,请考虑使用Java8提供的LocalDate.parse()。

结论

在这篇文章中,我们已经学会了如何使用正则表达式来匹配公历的严格格式化的日期,同时提供了格式、范围和月份长度的规则。