前文《模組化 Java:靜態模組化》討論如何建構 Java 模組並作為單一 JAR 部署。範例給出 client 和 server bundle (同一 VM),client 透過工廠方法尋找 server。工廠實例化已知類別,當然也可用反射獲取服務實現;Spring 大量運用此技術綁定 Spring 物件。
討論動態服務前,需回顧類路徑,因標準 Java 程式碼和模組化 Java 程式碼差異在於依賴在運行時如何綁定。之後簡單討論類別垃圾回收;若已熟悉可跳過。
一般 Java 程式只有一個 classpath (啟動應用程式用的)。通常在命令列用 -classpath 指定,或透過 CLASSPATH 環境變數設定。Java 類別載入器在運行時解析類別掃描此路徑,無論靜態 (編譯進程式碼) 或動態 (用反射及 class.forName())。但運行時可用多個類別載入器;Jetty、Tomcat 等 Web 應用引擎使用多個類別載入器支援應用熱部署。
OSGi 中每個 bundle 有自己的類別載入器。需被其他 bundle 存取的類別委派給其他 bundle 的類別載入器。因此,傳統應用中來自 logging 函式庫、client 和 server JAR 的類別由同一類別載入器載入,但 OSGi 模組系統中各自由自己的類別載入器載入。
結果:一個 VM 可能有多個類別載入器,存在名稱相同但不同 Class 物件。同一 VM 中 com.infoq.example.App 類別不同版本可由 com.infoq.example bundle v1 和 v2 同時輸出。Client bundle v1 用該類別 v1,client v2 用 v2。這在模組化系統常見:同一 VM 中部分程式碼需載入函式庫舊版,另一 bundle 中較新程式碼需新版。幸好 OSGi 管理依賴傳遞,確保不再出現不相容類別引發問題。
每個類別有對其類別載入器的引用。若想從不同 bundle 存取這些類別,不僅要有對類別實例引用,還要有對類別載入器引用。當一個 bundle 持有另一個 bundle 的類別,也會將該 bundle 固定在記憶體中。前文範例中 client 被固定到 server。
靜態世界裡,是否將類別固定到其他類別 (或函式庫) 無所謂;因無變化。但動態世界裡,運行時將函式庫或工具替換成新版成為可能。聽起來複雜,但可熱部署應用的 Web 應用引擎早期出現 (如 Tomcat 1999 年釋出)。每個 Web 應用程式綁定到 Servlet API 某版本,停止時載入該 Web 應用的類別載入器也廢棄。重新部署時建立新類別載入器,新版類別由其載入。只要 servlet 引擎未保持對舊版應用引用,這些類別像其他 Java 物件被垃圾回收器回收。
非所有函式庫都意識到 Java 程式碼可能存在類別洩漏,如記憶體洩漏。典型例子 Log4J addAppender() 呼叫,執行後將類別綁定在 Log4J bundle 生命週期上。即使 bundle 停止,Log4J 仍保持對 appender 引用,持續發送日誌事件 (除非 bundle 停止時適當呼叫 removeAppender())。
為完全動態,需有機制查找服務而非持久持有 (免 bundle 停止)。透過簡單 Java 介面和 POJO 實現,即熟知的 services (非 WS-DeathStar 或 XML 底層架構;只是普通 Java 物件)。
典型工廠實現用從 properties 檔獲取類別名稱,再用 Class.forName() 實例化。OSGi 不同,維護 服務註冊器,實為類別名稱與服務映射列表。OSGi 系統可用 context.getService(getServiceReference("java.sql.Driver")) 而非 class.forName("com.example.JDBCDriver") 獲取 JDBC 驅動。這讓 client 程式碼解脫,不需知道特定客戶端實現;反而可在運行時綁定任何可用驅動程式。移植到不同資料庫伺服器簡單:只需停止一模組啟動新模組;client 甚至無需重啟、無需改變配置。
因 client 只需知曉所需服務 API (基本都是介面,雖 OSGi 規範允許其他類別)。上述情況介面名 java.sql.Driver;回傳介面實例是具體資料庫實現 (無需了解哪些類別,編碼在那裡)。此外,若服務不可用 (資料庫不存在或暫時停掉),方法回傳 null 說明服務不可用。
為完全動態,回傳結果不應快取。換句話說,每當需要服務需重新呼叫 getService。框架底層執行快取操作,無太大效能問題。但重要允許資料庫服務線上替換成新服務,無快取程式碼下次呼叫時 client 透明綁定新服務。
為證明此點,建立縮短 URL OSGi 服務。想法:服務接收長 URL (如 https://www.infoq.com/articles/modular-java-what-is-it) 轉換為短 URL (如 https://tr.im/EyH1)。服務廣泛用於 Twitter 等網站,把長 URL 轉短便簽名背後也寫得下。甚至《新科學家》、《Macworld》等雜誌用短 URL 列印媒體連結。
實現服務需:
雖無禁止將這些放同一 bundle,但分別放不同 bundle。即使同一 bundle,最好讓 bundle 透過服務通訊,彷彿處於不同 bundle;方便與其他服務提供者整合。
將縮短服務介面與實現 (或 client) 分開放單獨 bundle 很重要。介面代表 client 和 server 間 共享程式碼,各 bundle 都會載入。因此每個 bundle 實際固定到該介面特定版本,所有服務共同生命週期。將介面放單獨 bundle (整個 OSGi VM 生命週期運行),client 可自由變化。若介面放在某服務實現 bundle,服務變化後無法重新連接 client。
shorten 介面 manifest 和實現:
Bundle-ManifestVersion: 2
Bundle-Name: Shorten
Bundle-SymbolicName: com.infoq.shorten
Bundle-Version: 1.0.0
Export-Package: com.infoq.shorten
---
package com.infoq.shorten;
public interface IShorten {
public String shorten(String url) throws IOException;
}
建立單一介面 com.infoq.shorten.IShorten 的 bundle com.infoq.shorten 並輸出給 client。參數是 URL,回傳唯一壓縮版 URL。
實現相對有趣。雖近期縮短名稱應用多起,但祖師爺是 TinyURL.com (諷刺地 https://tinyurl.com 實際可壓縮更短 https://ow.ly/AvnC)。流行有 ow.ly、bit.ly、tr.im 等。非全面介紹或背書,實現可用其他同類服務。使用 TinyURL 和 Tr.im 因兩者均可匿名基於 GET 提交,易實現。
每種實現很小;以 URL 為參數回傳新壓縮文字:
package com.infoq.shorten.tinyurl;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import com.infoq.shorten.IShorten;
public class TinyURL implements IShorten {
private static final String lookup = "https://tinyurl.com/api-create.php?url=";
public String shorten(String url) throws IOException {
String line = new BufferedReader(
new InputStreamReader(
new URL(lookup + url).openStream())).readLine();
if(line == null)
throw new IllegalArgumentException("Could not shorten " + url);
return line;
}
}
Tr.im 實現類似,只需用 https://api.tr.im/v1/trim_simple?url= 替代 lookup 值。兩實現源碼分別在 com.infoq.shorten.tinyurl 和 com.infoq.shorten.trim bundle。
完成縮短服務實現,如何讓其他程式存取?需將實現註冊為 OSGi 框架服務。BundleContext 類別 registerService(class, instance, properties) 方法定義服務供後用,通常在 bundle start() 呼叫期間呼叫。如前文所述,必須定義 BundleActivator。實現該類別後,將 Bundle-Activator 放入 MANIFEST.MF 以便找到實現:
Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: TinyURl
Bundle-SymbolicName: com.infoq.shorten.tinyurl
Bundle-Version: 1.0.0
Import-Package: com.infoq.shorten,org.osgi.framework
Bundle-Activator: com.infoq.shorten.tinyurl.Activator
---
package com.infoq.shorten.tinyurl;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import com.infoq.shorten.IShorten;
public class Activator implements BundleActivator {
public void start(BundleContext context) {
context.registerService(IShorten.class.getName(), new TinyURL(), null);
}
public void stop(BundleContext context) { }
}
雖 registerService() 第一參數接收字串,用 "com.infoq.shorten.IShorten" 也可,但最好用 class.class.getName() 形式,重構包或改變類名時編譯期發現問題。用字串錯誤重構只在運行時知問題。
第二參數是實例本身。分開因可將同一服務實例輸出給多個服務介面 (需帶版本 API 有用,可演進介面)。一個 bundle 輸出同類型多服務也可能。
最後參數是服務屬性。允許給服務加額外中繼資料註解,如標註優先級表明服務相對其他服務重要性,或調用者關心資訊 (功能描述、廠商)。
bundle 一啟動縮短服務即可用。bundle 停止框架自動取消服務註冊。若想自己取消註冊 (如對錯誤代碼、網路介面不可用響應) 也容易 (用 context.unregisterService())。
服務啟動運行後,client 可存取。若運行 Equinox,用 services 命令列出所有已安裝服務及註冊者:
{com.infoq.shorten.IShorten}={service.id=27}
Registered by bundle: com.infoq.shorten.trim-1.0.0 [1]
No bundles using service.
{com.infoq.shorten.IShorten}={service.id=28}
Registered by bundle: com.infoq.shorten.tinyurl-1.0.0 [2]
No bundles using service.
調用服務處理 URL 前,client 需解析服務。需獲取服務參考,可查看服務內部屬性,再用其獲取感興趣服務。但需能重複處理相同及不同 URL,整合到 Equinox 或 Felix shell。實現:
package com.infoq.shorten.command;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceReference;
import com.infoq.shorten.IShorten;
public class ShortenCommand {
protected BundleContext context;
public ShortenCommand(BundleContext context) { this.context = context; }
protected String shorten(String url) throws IllegalArgumentException, IOException {
ServiceReference ref = context.getServiceReference(IShorten.class.getName());
if(ref == null) return null;
IShorten shorten = (IShorten) context.getService(ref);
if(shorten == null) return null;
return shorten.shorten(url);
}
}
shorten 方法呼叫時查找服務參考獲取服務物件。將服務物件賦值給 IShorten 物件,與已註冊服務交互。注意都在同一 VM 發生;無遠程呼叫、無強制異常、無參數序列化;只是 POJO 與 POJO 對話。與最初 class.forName() 範例唯一差別:如何獲得 shorten POJO。
在 Equinox 和 Felix 使用此服務需樣板程式碼。定義 manifest 時可在 Felix 和 Equinox 命令列聲明可選依賴,任一安裝後即可運行。(更好方案部署為獨立 bundles 移除選項;但 bundle 不存在 activator 會失敗無法啟動)。Equinox 和 Felix 特定命令源碼在 com.infoq.shorten.command bundle。
安裝命令 client bundle 後獲得新命令 shorten,透過 OSGi shell 呼叫。運行命令:執行 java -jar equinox.jar -console -noExit 或 java -jar bin/felix.jar,安裝 bundle 後使用:
java -jar org.eclipse.osgi_* -console -noExit
osgi> install file:///tmp/com.infoq.shorten-1.0.0.jar
Bundle id is 1
osgi> install file:///tmp/com.infoq.shorten.command-1.0.0.jar
Bundle id is 2
osgi> install file:///tmp/com.infoq.shorten.tinyurl-1.0.0.jar
Bundle id is 3
osgi> install file:///tmp/com.infoq.shorten.trim-1.0.0.jar
Bundle id is 4
osgi> start 1 2 3 4
osgi> shorten https://www.infoq.com
https://tinyurl.com/yr2jrn
osgi> stop 3
osgi> shorten https://www.infoq.com
https://tr.im/Eza8
注意:運行時 TinyURL 和 Tr.im 服務均可用,但一次只能用一種服務。可設 服務級別 (service ranking),整數範圍 Integer.MIN_VALUE 到 Integer.MAX_VALUE,服務初始註冊時給 Constants.SERVICE_RANKING 賦值。值越大級別越高,需要服務時回傳最高級別服務。無服務級別 (預設 0) 或多服務級別相同,用自動分配 Constants.SERVICE_PID,可能任意順序一服務。
另一問題:停止一服務時,client 自動失效轉移到列表下一服務。每次命令執行獲取 (當前) 服務處理 URL 壓縮需求。運行期間服務提供者變化不影響命令使用,只要有需求時有服務在。(若停止所有服務提供者,服務查找回傳 null,列印相應錯誤訊息 — 好程式碼應確保程式能預防服務參考為 null 情況)。
除每次查詢服務外,可用 ServiceTracker 代替。跳過中間獲取 ServiceReference 步驟,但構造後需呼叫 open() 開始追蹤服務。
對 ServiceReference 可呼叫 getService() 獲取服務實例。waitForService() 在服務不可用時阻塞一段時間 (依指定 timeout,0 則永遠阻塞)。重新實現 shorten 命令:
package com.infoq.shorten.command;
import java.io.IOException;
import org.osgi.framework.BundleContext;
import org.osgi.util.tracker.ServiceTracker;
import com.infoq.shorten.IShorten;
public class ShortenCommand {
protected ServiceTracker tracker;
public ShortenCommand(BundleContext context) {
this.tracker = new ServiceTracker(context, IShorten.class.getName(), null);
this.tracker.open();
}
protected String shorten(String url) throws IllegalArgumentException, IOException {
try {
IShorten shorten = (IShorten) tracker.waitForService(1000);
if (shorten == null) return null;
return shorten.shorten(url);
} catch (InterruptedException e) { return null; }
}
}
用 ServiceTracker 常見問題:構造後忘記呼叫 open()。還須在 MANIFEST.MF 內部引入 org.osgi.util.tracker 套件。
用 ServiceTracker 管理服務依賴通常被認為管理關係好方法。無服務情況下查找已輸出服務稍複雜:如 ServiceReference 在解析為服務前突然不可用。存在 ServiceReference 原因:相同實例可在多 bundle 間共享,可用來基於某些標準 (手動) 過濾服務。也可用過濾器限制可用服務集合。
服務註冊時可一起註冊服務屬性。大多情況屬性可為 null,但可提供 OSGi 特定或 URL 通用屬性。如想給服務分級區分優先級,註冊 Constants.SERVICE_RANKING (代表優先級數值) 作為初始註冊一部分。可能想放 client 想知中繼資料,如服務主頁、條款連結。修改 activator:
public class Activator implements BundleActivator {
public void start(BundleContext context) {
Hashtable properties = new Hashtable();
properties.put(Constants.SERVICE_RANKING, 10);
properties.put(Constants.SERVICE_VENDOR, "https://tr.im");
properties.put("home.page", "https://tr.im");
properties.put("FAQ", "https://tr.im/website/faqs");
context.registerService(IShorten.class.getName(), new Trim(), properties);
}
...
}
服務級別自動由 ServiceTracker 等物件管理,但可用特定條件過濾。Filter 由 LDAP 風格過濾器改編,用前綴表示法執行多重過濾。雖多數想提供類別名稱 (Constants.OBJECTCLASS),但可對值檢驗 (含限制連續變量取值範圍)。Filter 透過 BundleContext 建立;想追蹤實現 IShorten 介面服務並定義 FAQ:
public class ShortenCommand {
public ShortenCommand(BundleContext context) {
Filter filter = context.createFilter("(&" +
"(objectClass=com.infoq.shorten.IShorten)" +
"(FAQ=*))");
this.tracker = new ServiceTracker(context, filter, null);
this.tracker.open();
}
...
}
定義服務時可被過濾或設定的標準屬性:
service.ranking - 整數,區分服務優先級service.id - 整數,服務註冊時框架自動設定service.vendor - 字串,表明服務出自誰手service.pid - 字串,服務持久識別碼service.description - 服務描述objectClass - 介面列表,服務註冊在哪些介面下過濾器語法定義於 OSGi 核心規範 3.2.7 節 "Filter syntax"。基本允許等於 (=)、約等於 (~=)、大於等於、小於等於、子字串比較等操作符。括號分組過濾器,結合 &、|、! 分別代表 and、or、not。屬性名不區分大小寫,值可能 (若不用 ~= 比較)。* 通配符支援子字串匹配,如 com.infoq.*.*。
本文介紹如何用服務進行 bundle 間通訊,替代直接類別引用。服務讓模組系統動態化,應對運行時服務變化。接觸服務級別、屬性及過濾器,使用標準服務追蹤器更容易存取服務並追蹤變化服務。下一部分介紹如何用聲明式服務讓服務編寫更容易。