Skip to content

Files

Latest commit

c4e46db · Oct 12, 2021

History

History
789 lines (579 loc) · 44.2 KB

File metadata and controls

789 lines (579 loc) · 44.2 KB

四、日期计算器

如果您已经在 Java 中开发了很长时间,那么您知道有一件事是正确的——处理日期是很糟糕的。java.util.Date类及其相关类随 1.0 一起发布,Calendar及其相关类随 1.1 一起发布。即使在早期,问题也显而易见。例如,Date上的 Javadoc 这样说--不幸的是,这些函数的 API 不适合国际化。因此,在 1.1 中引入了Calendar。当然,这些年来还有其他的增强,但是考虑到 Java 严格遵守向后兼容性,语言架构师只能做这么多。尽管他们可能很想修复这些 API,但他们的手却被束缚住了。

幸运的是,Java 规范请求JSR 310已经提交。在 Stephen Colebourne 的领导下,一项基于非常流行的开源库 Joda Time 的新 API 的创建工作已经开始。在本章中,我们将深入了解这个新 API,然后构建一个简单的命令行实用程序来执行日期和时间计算,这将使我们有机会看到一些 API 的实际应用。

因此,本章将涵盖以下主题:

  • Java8 日期/时间 API
  • 重新访问命令行实用程序
  • 文本分析

开始

就像第 2 章中的项目一样,用 Java管理流程,这个项目在概念上相当简单。最终目标是使用命令行实用程序来执行各种日期和时间计算。然而,当我们进行此项工作时,如果将实际的日期/时间工作放在一个可重用的库中,那将是非常好的,因此我们将这样做。这就给我们留下了两个项目,我们将像上次一样设置为一个多模块 Maven 项目。

父 POM 将如下所示:

    <?xml version="1.0" encoding="UTF-8"?> 
    <project 
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
      http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
      <modelVersion>4.0.0</modelVersion> 

      <artifactId>datecalc-master</artifactId> 
      <version>1.0-SNAPSHOT</version> 
      <packaging>pom</packaging> 
      <modules> 
        <module>datecalc-lib</module> 
        <module>datecalc-cli</module> 
      </modules> 
    </project> 

如果您阅读了第 2 章用 Java管理流程,或者之前使用过多模块 Maven 构建,那么这里没有什么新内容。它只是为了完整而包括在内。如果您不熟悉这一点,请花点时间回顾第 2 章的前几页,然后继续。

建设图书馆

由于我们希望能够在其他项目中重用此工具,因此我们将首先构建一个公开其功能的库。我们需要的所有功能都内置在平台中,因此我们的 POM 文件非常简单:

    <?xml version="1.0" encoding="UTF-8"?> 
    <project 
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
      xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
      http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
      <modelVersion>4.0.0</modelVersion> 
      <parent> 
        <groupId>com.steeplesoft</groupId> 
          <artifactId>datecalc-master</artifactId> 
          <version>1.0-SNAPSHOT</version> 
      </parent> 
      <artifactId>datecalc-lib</artifactId> 
      <packaging>jar</packaging> 
      <dependencies> 
        <dependency> 
          <groupId>org.testng</groupId> 
          <artifactId>testng</artifactId> 
          <version>6.9.9</version> 
          <scope>test</scope> 
        </dependency> 
      </dependencies> 
    </project> 

几乎没有外部依赖关系。列出的唯一依赖项是测试库 TestNG。在上一章中,我们没有谈论太多关于测试的内容(请放心,项目中有测试)。在本章中,我们将介绍测试主题并展示一些示例。

现在我们需要定义我们的模块。请记住,这些是 Java9 项目,因此我们希望利用模块功能来帮助保护内部类免受意外公开。我们的模块非常简单。我们需要给它一个名称,然后导出我们的公共 API 包,如下所示:

    module datecalc.lib { 
      exports com.steeplesoft.datecalc; 
    } 

因为我们需要的所有东西都已经在 JDK 中了,所以除了我们导出的东西之外,我们没有什么需要声明的。

随着我们的项目的建立,让我们快速浏览一下功能需求。本项目的目的是构建一个系统,允许用户提供表示日期或时间计算表达式的任意字符串并获得响应。字符串可能类似于"today + 2 weeks"以查找从今天起两周后的日期,"now + 3 hours 15 minutes"以查找 3 小时 15 分钟内的时间,或者"2016/07/04 - 1776/07/04"以查找两个日期之间的年、月和日。这些表达式的处理将是一次一行,因此传递(例如)包含多个表达式的文本文档并获取多个结果的能力将明确排除在范围之外。当然,这可以很容易地由任何消费应用程序或库实现。

现在我们有了一个项目,已经准备好了,我们有了一个简单功能需求的草图。我们已经准备好开始编码了。在我们这样做之前,让我们快速浏览一下新的 Protot0 包,以更好地了解我们在这个项目中看到的东西,以及我们在这个简单的项目中不使用的一些功能。

适时的插曲

