Skip to content

Latest commit

 

History

History
1305 lines (937 loc) · 96.5 KB

File metadata and controls

1305 lines (937 loc) · 96.5 KB

八、扩展我们的电子商务应用

在上一章中,我们开始开发一个电子商务应用,并创建了基于产品 ID 和几个参数来查找产品的功能。在本章中,我们将扩展此功能,以便我们也可以订购所选的产品。在此过程中,我们将学习新技术,重点关注 Java 中的函数式编程和其他一些语言特性,如运行时的反射和注解处理,以及脚本接口。

如前几章所述,我们将逐步开发应用。当我们发现新学到的技术时,我们将重构代码以加入新的工具和方法,从而产生更可读和更有效的代码。我们也会模仿现实项目的开发,一开始我们会有简单的需求,后来随着我们想象中的业务发展和销售越来越多的产品,会有新的需求。我们将成为想象中的百万富翁。

我们将使用前一章的代码库,我们将进一步开发它,但是,对于一个新的项目。我们将使用 Spring、Gradle、Tomcat 和 SoapUI,这不是新的,因为我们在前一章中已经了解了这些。在本章中,您将了解以下主题:

  • 注解处理
  • 使用反射
  • Java 函数式编程
  • Lambda 表达式
  • 从 Java 调用脚本

我的业务订单

订购过程比仅仅查找产品要复杂一些。订单表单本身列出产品和金额,并标识该订单的客户。我们所要做的就是检查产品是否在我们的商店有售,以及我们是否可以将它们交付给特定的客户。这是最简单的方法;但是,对于某些产品,有更多的限制。例如,当有人订购台灯时,我们会单独提供电源线。这是因为电源线是特定于国家的。我们向英国和德国提供不同的电源线。一种可能的方法是确定客户的国家。但这种方法没有考虑到我们的客户是转售商这一事实。所有的客户都可以在英国,同时,他们可能希望将灯与电力电缆一起运送到德国。为了避免这种情况和模棱两可,我们的客户最好将台灯和电源线作为单独的项目在同一订单中订购。在某些情况下,我们提供的台灯没有电源线,但这是一个特殊的情况。我们需要一定程度的逻辑来识别这些特殊情况。因此,我们必须执行逻辑,看看是否有一个台灯电源线,如果没有自动处理的命令,它被拒绝。这并不意味着我们将不交付产品。我们只需将订单放入队列中,运算符就必须查看它。

这种方法的问题在于,台灯只是一种需要配置支持的产品。我们拥有的产品越多,他们可能拥有的专业性就越强,检查订单一致性的代码也变得越来越复杂,直到达到无法管理的复杂程度。当一个类或方法变得太复杂时,程序员会对其进行重构,将该方法或类拆分为更小的部分。我们在产品检验方面也必须这样做。我们不应该试图创建一个庞大的类来检查产品和所有可能的订单星座,而是应该有许多较小的检查,以便每个检查只检查一个小集合。

在某些情况下,检查一致性比较简单。检查灯是否有电源线对于任何一个新手程序员来说都很复杂。我们在代码中使用这个示例是因为我们希望关注代码的实际结构,而不是检查本身的复杂性质。然而,在现实生活中,检查可能相当复杂。想象一下一家卖电脑的商店。它将一个配置放在一起:电源、图形卡、主板、适当的 CPU 和内存。有很多选择,其中一些可能无法协同工作。在现实生活中,我们需要检查主板是否与所选内存兼容,是否有按顺序排列的尽可能多的内存组,它们是否正确配对(有些内存只能成对安装),是否有图形卡的兼容插槽,而且电源有足够的瓦特来可靠地运行整个配置。这非常复杂,最好不要与检查灯是否有电源线的代码混淆。

设置项目

由于我们仍在使用 SpringBoot,构建文件不需要任何修改;我们将使用与上一章相同的文件。然而,包的结构有点不同。这一次,我们做的事情比获取请求和响应后端服务提供给我们的任何内容都要复杂。现在,我们必须实现复杂的业务逻辑,正如我们将看到的,它需要许多类。当我们在一个特定的包中有 10 个以上的类时,是时候考虑把它们放在不同的包中了。相互关联并具有类似功能的类应该放在一个包中。这样,我们就有了以下产品的包装:

  • 控制器(虽然在本例中我们只有一个,但通常有更多)
  • 数据存储 bean,除了存储数据之外没有其他功能,因此是字段、设置器和获取器
  • 检查器,将帮助我们在订购桌面台灯时检查电源线
  • 为控制器执行不同服务的服务
  • 我们程序的主包,包含Application类、SpringConfiguration和几个接口

订单控制器和 DTO

当服务器请求订购一系列产品时,它会收到 HTTPSPOST请求。请求的主体是用 JSON 编码的。到目前为止,我们有控制器在处理GET参数。当我们可以依赖 Spring 的数据封送时,处理POST请求就不难了。控制器代码本身很简单:

package packt.java11.bulkorder.controllers;

import ...

@RestController
public class OrderController {
    private static final Logger log = LoggerFactory.getLogger((OrderController.class));
    private final Checker checker;

    public OrderController(@Autowired Checker checker) {
        this.checker = checker;
    }

    @RequestMapping("/order")
    public Confirmation getProductInformation(@RequestBody Order order) {
        if (checker.isConsistent(order)) {
            return Confirmation.accepted(order);
        } else {
            return Confirmation.refused(order);
        }
    }
}

我们在这个控制器order中只处理一个请求。这被映射到 URL,/order。订单从 JSON 自动转换为请求体中的订单对象。这就是@RequestBody注解要求 Spring 为我们做的事情。控制器的功能只是检查顺序的一致性。如果订单一致,那么我们接受订单;否则,我们拒绝订单。实际例子还将检查订单是否不仅一致,而且是否来自有资格购买这些产品的客户,以及根据生产者的承诺和交货期,产品是否在仓库中可用,或者至少可以交货。

为了检查订单的一致性,我们需要一些能帮我们完成这项工作的东西。因为我们知道我们必须模块化代码,并且不能在一个类中实现太多的东西,所以我们需要一个检查器对象。这是根据类上的注解以及@Autowired对控制器的构造器自动提供的。

Order类是一个简单的 bean,只列出以下项:

package packt.java11.bulkorder.dtos;

import ...

public class Order {
    private String orderId;
    private List<OrderItem> items;
    private String customerId;

    // ... setters and getters ...
}

包的名称为dtos,代表数据传输对象DTO)的复数形式。DTO 是用于在不同组件(通常通过网络)之间传输数据的对象。由于另一方可以用任何语言实现,封送可以是 JSON、XML 或其他一些只能传递数据的格式。这些类没有真正的方法。DTO 通常只有字段、设置器和获取器。

以下是包含订单中一个项目的类:

package packt.java11.bulkorder.dtos;

public class OrderItem {
    private double amount;
    private String unit;
    private String productId;

    // ... setters and getters ...
}

订单确认也在这个包中,虽然这也是一个真正的 DTO,但它有几个简单的辅助方法:

package packt.java11.bulkorder.dtos;

public class Confirmation {
    private final Order order;
    private final boolean accepted;

    private Confirmation(Order order, boolean accepted) {
        this.order = order;
        this.accepted = accepted;
    }

    public static Confirmation accepted(Order order) {
        return new Confirmation(order, true);
    }

    public static Confirmation refused(Order order) {
        return new Confirmation(order, false);
    }

    public Order getOrder() {
        return order;
    }

    public boolean isAccepted() {
        return accepted;
    }
}

我们为类提供了两个工厂方法。这有点违反了纯粹主义者痛恨的单一责任原则。大多数时候,当代码变得更复杂时,这样的快捷方式会起反作用,代码必须重构才能更干净。纯粹的解决方案是创建一个单独的工厂类。使用工厂方法,无论是从这个类还是从一个分离的类,都可以使控制器的代码更具可读性。

我们的主要任务是一致性检查。到目前为止,代码几乎是微不足道的。

一致性检查器

我们有一个一致性检查器类,它的一个实例被注入到控制器中。这个类用于检查一致性,但实际上它本身并不执行检查。它只控制我们提供的不同的检查器,并逐个调用它们来完成真正的工作。

我们要求一致性检查器(例如在订购台灯时检查订单是否包含电源线的检查器)实现ConsistencyChecker接口:

package packt.java11.bulkorder;

import packt.java11.bulkorder.dtos.Order;

public interface ConsistencyChecker {

    boolean isInconsistent(Order order);
}

如果顺序不一致,方法isInconsistent应该返回true。如果不知道订单是否不一致,则返回false,但从实际检查者检查订单的角度来看,不存在不一致。有几个ConsistencyChecker类,我们必须一个接一个地调用,直到其中一个返回true,否则我们就没有这些类了。如果没有一个返回true,那么我们可以安全地假设,至少从自动检查器的角度来看,顺序是一致的。

我们知道,在开发之初,我们将有很多一致性检查,并不是所有的订单都相关。我们希望避免为每个订单调用每个检查器。为此,我们实现了一些过滤。我们让产品指定他们需要什么类型的检查。这是一段产品信息,如尺寸或描述。为了适应这种情况,我们需要扩展ProductInformation类。

我们将创建每个ConsistencyChecker接口,将类实现为一个 SpringBean(用@Component注解进行注解),同时,我们将用一个注解对它们进行注解,该注解指定它们实现的检查类型。同时,ProductInformation被扩展,包含一组Annotation类对象,这些对象指定要调用哪些检查器。我们可以简单地列出检查器类,而不是注解,但是这给了我们在配置产品和注解之间的映射时更多的自由。注解指定产品的性质,并对检查器类进行注解。台灯是PoweredDevice类型,检查器类NeedPowercord@PoweredDevice注解。如果有任何其他类型的产品也需要电源线,那么该类型的注解应该添加到NeedPowercord类中,我们的代码就可以工作了。既然我们开始深入研究注解和注解处理,我们就必须首先了解注解到底是什么。我们从第 3 章“优化专业排序代码”开始就已经使用了注解,但我们所知道的只是如何使用它们,如果不了解我们所做的事情,这通常是危险的。

注解

注解前面带有@字符,可以附加到包、类、接口、字段、方法、方法参数、泛型类型声明和用法,最后附加到注解。注解几乎可以在任何地方使用,它们被用来描述一些程序元信息。例如,@RestController注解不会直接改变OrderController类的行为。类的行为由其内部的 Java 代码描述。注解有助于 Spring 理解类是什么以及如何使用它。当 Spring 扫描所有包和类以发现不同的 SpringBean 时,它会看到类上的注解并将其考虑在内。这个类上可能还有 Spring 不理解的其他注解。它们可能被其他框架或程序代码使用。Spring 将它们视为任何行为良好的框架。例如,正如我们稍后将看到的,在我们的代码库中,我们有一个NeedPowercord类,它是一个 SpringBean,因此用@Component注解进行了注解。同时,还附有@PoweredDevice注解。Spring 不知道什么是电动设备。这是我们定义和使用的东西。Spring 忽略了这一点。

