java模块加载器



重要要点

  • Java模块是一个自包含,自描述的组件,它隐藏内部细节并提供接口,类和服务供客户端使用
  • 服务是一组众所周知的接口或类(通常是抽象的)。 服务提供者是服务的具体实现。 Java的ServiceLoader是一种用于加载实现给定服务接口的服务提供者的工具。
  • 可以通过库扩展Java的服务加载机制,以减少样板代码,并提供有用的功能,例如注入服务引用和激活给定的服务提供者。



如果您有机会在其中一个Java项目中使用Java的简单日志记录外观( SLF4J ),那么您就会知道,它允许最终用户插入对java.util之类的日志记录框架的选择。部署时记录(JUL)或logback或log4j。 在开发过程中,通常使用SLF4J API,该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) {
        // charge the customer and return the result.
        ...
        return new Result.Builder()
                .build();
    }
}

接下来,服务提供者应该通过创建提供者配置文件来注册自己,该文件必须存储在服务提供者jar文件的META-INF / services目录中。 配置文件的名称是服务提供商的完全限定的类名称,其中名称的每个组成部分都用句点(。)分隔。 文件本身应包含服务提供者的完全合格的类名,每行一个。 该文件也必须采用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的Module类方法“ addUses()”提供了一种更新模块并在给定服务接口上添加服务依赖性的方法。 该方法是专门为支持Susel之类的库而提供的,该库使用ServiceLoader类代表其他模块来定位服务。 我们可以使用以下方法:

var module = SuselImpl.class.getModule();
module.addUses(PaymentService.class);

如您所见,Susel获得了对其自身模块的引用,并对其进行自我更新以确保ServiceLoader可以看到所请求的服务。 在模块API上调用“addUses()”方法时,我们应该注意一些警告。 首先,如果调用者的模块不是同一模块( “this” ),则抛出IllegalCallerException。 其次,该方法不适用于未命名的模块和自动模块。

我们提到Susel可以定位其他服务并将其注入给定的服务提供商。 Susel通过注释和在构建期间生成的相关元数据来提供此功能。 让我们看一下注释。

@ServiceReference批注用于标记引用类(服务提供者)中的公共方法,Susel将使用该方法来注入指定的服务。 注释采用可选的cardinality参数。 Susel使用基数来确定要注入的服务数量,以及所请求的服务是强制性还是可选性。

public @interface ServiceReference {
    /**
     * Specifies the service cardinality being requested by the referer.
     * Default value is {@link Cardinality#ONE}
     *
     * @return the service cardinality being requested by the referer.
     */
    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开始重复
  • 如果注册了可选的Activate事件,请通过传递全局上下文来调用激活
  • 返回完全加载的服务提供商列表

这是执行上述步骤的高级代码段:

public <S> List<S> getAll(Class<S> service) {
    List<S> serviceProviders = new ArrayList<>();
       
    // Susel's module should indicate the intention to use the given
    // service so that the ServiceLoader can lookup the requested

    // service providers
    SUSEL_MODULE.addUses(service);

    // Pass the application module layer that typically loads Susel
    var iterator = ServiceLoader.load(SUSEL_MODULE.getLayer(), service);
    for (S serviceProvider : iterator) {
        // Load metadata to inject references and activate service
        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());
        // Use the customer service and stripe token to call Stripe
        // service and charge the customer
        ...
        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一起使用。 电锯插件可帮助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) {
        // Ideally config should be loaded from an external source
        Susel.bootstrap(Map.of("STRIPE_TOKEN", "dev_token123"));
        ...
        // Susel will load the Stripe service provider that it finds
        // in its module layer and prepares the service for use by clients
        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 Runtime Environment(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模块系统的模块化和可扩展应用程序。 该库删除了定位和加载服务所需的样板代码。 此外,它使服务提供商的作者能够依赖自动定位并注入给定服务提供商的其他服务。