解读:Java 11 中的模块感知服务加载器

阅读数:7151 2019 年 1 月 10 日

Java 模块是一个自包含、自描述组件,隐藏了内部细节,为客户端使用提供接口、类和服务。Java 的 ServiceLoader 可以用来加载实现给定服务接口程序。Java 的服务加载机制可以通过库进行扩展,以减少样板代码,并提供一些有用的特性。

正文

本文要点

  • Java 模块是一个自包含、自描述的组件,它隐藏内部细节,为客户端使用提供接口、类和服务。
  • 服务是一组我们熟知的接口或类(通常是抽象的)。服务提供程序是服务的具体实现。Java 的 ServiceLoader 是一种用来加载实现了给定服务接口的服务提供程序的工具。
  • Java 的服务加载机制可以通过库进行扩展,以减少样板代码,并提供一些有用的特性,如注入服务引用和激活给定的服务提供程序。

如果有机会在某个 Java 项目中使用 Simple Logging Facade for Java ( SLF4J ),你就会知道,它允许你(最终用户)在部署时插入你选择的日志框架,如 java.util.logging(JUL)、logback 或 log4j。在开发期间,你通常使用 SLF4J API,它提供了一个接口或抽象,你可以使用它来记录应用程序消息。

比如说,在部署期间,你最初选择 JUL 作为你的日志框架,但后来你注意到,日志性能没有达到标准。因为你的应用程序是按照 SLF4J 接口进行编码的,所以你可以很容易地插入高性能日志框架,如 log4j,而不需要修改任何代码及重新部署应用程序。应用程序本质上是一个可扩展的应用程序。它能够通过 SLF4J 在运行时选择类路径上可用的兼容的日志框架。

可扩展应用程序的特定部分可以扩展或增强,而不需要对应用程序的核心代码库进行代码更改。换句话说,应用程序可以通过接口编程和委托工作来定位和加载一个中心框架的具体实现,从而实现松耦合。

Java 为开发人员提供了在不修改原始代码库的情况下设计和实现可扩展应用程序的能力,其解决方案是服务和 ServiceLoader 类——在 Java 版本 6 中引入。SLF4J 使用这种服务加载机制来提供我们前面描述的插件模型。

当然,依赖注入或控制反转框架是达到这种目的的另一种方式。但是,本文将专注于原生解决方案。为了了解 ServiceLoader 机制,我们需要看一些 Java 语境下的定义:

  • 服务:一个服务就是我们所熟知的接口或类(通常是抽象的);
  • 服务提供程序:服务提供程序是服务的具体实现;
  • ServiceLoader:ServiceLoader 是一种用来加载实现了给定服务接口的服务提供程序的工具。

有了这些定义,让我们来看一下如何构建一个可扩展的应用程序。假设一个虚拟的电子商务平台允许客户从一个支付服务提供程序列表中选择要部署在其站点上的服务。平台可以根据支付服务接口进行编码,该接口具有加载所需的支付服务提供程序的机制。开发人员和供应商可以使用一个或多个特定的实现提供支付功能。让我们先定义一个支付服务接口:

复制代码
package com.mycommerce.payment.spi;
public interface PaymentService {
Result charge(Invoice invoice);
}

在电子商务平台启动的时候,我们将使用类似下面这样的代码从 Java 的 ServiceLoader 类请求支付服务:

复制代码
import java.util.Optional;
import java.util.ServiceLoader;
import com.mycommerce.payment.spi;
Optional<PaymentService> loadPaymentService() {
return ServiceLoader
.load(PaymentService.class
.findFirst();
}

在默认情况下,ServiceLoader 的“load”方法使用默认的类加载器搜索应用程序类路径。你可以使用重载的“load”方法传递自定义加载器来实现对服务提供程序的更复杂的搜索。为了使 ServiceLoader 定位服务提供程序,服务提供程序应该实现服务接口——在我们的例子中是 PaymentService 接口。下面是一个支付服务提供程序的例子:

复制代码
package com.mycommerce.payment.stripe;
public class StripeService implements PaymentService {
@Override
public Result charge(Invoice invoice) {
// 收取客户的费用并返回结果
...
return new Result.Builder()
.build();
}
}

接下来,服务提供程序应通过创建一个提供程序配置文件来对自己进行注册,该文件必须保存在 META-INF/services 目录下,这也是保存服务提供程序 jar 文件的目录。配置文件的名称是服务提供程序的完全限定类名,名称的每个部分以句点(.)分割。文件本身应该包含服务提供程序的完全限定类名,每行一个。文件还必须是 UTF-8 编码的。文件中可以包含注释,注释行以井号(#)开始。

在我们的例子中,将 StripeService 注册为服务提供程序,我们必须创建一个名为“com.mycommerce.payment.spi.Payment”的文件,并添加以下行:

复制代码
com.mycommerce.payment.stripe.StripeService

使用上述设置和配置,该电子商务平台就可以在它们变得可用时加载新的支付服务提供程序,而不需要任何代码更改。遵循这个模式,你就可以构建可扩展的应用程序。

现在,随着 Java 9 中模块系统的引入,服务机制已经得到增强,可以支持模块所提供的功能强大的封装和配置。Java 模块是一个自包含、自描述的组件,它隐藏了内部细节,为客户端提供接口、类和服务。

让我们看一下,在新 Java 模块系统的语境下,如何定义和使用服务。使用我们前面定义的 PaymentService 创建相应的模块描述符:

复制代码
module com.mycommerce.payment {
exports com.mycommerce.payment.spi;
}

通过配置其模块描述符,电子商务平台的主模块现在可以根据支付服务接口进行编码:

复制代码
module com.mycommerce.main {
requires com.mycommerce.payment;
uses com.mycommerce.payment.spi.PaymentService;
}

注意,上面的模块描述符中使用了“uses”关键字。我们就是通过它通知 Java 我们需要它使用 ServiceLoader 类来定位和加载支付服务接口的具体实现。在应用程序启动(或稍后)过程中的某个点,主模块将使用类似下面这样的代码从 ServiceLoader 请求支付服务:

复制代码
import java.util.Optional;
import java.util.ServiceLoader;
import com.mycommerce.payment.spi;
Optional<PaymentService> loadPaymentService() {
return ServiceLoader
.load(PaymentService.class
.findFirst();
}

为了让 ServiceLoader 能够定位支付服务提供程序,我们必须遵循一些规则。显然,服务提供程序需要实现 PaymentService 接口。然后,该支付服务提供程序的模块描述符应指定其意图,向客户端提供支付服务:

复制代码
module com.mycommerce.payment.stripe {
requires com.mycommerce.payment;
exports com.mycommerce.payment.stripe;
provides com.mycommerce.payment.spi.PaymentService
with com.mycommerce.payment.stripe.StripeService;
}

如你所见,我们使用“provides”关键字指定这个模块提供的服务。“with”关键字用于指明实现给定服务接口的具体类。注意,单个模块中的多个具体实现可以提供相同的服务接口。一个模块也可以提供多个服务。

到目前为止一切顺利,但是,当我们开始使用这种新的服务机制实现一个完整的系统时,我们很快就会意识到,每次我们需要定位和加载一个服务时,都必须编写样板代码,每次加载服务提供程序时,都必须运行一些初始化逻辑,这使开发人员的工作变得更加繁琐和复杂。

典型的做法是将样板代码重构为实用工具类,并将其添加到应用程序中,作为和其他模块共享的公共模块的一部分。虽然这是个良好的开端,但是,由于 Java 模块系统提供的强大封装和可靠的配置保障,我们的实用工具方法将无法使用 ServiceLoader 类加载服务。

由于公共模块不知道给定的服务接口,其模块描述符中未包含“uses”子句,所以 ServiceLoader 不能定位实现服务接口的提供程序,尽管它们可能出现在模块路径中。不仅如此,如果你将“uses”子句添加到公共模块描述符中,就违背了封装的本意,更糟的是引入循环依赖。

我们将构建一个名为 Susel 的自定义库来解决上述问题。该库的主要目标是帮助开发人员构建利用原生 Java 模块系统构建模块化、可扩展的应用程序。该库将消除定位和加载服务所需的样板代码。此外,它允许服务提供程序编写者可以依赖于其他服务,而这些服务会自动定位并注入到给定的服务提供程序。Susel 还将提供一个简单的激活生命周期事件,服务提供程序可以使用该事件对自身进行配置并运行一些初始化逻辑。

首先,让我们看一下,Susel 如何解决因模块描述符中没有明确的“uses”子句而无法定位服务的问题。Java 的模块类方法“addUses()”提供了一种方法来更新模块,并添加一个依赖于给定服务接口的服务。该方法专门用于支持像 Susel 这样的库,它们使用 ServiceLoader 类来代表其他模块定位服务。下面的代码展示了我们如何使用这个方法:

复制代码
var module = SuselImpl.class.getModule();
module.addUses(PaymentService.class);

如你所见,Susel 有到自己模块的引用,可以通过自我更新来确保 ServiceLoader 可以看到所请求的服务。在模块 API 上调用“addUses()”方法时有几个注意事项。首先,如果调用者模块是不同的模块(“this”),就会抛出 IllegalCallerException 异常。其次,该方法不适用于匿名模块和自动模块。

我们已经提到过,Susel 可以定位并将其他服务注入到给定的服务提供程序。Susel 借助构建时生成的注解和相关元数据提供了这项功能。让我们看一下注解。

@ServiceReference 注解用于标记引用类(服务提供者)中的公共方法,Susel 将使用它注入指定的服务。注解接受一个可选的 cardinality 参数。Susel 使用 Cardinality 来决定要注入的服务的数量,以及请求的服务是必须的还是可选的。

复制代码
public @interface ServiceReference {
/**
* 指定引用者请求的服务 cardinality
* 默认值是 {@link Cardinality#ONE}
*
* 返回引用者请求的服务 cardinality
*/
Cardinality cardinality() default Cardinality.ONE;
}

@Activate 注解用于标记服务提供程序类中的公共方法,Susel 将使用该方法来激活服务提供程序的实例。和该事件挂钩到的典型用例是一些重要方面的初始化,如服务提供程序的配置。

复制代码
public @interface Activate {}

Susel 提供了一个工具,它使用反射来构建给定模块的元数据。该工具会读取模块描述符识别出服务提供程序,对于每个服务提供程序,该工具会扫描带有 @ServiceReference 和 @Activate 注解的方法,并创建一个元数据条目。然后,该工具将元数据项保存到一个名为 susel.metadata 的文件中。该文件位于 META-INF 文件夹下,会和 jar 文件一起打包。现在,在运行时,当模块向 Susel 请求实现了特定服务接口的服务提供程序时,Susel 会执行以下步骤:

  • 调用 Susel 模块的 addUses()方法使 ServiceLoader 定位请求的服务;
  • 调用 ServiceLoader 获取服务提供程序迭代器;
  • 对于每个服务提供程序,加载并获取包含服务提供程序的模块的元数据;
  • 定位与服务提供程序相对应的元数据项;
  • 对于元数据项中指定的每个服务引用从步骤 1 开始重复上述过程;
  • 如果注册了可选的激活事件,则通过传递全局上下文来触发激活;
  • 返回完全加载的服务提供程序的列表。

下面是一个执行上述步骤的高级代码片段:

复制代码
public <S> List<S> getAll(Class<S> service{
List<S> serviceProviders = new ArrayList<>();
// Susel 的模块应该指明使用给定服务的意图,
// 以便 ServiceLoader 可以查找所请求的服务提供程序
SUSEL_MODULE.addUses(service);
// 传递通常加载 Susel 的应用程序模块层
var iterator = ServiceLoader.load(SUSEL_MODULE.getLayer(), service);
for (S serviceProvider : iterator) {
// 加载元数据注入引用并激活服务
prepare(serviceProvider);
serviceProviders.add(serviceProvider);
}
return serviceProviders;
}

请注意下我们如何使用 ServiceLoader 类中的重载方法 load()来传递应用程序模块层。这种重载方法(在 Java 9 中引入)会为给定的服务接口创建一个新的服务加载器,并从给定模块层及其祖先的模块中加载服务提供程序。

值得一提的是,为了避免在应用程序运行时进行大量反射,在定位和加载服务提供程序时,Susel 会使用元数据文件来标识服务引用和激活方法。还有一点要注意,虽然 Susel 具有 OSGI(Java 生态系统中一个可用的成熟而强大的模块系统)和 / 或 IoC 框架的一些特性,但 Susel 的目标是通过原生 Java 模块系统增强服务加载机制,减少定位和调用服务所需的样板代码。

让我们看一下,如何在我们的支付服务示例中使用 Susel。假设我们使用 Stripe 实现了一个支付服务。下面的代码片段展示了 Susel 的注解:

复制代码
package com.mycommerce.payment.stripe;
import io.github.udaychandra.susel.api.Activate;
import io.github.udaychandra.susel.api.Context;
import io.github.udaychandra.susel.api.ServiceReference;
public class StripeService implements PaymentService {
private CustomerService customerService;
private String stripeSvcToken;
@ServiceReference
public void setCustomerService(CustomerService customerService) {
this.customerService = customerService;
}
@Activate
public void activate(Context context) {
stripeSvcToken = context.value("STRIPE_TOKEN");
}
@Override
public Result charge(Invoice invoice) {
var customer = customerService.get(invoice.customerID());
// 使用 customer 服务和 stripe token 来调用 Stripe
// 服务,收取客户的费用
...
return new Result.Builder()
.build();
}
}

为了在构建阶段生成元数据,我们必须调用 Susel 的工具。有一个现成的 gradle插件可以自动完成这个步骤。让我们看一个 build.gradle 示例文件,它会自动配置该工具以便在构建阶段调用。

复制代码
plugins {
id "java"
id "com.zyxist.chainsaw" version "0.3.0"
id "io.github.udaychandra.susel" version "0.1.2"
}
dependencies {
compile "io.github.udaychandra.susel:susel:0.1.2"
}

请注意下我们如何把两个自定义插件与 Java 模块系统及 Susel 搭配使用。 chainsaw 插件帮助 gradle 构建模块 jar 包。Susel 插件帮助创建和打包关于服务提供程序的元数据。

最后,让我们来看一个代码片段,在应用程序启动期间引导 Susel 并从 Susel 检索支付服务提供程序:

复制代码
package com.mycommerce.main;
import com.mycommerce.payment.spi.PaymentService;
import io.github.udaychandra.susel.api.Susel;
public class Launcher {
public static void main(String... args) {
// 在理想情况下,配置应该从外部源加载
Susel.bootstrap(Map.of("STRIPE_TOKEN""dev_token123"));
...
// Susel 将加载它在其模块层中发现的 Stripe 服务提供程序
// 并准备好该服务供客户端使用
var paymentService = Susel.getPaymentService.class);
paymentService.charge(invoice);
}
}

现在,我们可以使用 gradle 构建模块化 jar 并运行示例应用程序。下面是要运行的命令:

复制代码
java --module-path :build/libs/:$JAVA_HOME/jmods \
-m com.mycommerce.main/com.mycommerce.main.Launcher

为支持 Java 模块系统,现有的命令行工具(如“Java”)添加了新的选项。让我们看一下,可以在上面的命令中使用的新选项:

  • -p 或–module-path 用于告诉 Java 查看包含 Java 模块的特定文件夹;
  • -m 或–module 用于指定用于启动应用程序的模块和主类。

当你开始使用 Java 模块系统开发应用程序时,你可以利用模块解析策略来创建特别的 Java 运行时环境(JRE)发行版。这些自定义的发行版或运行时镜像只包含运行应用程序所需的模块。Java 9 引入了一个名为 jlink 的新组装工具,可用于创建自定义运行时镜像。不过,我们应该知道,与 ServiceLoader 的运行时模块解析相比,它的模块解析是如何实现的。由于服务提供程序几乎总是被认为是可选的,jlink 不能根据 “uses” 子句自动解析包含服务提供程序的模块。jlink 提供了几个选项帮助我们解析服务提供程序模块:

  • –bind-services 用于让 jlink 解析所有服务提供程序及其依赖;
  • –suggest-providers 用于让 jlink 提供模块路径中实现了服务接口的提供程序。

建议使用–suggest-providers,只添加那些对你的特定用例有意义的模块,而不是盲目地使用–bind-services 添加所有可用的提供程序。让我们借助我们的支付服务示例实际地看一下–suggest-providers 开关:

复制代码
"${JAVA_HOME}/bin/jlink" --module-path "build/libs" \
--add-modules com.mycommerce.main \
--suggest-providers com.mycommerce.payment.PaymentService

上述命令的输出类似下面这个样子:

复制代码
Suggested providers:
com.mycommerce.payment.stripe provides
com.mycommerce.payment.PaymentService used by
com.mycommerce.main

有了这些知识,你现在就可以创建自定义镜像并打包运行应用程序和加载所需服务提供程序所需的所有模块。

小结

本文描述了 Java 服务加载机制以及为了支持原生 Java 模块系统而对其进行的更改,介绍了名为 Susel 的试验性库,它可以帮助开发人员利用原生 Java 模块系统构建模块化、可扩展的应用程序。该库消除了定位和加载服务所需的样板代码。此外,它允许服务提供程序编写者依赖于其他可以自动定位并注入给定程序服务。

关于作者

Uday Tatiraju 是 Oracle 首席工程师,有十多年电子商务平台、搜索引擎、后端系统、Web 和移动编程经验。

查看英文原文: Super Charge the Module Aware Service Loader in Java 11

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论