包、类、接口、字段等可以附加许多注解。这些注解应该简单地写在它们所附加的语法单元声明的前面。

对于包,注解必须写在package-info.java文件中包名的前面。这个文件可以放在包的目录中,可以用来编辑包的JavaDoc,也可以给包添加注解。此文件不能包含任何 Java 类,因为名称package-info不是有效的标识符。

我们不能在任何东西前面写任何东西作为注解。应声明注解。它们在 Java 特殊接口的运行时。例如,声明@PoweredDevice注解的 Java 文件如下所示:

package packt.java11.bulkorder.checkers;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface PoweredDevice {
}

interface关键字前面的@字符表示这是一种特殊的注解类型。有一些特殊的规则;例如,注解接口不应扩展任何其他接口,甚至注解接口也不应扩展。另一方面,编译器会自动生成注解接口,从而扩展 JDK 接口java.lang.annotation.Annotation

注解在源代码中,因此,它们在编译过程中可用。它们还可以由编译器保留并放入生成的类文件中,当类加载器加载类文件时,它们也可以在运行时使用。默认的行为是编译器将注解与注解元素一起存储在类文件中,但类加载器不会使其在运行时可用。

为了在编译过程中处理注解,必须使用注解处理器扩展 Java 编译器。这是一个相当高级的主题,在使用 Java 时只能遇到几个例子。注解处理器是一个 Java 类,它实现了一个特殊的接口,当编译器处理声明处理器感兴趣的源文件中的注解时,编译器会调用它。

注解保留

Spring 和其他框架通常在运行时处理注解。必须指示编译器和类加载器在运行时保持注解可用。为此,必须使用@Retention注解对注解接口本身进行注解。此注解有一个参数为RetentionPolicy类型,即enum。我们将很快讨论如何定义注解参数。

有趣的是,注解接口上的@Retention注解必须在类文件中可用;否则,类装入器将不知道如何处理注解。在编译过程结束后,我们如何表示编译器将保留注解?我们对注解接口声明进行注解。因此,@Retention的声明被自己注解并声明在运行时可用。

注解声明可以使用@Retention(RetentionPolicy.SOURCE)@Retention(RetentionPolicy.CLASS)@Retention(RetentionPolicy.RUNTIME)进行注解。

注解目标

最后的保留类型将是最常用的保留类型。还有其他注解可以用于注解声明。@Target注解可用于限制注解在特定位置的使用。此注解的参数是单个java.lang.annotation.ElementType值或这些值的数组。有充分的理由限制注解的使用。当我们将注解放置在错误的地方时,获得编译时间错误比在运行时搜索框架为什么忽略注解要好得多。

注解参数

正如我们前面看到的,注解可以有参数。在注解的@interface声明中声明这些参数,我们使用方法。这些方法有名称和返回值,但它们不应该有参数。您可能尝试声明一些参数,但是 Java 编译器将是严格的,不会编译代码。

这些值可以在使用注解的地方定义,使用方法的名称和=字符,给它们分配一个与方法类型兼容的值。例如,假设我们将PoweredDevice注解的声明修改为:

public @interface ParameteredPoweredDevice { 
    String myParameter(); 
}

在这种情况下,在使用注解时,我们应该为参数指定一个值,如下所示:

@Component 
@ParameteredPoweredDevice(myParameter = "1966") 
public class NeedPowercord implements ConsistencyChecker { 
...

如果参数的名称是一个值,并且在注解的使用位置没有定义其他参数,则可以跳过名称value。例如,当我们只有一个参数时,按以下方式修改代码是一种方便的速记:

public @interface ParameteredPoweredDevice{ 
    String value(); 
} 
... 
@Component 
@ParameteredPoweredDevice("1966") 
public class NeedPowercord implements ConsistencyChecker { 
...

我们还可以使用方法声明后面的default关键字来定义可选参数。在这种情况下,我们必须为参数定义一个默认值。进一步修改示例注解,我们仍然可以(但不需要)指定值。在后一种情况下,它将是一个空字符串:

public @interface ParameteredPoweredDevice { 
    String value() default ""; 
}

由于我们指定的值应该是常量并且在编译时是可计算的,所以复杂类型的使用并不多。注解参数通常是字符串、整数,有时还包括double或其他基本类型。语言规范给出的确切类型列表如下:

  • 原始类型(doubleint等)
  • 字符串
  • 枚举
  • 另一个注解
  • 上述任何一种类型的数组

我们已经看到了String的例子,而且enumRetentionTarget都有enum参数。我们要关注的有趣部分是前面列表中的最后两项。

当参数的值是数组时,该值可以指定为在{}字符之间用逗号分隔的值。例如:

String[] value();

然后可以将其添加到@interface注解中,我们可以编写以下内容:

@ParameteredPoweredDevice({"1966","1967","1991"})

但是,如果只有一个值要作为参数值传递,我们仍然可以使用以下格式:

@ParameteredPoweredDevice("1966")

在这种情况下,属性的值将是长度为1的数组。当注解的值是注解类型的数组时,事情会变得更复杂一些。我们创建一个@interface注解(注意名称中的复数):

@Retention(RetentionPolicy.RUNTIME) 
public @interface PoweredDevices { 
ParameteredPoweredDevice[] value() default {}; 
}

此注解的用法如下:

@PoweredDevices( 
        {@ParameteredPoweredDevice("1956"), @ParameteredPoweredDevice({"1968", "2018"})} 
)

注意,这与具有三个参数的ParameteredPoweredDevice注解不同。这是一个具有两个参数的注解。每个参数都是一个注解。第一个有一个字符串参数,第二个有两个。

正如您所看到的,注解可能相当复杂,一些框架(或者更确切地说是创建它们的程序员)在使用它们时乱作一团。在开始编写框架之前,先进行研究,看看是否已经有了一个可以使用的框架。另外,检查是否有其他方法来解决你的问题。99% 的注解处理代码可以避免,并且变得更简单。我们为相同功能编写的代码越少,我们就越高兴。美国程序员是懒惰的,这是必须的。

最后一个例子,注解的参数是注解数组,对于理解如何创建可重复的注解非常重要。

可重复注解

@Repeatable注解注解的声明,表示注解可以在一个地方多次应用。此注解的参数是注解类型,该类型应具有类型为的参数,该参数是此注解的数组。不要试图理解!我来举个例子。我已经有了,事实上我们有@PoweredDevices。它有一个参数是一个数组@ParameteredPoweredDevice。我们现在把这个@interface注解如下:

... 
@Repeatable(PoweredDevices.class) 
public @interface ParameteredPoweredDevice { 
...

然后,我们可以简化@ParameteredPoweredDevice的使用。我们可以多次重复注解,Java 运行时会自动将其括在包装类中,在本例中,包装类是@PoweredDevices。在这种情况下,以下两个将是等效的:

... 
@ParameteredPoweredDevice("1956") 
@ParameteredPoweredDevice({"1968", "2018"}) 
public class NeedPowercord implements ConsistencyChecker { 
... 

@PoweredDevices( 
        {@ParameteredPoweredDevice("1956"), @ParameteredPoweredDevice({"1968", "2018"})} 
) 
public class NeedPowercord implements ConsistencyChecker { 
...

这种复杂方法的原因同样是 Java 严格遵循的向后兼容性的一个例子。注解是在 Java1.5 中引入的,可重复的注解只有在 1.8 版本之后才可用。我们将很快讨论在运行时用于处理注解的反射 API。java.lang.reflect.AnnotatedElement接口中的这个 API 有一个getAnnotation(annotationClass)方法,它返回一个注解。如果单个注解可以在一个类、方法等上出现多次,则无法调用此方法来获取具有所有不同参数的所有不同实例。通过引入包装多个注解的包含类型,确保了向后兼容性。

注解继承

注解,就像方法或字段一样,可以在类层次结构之间继承。如果一个注解声明被标记为@Inherited,那么用这个注解扩展另一个类的类可以继承它。如果子类具有注解,则可以覆盖注解。因为 Java 中没有多重继承,所以不能继承接口上的注解。即使继承了注解,检索特定元素注解的应用代码也可以区分继承的注解和在实体本身上声明的注解。有两种方法可以获取注解,另外两种方法可以获取在实际元素上声明的、未继承的已声明注解。

@Documented注解

@Documented注解表示注解是实体合同的一部分的意图,因此必须进入文档。这是一个注解,当为引用@Documented注解的元素创建文档时,JavaDoc 生成器将查看该注解。

JDK 注解

除了用于定义注解的注解外,JDK 中还定义了其他注解。我们已经看到了其中的一些。最常用的是@Override注解。当编译器看到此注解时,它会检查该方法是否确实覆盖了继承的方法。否则将导致一个错误,使我们免于痛苦的运行时调试。

方法、类或其他元素的文档中的注解信号,表示不使用该元素。代码中仍然存在,因为有些用户可能仍然使用它,但是如果是依赖于包含元素的库的新开发,新开发的代码不应该使用它。注解有两个参数。一个参数是since,它可以有字符串值,可以传递关于方法或类的版本的过期时间或版本信息。另一个参数为forRemoval,如果元素在库的未来版本中不出现,则为true。有些方法可能会被否决,因为有更好的替代方案,但是开发人员不打算从库中删除该方法。在这种情况下,forRemoval可以设置为false

@SuppressWarning注解也是一个常用的注解,尽管它的用法值得怀疑。它可以用来抑制编译器的某些警告。如果可能的话,建议编写代码,可以在没有任何警告的情况下编译。

@FunctionalInterface注解声明一个接口只打算有一个方法。这样的接口可以实现为 Lambda 表达式。您将在本章后面学习 Lambda 表达式。当此注解应用于接口并且接口中声明了多个方法时,编译器将发出编译错误信号。这将防止任何开发人员在早期将另一个方法添加到与函数式编程和 Lambda 表达式一起使用的接口中。

使用反射

既然您已经学会了如何声明注解,以及如何将它们附加到类和方法中,我们可以返回到我们的ProductInformation类。您可能会记得,我们想指定此类中的产品类型,并且每个产品类型都用@interface注解表示。我们已经在前面的几页中列出了它,这是我们在@PoweredDevice示例中实现的一个。我们将开发代码,假设以后会有许多这样的注解、产品类型和一致性检查程序,这些注解都用@Component和一个或多个注解进行注解。

获取注解

我们将用以下字段扩展ProductInformation类:

private List<Class<? extends Annotation>> check;

因为这是一个 DTO,而且 Spring 需要设置器和获取器,所以我们还将向它添加一个新的设置器和获取器。该字段将包含每个类为我们的一个注解实现的类的列表,以及内置的 JDK 接口Annotation,因为 Java 编译器是通过这种方式生成它们的。在这一点上,这可能有点模糊,但我保证黎明将破晓,隧道尽头将有光明。

为了获得产品信息,我们必须根据 ID 进行查找。这是我们在上一章中开发的接口和服务,只是这次我们有了另一个新领域。事实上,这是一个显著的差异,尽管ProductLookup接口根本没有改变。在最后一章中,我们开发了两个版本。其中一个版本正在从属性文件读取数据,而另一个版本正在连接到 REST 服务。

属性文件很难看,而且是一项古老的技术,但是如果你想通过 Java 面试或者在 21 世纪初开发的企业应用上工作,那么属性文件是必须的。我不得不把它写进最后一章。在我的坚持下,这本书收录了这本书。同时,在为本章编写代码时,我没有勇气继续使用它。我还想向您展示同样的内容可以用 JSON 格式管理。

现在,我们将扩展ResourceBasedProductLookup的实现,从 JSON 格式的资源文件中读取产品信息。大多数代码在类中保持不变;因此,我们仅在此处列出差异:

package packt.java11.bulkorder.services;
import ...

@Service
public class ResourceBasedProductLookup implements ProductLookup {
    private static final Logger log =
        LoggerFactory.getLogger(ResourceBasedProductLookup.class);

