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

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.get(PaymentService.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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章