解讀: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

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