    private ProductInformation fromJSON(InputStream jsonStream) throws IOException {
        final var mapper = new ObjectMapper();
        return mapper.readValue(jsonStream, ProductInformation.class);
    }

// ...
    private void loadProducts() {
        if (productsAreNotLoaded) {
            try {
                final var resources = new PathMatchingResourcePatternResolver().
                        getResources("classpath:products/*.json");
                for (final var resource : resources) {
                    loadResource(resource);
                }
                productsAreNotLoaded = false;
            } catch (IOException ex) {
                log.error("Test resources can not be read", ex);
            }
        }
    }

    private void loadResource(Resource resource) throws IOException {
        final var dotPos = resource.getFilename().lastIndexOf('.');
        final var id = resource.getFilename().substring(0, dotPos);
        final var pi = fromJSON(resource.getInputStream());
        pi.setId(id);
        products.put(id, pi);
        if( pi.getCheck() != null )
        log.info("Product {} check is {}",id,pi.getCheck().get(0));
    }
// ...

project resources/products目录中,我们有一些 JSON 文件。其中一个包含台灯产品信息:

{ 
  "id" : "124", 
  "title": "Desk Lamp", 
  "check": [ 
    "packt.java11.bulkorder.checkers.PoweredDevice" 
  ], 
  "description": "this is a lamp that stands on my desk", 
  "weight": "600", 
  "size": [ "300", "20", "2" ] 
}

产品的类型是在 JSON 数组中指定的。在本例中,此数组只有一个元素,该元素是表示产品类型的注解接口的完全限定名。当 JSON Marshaller 将 JSON 转换为 Java 对象时,它会识别出需要此信息的字段是一个List,因此它会将数组转换为一个列表,以及从StringClass对象中表示注解接口的元素。

现在我们已经从 JSON 格式的资源中加载了资源,并且我们已经看到了在使用 Spring 时读取 JSON 数据是多么容易,我们可以回到顺序一致性检查。Checker类实现了收集可插入检查器并调用它们的逻辑。它还实现了基于注解的过滤,以避免调用我们在实际订单中实际产品并不需要的检查:

package packt.java11.bulkorder.services;

import ...

@Component()
@RequestScope
public class Checker {
    private static final Logger log = LoggerFactory.getLogger(Checker.class);

    private final Collection<ConsistencyChecker> checkers;
    private final ProductInformationCollector piCollector;
    private final ProductsCheckerCollector pcCollector;

    public Checker(@Autowired Collection<ConsistencyChecker> checkers,
                   @Autowired ProductInformationCollector piCollector,
                   @Autowired ProductsCheckerCollector pcCollector
    ) {
        this.checkers = checkers;
        this.piCollector = piCollector;
        this.pcCollector = pcCollector;
    }

    public boolean isConsistent(Order order) {
        final var map = piCollector.collectProductInformation(order);
        if (map == null) {
            return false;
        }
        final var annotations = pcCollector.getProductAnnotations(order);
        for (final var checker : checkers) {
            for (final var annotation : checker.getClass().getAnnotations()) {
                if (annotations.contains(annotation.annotationType())) {
                    if (checker.isInconsistent(order)) {
                        return false;
                    }
                    break;
                }
            }
        }
        return true;
    }
}

其中一件有趣的事情是,Spring 自动布线是非常聪明的。我们有一个Collection<ConsistencyChecker>类型的字段。通常,如果只有一个类与要连接的资源具有相同的类型,则自动连接可以工作。在我们的例子中,因为这是一个集合,所以我们没有任何这样的候选者,但是我们有许多ConsistencyChecker类。我们所有的检查器都实现了这个接口,Spring 识别它,实例化它们,神奇地创建它们的集合,并将集合注入这个字段。

通常,一个好的框架在逻辑上工作。我不知道 Spring 的这个特征,但我认为这是合乎逻辑的,而且神奇地,它起作用了。如果事情是合乎逻辑的,并且只是工作的话,你不需要阅读和记住文档。不过,稍微小心一点也不会有任何危害。在我意识到这个功能是这样工作的之后,我在文档中查阅了它,以看到这确实是 Spring 的一个保证特性,而不是仅仅发生在工作中的特性,而是在未来版本中可能会发生更改而不需要注意。仅使用保证功能是非常重要的,但在我们的行业中经常被忽略。

调用isConsistent()方法时,首先将产品信息收集到HashMap中,为每个OrderItem分配一个ProductInformation实例。这是在一个单独的类里完成的。在此之后,ProductsCheckerCollector收集一个或多个产品项所需的ConsistencyChecker实例。当我们拥有这个集合时,我们只需要调用那些用这个集合中的注解之一进行注解的检查器。我们循环着做。

在这段代码中,我们使用反射。我们循环每个检查器都有的注解。为了获取注解集合,我们调用checker.getClass().getAnnotations()。此调用返回对象集合。每个对象都是一些 JDK 运行时生成的类的实例,这些类实现了我们在其源文件中声明为注解的接口。但是,没有保证动态创建的类只实现我们的@interface,而不是其他接口。因此,要获得实际的注解类,必须调用annotationType()方法。

ProductCheckerCollectorProductInformationCollector类非常简单,我们将在稍后学习流时讨论它们。在这一点上,当我们使用循环实现它们时,它们将成为一个很好的例子,紧接着,使用流。

拥有它们,我们最终可以创建实际的检查器类。帮助我们看到我们的灯有一根电源线的命令如下:

package packt.java11.bulkorder.checkers;

//SNIPPET SKIL TILL "import ..."

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import packt.java11.bulkorder.ConsistencyChecker;
import packt.java11.bulkorder.dtos.Order;

import ...
@Component
@PoweredDevice
public class NeedPowercord implements ConsistencyChecker {
    private static final Logger log = LoggerFactory.getLogger(NeedPowercord.class);

