對模塊和元數據進行打包
我們這個模塊系統需要一種方法來對模塊的內容以及描述導入和導出的元數據進行打包,將其包括到一個可部署的單元中。
Java 已經有了標准的部署單元:JAR 文件。JAR 文件可能並不算一種非常成熟的模塊,但對於移動大塊的編譯代碼還是不錯的,所以我們並不需要創建新的東西。那麼現在的唯一問題是,將元數據(即導入和導出列表、版本等等)放在哪裡?
看起來配置格式強烈地受到一時潮流的影響;如果我們是在 2000 年到 2006 年期間設計這個模塊系統,我們很可能會選擇將元數據放到 JAR 文件下的某個 XML 文件中這種方式能夠工作,但會遇到許多問題:對於流程,XML 文件並不是特別有效率,尤其是我們必須在 JAR 文件的某個地方才能找到它,而且在進行語法分析之前還要對其進行解壓。JAR 文件是一個 ZIP 壓縮包,所以要找到某個特定文件,意味著必須讀取末端,找到用於跟蹤記錄的中央目錄,然後再跳轉到該目錄指定的分支上。換句話說,通常不得不讀取整個 JAR 文件,對於需掃描大型目錄的工具,如果這個目錄下有很多模塊,這個過程將變得非常痛苦。比如,搜索某個可用的模塊,以滿足某個依賴關系。
另外 XML 幾乎不能人工編輯。為了正確的編輯這種文件,我們需要使用特定的編輯根據。
另一方面,如果是在 2006年之後設計這個模塊系統,我們的第一個想法會是使用 Java 注釋(annotation)。如果使用適當,我非常喜歡注釋,將類似 @Export(version="1.0.0") 的東西放到 Java 源文件中的包聲明上,很明顯比在單獨文件中對其進行維護要更有吸引力。不過,等一下……在包的每個源文件中,包聲明都會重復一次;難道我們也必須在所有源文件中加入注釋?
為了解決這個問題,Java 語言規范(JLS)建議使用一個名為“package-info.java” 特定源文件。但對於不屬於任何特定包的元數據怎麼處理呢?比如導入包的列表或模塊本身的名稱和版本。Java 語言規范建議我們需要使用另一個特定源文件,使用類似“module-info.Java”名稱。
到目前一切順利,現在讓我們看看如何對模塊進行處理。
這些特定的源文件將在 package-info.class 和 module-info.class 中被編譯為字節碼,這樣就不需要打開 ZIP 壓縮的 JAR 文件來查看元數據了。所有模塊掃描工具都必須對整個模塊系統進行讀取,而且也必須能夠處理字節碼。運行時模塊系統自身也必須立即為模塊常見一個類加載器,用於讀取它的元數據;結果是,如果我們能夠將類加載器的創建推遲到真正從模塊中加載某個類那個時刻,就可以消除大量的優化工作。
已經發生的事實是,OSGi 的設計的確是在 2000 年之前,所以它的確選擇了這些方案中的其中之一。回頭看看 JAR 文件規范,答案自動浮現:META-INF/MANIFEST.MF 是應用程序專用元數據的標准位置。在規范中這樣寫道:“忽略不可理解的屬性。這類屬性可能包含應用程序所用的特定部署新型。”
MANIFEST.MF 專為提高流程的效率而設計,而且它至少比 XML 更快。某種長度上,它是可讀的;至少與 XML 一樣可讀,很明顯比編譯的 Java 字節碼更具有可讀性。此外,標准的 jar 命令行工具通常將 MANIFEST.MF 放到 JAR 文件的第一項中,所以為了獲取元數據,工具只需掃描文件中的前幾百個字節。
令人遺憾的是 MANIFEST.MF 並不完美。其一,由於規則要求每行不超過 72 個字節,手工編寫相對困難,考慮到單個 UTF-8 字符為 1-6 個字節,這種規則會導致一些問題。一個更好的方式是利用另一格式的模板來生成 MANIFEST.MF。Bnd 工具是這樣的,Maven 的 Bundle Pulin 和 SpringSource 的 Bundlor 也是如此。
事實上,Bnd 甚至包括對於處理注釋的實驗式的支持,比如 @Exporton 源代碼注釋。這樣我們將能夠獲得來自2個方面的好處:注釋的便利性,以及 MANIFEST.MF 的效率和運行時可讀性/工具性。
後期綁定
模塊拼圖的最後一塊是部署到接口的後期綁定。我認為這是模塊化一個至關重要的功能,雖然某些模塊系統對此完全忽略,或者認為它不屬於模塊化這個范圍。
人們都知道,Java 中的接口會破壞功能提供者和使用方之間的耦合性。定義一個接口,其作用相對於使用方和提供方的合同,如何一方都不需直接獲得對方的信息,這樣我們就可以將它們放到不同的模塊中,而這些模塊之間不存在互相的依賴關系。而是每一個模塊對於接口存在依靠性,我們可以選擇囧這個接口放在第三個模塊中。唯一的問題是如何為使用方類提供接口實例,而最常見的答案是使用依賴注入(Dependency Injection,縮寫為DI),比如 Spring 或 Guice。
因此,為了完成我們的模塊系統,只需使用現有的 DI 框架即可。畢竟我們追求的簡潔性,聲明一個問題不屬於我們處理的范圍,讓別人來解決,沒有什麼比這個還簡單。但是,這種方式並不是非常令人滿意,因為 DI 框架事實上也需要知道模塊的邊界。傳統的 DI 使用方式的問題在於它會創建巨大的中心化配置,這個配置會對所有模塊產生影響。Peter KrIEns 將這一問題稱為“全能類”(God Class)問題,在這個問題中,一個組件了解每個模塊的所有內容,並要求所有模塊對其進行綁定(作為一個無神論者,我認為這個不可能做到,但即便你是有神論者,我肯定你也同意除了當前已存在的上帝之外,我們不應再去制造更多神)。這些全能類(或 XML 配置)非常脆弱,難於維護,否定了將代碼劃分到模塊中所帶來的大多數好處。
我們應該尋找一種去中心化的方法。不是讓全能類告訴我們去做什麼,我們可以假設,每個模塊可能常見對象並將它們發布到某些地方,而其他模塊可以找到它們。我們將這些發布的對象成為“服務”,而它們發布的地方稱為“服務寄存器”。有關服務,最重要的信息是它進行部署的接口,所以我們可以將它作為最初的注冊碼。現在,一個模塊,如果需要找到特定接口的實例,只需查詢寄存器,看看當時提供哪些服務。寄存器本身仍然是位於任何模塊之外的中性化組件,但它不是全能的,而是更像一個共享黑板。
我們不需要放棄 DI,事實上它還非常有用:現有的 DI 框架可用來向其他服務中注入服務,以及將某些對象發布為服務。DI 框架不在指揮整個系統,相反它只是在單個模塊中的部署的應用。我們甚至可以使用多個 DI 框架,比如在同一個應用程序中同時使用 Spring 和 Guice,當想要集成第三方組件而這個組件使用的框架不是我們所選擇的那個時,這是非常有用的。最後,服務寄存器為發布和查詢提供可編程的 API 接口,但只能用於低階工作,如部署一個新的 DI 框架。
總結
希望以上的泛泛而論能夠解釋為什麼OSGi 會是現在這個樣子;從某種意義上說,這是一種技術的進化。人們將會繼續抱怨OSGi 太復雜,但我認為任何存在的復雜性都是必要的,用於解決我以上描述的難題。
當然它並不是完美的。比如,版本控制還可以進行改善,尤其是對於那些版本方案非常奇怪的第三方庫。為版本編號賦予一定的意義,仍然是正確的做法,但為了對版本和 API 兼容性進行管理,還需要更多的協助工具。還有傳統的庫,仍然在危險的假設一個扁平化系統類路徑的存在。按照我的觀點,任何在類名稱中使用字符串或調用 Class.forName() 來獲得對象的庫都是錯誤的,因為它假設所有類對於模塊都是可見的,而在任何類型的模塊化系統中,這都是不正確的。很遺憾,這些問題還不能在一夜之間完全解決,所以處理這些破損的庫,我們需要一些策略。不過處理這些問題需要一種不同的方式,從而對於其他人來說,不至於破壞模塊化的規則。