在 Java8 之前,两个主要的日期相关类是DateCalendar(当然还有GregorianCalendar。新的java.time包提供了几个新的类,例如DurationPeriodClockInstantLocalDateLocalTimeLocalDateTimeZonedDateTime。有过多的支持类,但这些是主要的出发点。让我们来看看每一个。

期间

Duration基于时间的时间单位。虽然用这种方式表达可能听起来很奇怪,但选择这种措辞是为了将其与基于日期的时间单位区分开来,我们将在下面介绍。用简单的英语来说,它是时间的度量,例如10 秒1 小时100 纳秒Duration是以秒为单位测量的,但是有很多方法可以用其他测量单位表示持续时间,如下所示:

  • getNano():这是纳米生态系统中的Duration
  • getSeconds():这是Duration
  • get(TemporalUnit):这是Duration中指定的度量单位

还有各种算术方法,如下所述:

  • add/minus (int amount, TemporalUnit unit)
  • add/minus (Duration)
  • addDays/minusDays(long)
  • addHours/minusHours(long)
  • addMillis/minusMillis(long)
  • addMinutes/minusMinutes(long)
  • addNanos/minusNanos(long)
  • addSeconds/minusSeconds(long)
  • dividedBy/multipliedBy

我们还有许多方便的工厂和提取方法,例如:

  • ofDays(long)/toDays()
  • ofHours(long)/toHours()
  • ofMinutes(long)/toMinutes()
  • ofSeconds(long)/toSeconds()

还提供了一种parse()方法。不幸的是,对于某些人来说,此方法的输入可能不是您所期望的。因为我们处理的持续时间通常是,比如说,以小时和分钟为单位,所以您可能希望该方法接受类似“1:37”的 1 小时 37 分钟。但是,这将导致系统抛出DateTimeParseException。该方法希望接收的是 ISO-8601 格式的字符串,如下所示--PnDTnHnMn.nS。那太棒了,不是吗?虽然一开始可能会让人困惑,但一旦你理解了,情况就不太糟了:

  • 第一个字符是可选的+(加号)或-(减号)。
  • 下一个字符是P,可以是大写或小写。
  • 以下是四个部分中的至少一个,分别表示天(D)、小时(H)、分钟(M)和秒(S)。再说一次,情况并不重要。
  • 必须按此顺序申报。
  • 每个部分都有一个数字部分,其中包括可选的+-符号、一个或多个 ASCII 数字以及度量单位指示器。秒数可以是小数(表示为浮点数),并且可以使用句点或逗号。
  • 字母T必须在小时、分钟或秒之前出现。

很简单,对吧?它可能对非技术受众不太友好,但它支持在字符串中编码持续时间,从而允许明确的解析,这是向前迈出的一大步。

时期

Period是基于日期的时间单位。而Duration是关于时间(小时、分钟、秒等),Period是关于年、周、月等。与Duration一样,它公开了几种加减运算方法,尽管这些方法涉及年、月和日。它还提供plus(long amount, TemporalUnit unit)(以及同等的minus)。

另外,像Duration一样,Period有一个parse()方法,它也采用 ISO-8601 格式,看起来像这样--PnYnMnDPnW。根据前面的讨论,结构可能非常明显:

  • 字符串以可选符号开头,后跟字母P
  • 之后,对于第一种形式,有三个部分,其中至少一个必须存在——年(Y)、月(M)和日(D)。
  • 对于第二种形式,只有一个部分——周(W
  • 每个部分中的金额可以有正号或负号。
  • W单元不能与其他单元组合。在内部,该金额乘以7并被视为天数。

时钟

Clock是一个抽象类,使用时区提供对当前瞬间(我们将在下面看到)、日期和时间的访问。在 Java8 之前,我们必须调用System.currentTimeInMillis()TimeZone.getDefault()来计算这些值。Clock提供了一个很好的接口,可以从一个对象中获取。

Javadoc 声明,Clock的使用完全是可选的。事实上,主要的日期/时间类有一个now()方法,它使用系统时钟来获取它们的值。但是,如果您需要提供一个替代实现(例如,在测试中,您需要另一个时区中的LocalTime,则可以扩展该抽象类以提供所需的功能,然后可以将其传递给相应的now()方法。

瞬间

Instant是一个单一的、精确的时间点(或者时间线上的**,您将看到 Javadoc 的说法)。本课程提供算术方法,非常类似于PeriodDuration。解析也是一个选项,字符串为 ISO-8601 即时格式,如1977-02-16T08:15:30Z。**

本地日期

LocalDate是一个没有时区的日期。虽然此类的值是日期(年、月和日),但其他值也有访问器方法,如下所示:

  • getDayOfWeek():返回日期所代表的星期几的DayOfWeek枚举。
  • getDayOfYear():返回日期表示的一年中的某一天(1 到 365,或闰年为 366)。这是从指定年份的 1 月 1 日开始的基于 1 的计数器。
  • getEra():返回给定日期的 ISO 纪元。

当然,可以从字符串中解析本地日期,但是,这一次,格式似乎更合理--yyyy-mm-dd。如果您需要不同的格式,parse()方法已被重写,以允许您指定可以处理字符串格式的DateTimeFormatter

当地时间

LocalTimeLocalDate基于时间的等价物。它存储HH:MM:SS,但不存储时区。解析时间需要上面的格式,但是,就像LocalDate一样,允许您为备用字符串表示指定DateTimeFormatter

LocalDateTime

LocalDateTime基本上是最后两个类的组合。所有算法、工厂和提取方法都按预期应用。解析文本也是两者的结合,除了T必须分隔字符串的日期和时间部分--'2016-01-01T00:00:00'。此类存储或表示时区。

分区截止时间

如果您需要表示日期/时间时区,那么ZonedDateTime就是您需要的类。正如您所料,这个类的接口是LocalDateLocalTime的组合,并添加了额外的方法来处理时区。

正如 duration 的 API 概述中详细所示(并暗示,尽管没有在其他类中清楚地显示),此新 API 的一个优点是能够以数学方式操纵和处理各种日期和时间工件。当我们探索这个新的库时,我们将在这个项目中花费大部分时间使用这个功能。

回到我们的代码

我们需要处理的过程的第一部分是将用户提供的字符串解析为可以编程使用的内容。如果您要搜索解析器生成器,您会发现大量选项,顶部附近会显示 Antlr 和 JavaCC 等工具。我们很想使用这些工具中的一种,但是我们的目的很简单,语法也不那么复杂。我们的功能要求包括:

  • 我们希望能够在日期或时间上加/减时间
  • 我们希望能够从另一个日期或时间中减去一个日期或时间,得到两者之间的差值
  • 我们希望能够将时间从一个时区转换为另一个时区

对于像这样简单的事情,无论是在复杂性还是二进制大小方面,解析器都太昂贵了。我们可以使用 JDK 中内置的工具轻松编写解析器,这就是我们要做的。

为了在我们进入代码之前设置阶段,计划是这样的——我们将定义一些标记来表示日期计算表达式的逻辑部分。使用正则表达式,我们将分解给定的字符串,返回这些标记的列表,然后将其从左向右处理以返回结果。

也就是说,让我们列出我们需要的代币类型。我们需要一个日期、时间、运算符、任何数字量、度量单位和时区。显然,我们不需要对每个表达式都使用这些,但这应该涵盖所有给定的用例。

让我们从令牌的基类开始。定义类型层次结构时,最好询问您是想要基类还是接口。如果需要扩展一个不同的类,使用接口可以为开发人员在类层次结构方面提供额外的灵活性。然而,基类允许我们以类型层次结构中的一些刚性为代价来提供默认行为。为了使我们的Token实现尽可能简单,我们希望在基类中放入尽可能多的内容,因此我们将使用基类,如下所示:

    public abstract class Token<T> {
      protected T value;
      public interface Info {
        String getRegex();
        Token getToken(String text);
      }
      public T getValue() {
        return value;
      }
    }

Java8 确实引入了一种从接口提供默认行为的方法,即默认方法。默认方法是提供具体实现的接口上的方法,这与接口有很大区别。在此更改之前,所有接口都可以定义方法签名并强制实现类定义主体。这允许我们向接口添加方法,并提供默认实现,以便接口的现有实现不需要更改。在我们的例子中,我们提供的行为是存储一个值(实例变量value)和它的访问器(getValue()),因此使用默认方法的接口是不合适的。

请注意,我们还定义了一个嵌套接口,Info,当我们谈到解析器时,我们将更详细地介绍它。

定义了基类后,我们现在可以创建需要的令牌,如下所示:

    public class DateToken extends Token<LocalDate> { 
      private static final String TODAY = "today"; 
      public static String REGEX = 
        "\\d{4}[-/][01]\\d[-/][0123]\\d|today"; 

为了开始这个类,我们定义了两个常量。TODAY是一个特殊字符串,我们将允许用户指定今天的日期。第二个是用于标识日期字符串的正则表达式:

    "\\d{4}[-/][01]\\d[-/][0123]\\d|today" 

正则表达式难看已经不是什么秘密了,随着这些事情的发展,这一个也不太复杂。我们将匹配 4 个数字(\\d{4}),a-或/([-/]),0 或 1 后跟任何数字([01]\\d),另一个-或/,然后是 0、1、2 或 3 后跟任何数字。最后,最后一段|today告诉系统匹配前面的模式文本today。这个正则表达式所能做的就是识别一个看起来像日期的字符串。在目前的形式下,它实际上无法确保它是有效的。我们或许可以制作一个正则表达式来实现这一点,但它所带来的复杂性是不值得的。我们可以做的是让 JDK 为我们验证字符串,我们将在of方法中进行验证,如下所示:

    public static DateToken of(String text) { 
      try { 
        return TODAY.equals(text.toLowerCase()) ? 
          new DateToken(LocalDate.now()) : 
          new DateToken( 
            LocalDate.parse(text.replace("/", "-"))); 
      } catch (DateTimeParseException ex) { 
          throw new DateCalcException( 
            "Invalid date format: " + text); 
        } 
    } 

这里,我们定义了一个静态方法来处理DateToken实例的创建。如果用户提供字符串today,我们将提供值LocalDate.now(),这与您认为的一样。否则,我们将字符串传递给LocalDate.parse(),将任何前斜杠更改为破折号,因为这是方法所期望的。如果用户提供了无效的日期,但正则表达式仍与之匹配,我们将在这里得到一个错误。因为我们有内置的支持来验证字符串,所以我们可以满足于让系统为我们完成繁重的工作。

其他代币看起来非常相似。我们将跳过这些类中的大部分,只看正则表达式,而不是显示每个类,因为其中一些非常复杂。请看下面的代码:

    public class IntegerToken extends Token<Integer> { 
      public static final String REGEX = "\\d+"; 

嗯,那还不错,是吗?一个或多个数字将在此处匹配:

    public class OperatorToken extends Token<String> { 
      public static final String REGEX = "\\+|-|to"; 

另一个相对简单的选项,将匹配 a+、a-或to文本:

    public class TimeToken extends Token<LocalTime> { 
      private static final String NOW = "now"; 
      public static final String REGEX = 
        "(?:[01]?\\d|2[0-3]):[0-5]\\d *(?:[AaPp][Mm])?|now"; 

正则表达式分解如下:

  • (?::这是一个非捕获组。我们需要将一些规则组合在一起,但我们不希望在 Java 代码中处理这些规则时它们显示为单独的组。
  • [01]?:这是零还是一。?表示这应该发生一次或根本不发生。
  • |2[0-3]:我们想在上半场这一节比赛,这一节是 2,然后是 0、1、2 或 3。
  • ):结束非捕获组。该组将允许我们匹配 12 或 24 小时的时间。
  • ::这个姿势需要结肠。它的存在不是可选的。
  • [0-5]\\d:接下来,图案必须匹配0-5的一个数字,后面跟着另一个数字。这是时间的分钟部分。
  • ' *':很难看到,所以我添加了引号来帮助说明,但我们希望匹配 0 或更多(由星号表示)空格。
  • (?::这是另一个非捕获组。
  • [AaPp][Mm]:这些是AP字母(任意一种情况下)后跟M(也任意一种情况下)。
  • )?:我们结束非捕获组,但用一个?标记,表示它应该发生一次或不是全部。该组允许我们捕获任何AM/PM名称。
  • |now:与今天一样,我们允许用户指定此字符串以指示当前时间。

同样,此模式可能匹配无效的时间字符串,但我们将让LocalTime.parse()TimeToken.of()中为我们处理此问题:

    public static TimeToken of(final String text) { 
      String time = text.toLowerCase(); 
      if (NOW.equals(time)) { 
        return new TimeToken(LocalTime.now()); 
      } else { 
          try { 
            if (time.length() <5) { 
                time = "0" + time; 
            } 
            if (time.contains("am") || time.contains("pm")) { 
              final DateTimeFormatter formatter = 
                new DateTimeFormatterBuilder() 
                .parseCaseInsensitive() 
                .appendPattern("hh:mma") 
                .toFormatter(); 
                return new 
                TimeToken(LocalTime.parse( 
                  time.replaceAll(" ", ""), formatter)); 
            } else { 
                return new TimeToken(LocalTime.parse(time)); 
            } 
          } catch (DateTimeParseException ex) { 
              throw new DateCalcException( 
              "Invalid time format: " + text); 
            } 
        }
    } 

这比其他格式要复杂一些,主要是因为LocalTime.parse()所期望的默认格式是 ISO-8601 时间格式。通常,时间以 12 小时格式指定,并带有 am/pm 名称。不幸的是,API 不是这样工作的,所以我们必须进行调整。

首先,如果需要的话,我们会把时间记下来。其次,我们查看用户是否指定了"am""pm"。如果是这样,我们需要创建一个特殊的格式化程序,这是通过DateTimeFormatterBuilder完成的。我们首先告诉构建器构建一个不区分案例的格式化程序。如果我们不这样做,"AM"会起作用,但"am"不会。接下来,我们附加所需的模式,即小时、分钟和 am/pm,然后构建格式化程序。最后,我们可以通过将字符串和格式化程序传递给LocalTime.parse()来解析文本。如果一切顺利,我们将获得一个LocalTime实例。如果没有,我们将得到一个Exception实例,我们将处理它。注意,我们在字符串上调用replaceAll()。我们这样做是为了消除时间和上午/下午之间的空隙。否则,解析将失败。

最后,我们来到我们的UnitOfMeasureToken。这个标记不一定复杂,但肯定不简单。对于我们的度量单位,我们希望支持单词yearmonthdayweekhourminutesecond,它们都可以是复数,并且大多数可以缩写为其初始字符。这使正则表达式变得有趣:

    public class UnitOfMeasureToken extends Token<ChronoUnit> { 
      public static final String REGEX =
        "years|year|y|months|month|weeks|week|w|days|
         day|d|hours|hour|h|minutes|minute|m|seconds|second|s"; 
      private static final Map<String, ChronoUnit> VALID_UNITS = 
        new HashMap<>(); 

这与其说是复杂,不如说是丑陋。我们有一个可能的字符串列表,由逻辑OR操作符垂直管道分隔。也许可以编写一个正则表达式来搜索每个单词或其中的一部分,但这样的表达式可能很难正确编写,而且几乎肯定很难调试或更改。简单明了几乎总比聪明复杂好。

这里还有最后一个需要讨论的因素:VALID_UNITS。在静态初始值设定项中,我们构建一个Map以允许查找正确的ChronoUnit

    static { 
      VALID_UNITS.put("year", ChronoUnit.YEARS); 
      VALID_UNITS.put("years", ChronoUnit.YEARS); 
      VALID_UNITS.put("months", ChronoUnit.MONTHS); 
      VALID_UNITS.put("month", ChronoUnit.MONTHS); 

等等

我们现在准备看一下解析器,如下所示:

    public class DateCalcExpressionParser { 
      private final List<InfoWrapper> infos = new ArrayList<>(); 

      public DateCalcExpressionParser() { 
        addTokenInfo(new DateToken.Info()); 
        addTokenInfo(new TimeToken.Info()); 
        addTokenInfo(new IntegerToken.Info()); 
        addTokenInfo(new OperatorToken.Info()); 
        addTokenInfo(new UnitOfMeasureToken.Info()); 
      } 
      private void addTokenInfo(Token.Info info) { 
        infos.add(new InfoWrapper(info)); 
      } 

当我们构建解析器时,我们将每个Token类注册到List中,但我们看到了两种新类型:Token.InfoInfoWrapperToken.Info是嵌套在Token类中的接口:

    public interface Info { 
      String getRegex(); 
      Token getToken(String text); 
    } 

我们添加了这个接口,为我们提供了一种方便的方法来获取Token类以及Token的正则表达式,而无需借助反射。例如,DateToken.Info看起来像这样:

    public static class Info implements Token.Info { 
      @Override 
      public String getRegex() { 
        return REGEX; 
      } 

      @Override 
      public DateToken getToken(String text) { 
        return of(text); 
      } 
    } 

因为这是一个嵌套类,所以我们可以轻松访问封闭类的成员,包括静态。

下一个新类型InfoWrapper如下所示:

    private class InfoWrapper { 
      Token.Info info; 
      Pattern pattern; 

      InfoWrapper(Token.Info info) { 
        this.info = info; 
        pattern = Pattern.compile("^(" + info.getRegex() + ")"); 
      } 
    } 

这是一个简单的私有类,因此可以将一些正常的封装规则放在一边(尽管,如果这个类公开,这肯定需要清理)。不过,我们要做的是存储令牌正则表达式的编译版本。注意,我们正在用几个额外的字符包装正则表达式。第一个是插入符号(^),表示匹配必须在文本的开头。我们还将正则表达式包装在括号中。然而,这次这是一个捕获组。我们将在以下解析方法中了解原因:

    public List<Token> parse(String text) { 
      final Queue<Token> tokens = new ArrayDeque<>(); 

      if (text != null) { 
        text = text.trim(); 
        if (!text.isEmpty()) { 
          boolean matchFound = false; 
          for (InfoWrapper iw : infos) { 
            final Matcher matcher = iw.pattern.matcher(text); 
            if (matcher.find()) { 
              matchFound = true; 
              String match = matcher.group().trim(); 
              tokens.add(iw.info.getToken(match)); 
              tokens.addAll( 
                parse(text.substring(match.length()))); 
                break; 
            } 
          } 
          if (!matchFound) { 
            throw new DateCalcException( 
              "Could not parse the expression: " + text); 
          } 
        } 
      } 

      return tokens; 
    } 

我们首先确定text不为空,然后确定trim()不为空。完成了健全性检查后,我们通过List信息包装器找到匹配项。记住,编译的模式是一个捕获组,查看文本的开头,因此我们循环遍历每个Pattern,直到其中一个匹配。如果找不到匹配项,我们会抛出一个Exception

一旦找到匹配项,我们从Matcher中提取匹配文本,然后使用Token.Info调用getToken()获取匹配PatternToken实例。我们将其存储在列表中,然后递归调用parse()方法,传递匹配后开始的文本子字符串。从原始文本中删除匹配的文本,然后重复该过程,直到字符串为空。一旦递归结束,事情展开,我们将返回表示用户提供的字符串的标记的Queue。我们使用Queue而不是List,因为这将使处理更加容易。我们现在有了一个解析器,但我们的工作只完成了一半。现在我们需要处理这些代币。

本着关注点分离的精神,我们将这些标记的处理——表达式的实际计算——封装在一个单独的类DateCalculator中,该类使用我们的解析器。考虑下面的代码:

    public class DateCalculator { 
      public DateCalculatorResult calculate(String text) { 
        final DateCalcExpressionParser parser = 
          new DateCalcExpressionParser(); 
        final Queue<Token> tokens = parser.parse(text); 

        if (tokens.size() > 0) { 
          if (tokens.peek() instanceof DateToken) { 
            return handleDateExpression(tokens); 
          } else if (tokens.peek() instanceof TimeToken) { 
              return handleTimeExpression(tokens); 
            } 
        } 
        throw new DateCalcException("An invalid expression
          was given: " + text); 
    } 

每次调用calculate()时,我们都会创建一个新的解析器实例。另外,请注意,当我们查看代码的其余部分时,我们会传递Queue。虽然这确实使方法签名更大一些,但它也使类线程安全,因为类本身没有状态。

在我们的isEmpty()检查之后,我们可以看到QueueAPI 在哪里派上了用场。通过调用poll(),我们可以得到对集合中下一个元素的引用,但是——这一点很重要——我们将该元素保留在集合中。这让我们可以在不改变集合状态的情况下查看它。根据集合中第一个元素的类型,我们将委托给适当的方法。

对于处理日期,表达式语法为<date> <operator> <date | number unit_of_measure>。我们可以通过提取DateTokenOperatorToken开始处理,如下所示:

    private DateCalculatorResult handleDateExpression( 
      final Queue<Token> tokens) { 
        DateToken startDateToken = (DateToken) tokens.poll(); 
        validateToken(tokens.peek(), OperatorToken.class); 
        OperatorToken operatorToken = (OperatorToken) tokens.poll(); 
        Token thirdToken = tokens.peek(); 

        if (thirdToken instanceof IntegerToken) { 
          return performDateMath(startDateToken, operatorToken,
            tokens); 
        } else if (thirdToken instanceof DateToken) { 
            return getDateDiff(startDateToken, tokens.poll()); 
          } else { 
              throw new DateCalcException("Invalid expression"); 
            } 
    } 

为了从Queue中检索元素,我们使用poll()方法,并且我们可以安全地将其转换为DateToken,因为我们在调用方法中检查了它。接下来,我们在下一个元素处进行peek(),并通过validateToken()方法验证该元素是否为空,是否为所需类型。如果令牌有效,我们可以poll()安全施放。接下来,我们在第三个令牌处peek()。根据其类型,我们将委托给正确的方法来完成处理。如果我们发现一个意外的Token类型,我们抛出一个Exception

在看这些计算方法之前,我们先看一下validateToken()

    private void validateToken(final Token token,
      final Class<? extends Token> expected) { 
        if (token == null || ! 
          token.getClass().isAssignableFrom(expected)) { 
            throw new DateCalcException(String.format( 
              "Invalid format: Expected %s, found %s", 
               expected, token != null ? 
               token.getClass().getSimpleName() : "null")); 
        } 
    } 

这里没有什么太令人兴奋的事情,但是目光敏锐的读者可能会注意到,我们正在返回令牌的类名,并且,通过这样做,我们正在将未导出类的名称泄漏给最终用户。这可能不太理想,但我们将把它作为练习留给读者。

执行日期数学的方法如下所示:

    private DateCalculatorResult performDateMath( 
      final DateToken startDateToken, 
      final OperatorToken operatorToken, 
      final Queue<Token> tokens) { 
        LocalDate result = startDateToken.getValue(); 
        int negate = operatorToken.isAddition() ? 1 : -1; 

        while (!tokens.isEmpty()) { 
          validateToken(tokens.peek(), IntegerToken.class); 
          int amount = ((IntegerToken) tokens.poll()).getValue() *
            negate; 
          validateToken(tokens.peek(), UnitOfMeasureToken.class); 
          result = result.plus(amount, 
          ((UnitOfMeasureToken) tokens.poll()).getValue()); 
        } 

        return new DateCalculatorResult(result); 
    } 

因为我们已经有了起始令牌和操作员令牌,所以我们将它们以及Queue传递进来,以便我们可以处理剩余的令牌。我们的第一步是确定运算符是加号还是减号,根据需要将正的1-1分配给negate。我们这样做是为了使用一种方法,LocalDate.plus()。如果运算符是负数,我们加一个负数,得到的结果与减去原始数的结果相同。

最后,我们循环遍历剩余的令牌,在处理之前验证每个令牌。我们得到了IntegerToken;抓住它的价值;乘以我们的负修饰符negate;然后将该值添加到LocalDate中,使用UnitOfMeasureToken来说明我们正在添加的值。

计算日期之间的差异非常简单,如下所示:

    private DateCalculatorResult getDateDiff( 
      final DateToken startDateToken, final Token thirdToken) { 
        LocalDate one = startDateToken.getValue(); 
        LocalDate two = ((DateToken) thirdToken).getValue(); 
        return (one.isBefore(two)) ? new
          DateCalculatorResult(Period.between(one, two)) : new
            DateCalculatorResult(Period.between(two, one)); 
    } 

我们从两个DateToken变量中提取LocalDate,然后调用Period.between(),返回一个Period,指示两个日期之间经过的时间量。我们会检查哪个日期先到,这样我们就可以方便地向用户返回一个正的Period,因为大多数人通常不会考虑负周期。

基于时间的方法基本相同。最大的区别在于时差法:

    private DateCalculatorResult getTimeDiff( 
      final OperatorToken operatorToken, 
      final TimeToken startTimeToken, 
      final Token thirdToken) throws DateCalcException { 
        LocalTime startTime = startTimeToken.getValue(); 
        LocalTime endTime = ((TimeToken) thirdToken).getValue(); 
        return new DateCalculatorResult( 
          Duration.between(startTime, endTime).abs()); 
    } 

这里的显著区别在于Duration.between()的用法。它看起来与Period.between()相同,但Duration类提供了Period没有的方法:abs()。这个方法让我们返回Period的绝对值,这样我们就可以按照我们想要的任何顺序将LocalTime变量传递给between()

在我们离开这里之前,还有最后一个注意事项——我们正在DateCalculatorResult实例中包装我们的结果。由于不同的操作返回几个不同的、不相关的类型,因此允许我们从calculate()方法返回单个类型。由调用代码来提取适当的值。我们将在命令行界面中实现这一点,我们将在下一节中介绍。

关于测试的简短插曲

在我们继续之前,我们需要访问一个尚未讨论的主题,即测试。任何在该行业工作了一段时间的人都可能听说过术语测试驱动开发(简称TDD。这是一种软件开发方法,它假定首先应该编写一个测试,它将失败(因为没有代码可以运行),然后应该编写代码,使测试变为绿色,这是 IDE 和其他工具中给出的绿色指示器的参考,指示测试已经通过。这个过程根据需要重复多次以构建最终系统,总是以小增量进行更改,并且总是从测试开始。关于这个话题,已经有无数的书被写了出来,这是一个既有激烈争论,又常常有严重细微差别的话题。该方法的具体实现方式(如果有的话)几乎总是各有千秋。

显然,在我们的工作中,我们没有严格遵循 TDD 原则,但这并不意味着我们没有进行测试。虽然 TDD 纯粹主义者可能会吹毛求疵,但我的一般方法在测试方面往往有点松散,直到我的 API 开始固化一些。这需要多长时间取决于我对所使用的技术的熟悉程度。如果我对它们非常熟悉,我可能会草拟一个快速接口,然后在此基础上构建一个测试,作为测试 API 本身的一种手段,然后对其进行迭代。对于新库,我可能会编写一个非常广泛的测试来帮助推动对新库的研究,使用测试框架作为引导运行时环境的手段,我可以在其中进行实验。无论如何,在开发工作结束时,新系统应该经过全面测试(其中全面的准确定义是另一个备受争议的概念),这正是我在这里努力的目标。不过,关于测试和测试驱动开发的完整论述超出了我们的范围。

在 Java 测试方面,您有很多选择。然而,最常见的两种是 TestNG 和 JUnit,JUnit 可能是最流行的。你应该选哪一个?那要看情况。如果您使用的是现有的代码库,那么您可能应该使用已经在使用的任何代码,除非您有充分的理由这样做。例如,该库可能是旧的,不再受支持,可能明显不足以满足您的需要,或者您已收到更新/替换现有系统的明确指示。如果这些条件中的任何一个是真的,或者其他类似的条件是真的,我们回到问题上来--*我应该选择哪一个?*这要看情况而定。JUnit 非常流行和常见,因此使用它可以降低进入项目的门槛。然而,有些人认为 TestNG 是一个更好、更干净的 API。例如,对于某些测试设置方法,TestNG 不需要使用静态方法。它的目标也不仅仅是一个单元测试框架,它为单元测试、功能测试、端到端测试和集成测试提供工具。对于我们这里的测试,我们将使用 TestNG。

要开始使用 TestNG,我们需要将其添加到我们的项目中。为此,我们将向 Maven POM 文件添加一个测试依赖项,如下所示:

    <properties>
      <testng.version>6.9.9</testng.version>
    </properties>
    <dependencies> 
      <dependency> 
        <groupId>org.testng</groupId>   
        <artifactId>testng</artifactId>   
        <version>${testng.version}</version>   
        <scope>test</scope> 
      </dependency>   
    </dependencies> 

编写测试非常简单。根据 TestNG Maven 插件的默认设置,该类只需位于src/test/java中并以Test字符串结尾即可。每种测试方法都需要加上@Test注释。

库模块中有许多测试,因此让我们从一些非常基本的测试开始,这些测试测试令牌用来识别和提取表达式的相关部分的正则表达式。例如,考虑下面的代码:

    public class RegexTest { 
      @Test 
      public void dateTokenRegex() { 
        testPattern(DateToken.REGEX, "2016-01-01"); 
        testPattern(DateToken.REGEX, "today"); 
      } 
      private void testPattern(String pattern, String text) { 
        testPattern(pattern, text, false); 
      } 

      private void testPattern(String pattern, String text, 
        boolean exact) { 
          Pattern p = Pattern.compile("(" + pattern + ")"); 
          final Matcher matcher = p.matcher(text); 

          Assert.assertTrue(matcher.find()); 
          if (exact) { 
            Assert.assertEquals(matcher.group(), text); 
          } 
      } 

这是对DateToken正则表达式的一个非常基本的测试。测试委托给testPattern()方法,将正则表达式传递给测试,并使用字符串进行测试。我们的功能通过以下步骤进行测试:

  1. 编制Pattern
  2. 创建一个Matcher
  3. 调用matcher.find()方法。

这样,测试系统的逻辑就得以实现。剩下的就是验证它是否按预期工作。我们通过呼叫Assert.assertTrue()来实现这一点。我们断言matcher.find()返回true。如果正则表达式是正确的,我们应该得到一个true响应。如果正则表达式不正确,我们将得到false响应。在后一种情况下,assertTrue()将抛出一个Exception,测试将失败。

这个测试当然是非常基本的。它可以——应该——更强大。它应该测试更多种类的字符串。它应该包括一些已知的坏字符串,以确保我们在测试中没有得到错误的结果。可能还有很多其他的增强功能。不过,这里的要点是展示一个简单的测试,以演示如何设置基于 TestNG 的环境。在继续之前,让我们再看几个例子。

这里有一个检查失败的测试(一个阴性测试

    @Test 
    public void invalidStringsShouldFail() { 
      try { 
        parser.parse("2016/12/25 this is nonsense"); 
        Assert.fail("A DateCalcException should have been
          thrown (Unable to identify token)"); 
      } catch (DateCalcException dce) { 
      } 
    } 

在这个测试中,我们预计对parse()的调用将失败,并出现一个DateCalcException。如果调用没有失败,我们将调用Assert.fail(),这将使用提供的消息强制测试失败。如果Exception被抛出,它将被默默吞下,测试成功完成。

吞咽Exception是一种方法,但您也可以告诉 TestNG 希望抛出Exception,就像我们在这里通过expectedExceptions属性所做的那样:

    @Test(expectedExceptions = {DateCalcException.class}) 
    public void shouldRejectBadTimes() { 
      parser.parse("22:89"); 
    } 

我们再次向解析器传递了一个错误的字符串。然而,这一次,我们告诉 TestNG 通过注释--@Test(expectedExceptions = {DateCalcException.class})预期异常。

关于测试,尤其是 TestNG,可以写更多的内容。对这两个主题的彻底讨论超出了我们的范围,但如果您对其中任何一个主题都不熟悉,您可以从众多可用的优秀资源中找到一个,并对其进行彻底研究。

现在,让我们把注意力转向命令行界面。

构建命令行界面

在上一章中,我们使用来自 Tomitribe 的 Crest 库构建了一个命令行工具,它运行得非常好,因此我们也将在构建此命令行时返回该库。

为了在我们的项目中启用 Crest,我们必须做两件事。首先,我们必须按如下方式配置 POM 文件:

    <dependency> 
      <groupId>org.tomitribe</groupId> 
      <artifactId>tomitribe-crest</artifactId> 
      <version>0.8</version> 
    </dependency> 

我们还必须更新src/main/java/module-info.java中的模块定义,如下所示:

    module datecalc.cli { 
      requires datecalc.lib; 
      requires tomitribe.crest; 
      requires tomitribe.crest.api; 

      exports com.steeplesoft.datecalc.cli; 
    } 

我们现在可以这样定义 CLI 类:

    public class DateCalc { 
      @Command 
      public void dateCalc(String... args) { 
        final String expression = String.join(" ", args); 
        final DateCalculator dc = new DateCalculator(); 
        final DateCalculatorResult dcr = dc.calculate(expression); 

与上一章不同,此命令行非常简单,因为我们需要的唯一输入是要计算的表达式。在前面的方法签名中,我们告诉 Crest 将所有命令行参数作为args值传递,然后通过String.join()将其合并到expression中。接下来,我们创建计算器并计算结果。

我们现在需要询问我们的DateCalcResult以确定表达式的性质。考虑下面的代码作为一个例子:

    String result = ""; 
    if (dcr.getDate().isPresent()) { 
      result = dcr.getDate().get().toString(); 
    } else if (dcr.getTime().isPresent()) { 
      result = dcr.getTime().get().toString(); 
    } else if (dcr.getDuration().isPresent()) { 
      result = processDuration(dcr.getDuration().get()); 
    } else if (dcr.getPeriod().isPresent()) { 
      result = processPeriod(dcr.getPeriod().get()); 
    } 
    System.out.println(String.format("'%s' equals '%s'", 
      expression, result)); 

LocalDateLocalTime响应非常简单——我们可以简单地对它们调用toString()方法,因为出于我们这里的目的,默认值是完全可以接受的。持续时间和周期有点复杂。两者都提供了许多提取细节的方法。我们将用不同的方法隐藏这些细节:

    private String processDuration(Duration d) { 
      long hours = d.toHoursPart(); 
      long minutes = d.toMinutesPart(); 
      long seconds = d.toSecondsPart(); 
      String result = ""; 

      if (hours > 0) { 
        result += hours + " hours, "; 
      } 
      result += minutes + " minutes, "; 
      if (seconds > 0) { 
        result += seconds + " seconds"; 
      } 

      return result; 
    } 

方法本身非常简单——我们从Duration中提取各个部分,然后根据该部分是否返回值来构建字符串。

与日期相关的方法processPeriod()类似:

    private String processPeriod(Period p) { 
      long years = p.getYears(); 
      long months = p.getMonths(); 
      long days = p.getDays(); 
      String result = ""; 

      if (years > 0) { 
        result += years + " years, "; 
      } 
      if (months > 0) { 
        result += months + " months, "; 
      } 
      if (days > 0) { 
        result += days + " days"; 
      } 
      return result; 
    } 

每个方法都以字符串形式返回结果,然后将其写入标准输出。就这样。这不是一个非常复杂的命令行实用程序,但是这里的练习主要是在库中找到的。

总结

我们的日期计算器现在完成了。该实用程序本身并不太复杂,但它确实起到了预期的作用,它必须是一个试验 Java8 的日期/时间 API 的工具。除了新的日期/时间 API 之外,我们还初步了解了正则表达式,这是一种非常强大和复杂的字符串解析工具。我们还回顾了上一章中的命令行实用程序库,深入单元测试和测试驱动开发的领域。

在下一章中,我们将更加雄心勃勃,踏入社交媒体的世界,构建一个应用程序,帮助我们将一些喜爱的服务聚合到一个应用程序中。