    @Override
    public boolean isInconsistent(Order order) {
        log.info("checking order {}", order);
        var helper = new CheckHelper(order);
        return !helper.containsOneOf("126", "127", "128");
    }
}

助手类包含许多检查器需要的简单方法,例如:

public boolean containsOneOf(String... ids) {
    for (final var item : order.getItems()) {
        for (final var id : ids) {
            if (item.getProductId().equals(id)) {
                return true;
            }
        }
    }
    return false;
}

调用方法

在本例中,我们仅使用一个反射调用来获取附加到类的注解。反思可以做更多的事情。处理注解是这些调用最重要的用途,因为注解没有自己的功能,在运行时不能以任何其他方式处理。然而,反射并没有停止告诉我们一个类或任何其他包含注解的元素有什么注解。反射可以用来获取一个类的方法列表、作为字符串的方法名称、类的实现接口、它扩展的父类、字段、字段类型等等。反射通常提供方法和类,以编程方式遍历实际的代码结构直至方法级别。

本演练不仅允许读取类型和代码结构,还允许在编译时设置字段值和调用方法,而不必知道方法的名称。我们甚至可以设置private字段,这些字段通常是外部世界无法访问的。还应该注意,通过反射访问方法和字段通常比通过编译代码访问慢,因为它总是涉及根据代码中元素的名称进行查找。

经验法则是,如果您看到必须使用反射来创建代码,那么就要意识到您可能正在创建一个框架(或者写一本关于 Java 的书来详细介绍反射)。这听起来熟悉吗?

Spring 还使用反射来发现类、方法和字段,并注入对象。它使用 URL 类加载器列出类路径上的所有 JAR 文件和目录,加载它们,并检查类。

举一个人为的例子,为了演示,我们假设ConsistencyChecker实现是由许多外部软件供应商编写的,而最初设计程序结构的架构师只是忘记在接口中包含isConsistent()方法。(同时,为了保护我们的心理健康,我们还可以想象这个人已经不再在公司工作了。)因此,不同的供应商提供了“实现”这个接口的 Java 类,但是我们不能调用这个方法,这不仅是因为我们没有一个拥有这个方法的公共父接口,但也因为供应商只是碰巧对他们的方法使用了不同的名称。

在这种情况下我们能做什么?从商业角度来看,要求所有供应商重写他们的跳棋是不可能的,因为他们知道我们有麻烦了,这会给任务贴上一个很高的价格标签。我们的管理者希望避免这一成本,而我们的开发人员也希望表明,我们能够纠正这种情况,创造奇迹(我稍后将对此发表评论)。

我们可以有一个类,它知道每个检查器以及如何以多种不同的方式调用它们。这将要求我们在系统中引入新检查器时维护所述类,我们希望避免这种情况。我们使用的整个插件架构最初就是为了这个目的而发明的。

如果我们知道一个对象只有一个声明的方法,而这个方法接受一个命令作为参数,那么我们如何调用这个对象上的方法呢?这就是反射进入画面的地方。我们没有调用checker.isInconsistent(order),而是实现了一个小的private方法isInconsistent(),通过反射调用这个方法,不管它叫什么名字:

private boolean isInconsistent(ConsistencyChecker checker, Order order) {
    final var methods = checker.getClass().getDeclaredMethods();
    if (methods.length != 1) {
        log.error("The checker {} has zero or more than one methods",
            checker.getClass());
        return false;
    }
    final var method = methods[0];
    final boolean inconsistent;
    try {
        inconsistent = (boolean) method.invoke(checker, order);
    } catch (InvocationTargetException |
        IllegalAccessException |
        ClassCastException e) {
        log.error("Calling the method {} on class {} threw exception",
            method, checker.getClass());
        log.error("The exception is ", e);
        return false;
    }
    return inconsistent;
}

通过调用getClass()方法可以得到对象的类,在表示类本身的对象上,可以调用getDeclaredMethods。幸运的是,检查器类没有被很多方法乱放,因此我们检查检查器类中声明的方法是否只有一个。注意,反射库中也有一个getMethods()方法,但它将始终返回多个方法。它返回声明的和继承的方法。因为每个类都继承了java.lang.Object,所以至少会有Object类的方法。

之后,我们尝试使用表示反射类中方法的Method对象来调用该类。请注意,这个Method对象并没有直接连接到实例。我们从类中检索该方法,因此,当我们调用它时,应该将它应该处理的对象作为第一个参数传递。这样,x.y(z)就变成了method.invoke(x,z)invoke()的最后一个参数是作为Object数组传递的变量数。在大多数情况下,当我们调用一个方法时,我们知道代码中的参数,即使我们不知道方法的名称并且必须使用反射。当连参数都不知道,但作为计算的问题是可用的时,我们必须将它们作为一个Object数组传递。

通过反射调用方法是一个危险的调用。如果我们尝试以正常方式调用一个方法,即private,那么编译器将发出错误信号。如果参数或类型的数目不合适,编译器将再次给我们一个错误。如果返回值不是boolean,或者根本没有返回值,那么我们再次得到一个编译器错误。在反射的情况下,编译器是无知的。它不知道在代码执行时我们将调用什么方法。另一方面,invoke()方法在被调用时可以并且将会注意到所有这些失败。如果出现上述任何问题,那么我们将得到异常。如果invoke()方法本身发现它不能执行我们对它的要求,那么它将抛出InvocationTargetExceptionIllegalAccessException。如果无法将实际返回值转换为boolean,则得到ClassCastException

关于表演魔术,这是一种自然的冲动,我们觉得要做一些非凡的东西,一些杰出的。当我们尝试一些事情,做一些有趣的事情时,这是可以的,但是当我们从事专业工作时,这绝对是不可以的。一般的程序员,如果不了解您的优秀解决方案,就会在企业环境中维护代码。他们会在修复一些 bug 或实现一些小的新特性的同时,把你精心梳理的代码变成草堆。即使你是编程界的莫扎特,他们充其量也只是无名歌手。在企业环境中,一个优秀的代码可以是一首安魂曲,包含了隐喻所包含的所有含义。

最后但同样重要的是,可悲的现实是,我们通常不是编程的莫扎特。

请注意,如果原始值的返回值是原始类型,那么它将通过反射转换为对象,然后我们将它转换回原始值。如果方法没有返回值,换句话说,如果它是void,那么反射将返回java.lang.Void对象。Void对象只是一个占位符。我们不能将它转换为任何原始类型值或任何其他类型的对象。它是必需的,因为 Java 是严格的,invoke必须返回一个Object,所以运行时需要一些它可以返回的东西。我们所能做的就是检查返回值类是否真的是Void

让我们继续我们的故事和解决方案。我们提交了代码,它在生产中运行了一段时间,直到一个软件供应商的新更新打破它。我们在测试环境中调试代码,发现类现在包含多个方法。我们的文档清楚地说明了他们应该只有一个public方法,并且他们提供了一个代码,这个代码有……嗯……我们意识到其他方法是private。他们是对的,根据合同他们可以有private方法,所以我们必须修改代码。我们替换查找唯一方法的行:

final var methods = checker.getClass().getDeclaredMethods(); 
if (methods.length != 1) { 
... 
} 
final var method = methods[0];

新代码如下:

final var method = getSingleDeclaredPublicMethod(checker); 
if (method == null) { 
    log.error( 
            "The checker {} has zero or more than one methods", 
            checker.getClass()); 
    return false; 

}

我们编写的新方法用于查找唯一的public方法如下:

private Method getSingleDeclaredPublicMethod(
    ConsistencyChecker checker) {
    final var methods = checker.getClass().getDeclaredMethods();
    Method singleMethod = null;
    for (final var method : methods) {
        if (Modifier.isPublic(method.getModifiers())) {
            if (singleMethod != null) {
                return null;
            }
            singleMethod = method;
        }
    }
    return singleMethod;
}

为了检查方法是否为public,我们使用了Modifier类中的static方法。有一些方法可以检查所有可能的修饰符。getModifiers()方法返回的值是int位字段。不同的位有不同的修饰符,有常量定义这些修饰符。只可用于其他类型反射对象的位永远不会被设置。

有一个例外,那就是volatile。该位被重新用于信号桥方法。桥接方法是由编译器自动创建的,并且可能有一些我们在本书中没有讨论的深层次和复杂的问题。重复使用同一位不会造成混淆,因为字段可以是volatile,但作为字段,它不能是桥接方法。显然,字段是字段而不是方法。同样地,方法不能是volatile字段。一般规则如下:不要在反射对象没有意义的地方使用方法;否则,要知道你在做什么。

一个新版本的检查器意外地将check方法实现为一个private包,这使得故事情节更加复杂,程序员只是忘记了使用public关键字。为了简单起见,让我们假设类再次只声明一个方法,但它不是公共的。我们如何使用反射来解决这个问题?

显然,最简单的解决方案是要求供应商解决问题-这是他们的错。然而,在某些情况下,我们必须为某些问题创建一个解决方案。另一种解决方案是在同一个包中创建一个具有public方法的类,从另一个类调用private包方法,从而中继另一个类。事实上,这个解决方案,作为这样一个 bug 的解决方案,似乎更符合逻辑,更清晰,但是这次,我们希望使用反射。

为了避免java.lang.IllegalAccessException,我们必须将method对象设置为可访问。为此,我们必须在调用前插入以下行:

method.setAccessible(true);

注意,这不会将方法更改为public。它只会通过我们设置为可访问的method对象的实例来访问调用方法。

我见过这样的代码:通过调用isAccessible()方法检查方法是否可访问,并保存此信息;如果方法不可访问,则将其设置为可访问,并在调用后恢复原始的可访问性。这完全没用。一旦method变量超出范围,并且没有对设置可访问性标志的对象的引用,设置的效果就会消失。另外,设置一个public或一个其他可调用方法的可访问性也不会受到惩罚。

设置字段

我们还可以对Field对象调用setAccessible,然后我们甚至可以使用反射设置私有字段的值。没有更多的假故事,就为了这个例子,让我们制作一个名为SettableCheckerConsistencyChecker

@Component 
@PoweredDevice 
public class SettableChecker implements ConsistencyChecker { 
    private static final Logger log = LoggerFactory.getLogger(SettableChecker.class); 

    private boolean setValue = false; 

