閒談軟體架構:Plug-in


目前閒談軟體架構系列,每一篇大概都需要 1x 小時,包含構思、收集資料、撰寫與修稿,每個禮拜一篇已經是極限了,還好有龐大的 architecture pattern 素材,題材不是大問題,但要有自己的風格,內容還是盡量以實作及自身的經驗為主,撰寫起來還是要想蠻久的。話雖如此,有任何想知道的題材,可以留言告知,若本來就有打算寫相關的題材,可以盡量將該題材的 priority 往前移。本文是 2016 年 11 月發於 Logdown 的舊文,最近會慢慢將這個系列的舊文搬移到 Medium。

高中參加資訊社,社團有年度刊物,為了編輯與發行年度刊物,跟著學長學了 PageMaker 和 Photoshop,前者是出版業常用的排版軟體 (現今被 InDesign 取代),後者不用多說,號稱是軟體界的 SK II,什麼照片都能修,手繪功力不好的我,常常玩的是 Photoshop 的多種濾鏡,在還有大補帖的時代,我就收集了許多濾鏡,只要把檔案丟到特定目錄,開啟 Photoshop 就可以多好幾種濾鏡可以用,那時就覺得好神奇,為什麼這樣就可以用了?

後來用 Eclipse 開發軟體時,再次為了其 plug-in 的架構感到好奇,不過 Eclipse 的 plug-in 底層運行架構是極為複雜的 OSGi,所以當初在執行國科會計畫時,試著想用自己設計的簡化版 plug-in 架構將兩個不同子計畫的 UI 整合在一起,計畫成果是成功的,不過後來回頭看架構,其實並不是很好的設計,因為沒有 plug-in 相依管理的設計,所以兩個子計畫的程式在彼此相依的情況下,無法單獨安裝也無法像 Eclipse 在安裝時能將相依的套件一併安裝或移除。

後來自己閒暇之餘寫的看漫畫軟體 Comic Surfer,一開始並沒有 plug-in 架構,但後來想想,自己再怎麼寫,也不可能支援所有格式,所以在想有什麼方法可以讓別人擴充 Comic Surfer 能讀取的格式呢?答案很明顯,就是 plug-in 架構,事實上這已經是常用的一種架構模式 (architecture pattern),如果看 Martin Fowler 的 《Pattern of Enterprise Application Architecture》描述何時使用 plug-in pattern?你會看到:

Use Plugin whenever you have behaviors that require different implementations based on runtime environment. — 《Pattern of Enterprise Application Architecture》

更簡單的說法:

Link classes during configuration rather than compilation.
 — David Rice and Matt Foemmel

不是在軟體建置之初就決定所有一切的行為,而是執行時根據配置 (configuration) 或執行時的環境 (例如:特定目錄有什麼 plug-in) 決定有哪些行為,話是這麼說,但要實作一個可以載入 plug-in 的軟體 (以下稱作 host application) 倒是有不少事情要考慮。

首先是怎麼建立 plug-in 的實體 (instance)?以 Java 為例,有 Reflection 機制可以在執行期間動態地載入與建立物件,但要建立那個物件呢?總不能把特定目錄底下的所有 JAR 檔載入後,建立全部類別的實體吧?畢竟不是所有的物件都是作為 plug-in 使用,因此,設計 host application 時要先決定用什麼機制載入 plug-in,可能的機制有:

  • 透過實作特定的介面:在還沒有 annotation 之前,是可以要求 plug-in 實作特定的介面,該介面可以是一個空介面,目的是讓 host application 可以透過掃描所有類別,看哪個類別有實作指定的介面,若有實作就視為是 plug-in 的主物件。
  • 為 plug-in 物件加上特定annotation:自從 Java 可以自訂 annotation 後,主流就變成以 annotation 提供 metadata 了,所以 host application 可以掃描所有類別,看有沒有特定的 annotation,像 Spring framework 就大量使用這個機制載入元件。
  • 透過文件提供 metadata:雖然說以現在 CPU 的能力,掃描所有類別應該花不上多少時間,但若有指定的規則,可以加速載入的時間,像 Eclipse 的 plugin.xml,裡面提供足夠的資訊讓 Eclipse 決定怎麼載入它,例如 Comic Surfer 就要求 plug-in 提供一個 reader.xml 的檔案,讓 Comic Surfer 知道該建立哪個物件:

不管是哪一種方式,都只是讓 host application 得知該載入哪個類別,但是要用 Reflection 建立物件,還是得知道建構子有哪些參數,最簡單的機制是要求 plug-in 的物件提供預設建構子,也就是零參數的建構子 (zero-argument constructor),讓 host application 簡單地建立物件,但這就會讓 plug-in 失去 constrcutor-injetion,若要讓 plug-in 的建構子有參數,可以參考 Spring framework,用 XML 或 annotation 描述參數的來源。

當 plug-in 的實體被載入,host application 怎麼知道該 plug-in 提供什麼服務呢?最單純的做法是由 host application 定義介面,然後由 plug-in 提供實作,以 Comic Surfer 為例,plug-in 能提供多個讀取器,透過 reader.xml 所建立第一個物件,必須實作 ComicBookSuiteReaderFactory,host application 可以呼叫 createReaders() 由 plug-in 自行建立讀取器的實體:

除此之外,Comic Surfer 還定義了許多介面要求讀取器實作,像是 ComicBookSuiteReader 負責讀取整套的漫畫,每套漫畫必須是實作 ComicBookSuite 的實體,一套漫畫中的每本漫畫是 ComicBook 的實體,最後每本漫畫的每一頁面是 Page 的實體,如此,Comic Surfer 根據這些介面知道這個讀取器能讀什麼漫畫 (透過 canRead(URI) 判斷),當漫畫被讀進來後 (透過 read(URI)`),可以知道這套漫畫有幾本,每本有幾頁,能讀取每一頁的內容,有了這些,所有換頁、換冊以及快取等機制都能正常運作。

Comic Surfer plug-in 需實作的介面

Comic Surfer 會在程式啟動時檢查自身路徑底下的 readers 目錄是否有 plug-in,有的話就會讀取進來,一般來說這已經很足夠,如果要更進一步可以監控目錄是否有變化,這可以做到在程式運作時就能動態載入 plug-in 不須重新啟動應用程式,若搭配上安裝介面,就能做到安裝完就立即能使用。

回頭看 Photoshop 的濾鏡,每個濾鏡都是一個 plug-in,不過由於每種濾鏡都需要不同類型、數量的參數才能使用,更重要的是提供預覽畫面。為此,host application 可以有幾種做法,第一種是 host application 定義一個函式,例如 preview(Image),host application 將要套用濾鏡效果的原始圖副本 (讓使用者可以取消) 交給 plug-in,由 plug-in 自行建立預覽畫面與設定參數的 UI controls,按下確定後,host application 呼叫 apply(Image) 讓變動生效,這對 host application 較為簡單,因為預覽畫面的責任在 plug-in 身上。

但若為了 host application 整體 UI 的一致性,也可以由 host application 定義大多數參數的 UI controls,然後定義介面讓 plug-in 提供參數,host application 建立預覽畫面與控制項,然後將參數與控制項綁定,plug-in 則藉由事件綁定得知使用者調整了哪些參數,立即更新預覽的效果,這樣的話 host application 責任較大,但對開發 plug-in 的開發者就會比較輕鬆,專注在濾鏡的演算法上。

定義介面,然後由 plug-in 提供實作,但當 host application 需要 plug-in 提供更多功能時,一般會在介面上新增函式。某些語言,像 Objective C 可以將介面中某些函式宣告為 @optional,也就是說繼承的類別可以不需要提供實作,host application 可以在執行期間檢查是否有時做特定函式,若有才呼叫,若沒有則以預設的行為取代。

但並不是所有的語言都有這樣的設計,像 Java 在載入類別時會檢查類別是否滿足所實作的介面,若驗證失敗就不會載入該類別,因此 host application 在提供 SDK 讓第三方開發 plug-in 時,盡量提供一個抽象類別讓第三方繼承,而不是直接讓第三方實作介面,當要在介面新增函式時,可以在抽象類別中提供預設行為的實作,如此第三方即使不重新編譯,既有的 plug-in 也能繼續使用。不過,這是有預設行為的前提,若沒有的話還是會面臨不相容的問題。[2017/5/28 補充,若當初是使用 Java 8,那介面的 default methods 可以解決擴充引起的相容性問題。]

到目前為止,都是由 host application 向 plug-in 請求服務,但 plug-in 有時候也會需要向 host application 請求資源才有辦法提供服務,例如可以向 Eclipse 請求取得目前檔案的 AST (abstract syntax tree),進行語義的分析然後提供額外的服務。為了保護 host application 自身,並不會讓 plug-in 毫無限制取得任何資源或服務,因此,還是會定義一個介面作為 Facade,讓 plug-in 註冊事件、取得資源、操作資源等等,不在 Facade 介面中的函式 plug-in 就無法使用。如何讓 plug-in 取得該介面的實體呢?constructor injection 或 setter injection 都是可以使用的方法,要求 plug-in 提供注入 Facade 的建構子或是提供 setter 讓 host application 在建立 plug-in 後呼叫 setter 注入。

現在,host application 可以呼叫 plug-in 特定介面取得額外的服務,plug-in 也可以透過介面存取 host application 的服務與資源,那 plug-in 之間要交換資訊怎麼辦呢?原則上,plug-in 之間直接互動不是不行,但如此一來,plug-in 之間就變成較強的耦合,若想避免這情況,一個較好的做法是透過 host application 中介,plug-in 向 host application 請求服務,host application 看自身能否提供,若不能提供再詢問是否有其他 plug-in 提供該服務。

不過,不管是 plug-in 直接呼動或是透過 host application 中介,最後都會在交換的資料結構產生耦合,畢竟,直接存取資料物件會方便很多,特別是交換大量資料更是如此。若資料量不大,是可以轉成與特定物件格式無關的文字或文字檔,例如以 URL 的 query parameters 傳遞參數,以 JSON 格式回傳資料等等,但資料的解析還是省不掉 (至少要知道參數名稱),因此由 host application 定義可能交換的資料格式或使用語言內建的資料格式也是一種方法,就看資料存取的方便性與 plug-in 之間獨立性的平衡點要抓在哪裡。

設計 plug-in 架構還有很多東西可以考慮,例如怎麼安裝、移除、檢查相依性等等,但如果只是簡單的 plug-in 架構,上述的內容應該能滿足大多數的需求。最後,若以 plug-in 架構來看 Android,就可以知道什麼是 Intent,知道為什麼 Activity 必須要有 zero-argument constructor,還有為什麼要在 AndroidManifest.xml 宣告那麼多東西了?

簡單說,每個安裝到 Android 中的 apk 都可以視為是一個 plug-in,AndroidManifest.xml 告訴 Android (也就是 host application) 啟動這個 plug-in 要建立哪個 activity,該 plug-in 提供什麼 <service><provider> (資料)。而 plug-in 之間則是以 Android 中介,透過 Intent 告訴 Android 想要什麼服務或資料,Android 會根據每個 plug-in 的 <intent-filter> 描述決定啟動哪個 plug-in 來滿足請求,Intent 可以夾帶參數給另一個 plug-in,並在完成後回傳資料。

雖說用 Android 的例子做結尾,但是我個人還是不喜歡 Activity 的建構子不能有參數,也不能自己建立 Activity 的物件,造成這的最主要原因是 Activity 身兼太多責任了,既是 plug-in,也是 MVP 中的 presenter (是的,不是 MVC),實在不是一個 high cohesion 原則下該有的設計。


話說 Comic Surfer 已經好幾年沒空維護了,趁這次乾脆把 Comic Surfer 變成公開專案,不過當初並沒有把 Maven 的 pom 檔處理好,所以 check out 下來如果沒有對應的設定應該也無法編譯就是了,但如果想看 Comic Surfer 的原始碼可以到這裡取得。

系列索引
上一篇:《閒談軟體架構:例外處理
下一篇:《閒談軟體架構:UUID