設計與實作一個函式庫
Joda-Time 首席設計師為撰寫自己的函式庫提出的最佳實務
Translated from “Designing and Implementing a Library” by Stephen Colebourne, Java Magazine May/June 2017, page 28–32. Copyright Oracle Corporation.
有許多方式可以建立一個應用程式,但大多數情況下,您會引入一個框架,二個或一些函式庫,現在,工具讓這事很容易,使用像 Maven 或 Gradle 等建構工具,感謝開放原始碼世界的貢獻,只要連到儲放 JAR 檔的中央產出物儲存庫 (artifact repository),便有數千個函式庫與框架可以選擇 (且大多數公司有內部的產出物儲存庫提供更多選擇),但什麼能造就一個好的函式庫?如何讓它的設計變好?
函式庫的風格
設計一個函式庫時,記住一些常見的函式庫風格會很有用,回到 2004 年,我將 Apache Commons 函式庫的風格分成二種:寬且淺對上窄且深。
寬且淺的風格有許多公開的函式讓使用者呼叫 (形成寬的部分),這些函式每一個都只做一些相對小的處理 (淺),函式庫的使用注重在找到對的類別去呼叫或建立,然後遵照 Javadoc 中詳細說明的語法和操作,因為公開的函式都很淺,所以它們容易從其他部分完全地分離,因此它很有可能從一個函式庫分成多個小的函式庫,這種風格的函式庫由許多靜態函式的類別組成,也常包含實例的類別,這類風格的例子像是 Apache Commons Lang、Apache Commons IO、Google Guava 及 Joda-Time。
窄且深的風格給使用者呼叫的公開函式相對少 (窄),但每個函式傾向處理適量的處理 (深),這類函式庫的使用傾向搭配特定的使用模式,這通常會在 Javadoc 之外的文件以較高層次的方式描述,這類風格的例子像是 XML 解析器與像 Apache FreeMarker 這樣的樣板函式庫,這方法能成功的關鍵是有一個清楚、詳細文件說明的公開 API,並隱藏內部類別。
在這二種風格中,函式庫傾向有相對小的邊界,這結果是如果您發現您選的函式庫有許多錯誤或不合您的喜好,應該不難去換掉它,這導致第三種風格,也許最適合稱之為商業函式庫,這裡,這函式庫是更具體,可能主要用於特定產業,函式庫的採用與否主要是應用程式架構的選擇。在我的日常工作中,我開發 Strata,獲選 Duke’s Choice Award 的金融函式庫,這是這類函式庫一個典型的開源例子,這類風格的例子大多數是私有的及公司專屬的函式庫。
相依 (Dependencies)
如 Maven Central 這類產出物儲存庫的易用性與便利性讓它很容易引入相依,但當您這麼做,思考一下,一個相依會帶來有其他多少相依,很快,您會發現您的應用程需有數百個相依,您會面臨不同版本的衝突,一種稱之為 classpath 地獄的情境,因此,所有好的函式庫都努力最小化它們的相依。
根據我在 Apache Commons 及 Joda 專案中的經驗,我發現寬且淺的函式庫運作地最好,如果它們完全沒有相依,Commons Lang、Commons IO 及Google Guava 都沒有相依。
有個有趣的例子,Joda-Time 及 Joda-Money 函式庫,二者都是寬且淺的函式庫,確實有一個相依 Joda-Convert,但這相依是可選的,多數使用 Joda-Time 的應用程式不需要 Joda-Convert,只有當您使用它提供的額外功能,才需要引入它。
在我的經驗中,窄且深的函式庫通常比較複雜,因此,它們通常依賴一些其他函式庫,這些相依在受限的情況下沒什麼問題,較大的商業函式庫通常有較大的相依集合,但這通常也沒問題,因為它們對應用程式來說非常重要,而且是由函式庫來驅動這些相依,而不是其他方式。
只為了使用少數幾行程式,例如幾個靜態函式,而相依於一個函式庫其實沒有意義,相反,應該考慮複製函式庫的部分程式到您的程式中,然後清楚地說明程式從哪裡來,透過追蹤複製的程式碼,讓找出附加的相依更容易是值得的,理想情況,複製的程式碼會是 package 範圍的,不會成為您的 API 的一部份。
最後,您在 low-level 函式庫中使用 Google Guava 時要額外小心,因為它被廣泛地使用,但在不同版本間並不相容,這是典型的 classpath 地獄問題。
整合
一個麻煩的情境是整合,當一個函式庫需要提供程式給跟其他函式庫相互運作,最常見的方式是釋出一個核心函式庫與為整合提供額外的函式庫,用這種方式,核心函式庫不會被額外的相依給限制住,但使用者必須挑選正確的額外 JAR 檔。
一個有趣的替代方案是使用選用的相依,用這方式,函式庫由單一個 JAR 檔組成,並包含所有整合的程式,但是,要讓整合能運作,使用者也需要將額外的 JAR 檔加到 classpath 中,這對使用者來說很方便,而且整合能無縫地運作。
最佳實務通常偏好第一個方案,分開的 JAR 檔,但當整合的程式相對較小,而便利性是重要的時候,第二個方案很值得考慮。
結構
多數的函式庫由少數的 packages 組成,只由一個 package 組成的函式庫也相當常見,當設計一個函式庫時,設計一個有清楚根 package 的 package 結構對幫助首次使用者十分有用,這對窄且深的函式庫特別重要。
例如,com.foo.shared
函式庫的根 package,應該包含最重要的函式庫進入點,額外的 packages 可能包含次重要的類別,例如 com.foo.shared.config
和com.foo.shared.model
。任何不希望使用者直接呼叫的程式,應放在內部的 packae,例如com.foo.shared.internal
或com.foo.shared.impl
。當然,在 Java SE 8 之前,使用者可以存取內部 package,但是,在 Java SE 9 的模組中,它可嚴格地限制內部 packages,讓使用者無法直接存取。
除了模組外,函式庫設計者應該多考慮盡可能使用 package 可見度,在 Java 中 package 可見度並沒有被充分利用,但它是一個很好的工具,用來隱藏內部的邏輯。特別是,Java SE 8 讓設計師更能大幅使用 package 可見度,這得感謝介面能帶額外函式,在 Java SE 8 之前,您的函式庫可能會有一個介面,一個工廠建立實體,一個抽象類別允許未來的變動。現在,這三個功能能被結合在一起:可以用介面上的靜態函式來建立介面的實體,有介面的預設函式,就不再需要抽象類別。如果整個 API 可以用介面定義,是有可能讓實作的類別都是 package 可見度的,也就是用介面的靜態工廠函式建立實體。突然地,公開的 API 從可能五個類別縮減到只有一個,對後續維護有相當大的助益。
功能與發展
許多函式庫從一個簡單的需求出發:在二個專案中分享程式,程式隨時間發展,最後變成無法管理,反之,應該被切割,這問題主要是函式庫漫無宗旨地成長,這函式庫存在的理由是什麼?它解決什麼問題?為什麼您要使用分享的程式碼片段而不是親自直接寫它?
寫點什麼,通常在專案首頁或 README 檔案的開頭,設定函式庫的邊界,當有新功能的請求近來,這樣更容易決定這功能應該在邊界內部或外部,這能讓您推回或拒絕這功能,或建立一個新的函式庫。
然而,如果,功能請求在函式庫的邊界內,應該嚴謹思考是否加入它,函式庫是共享的程式碼,也許您的使用情境不需要這功能,但其他人可能需要,但要注意過度膨脹,因為加入某些功能後,對新使用者來說,它變得難以學習及找出它包含什麼?判斷是否添加程式的一種方式是考慮有多少程式碼能被分享以及使用者最接近的變通方法是什麼?如果變通方法很麻煩,而使用案例似乎足夠好,添加的程式碼應該放入函式庫中。
如果您有幸撰寫獨立的函式庫,那不只是在二個應用程式分享程式碼而已,有一點要注意的是,您不需要它 (YAGNI, “you aren’t gonna need it”) 的法則通常不適用,這是因為您的目的是要滿足函式庫所在的利基需求,所以如果當需要的時候程式已經在那裡,使用者會更有信心。這樣做,除了滿足您心目中想要的最小使用情境外,可能會需要額外的功能或是便利的函式。
隨著時間管理程式的發展,其中一部分是計畫相容性,多數情境下,函式庫應遵守語意版本控制,清楚說明各版本之間的相容性,工具能在建置流程中檢查相容性。為了避免使用者掉入 classpath 地獄,做到二進制的相容是非常重要的,當有函式庫有新版本時,可以直接放入,這對函式庫的作者來說很痛苦,但當有其他人依賴您的函式庫,它是必要的。Joda-Time 成功的關鍵一部分是使用者能仰賴穩定具相容性的 API,就如同他們信賴 JDK 一樣。
良好的設計
函式庫通常位於應用程式的底層,因此,它需要是穩定且高品質的,當應用程式呼叫一個函式庫的函式,應用程式需明確知道函式庫會執行所要求的操作,事實證明,最佳的方式是使用好的、現代的 API 設計。
在可能的情況下,函式庫盡可能傳遞不可變的物件,不可變物件能提供使用者更清晰的語意:它們只有一種狀態,不受程式複雜的並行 (concurrency) 所影響,當然,對函式庫設計師來說,不可變物件需要更多的工作,您需要寫工廠方法 (factory method),而且可能需要一個可變的建構器類別,但這能帶來更少錯誤的好處 (試著思考這一點:如果您允許使用者傳入可變的物件到您的函式庫中,當您的函式庫正在處理中時,使用者改變物件會發生什麼事?)
對於 null
,API 也應該要良好地定義,最簡單的方式是所有函式拒絕 null
作為輸入,Java 現在 [譯註:Java 8] 有 Objects.requireNotNull()
能幫上忙,另一種方式是接受 null
,然後將它視為無作用或是預設值,但根據我從 Joda-Time 學到的經驗,這方式通常是壞點子。作為一個通用的規則,五年前,定義上可能回傳 null
的函式,現在應該回傳 Optional
。我的經驗是,如果您嚴格遵守決不回傳 null
的方式,對使用者來說,整個程式碼會變得更清晰與更安全。
函式庫中公開的 API 應該遵守有意義與一致的命名,這一點對使用者至關重要,只透過命名能找到他們需要的功能,一致性是關鍵,例如,如果在函式庫的情境中縮寫是有意義的,使用縮寫沒問題,但要一致地使用,首字母縮略字的第一個字母大寫的一般性建議仍適用,您應該選擇 HttpResult
優於 HTTPResult
。
有助於相容性,關鍵 API 函式值得考慮使用傳入請求的 bean [譯註:簡單的 POJO 物件],並以結果的 bean 作為回傳值,這有個優點,當需要一個新功能,您可以簡單新增到請求或回覆的 bean 中,而不用關鍵 API 上建立一個新的函式特徵 (method signature)。
說明文件
許多開發人員發現寫文件是撰寫程式時的阻礙,當建立一個函式庫時,您千萬別這麼想,函式庫的使用者通常不認識您,他們不能直接在您桌前問您問題,您唯一實際可行的選項是提供他們需要的說明文件。
實務上,這意指公開的 API 必須有良好與清楚的 Javadoc,此外,package 層級的 Javadoc、總覽式的 Javadoc 以及使用方法的文件都應該考慮,特別是窄且深的函式庫。這種高層次的文件應該說明如何使用函式庫、什麼是主要的切入點,以及指出哪些 packages 不應該直接使用。
說明文件中一個非常重要的資訊,是與關鍵生命週期及 session 類別有關的多執行緒安全性資訊。例如,當您使用一個 XML 或 JSON 解析器,通常會有一個單一的進入點,但是您需要每次建立一個新的實體嗎?或是將它放入一個靜態變數。預期的模式取決於類別的多執行緒安全,必須記載於文件中,如果您不這麼做,您的使用者可能會認為您的函式庫不瞭解並行的重要性與難度。
類似的討論適用在持有外部資源的物件上,例如串流與暫存區,說明文件需要說明誰應該關閉資源。如果是函式庫自己本身管理資源,例如,透過一個ExecutorService
,它應該實作 AutoClosable
,並在文件中清楚說明使用的模式。
說明文件最後關鍵的部分是授權,函式庫若是公司內部使用,那就不需要,可是開源函式庫需要,我推薦多數函式庫可以使用 Apache License version 2.0,它是很好、詳細描述且廣為使用的授權,而且使用者容易接受 [譯註:GPL 會要求使用的對象也要開源,會讓使用者猶豫要不要使用]。
結論
一個好的函式庫需要時間琢磨,是需要高品質與乾淨程式的工作,畢竟,建立一個應用程式時,所有的開發者都能分辨出他們使用的函式庫是好是壞,所以如果您正在建立一個函式庫,把它做到最好,您的使用者會感謝您。
Learn more
譯者的告白
看到這一篇時,感觸良多,因為現在的工作是替公司開發給第三方廠商使用的函式庫,為了易用性,要考慮的使用情境相當多,有些情境可能還會彼此衝突。除了易用性外,還要考慮其他問題,像是本文中提到的可見度真的很重要,因為不想讓實作細節曝露給使用者。選擇相容 Java 6 還是 Java 8 也很不容易,文中很多新的語言特性都要到 Java 8 才能使用,像是介面的預設函式與靜態函式。且設計函式庫時,有時不能太任性,即使不喜歡某些流行的風格,為了讓其他使用者能快速上手,還是會採用。最麻煩的是相容性,特別是這函式庫有一個很重要的需求:讓使用者可以接上現行的 server,並在未來無縫接軌新版本的 server,責任重大啊…