    public boolean isInconsistent(Order order) { 
        return setValue; 
    } 
}

此检查器将返回false,除非我们使用反射将字段设置为true。我们是这样设定的。我们在Checker类中创建一个方法,并从每个检查器的检查过程中调用它:

private void setValueInChecker(ConsistencyChecker checker) { 
    Field[] fields = checker.getClass().getDeclaredFields(); 
    for( final Field field : fields ){ 
        if( field.getName().equals("setValue") && 
            field.getType().equals(boolean.class)){ 
            field.setAccessible(true); 
            try { 
                log.info("Setting field to true"); 
                field.set(checker,true); 
            } catch (IllegalAccessException e) { 
                log.error("SNAFU",e); 
            } 
        } 
    } 
}

方法遍历所有声明的字段,如果名称为setValue,类型为boolean,则设置为true。这基本上会导致所有包含通电设备的订单被拒绝。

注意,尽管boolean是一个内置的语言原始类型,它无论如何都不是一个类,但它仍然有一个类,以便反射可以将字段的类型与boolean人工拥有的类进行比较。现在,boolean.class是语言中的一个类文本,对于每个原始类型,可以使用一个类似的常量。编译器将它们标识为类文本,并在字节码中创建适当的伪类引用,以便也可以通过这种方式检查原始类型,如在setValueInChecker()方法的示例代码中所示。

我们检查了字段是否具有适当的类型,并在字段上调用了setAccessible()方法。尽管编译器不知道我们真的做了所有的事情来避免IllegalAccessException,但它仍然相信调用field上的set会抛出这样一个异常,正如它声明的那样。然而,我们知道它不应该发生(著名的程序员遗言?)。为了处理这种情况,我们用一个try块包围方法调用,并在catch分支中记录异常。

Java 函数式编程

由于我们在本章的示例中创建了大量代码,我们将研究 Java 的函数式编程特性,这将帮助我们从代码中删除许多行。我们拥有的代码越少,维护应用就越容易;因此,程序员喜欢函数式编程。但这并不是函数式编程如此流行的唯一原因。与传统循环相比,它也是一种以可读性更强、更不易出错的方式描述某些算法的极好方法。

函数式编程不是什么新鲜事。它的数学背景是在 20 世纪 30 年代发展起来的,最早(如果不是最早)的函数式编程语言之一是 LISP。它是在 20 世纪 50 年代开发的,现在仍在使用,以至于有一个版本的语言在 JVM 上实现(Clojure)。

简而言之,函数式编程就是用函数来表示程序结构。从这个意义上说,我们应该把函数看作是数学中的函数,而不是编程语言(如 C)中使用的术语。在 Java 中,我们有方法,当我们遵循函数编程范式时,我们创建和使用的方法的行为类似于数学函数。如果一个方法无论调用多少次都给出相同的结果,那么它就是函数性的,就像sin(0)总是零一样。函数式编程避免了改变对象的状态,因为状态没有改变,所以结果总是一样的。这也简化了调试。

如果函数曾经为给定的参数返回了某个值,它将始终返回相同的值。我们还可以将代码作为计算的声明来读取,而不是作为一个接一个执行的命令来读取。如果执行顺序不重要,那么代码的可读性也可能增加。

Java 通过 Lambda 表达式和流帮助实现函数式编程风格。请注意,这些流不是 I/O 流,并且实际上与这些流没有任何关系。

我们将首先简要介绍 Lambda 表达式以及流是什么,然后,我们将转换程序的某些部分以使用这些编程结构。我们还将看到这些代码变得更可读。

可读性是一个值得商榷的话题。代码对一个开发人员来说可能可读,并且对另一个开发人员可能不太可读。这很大程度上取决于他们习惯了什么。根据我的经验,我知道开发人员经常被流分散注意力。当开发人员第一次遇到流时,思考它们的方式和他们的外观只是奇怪。但这和开始学骑自行车一样。当你还在学习如何骑车,你摔倒的次数比你实际前进的要多,但它绝对比走路慢。另一方面,一旦你学会了如何骑。。。

Lambda

在编写异常抛出测试时,我们已经在第 3 章中使用了 Lambda 表达式,“优化专业排序代码”。在该代码中,我们将比较器设置为一个特殊值,该值在每次调用时抛出RuntimeException

sort.setComparator((String a, String b) -> { 
        throw new RuntimeException(); 
    });

参数类型是Comparator,因此我们要设置的应该是实现java.util.Comparator接口的类的实例。该接口只定义了一个实现必须定义的方法-compare.,因此,我们可以将其定义为 Lambda 表达式。没有 Lambda,如果我们需要一个实例,我们必须输入很多。我们需要创建一个类,命名它,在其中声明compare()方法,并编写方法体,如下代码段所示:

public class ExceptionThrowingComparator implements Comparator { 
  public int compare(T o1, T o2){ 
    throw new RuntimeException(); 
  } 
}

在使用它的地方,我们应该实例化类并将其作为参数传递:

sort.setComparator(new ExceptionThrowingComparator());

如果我们将类定义为匿名类,我们可能会节省一些字符,但是开销仍然存在。我们真正需要的是我们必须定义的单一方法的主体。这就是 Lambda 出现的地方。

我们可以在任何地方使用 Lambda 表达式,否则我们需要一个只有一个方法的类的实例。定义并继承自Object的方法不计算在内,我们也不关心接口中定义为default方法的方法。他们在那里。Lambda 定义了一个尚未定义的。换句话说,Lambda 清楚地描述了这个值是一个函数,我们将它作为一个参数传递,而匿名类的开销要少得多。

Lambda 表达式的简单形式如下:

parameters -> body

参数可以用括号括起来,如果只有一个参数,则可以不用括号。同样地,正文可以括在{}字符之间,也可以是一个简单的表达式。通过这种方式,Lambda 表达式可以将开销降到最低,只在真正需要的地方使用括号。

这也是 Lambda 表达式的一个非常有用的特性,即我们不需要指定参数的类型,以防从我们使用表达式的上下文中显而易见。因此,前面的代码段甚至可以更短,如下所示:

sort.setComparator((a, b) -> { 
    throw new RuntimeException(); 
});

或者,我们可以这样写:

sort.setComparator((var a, var b) -> { 
    throw new RuntimeException(); 
});

参数ab将具有所需的类型。为了更简单,如果只有一个参数,我们还可以省略参数周围的()字符。

如果有多个参数,则括号不是可选的。这是为了避免在某些情况下出现歧义。例如,方法调用f(x,y->x+y)可能是一个具有两个参数的方法—x,以及一个具有一个参数y的 Lambda 表达式。同时,它也可以是一个具有 Lambda 表达式的方法调用,Lambda 表达式有两个参数,xy。当有多个参数并且编译器可以计算参数的类型时,自 Java11 发布以来就可以使用var关键字。

当我们想将函数作为参数传递时,Lambda 表达式非常方便。方法声明处参数类型的声明应为函数式接口类型。这些接口可以选择使用@FunctionalInterface进行注解。Java 运行时在java.util.function包中定义了许多这样的接口。我们将在下一节讨论其中的一些,以及它们在流中的使用。对于其余部分,标准 Java 文档可从 Oracle 获得。

流在 Java8 中也是新的,就像 Lambda 表达式一样。他们一起工作非常强烈,所以他们的出现在同一时间并不令人惊讶。Lambda 表达式以及流都支持函数式编程风格。

首先要澄清的是,流与输入和输出流没有任何关系,除了名称。它们是完全不同的东西。流更像是具有一些显著差异的集合。(如果没有区别,它们就只是集合。)流本质上是可以顺序或并行运行的操作管道。他们从收集或其他来源获得数据,包括动态制造的数据。

流支持对多个数据执行相同的计算。该结构称为单指令多数据SIMD)。别害怕这个表情。这是一件非常简单的事情。这本书我们已经做了很多次了。循环也是一种 SIMD 结构。当我们循环检查类以查看其中是否有一个反对该顺序时,我们对每个和每个检查程序执行相同的指令。多个检查器意味着多个数据。

循环的一个问题是,我们定义了不需要的执行顺序。在跳棋的情况下,我们并不关心跳棋的执行顺序。我们关心的是,所有人都同意这个命令。在编程循环时,我们仍然指定一些顺序。这来自循环的本质,我们无法改变这一点。他们就是这样工作的。然而,如果我们能,不知何故,说“对每个检查者做这个和那个”,那就太好了。这就是流发挥作用的地方。

另一点是,使用循环的代码更重要,而不是描述性的。当我们阅读循环构造的程序时,我们将重点放在各个步骤上。我们首先看到循环中的命令是做什么的。这些命令作用于数据的单个元素,而不是整个集合或数组。

当我们在大脑中把各个步骤放在一起时,我们就会意识到什么是大局,什么是循环。在流的情况下,操作的描述更高一级。一旦我们学习了流方法,就更容易阅读了。流方法作用于整个流而不是单个元素,因此更具描述性。

java.lang.Stream是一个接口。具有实现此接口的类型的对象表示许多对象,并提供可用于对这些对象执行指令的方法。当我们开始对其中一个对象执行操作时,这些对象可能不可用,也可能不可用,或者只在需要时创建。这取决于Stream接口的实际实现。例如,假设我们使用以下代码生成一个包含int值的流:

IntStream.iterate( 0, (s) -> s+1 )

在前面的代码段中,无法生成所有元素,因为流包含无限个元素。此示例将返回数字 0、1、2 等,直到其他流操作(此处未列出)终止计算。

当我们编程Stream时,我们通常从Collection创建一个流—不总是,但经常。在 Java8 中扩展了Collection接口,提供了streamparallelStream()方法。它们都返回表示集合元素的流对象。当stream返回元素时,如果存在自然顺序,parallelStream会创建一个可以并行处理的流。在这种情况下,如果我们在流上使用的某些方法是以这种方式实现的,那么代码可以使用计算机中可用的多个处理器。

一旦我们有了一个流,我们就可以使用Stream接口定义的方法。首先是forEach()。此方法有一个参数,通常作为 Lambda 表达式提供,并将为流的每个元素执行 Lambda 表达式。

Checker类中,我们有isConsistent()方法。在这个方法中,有一个循环遍历检查器类的注解。如果要记录循环中注解实现的接口,可以添加以下内容:

for (ConsistencyChecker checker :checkers) { 
  for (Annotation annotation : checker.getClass().getAnnotations()) { 
    Arrays.stream(annotation.getClass().getInterfaces()).forEach( 
      t ->log.info("annotation implemented interfaces {}",t)); 
...

在本例中,我们使用Arrays类中的工厂方法从数组创建流。数组包含反射方法返回的接口getInterfaces()。Lambda 表达式只有一个参数;因此,不需要在其周围使用括号。表达式的主体是一个不返回值的方法调用;因此,我们也省略了{}字符。

为什么这么麻烦?有什么好处?为什么我们不能写一个简单的循环来记录数组的元素呢?其好处是可读性和可维护性。当我们创建一个程序时,我们必须关注程序应该做什么,而不是它应该如何做。在一个理想的世界里,规范只是可执行的。当编程工作被人工智能所取代的时候,我们也许真的能达到目的。(虽然不是程序员)我们还没到。我们必须告诉计算机如何做我们想做的事。我们过去必须在 PDP-11 的控制台上输入二进制代码,以便将机器代码部署到内存中执行。后来,我们有了汇编器;后来,我们有了 FORTRAN 和其他高级编程语言,它们取代了 40 年前的大部分编程工作。所有这些编程的发展都从如何转向什么。今天,我们用 Java11 编程,这条路还有很长的路要走。我们越能表达我们该做什么,而不是如何做,我们的程序就越短,也越容易理解。它将包含本质,而不是一些人造垃圾,是机器所需要的只是做我们想要的。当我在我必须维护的代码中看到一个循环时,我假设循环的执行顺序有一定的重要性。可能根本不重要。几秒钟后可能很明显。可能需要几分钟或更长时间才能意识到订购并不重要。这种时间是浪费的,可以通过更好地表达要做什么部分而不是如何做**部分的编程构造来节省时间。

函数式接口

方法的参数应该是java.util.function.Consumer。这个接口需要定义accept()方法,这个方法是void。实现此接口的 Lambda 表达式或类将使用方法 T3 的参数而不产生任何结果。

该包中还定义了其他几个接口,每个接口都用作函数式接口,用于描述一些方法参数,这些参数可以在实际参数中作为 Lambda 表达式给出。

例如,Consumer的对立面是Supplier。这个接口有一个名为get()的方法,它不需要任何参数,但是它给出了一些Object作为返回值。

如果有一个参数和一个返回值,则该接口称为Function。如果返回值必须与参数的类型相同,那么UnaryOperator接口就是我们的朋友。类似地,还有一个BinaryOperator接口,它返回一个与参数类型相同的对象。正如我们从FunctionUnaryOperator一样,我们可以看到在另一个方向上,也有BiFunction,以防参数和返回值不共享类型。

这些接口不是相互独立定义的。如果一个方法需要Function,而我们有UnaryOperator要通过,那应该不是问题。UnaryOperatorFunction基本相同,参数类型相同。一个可以与接受一个对象并返回一个对象的Function一起工作的方法,如果它们具有相同的类型,应该不会有问题。这些可以是,但不一定是,不同的。为了实现这一点,UnaryOperator接口扩展了Function,因此可以用来代替Function

到目前为止,我们遇到的这个类中的接口是使用泛型定义的。因为泛型类型不能是原始类型,所以操作原始值的接口应该单独定义。例如,Predicate是定义booleantest(T t)的接口。它是一个返回boolean值的函数,常用于流方法。

还有一些接口,例如BooleanSupplierDoubleConsumerDoubleToIntFunction等等,它们与原始类型booleandoubleint一起工作。不同参数类型和返回值的可能组合的数量是无限的。。。几乎。

有趣的事实:确切地说,它不是无限的。 一个方法最多可以有 254 个参数。 此限制是在 JVM 中指定的,而不是在 Java 语言规范中指定的。 当然,一个没有另一个就没有用。 有 8 种原始类型(加上“对象”,再加上少于 254 个参数的可能性),这意味着可能的函数时接口总数为10 ** 254,给出或取几个幅度。 几乎是无限的!

我们不应该期望在这个包的 JDK 中定义所有可能的接口。这些只是最有用的接口。例如,没有使用shortchar的接口。如果我们需要这样的东西,那么我们可以在代码中定义interface。或者只是仔细想想,找出如何使用一个已经定义好的。(我在职业生涯中从未使用过short型号。从来就不需要它。)

这些函数式接口是如何在流中使用的?Stream接口定义了一些函数式接口类型作为参数的方法。例如,allMatch()方法有一个Predicate参数并返回一个Boolean值,如果流中的所有元素都匹配Predicate,则返回的值就是true。换句话说,当且仅当作为参数提供的Predicate为流的每个元素返回true时,此方法才返回true

在下面的代码中,我们将重写我们在示例代码中使用循环来使用流实现的一些方法,并且通过这些示例,我们将讨论流提供的最重要的方法。我们保存了两个类,ProductsCheckerCollectorProductInformationCollector来演示流的用法。我们可以从这些开始。ProductsCheckerCollector遍历Order中包含的所有产品,并收集产品中列出的注解。每个产品可能包含零个、一个或多个注解。这些在列表中提供。同一注解可以多次引用。为了避免重复,我们使用HashSet,它只包含元素的一个实例,即使产品中有多个实例:

public class ProductsCheckerCollector {
    private static final Logger log =
            LoggerFactory.getLogger(ProductsCheckerCollector.class);

    private final ProductInformationCollector pic;

    public ProductsCheckerCollector
            (@Autowired ProductInformationCollector pic) {
        this.pic = pic;
    }

    public Set<Class<? extends Annotation>> getProductAnnotations(Order order) {
        var piMap = pic.collectProductInformation(order);
        final var annotations = new HashSet<Class<? extends Annotation>>();
        for (var item : order.getItems()) {
            final var pi = piMap.get(item);
            if (pi != null && pi.getCheck() != null) {
                for (final var check : pi.getCheck()) {
                    annotations.addAll(pi.getCheck());
                }
            }
        }
        return annotations;
    }

现在,让我们看看当我们使用流重新编码时,这个方法是如何看待的:

public Set<Class<? extends Annotation>> getProductAnnotations(Order order) {
    var piMap = pic.collectProductInformation(order);
    return order.getItems().stream()
            .map(piMap::get)
            .filter(Objects::nonNull)
            .peek(pi -> {
                if (pi.getCheck() == null) {
                    log.info("Product {} has no annotation", pi.getId());
                }
            })
            .filter(ProductInformation::hasCheck)
            .peek(pi -> log.info("Product {} is annotated with class {}", pi.getId(), pi.getCheck()))
            .flatMap(pi -> pi.getCheck().stream())
            .collect(Collectors.toSet());
}

该方法的主要工作是进入一个单一的,虽然庞大,流表达式。我们将在接下来的几页中介绍这个表达式的元素。

order.getItems返回的List调用stream()方法进行转换:

return order.getItems().stream()

我们已经简单地提到过,stream()方法是Collection接口的一部分。任何实现Collection接口的类都会有这个方法,即使是那些在 Java8 中引入流之前实现的类。这是因为stream()方法在接口中实现为default方法。这样,如果我们碰巧实现了一个实现这个接口的类,即使我们不需要流,我们也可以免费获得它。

为了支持接口的向后兼容性,引入了 Java8 中的default方法。JDK 的一些接口将被修改以支持 Lambda 和函数式编程。一个例子是stream()方法。在 Java8 之前的特性集中,实现一些修改过的接口的类应该已经被修改过了。他们将被要求实现新方法。这样的变化是不向后兼容的,Java 作为一种语言和 JDK 非常关注向后兼容。为此,介绍了default方法。这使得开发人员可以扩展接口并保持其向后兼容,从而为新方法提供默认实现。与此相反,java8JDK 的全新函数式接口也有default方法,尽管 JDK 中没有以前的版本,它们没有什么可兼容的。在 Java9 中,接口也被扩展,现在它们不仅可以包含defaultstatic方法,还可以包含private方法。这样,接口就相当于抽象类,尽管接口中除了常量static字段外没有其他字段。这个接口功能扩展是一个备受批评的特性,它只会带来允许多类继承的其他语言所面临的编程风格和结构问题。Java 一直在避免这种情况,直到 Java8 和 Java9 出现。 这有什么好处?注意接口中的default方法和private方法。明智地使用它们,如果有的话。

这个流的元素是OrderItem对象。我们需要为每个OrderItem设置ProductInformation

方法引用

幸运的是我们有Map,它将订单项目与产品信息配对,所以我们可以在Map上调用get()

.map(piMap::get)

map()方法与 Java 中的其他方法同名,不应混淆。当Map类是数据结构时,Stream接口中的map()方法执行流元素的映射。该方法的参数是一个Function(回想一下,这是我们最近讨论的一个函数式接口)。此函数将值T转换为值Rmap()方法的返回值为Stream<R>,该值可用作原始流的元素(Stream<T>)。map()方法使用给定的Function<T,R>Stream<T>转换为Stream<R>,为原始流的每个元素调用它,并从转换后的元素创建一个新流。

可以说,Map接口以静态方式将键映射到数据结构中的值,流方法map()动态地将一种值映射到另一种(或相同)类型的值。

我们已经看到可以以 Lambda 表达式的形式提供函数式接口的实例。此参数不是 Lambda 表达式。这是一个方法引用。它说map()方法应该调用Map piMap上的get()方法,使用实际的流元素作为参数。我们很幸运get()也需要一个参数,不是吗?我们也可以这样写:

.map( orderItem ->piMap.get(orderItem))

然而,这与piMap::get完全相同。

这样,我们就可以引用在某个实例上工作的实例方法。在我们的示例中,实例是由piMap变量引用的实例。也可以引用static方法。在这种情况下,类的名称应该写在::字符前面。当我们使用来自Objects类的static方法nonNull时,我们很快就会看到这样一个例子(注意类名是复数形式的,它在java.util包中,而不是java.lang)。

也可以引用实例方法,而不给出应该调用它的引用。这可以在函数式接口方法有一个额外的第一个参数的地方使用,这个参数将用作实例。我们已经在第 3 章中使用过了,“优化专业排序代码”,当我们通过String::compareTo时,当期望的参数是Comparator时。compareTo()方法需要一个参数,而Comparator接口中的compare()方法需要两个参数。在这种情况下,第一个参数将用作必须调用compare()的实例,第二个参数将传递给compare()。在这种情况下,String::compareTo与写入 Lambda 表达式(String a, String b) -> a.compareTo(b)相同。

最后但并非最不重要的一点,我们可以使用构造器的方法引用。当我们需要SupplierObject时,我们可以写Object::new

下一步是从流中过滤出null元素。注意,此时流有ProductInformation个元素:

.filter(Objects::nonNull)

filter()方法使用Predicate并创建一个只包含与谓词匹配的元素的流。在本例中,我们使用了对static方法的引用。filter()方法不会改变流的类型。它只过滤掉元素。

我们应用的下一种方法是有点反功能。纯函数流方法不会改变对象的状态。它们创建返回的新对象,但除此之外,没有副作用。peek()它本身没有什么不同,因为它只返回一个与应用的元素相同的流。然而,这种无操作功能,诱使新手程序员做一些非函数式的事情,编写带有副作用的代码。毕竟,如果调用它没有(副作用)的话,为什么要使用它?

.peek(pi -> { 
    if (pi.getCheck() == null) { 
        log.info("Product {} has no annotation", pi.getId()); 
    } 
})

虽然peek()方法本身没有任何副作用,但是 Lambda 表达式的执行可能会有副作用。但是,对于其他任何方法也是如此。事实上,在这种情况下,做一些不适当的事情更具诱惑力。不要。我们是有纪律的成年人。正如该方法的名称所示,我们可以窥视流,但我们不应该做任何其他事情。由于编程是一项特殊的活动,在这种情况下,窥视就足够了。这就是我们在代码中实际做的:我们记录一些东西。

在此之后,我们去掉了没有ProductInformation的元素;我们也想去掉有ProductInformation的元素,但是没有定义检查器:

.filter(pi -> pi.getCheck() != null)

在这种情况下,我们不能使用方法引用。相反,我们使用 Lambda 表达式。作为替代方案,我们可以在ProductInformation中创建boolean hasCheck()方法,如果private字段检查不是null,则返回true。其内容如下:

.filter(ProductInformation::hasCheck)

尽管这个类没有实现任何函数式接口,并且有很多方法,而不仅仅是这个方法,但是这个方法是完全有效的。但是,方法引用是显式的,并指定要调用的方法。

在第二个过滤器之后,我们再次记录元素:

.peek(pi -> log.info( 
     "Product {} is annotated with class {}", pi.getId(), 
                                            pi.getCheck()))

下一种方法是flatMap,这是一种特殊的、不易理解的方法。至少对我来说,当我学习函数式编程时,这比理解map()filter()要困难一些:

.flatMap(pi ->pi.getCheck().stream())

此方法期望 Lambda、方法引用或作为参数传递给它的任何内容为调用该方法的原始流的每个元素创建一个全新的对象流。然而,结果不是流的流,这也是可能的,而是返回的流被连接成一个巨大的流。

如果我们应用它的流是一个整数流,比如 1,2,3,…,并且每个数的函数n返回一个包含三个元素的流nn+1n+2,那么得到的流flatMap()生成一个包含 1,2,3,2,3,4,4,5、6 等等。

最后,我们的流应该被收集到一个Set。这是通过调用collector()方法完成的:

.collect(Collectors.toSet());

collector()方法的参数是(同样,一个过度使用的表达式)Collector。它可以用于将流的元素收集到集合中。注意,Collector不是函数式接口。你不能仅仅用 Lambda 或者简单的方法来收集一些东西。为了收集元素,我们肯定需要一个地方来收集元素,因为不断更新的元素来自流。Collector接口不简单。幸运的是,java.util.streams.Collectors类(同样注意复数形式)有许多static方法创建并返回Object字段,这些字段反过来又创建并返回Collector对象。

其中之一是toSet(),它返回一个Collector,帮助将流中的元素收集到一个Set中。当所有元素都存在时,collect()方法将返回Set。还有其他一些方法可以帮助收集流元素,方法是将元素相加,计算平均值,或将其转换为ListCollectionMap。将元素收集到Map是一件特殊的事情,因为Map的每个元素实际上是一个键值对。当我们看ProductInformationCollector时,我们将看到这个例子。

ProductInformationCollector类代码包含collectProductInformation()方法,我们将从Checker类和ProductsCheckerCollector类中使用该方法:

private Map<OrderItem, ProductInformation> map = null;

public Map<OrderItem, ProductInformation> collectProductInformation(Order order) {
    if (map == null) {
        log.info("Collecting product information");
        map = new HashMap<>();
        for (OrderItem item : order.getItems()) {
            final ProductInformation pi = lookup.byId(item.getProductId());
            if (!pi.isValid()) {
                map = null;
                return null;
            }
            map.put(item, pi);
        }
    }
    return map;
}

简单的技巧是将收集到的值存储在Map中,如果不是null,则只返回已经计算的值,这样在处理同一 HTTP 请求时,如果多次调用此方法,可能会节省大量服务调用。

这种结构有两种编码方式。一种是检查Map的非空性,如果Map已经存在则返回。这种模式被广泛使用,并有一个名字,称为保护。在这种情况下,方法中有多个return语句,这可能被视为一个弱点或反模式。另一方面,该方法的制表法是一个标签浅。这是一个品味的问题,如果你发现自己正处于一个或另一个解决方案的争论中,那么就帮自己一个忙,让你的同伴在这个话题上获胜,并为更重要的问题节省精力,例如,你应该使用流还是简单的旧循环。

现在,让我们看看如何将此解决方案转换为函数式:

public Map<OrderItem, ProductInformation> collectProductInformation(Order order) {
    if (map == null) {
        log.info("Collecting product information");
        map =
        order.getItems()
                .stream()
                .map(item -> tuple(item, item.getProductId()))
                .map(t -> tuple(t.r, lookup.byId((String) t.s)))
                .filter(t -> ((ProductInformation)t.s).isValid())
                .collect(Collectors.toMap(t -> (OrderItem)t.r, t -> (ProductInformation)t.s));
        if (map.keySet().size() != order.getItems().size()) {
            log.error("Some of the products in the order do " +
                            "not have product information, {} != {} ",
                    map.keySet().size(),order.getItems().size());
            map = null;
        }
    }
    return map;
}

我们使用一个助手类Tuple,它只不过是两个Object实例,分别命名为rs。稍后我们将列出这个类的代码。这很简单。

在流表达式中,我们首先从集合中创建流,然后将OrderItem元素映射到一个由OrderItemproductId元组组成的流。然后,我们将这些元组映射到现在包含OrderItemProductInformation的元组。这两个映射可以在一个映射调用中完成,该调用将在一个映射调用中执行这两个步骤。我决定在每一行中创建两个简单的步骤,希望得到的代码更容易理解。

过滤步骤也不是什么新鲜事。它只是过滤掉无效的产品信息元素。实际上应该没有。如果订单包含不存在产品的订单 ID,则会发生这种情况。在下一个语句中,当我们查看收集的产品信息元素的数量,以确定所有项目都具有适当的信息时,就会检查这一点。

有趣的代码是我们如何将流的元素收集到一个Map中。为此,我们再次使用collect()方法和Collectors类。这次,toMap()方法创建Collector。这需要两个结果表达式。第一个应该将流的元素转换为键,第二个应该生成要在Map中使用的值。因为键和值的实际类型是从传递的 Lambda 表达式的结果计算出来的,所以我们必须显式地将元组的字段转换为所需的类型。

最后,简单的Tuple类如下:

public class Tuple<R, S> {
    final public R r;
    final public S s;

    private Tuple(R r, S s) {
        this.r = r;
        this.s = s;
    }

    public static <R, S> Tuple tuple(R r, S s) {
        return new Tuple<>(r, s);
    }
}

我们的代码中仍有一些类需要转换为函数式风格。这些是CheckerCheckerHelper类。

Checker类中,我们可以覆盖isConsistent()方法:

public boolean isConsistent(Order order) {
    var map = piCollector.collectProductInformation(order);
    if (map == null) {
        return false;
    }
    final var as = pcCollector.getProductAnnotations(order);
    return !checkers.stream().anyMatch(
            c -> Arrays.stream(c.getClass().getAnnotations()
            ).filter(a -> as.contains(a.annotationType())
            ).anyMatch(x -> c.isInconsistent(order)
            ));
}

因为您已经学习了大多数重要的流方法,所以这里几乎没有什么新问题。我们可以提到anyMatch()方法,如果至少有一个元素,则返回true,这样传递给anyMatch()Predicate参数就是true。它可能还需要一些住宿,这样我们就可以使用另一条流中的一条流。这很可能是一个例子,当一个流表达式过于复杂,需要使用局部变量分解成更小的片段。

最后,在离开函数样式之前,我们覆盖了CheckHelper类中的containsOneOf()方法。这不包含新元素,将帮助您检查您对map()filter()flatMap()Collector的了解。请注意,如我们所讨论的,如果order至少包含一个以字符串形式给出的订单 ID,则此方法返回true

public boolean containsOneOf(String... ids) {
    return order.getItems().parallelStream()
        .map(OrderItem::getProductId)
        .flatMap(itemId -> Arrays.stream(ids)
            .map(id -> tuple(itemId, id)))
        .filter(t -> Objects.equals(t.s, t.r))
        .collect(Collectors.counting()) > 0;
}

我们创建了OrderItem对象流,然后将其映射到流中包含的产品的 ID 流。然后,我们为每个 ID 创建另一个流,其中 ID 元素和作为参数的字符串 ID 之一。然后,我们将这些子流扁平成一个流。此流将包含order.getItems().size()ids.length元素:所有可能的对。我们将过滤两次包含相同 ID 的对,最后,我们将计算流中的元素数。

JavaScript

我们已经准备好了本章的示例程序。有一个问题,尽管它不专业。当我们有一个新产品需要一个新的检查器时,我们必须创建一个新的代码版本。

专业环境中的程序有版本。当修改代码、修复 bug 或实现新功能时,在应用投入生产之前,组织需要执行许多步骤。这些步骤包括释放过程。一些环境具有轻量级的发布过程;另一些环境需要严格且昂贵的检查。然而,这并不取决于组织中人员的偏好。当一个非工作的生产代码的成本很低,并且不管程序中是否有中断或不正确的功能时,那么发布过程可以很简单。这样,发布速度更快,成本更低。一个例子可以是用户用来取乐的聊天程序。在这种情况下,发布新的花哨特性可能比确保无 bug 工作更重要。另一方面,如果你创建了控制原子能发电厂的代码,那么失败的代价可能相当高。对所有特性进行认真的测试和仔细的检查,即使是在最小的更改之后,也会有回报。

在我们的示例中,简单的跳棋可能是一个不太可能导致严重错误的区域。这不是不可能的,但代码是如此简单…是的,我知道这样的论点有点可疑,但让我们假设,这些小例程可以用更少的测试和更简单的方式比其他部分的代码来改变。那么,如何将这些小脚本的代码分离开来,使它们不需要技术版本、应用的新版本,甚至不需要重新启动应用?我们有一个新产品,需要一个新的检查,我们希望有一些方法,注入这个检查到应用环境中,没有任何服务中断。

我们选择的解决方案是脚本。Java 程序可以执行用 JavaScriptGroovyJython(即 JVM 版本的 Python 语言)等多种语言编写的脚本。除了 JavaScript 之外,这些语言的语言解释器都不是 JDK 的一部分,但是它们都提供了一个标准接口,这个接口在 JDK 中定义。结果是,我们可以在代码中实现脚本执行,提供脚本的开发人员可以自由选择任何可用的语言;我们不需要关心执行一个 JavaScript 代码。我们将使用与执行 GroovyJython相同的 API。我们唯一应该知道的是剧本是用什么语言写的。这通常很简单,我们可以从文件扩展名猜测,如果猜测不够,我们可以要求脚本开发人员将 JavaScript 放入扩展名为.js的文件中,Jython 放入扩展名为.jy.py的文件中,Groovy 放入扩展名为.groovy的文件中,等等。同样重要的是要注意,如果我们希望我们的程序执行这些语言之一,我们应该确保解释器在类路径上。在 JavaScript 的情况下,这是给定的,因此,通过本章的演示,我们将用 JavaScript 来编写我们的脚本。不会有太多;毕竟,这是一本 Java 书,而不是一本 JavaScript 书。

当我们想通过编程方式配置或扩展应用时,脚本通常是一个很好的选择。这是我们的案子。

我们要做的第一件事是扩展生产信息。如果有一个脚本检查产品订单的一致性,我们需要一个字段来指定脚本的名称:

private String checkScript;

public String getCheckScript() {
    return checkScript;
}

public void setCheckScript(String checkScript) {
    this.checkScript = checkScript;
}

我们不希望为每个产品指定多个脚本;因此,我们不需要脚本名称列表。我们只有一个由名称指定的脚本。

老实说,检查器类和注解的数据结构,允许每个产品以及每个检查器类都有多个注解,这太复杂了。然而,我们无法避免拥有一个足够复杂的结构,可以证明流表达式的能力和能力。既然我们已经讨论了这个主题,我们可以继续使用更简单的数据结构,重点关注脚本执行。

我们还必须修改Checker类,以便不仅使用检查器类,而且使用脚本。我们不能扔掉检查器类,因为当我们意识到我们需要更好的脚本时,我们已经有很多检查器类,我们没有资金将它们重写为脚本。嗯,是的,我们是在书中,而不是在现实生活中,但在一个企业,这将是事实。这就是为什么在为企业设计解决方案时你应该非常小心的原因。结构和解决方案将存在很长一段时间,仅仅因为一段代码在技术上不是最好的,就很难抛出它。如果它能够工作并且已经存在,那么企业将非常不愿意在代码维护和重构上花钱。

总之,我们修改了Checker类。我们需要一个新类来执行我们的脚本;因此,我们必须插入一个新的final字段,如下所示:

private final CheckerScriptExecutor executor; 

我们还必须通过添加一个新参数来初始化final字段来修改构造器。

我们还必须在isConsistent()方法中使用此executor

public boolean isConsistent(Order order) {
    final var map = piCollector.collectProductInformation(order);
    if (map == null) {
        return false;
    }
    final var annotations = pcCollector.getProductAnnotations(order);
    var needAnntn = (Predicate<Annotation>) an ->
            annotations.contains(an.annotationType());
    var consistent = (Predicate<ConsistencyChecker>) c ->
            Arrays.stream(c.getClass().getAnnotations())
                    .parallel()
                    .unordered()
                    .filter(needAnntn)
                    .anyMatch(x -> c.isInconsistent(order));
    final var checkersOK = !checkers.stream().anyMatch(consistent);
    final var scriptsOK = !map.values().parallelStream().
            map(ProductInformation::getCheckScript).
            filter(Objects::nonNull).
            anyMatch(s -> executor.notConsistent(s, order));
    return checkersOK && scriptsOK;
}

注意,在这段代码中,我们使用并行流,因为,为什么不呢?只要有可能,我们就可以使用并行流(即使是无序的)来告诉底层系统,以及维护代码的程序员,顺序并不重要。

我们还修改了一个产品 JSON 文件,通过一些注解引用脚本而不是检查器类:

{ 
  "id" : "124", 
  "title": "Desk Lamp", 
  "checkScript" : "powered_device", 
  "description": "this is a lamp that stands on my desk", 
  "weight": "600", 
  "size": [ "300", "20", "2" ] 
}

即使是 JSON 也更简单。注意,当我们决定使用 JavaScript 时,命名脚本时不需要指定文件扩展名。

我们以后可能会考虑进一步的开发,允许产品检查器脚本维护人员使用不同的脚本语言。在这种情况下,我们可能仍然要求他们指定扩展名,如果没有扩展名,我们的程序会将其添加为.js。在我们当前的解决方案中,我们不检查这一点,但是我们可以花几秒钟来考虑它,以确保解决方案可以进一步开发。重要的是,我们不要为了进一步的开发而开发额外的代码。开发人员不是算命师,也不能可靠地判断未来需要什么。这是商界人士的任务。

我们把脚本放到scripts目录下的resource目录中。文件名必须为powered_device.js,因为这是我们在 JSON 文件中指定的名称:

function isInconsistent(order){
    isConsistent = false
    items = order.getItems()
    for( i in items ){
    item = items[i]
    print( item )
        if( item.getProductId() == "126" ||
            item.getProductId() == "127" ||
            item.getProductId() == "128"  ){
            isConsistent = true
            }
    }
    return ! isConsistent
}

这是一个非常简单的 JavaScript 程序。另请注意,在 JavaScript 中迭代列表或数组时,循环变量将迭代集合或数组的索引。由于我很少用 JavaScript 编程,我陷入了这个陷阱,花了半个多小时来调试我犯的错误。

我们已经准备好了所有我们需要的东西。我们还得调用它。为此,我们使用 JDK 脚本 API。首先,我们需要一个ScriptEngineManager。此管理器用于访问 JavaScript 引擎。尽管 JavaScript 解释器自 Java7 以来一直是 JDK 的一部分,但它仍然以抽象的方式进行管理。它是 Java 程序可以用来执行脚本的许多可能的解释器之一。它正好在 JDK 中,所以我们不需要将解释器 JAR 添加到类路径中。ScriptEngineManager发现类路径上的所有解释器并注册它们。

它使用服务提供者规范来实现这一点,服务提供者规范很长时间以来一直是 JDK 的一部分,而且通过 Java9,它还获得了模块处理方面的额外支持。这要求脚本解释器实现ScriptEngineFactory接口,并在META-INF/services/javax.script.ScriptEngineFactory文件中列出执行该接口的类。这些文件,从属于类路径的所有 JAR 文件中,作为资源被ScriptEngineManager读取,通过它,它知道哪些类实现了脚本解释器。ScriptEngineFactory接口要求解释器提供getNames()getExtensions()getMimeTypes()等方法。管理器调用这些方法来收集有关解释器的信息。当我们询问 JavaScript 解释器时,管理器会返回工厂创建的名称,其中一个名称是JavaScript

为了通过名称访问解释器,文件扩展名或 MIME 类型只是ScriptEngineManager的函数之一。另一个是管理Bindings

当我们在 Java 代码中执行一个脚本时,我们不会这样做,因为我们想增加多巴胺的水平。在脚本的情况下,它不会发生。我们想要一些结果。我们希望传递参数,并且在脚本执行之后,我们希望从脚本中获得可以在 Java 代码中使用的值。这可以通过两种方式实现。一种是将参数传递给脚本中实现的方法或函数,并从脚本中获取返回值。这通常是可行的,但有些脚本语言甚至可能没有函数或方法的概念。在这种情况下,这是不可能的。可以将环境传递给脚本,并在脚本执行后从环境中读取值。这个环境用Bindings表示。

Bindings是具有String键和Object值的映射。

在大多数脚本语言的情况下,例如,在 JavaScript 中,Bindings连接到我们执行的脚本中的全局变量。换句话说,如果我们在调用脚本之前在 Java 程序中执行以下命令,那么 JavaScript 全局变量globalVariable将引用myObject对象:

myBindings.put("globalVariable",myObject)

我们可以创建Bindings并将其传递给ScriptEngineManager,但也可以使用它自动创建的方法,并可以直接调用引擎对象上的put()方法。

当我们执行脚本时,有两个Bindings。一个设置在ScriptEngineManager层。这称为全局绑定。还有一个是由ScriptEngine自己管理的。这是当地的Bindings。从剧本的角度看,没有区别。从嵌入的角度看,存在一定程度的差异。如果我们使用相同的ScriptEngineManager来创建多个ScriptEngine实例,那么全局绑定将由它们共享。如果一个人得到一个值,所有人都会看到相同的值;如果一个人设置了一个值,其他人都会看到更改后的值。本地绑定特定于它所管理的引擎。由于本书只介绍了 Java 脚本 API,所以我们不做详细介绍,也不使用Bindings。我们擅长调用 JavaScript 函数并从中获得结果。

实现脚本调用的类是CheckerScriptExecutor。它从以下几行开始:

package packt.java11.bulkorder.services;
import ...

@Component
public class CheckerScriptExecutor {
    private static final Logger log =
            LoggerFactory.getLogger(CheckerScriptExecutor.class);

    private final ScriptEngineManager manager = new ScriptEngineManager();

    public boolean notConsistent(String script, Order order) {

        try {
            final var scriptReader = getScriptReader(script);
            final var result = evalScript(script, order, scriptReader);
            assertResultIsBoolean(script, result);
            log.info("Script {} was executed and returned {}", script, result);
            return (boolean) result;

        } catch (Exception wasAlreadyHandled) {
            return true;
        }
    }

唯一的public方法notConsistent()获取要执行的脚本的名称以及order。后者必须传递给脚本。首先得到Reader,可以读取脚本文本,对其进行求值,最后返回结果,如果是boolean或者至少可以转换成boolean。如果我们在这个类中实现的从这里调用的任何方法是错误的,它将抛出一个异常,但只有在适当地记录它之后。在这种情况下,安全的方法是拒绝命令。

实际上,这是企业应该决定的。如果存在无法执行的检查脚本,则显然是错误的情况。在这种情况下,接受订单并随后手动处理问题会产生一定的成本。由于某些内部错误而拒绝订单或确认对订单流程来说也不是一条愉快的道路。我们必须检查哪种方法对公司造成的损害最小。这当然不是程序员的职责。我们的处境很容易。

我们假设业务代表说在这种情况下订单应该被拒绝。在现实生活中,类似的决策被业务代表拒绝,他们说这不应该发生,IT 部门必须确保程序和整个操作完全没有 bug。这种反应是有心理原因的,但这确实使我们离 Java 编程非常遥远。

引擎可以执行通过Reader或作为String传递的脚本。因为现在我们在资源文件中有了脚本代码,所以让引擎读取资源似乎是一个更好的主意,而不是将其读取到一个String

private Reader getScriptReader(String script) throws IOException {
    final Reader scriptReader;
    try (final var scriptIS = new ClassPathResource(
            "scripts/" + script + ".js").getInputStream()) {
        scriptReader = new InputStreamReader(scriptIS);
    } catch (IOException ioe) {
        log.error("The script {} is not readable", script);
        log.error("Script opening exception", ioe);
        throw ioe;
    }
    return scriptReader;
}

为了从资源文件中读取脚本,我们使用 SpringClassPathResource类。脚本的名称前面有scripts目录,后面有.js扩展名。其余的是相当标准的,没有什么我们在这本书中没有看到。下一个求值脚本的方法更有趣:

private Object evalScript(String script, Order order, Reader scriptReader)
        throws ScriptException, NoSuchMethodException {
    final Object result;
    final var engine = manager.getEngineByName("JavaScript");
    try {
        engine.eval(scriptReader);
        final var inv = (Invocable) engine;
        result = inv.invokeFunction("isInconsistent", order);
    } catch (ScriptException | NoSuchMethodException se) {
        log.error("The script {} thruw up", script);
        log.error("Script executing exception", se);
        throw se;
    }
    return result;
}

要在脚本中执行该方法,首先,我们需要一个能够处理 JavaScript 的脚本引擎。我们从管理器那里得到了发动机的名字。如果不是 JavaScript,需要检查返回的engine不是null。在 JavaScript 的情况下,解释器是 JDK 的一部分,检查 JDK 是否符合标准将是偏执的。

如果我们想要扩展这个类来处理 JavaScript,以及其他类型的脚本,那么就必须完成这个检查,并且脚本引擎可能应该根据文件扩展名从管理器请求,而我们在这个方法中没有访问这个文件扩展名的权限。但这是未来的发展,不是本书的一部分。

当我们有了引擎,我们必须求值脚本。这将在脚本中定义函数,以便我们以后可以调用它。为了调用它,我们需要一些对象。对于 JavaScript,引擎还实现了一个Invocable接口。并非所有脚本引擎都实现此接口。有些脚本没有函数或方法,也没有可调用的内容。同样,当我们希望不仅允许 JavaScript 脚本,而且还允许其他类型的脚本时,这是以后要做的事情。

为了调用这个函数,我们将它的名称传递给invokeFunction()方法,同时传递我们想要传递的参数。在本例中,这是order。就 JavaScript 而言,两种语言之间的集成已经相当成熟。在我们的示例中,我们可以访问作为参数传递的 Java 对象的字段和方法,并且返回的 JavaScripttruefalse值也被神奇地转换为Boolean。但在有些情况下,访问并不是那么简单:

private void assertResultIsBoolean(String script, Object result) {
    if (!(result instanceof Boolean)) {
        log.error("The script {} returned non boolean", script);
        if (result == null) {
            log.error("returned value is null");
        } else {
            log.error("returned type is {}", result.getClass());
        }
        throw new IllegalArgumentException();
    }
}

该类的最后一个方法检查返回值(可以是任何值,因为这是一个脚本引擎)是否可以转换为一个boolean

需要注意的是,有些功能是在脚本中实现的,这并不能保证应用能够无缝地工作。可能有几个问题,脚本可能会影响整个应用的内部工作。一些脚本引擎提供了保护应用不受坏脚本影响的特殊方法,而另一些则没有。事实上,我们不传递,但给予命令,脚本并不保证脚本不能访问其他对象。使用反射、static方法和其他技术,可以访问 Java 程序中的任何内容。当我们的代码库中只有一个脚本发生变化时,我们的测试周期可能会简单一些,但这并不意味着我们应该盲目地信任任何脚本。

在我们的示例中,让产品的生产者将脚本上传到我们的系统可能是一个非常糟糕的主意。它们可以提供自己的检查脚本,但在部署到系统中之前,必须从安全角度对这些脚本进行检查。如果这是正确的,那么脚本是 Java 生态系统的一个非常强大的扩展,为我们的程序提供了极大的灵活性。

总结

在本章中,我们开发了我们企业应用的订购系统。随着代码的开发,我们遇到了很多新的东西。您了解了注解以及如何通过反射处理它们。虽然没有很强的相关性,但是您学习了如何使用 Lambda 表达式和流来表示比常规循环更简单的几个编程构造。在本章的最后一部分,我们通过从 Java 调用 JavaScript 函数和从 JavaScript 调用 Java 方法,使用脚本扩展了应用。

事实上,有了这些知识,我们已经成熟到了企业编程所需的 Java 级别。这本书其余的主题都是为王牌而写的。但你想成为一个,不是吗?这就是我为什么写剩下的章节。继续读!