解讀:Java 11中的模組感知服務載入器

weixin_33766168發表於2019-01-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\u0026lt;PaymentService\u0026gt; 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\u0026lt;PaymentService\u0026gt; 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 \u0026lt;S\u0026gt; List\u0026lt;S\u0026gt; getAll(Class\u0026lt;S\u0026gt; service) {    List\u0026lt;S\u0026gt; serviceProviders = new ArrayList\u0026lt;\u0026gt;();           // 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(\u0026quot;STRIPE_TOKEN\u0026quot;);    }    @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 \u0026quot;java\u0026quot;    id \u0026quot;com.zyxist.chainsaw\u0026quot; version \u0026quot;0.3.0\u0026quot;    id \u0026quot;io.github.udaychandra.susel\u0026quot; version \u0026quot;0.1.2\u0026quot;}dependencies {    compile \u0026quot;io.github.udaychandra.susel:susel:0.1.2\u0026quot;}

請注意下我們如何把兩個自定義外掛與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(\u0026quot;STRIPE_TOKEN\u0026quot;, \u0026quot;dev_token123\u0026quot;));        ...        // 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開關:

\u0026quot;${JAVA_HOME}/bin/jlink\u0026quot; --module-path \u0026quot;build/libs\u0026quot; \\    --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

相關文章