在我們這個模塊系統中,我們選擇的解決方式是允許模塊僅“導出”其內容的一部分。如果模塊中某些部分是非導出的,那麼對於其他模塊就是不可見的。但默認導出哪些內容?除了某些明顯需要隱藏的部分,我們應該導出所有內容嗎?或者除了那些明顯需導出的部分,我們應該隱藏所有其他內容?選擇後者看起來能夠到來更好的透明度:我們可以很方便查看導出列表,確定那些可見的部分,即模塊的“表面部分”。
請注意,我目前還沒指定具體導出什麼內容,這是一個需要仔細考慮的問題。
導出的反面是什麼?當然是導入。一個模塊想要使用其他模塊中代碼可以從後者進行導入。現在我們有了另一個選擇……我們應該導入另一個模塊導出的所有內容嗎?或者只導入我們所需的那部分?同樣我們還是選擇後者,因為它會帶來更好的透明度:重要的是我們導入了什麼而不是從哪裡導入。
與購物行為進行類比
關於導入的話題非常重要,所以這裡次岔開一下話題,讓我們看一個有點搞笑又有點誇張的購物行為。
我妻子和我的購物方式是不同的。我認為購物是一件麻煩的瑣事。每當不得不去買東西時,我就找到一家商店(或者一組商店),那裡有我需要的東西,我只買我需要的商品,買到之後回家。只要能買到我需要的東西,我不關心是從哪家商店買到的。
而我妻子去了一家商店,那家商店買什麼她就買什麼。
很明顯我覺得我的購物方式更好,因為我妻子無法控制她能買到什麼東西。如果她常去的一家商店更換了貨櫃上的商品,那她買回來的將是另外一些東西。當然很多東西並不是她需要的,而且她真正需要的又沒有買到。
更糟糕的是,有時她買回來的東西並不能獨自使用,因為還需要其他東西,比如電池。所以她不得不再次去商店裡買電池,同樣這次她會買下電池商店裡出售的各種電池。再進一步假設,從電池商店裡買到的某樣東西還依靠其他東西才能使用,所以她又跑去另一家商店,僅僅是為了讓某些商品能夠正常工作,而這些商品從最初就不是我們所需要的。這個問題被稱為“扇出”(fan-out)。
通過這個購物類比,相信你對模塊系統將有一個更清晰的概念。這種非理智的購物行為等同於這樣一個系統:我們申明了對某個模塊的依靠性,而這個系統強制我們從該模塊導入所有內容。當進行導入時,應導入所有我們實際需要的內容,而不管它來自哪裡,同時忽略其他所有內容,可能內容只是碰巧位於它的包內。使用 Maven 構建工具時我們遇到這個尖銳的“扇出”問題,這個工具僅提供整體模塊的依賴性(即“買下整個商店”方式)。其結果是,在編譯 200 個字節的源文件之前,必須下載整個互聯網的內容。
導入和導出的粒度(granularity)
從模塊導入和導出內容的粒度應該是怎樣的?由於存在各種嵌入等級,Java 中有多種等級的粒度。方法和域嵌入到類中,類又嵌入到包中,包嵌入到模塊或 JAR 文件中。
不難看出共享等級不應是方法和域。導入一個類的某些方法而排除例外一些,這種方式很明顯是荒唐的。不僅僅這種方式是如此。我們可以為某個模塊中類寫一些方法/域,在另一個模塊中再寫一些方法/域,這種方式也同樣是不可行的。想象一下,為在模塊中的每一個共享方法寫一些導入和導出列表,運行時對這些列表進行檢查以及診斷為題的復雜度將是非常恐怖的,會出現許多錯誤,因為類並不是設計用來在運行時進行分割的。
現在看看另一個極端,共享等級也不應是整個模塊,因為這樣模塊就不能隱藏實施細節的部分,導入方將經常性地遇到“買下整個商店”的問題。
所以唯一合理的選擇是類和包。老實說,選擇類也不是那麼合理。雖然沒有方法/域那麼糟糕,但類的數量非常多,由於它太過於依賴同一個包中的其他類,無論是將類列出作為我們的導入和導出,還是將包中的一些類劃分到某個模塊同時將同一個包中另一些類劃分到了另一個模塊中,都是不合理的。
最終的結果,OSGi 選擇了包。Java 包的內容通常具有某種程度的一致性,但列出導入和導出的包並不是那麼麻煩,而且在某個模塊加入一些包而在另一個模塊在加入另一些包,並不會對如何東西造成損壞。應該屬於模塊內部的代碼可以放到一個或多個非導出的包中。
我們的損失的無法干淨地處理那些所謂的“分裂包”(split-package)。在 OSGi 中,包是進行共享的最基本單元:當導入個包時,你獲得一個模塊導出包的所有內容而不包括其他內容。一些傳統的包,一直堅持在許多模塊中共享包內容,對於這些包也存在一些方法進行處理,但這好過對每個包進行調整以便讓它作為整體只能由某個模塊導出。
包連線(wiring)
既然對於模塊如何自我分離然後再連接有了一個模型,我們現在可以想象創建一個框架,這個框架將為這些模塊構造實際的運行時實例。它將負責安裝模塊以及構造類加載器(這些類加載器知道相應模塊的內容)。
然後它將查看新安裝的模塊的導入,並試圖找到匹配的導出。假設模塊 A 導出包 com.foo,模塊 B 要導入這個包。該框架將通知 B,它可以從模塊 A 獲得 com.foo 的類,這個稱為連線(wiring)。如果 B 的類加載器要加載類 com.foo.Bar,它將委派 A 的類加載器來做。對整個模塊的導入進行連線的過程成為解析(resolution),當所有導入都成功進行連線後,那麼這個組件(bundle)就被解析(resolved)了,這將令它完全可用。
一個預料之外的好處是我們可以動態地安裝、更新和卸載模塊。對於已經解析的模塊,安裝新模塊對它們沒有影響,雖然這可能導致某些之前不可解析的模塊變得可解析。當進行卸載或更新時,該框架非常清楚那些模塊受到影響,並且如果需要它將更改它們的狀態。為了能夠順利地進行,還有一些額外的細節需要處理,比如,一個模塊在卸載或者取消解析之前正在做非常的事情,那麼需要向它發送通知,以便讓它干淨利落地關閉。所以,OSGi 中的動態模塊並不是憑空出現的,這裡並沒有什麼神奇的功能,但 OSGi 至少讓它成為可能。
某些 OSGi 用戶更喜歡避免動態加載,這樣做沒有問題。這不是 OSGi 最重要的功能,但由於對於 OSGi 它是獨一無二的,英尺獲得了過多的關注。無論如何,沒有人強迫你使用它,即使從來不去利用動態性的優勢,你仍然能夠從 OSGi 獲得許多好處。
版本控制
我們的模塊系統現在看起來非常不錯,但隨著時間的推移,模塊不可避免地會發生方便,對稱我們還不能處理。所以,我們還需要支持“版本控制”。
如何進行版本控制?手洗,導出方可可進行聲明,為其導出的包提供一些有用的信息:“這個是 API 版本 1.0.0”。導入方現在能夠只導入與其預期匹配並且經過編譯/測試的版本,並且解決接受某個版本,比如版本 3.0.0。但是如果導入方想要版本 1.0.0 而只有版本 1.0.1 可用時,應該如何處理呢?一個稍高一點的版本看起來不會保護巨大的更改,所以導入方應該可以接受版本 1.0.1。事實上,導入方應為其可接受版本指定一個范圍,比如類似這樣的一個范圍:“版本 1.0.0 到 2.0.0 但不含 2.0.0”。對包進行連線的流程可以支持這種范圍,如果導出的導出版本位於導入指定的范圍內,就將導入與該導出進行連線。為了讓這個機制能夠正常使用,版本編號應該是有順序的並且能夠進行比較。
我們如何確定版本 1.0.1 相對於 1.0.0 沒有包含巨大的更改呢?很遺憾,我們無法確認這種事情。對於版本編號,OSGi 強烈建議而不是強制使用以下語法規則:
1. 對於非向後兼容的更改,對主要(第一)部分進行遞增。
2. 對於向後兼容的功能改善,對次要(中間)部分進行遞增。
3. 對於未造成可見的功能更改的故障修復,對最後部分進行遞增。
如果所有人都遵守這些語法規則,那麼指定導入范圍將是一件輕松簡單的事情。但現實世界並不是這麼簡單,因此在試用如何外部庫時,我們必須小心地處理兼容問題。