iThome 網站首載:寫一手流暢的API
現在不少程式庫在使用風格上採用了方法鏈串(Method chaining)創造出流暢語義,多數人耳熟能詳的實例就是jQuery,而Guava程式庫中如
ComparisonChain
、Ordering
也採用了此風格,有些程式庫改版時亦從命令查詢(Command-Query)風格丕變為流暢API風格,像是Quartz,此類風格似乎打破了一些設計原則?流暢API設計就等於方法鏈串嗎?- 命令查詢分離概念
在探討流暢API設計之前,得先來看看命令查詢分離(Command Query Separation)概念,這在Martin Fowler的2005年文章〈CommandQuerySeparation〉中曾經提過,基本概念是將物件的方法分為命令與查詢兩類,改變系統狀態但不傳回值的方法稱為命令,傳回結果但不改變系統狀態的方法稱為查詢。在JDK中,
Calendar
類別具備命令查詢分離概念,其set
、add
、roll
等方法變動Calendar
實例的方法,傳回型態都是void
,而查詢結果的get
等方法,並不改變物件狀態。Martin Fowler提到採用此原則的益處是,對於一個狀態可變的物件來說,可以清楚地辨別哪些操作會有副作用(Side effect)而哪些不會,你可以將物件傳遞給需要查詢的場合,而不用擔心會改變狀態;另一個好處是,查詢方法的傳回型態可洩露物件命令操作後的差異性,以
Iterator
的next
方法為例,Martin Fowler個人不喜歡next
同時傳回下個項目並推進Iterator
,依命令查詢分離概念將之分開為advance
與current
方法,會是他偏好的設計方式。命令查詢分離概念的優點是方法的職責清楚(後來又演變出CQRS模式,是有關物件的查詢與命令職責分離,Martin Fowler在2011年的〈CQRS〉討論過),然而缺點是有時必須透過一連串的命令操作,方可獲得想要的物件狀態,容易形成冗長的程式碼,以
Calendar
類別為例,若想知道1975年5月26日五天後的六個月又三星期是什麼日子,在透過Calendar.getInstance()
取得Calendar
實例後,得進行以下的冗長命令操作:calendar.set(1975, Calendar.MAY, 26, 0, 0, 0);
calendar.add(Calendar.DAY_OF_MONTH, 5);
calendar.add(Calendar.MONTH, 6);
calendar.add(Calendar.WEEK_OF_MONTH, 3);
- 方法鏈串/不可變物件的連續技
命令查詢分離在概念上,是將變更物件狀態的命令與物件狀態的查詢在方法職責上加以分離,如果物件不可變(Immutable)呢?那麼在操作後你勢必得傳回某個值,如果該值就是物件,那就可以直接對傳回的物件進行操作,形成鏈狀操作。Java的字串就是個例子,因為
String
實例不可變,因而可以進行如下的鏈結操作:param.trim()
.toLowerCase()
.replace(regx, mask);
這會比在每個方法操作後,使用變數接受結果,再透過變數進行下一操作來得簡潔,且不違反命令查詢分離概念,因為你並沒有改變物件狀態,而是基於目前物件狀態,(也就是查詢後)建立新物件。對創造流暢的可讀性而言,不可變物件結合方法鏈串似乎是絕佳組合,例如作為打算取代
Calendar
與Date
的Joda-Time程式庫或JSR310來說,就利用了方法鏈串來改進日期運算時的程式碼流暢度,像是同樣想知道1975年5月26日五天後的六個月又三星期是什麼日子,利用JSR310可以寫為LocalDate.of(1975, 5, 26).plus(5, DAYS).plus(6, MONTHS).plus(3, WEEKS)
,相較於Calendar
的操作簡潔許多。在我先前專欄〈排序處理模式〉中,談到了Guava的Ordering
實作,也利用了方法鏈串讓不可變的Ordering
在建構流程一目瞭然。Quartz在2.0之後實際上就是採用此模式,例如在建立
Trigger
時,會建立TriggerBuilder
以進行組態設定,TriggerBuilder
不可變,每個組態操作都建立了新的TriggerBuilder
實例,組態設定完成後,才從TriggerBuilder
的build
方法,產生想要的Trigger
物件。- 流暢介面/可變物件的連續技
命令查詢分離是個概念,Martin Fowler說過在可以的情況下儘量遵守,不過在語義明確的情境下不用墨守成規,他以堆疊的
pop
方法為例,pop
很明確地表示「取出」堆疊頂端物件,雖然它同時改變物件狀態並傳回堆疊頂端物件,這種違反的情況他倒是樂於接受。Martin在2005年另一篇〈FluentInterface〉文中也談到了另一個情況,一個Order物件需要在多行程式碼中多次執行addLine
方法,他建議改以流暢風格設計為customer.newOrder().with(6, "TAL").with(5, "HPK").skippable().with(3, "LGV").priorityRush()
。建立這個風格導致一些不尋常的習慣,會改變
Order
物件狀態的with
方法傳回了本身(this),明顯違反了命令查詢分離概念,然而基於流暢的可讀性優點下,Martin Fowler建議暫且忽略這個習慣。Martin Fowler將這風格命名為流暢介面(Fluent Interface)。Hibernate中的Criteria
API亦採用此風格,你可以用session.createCriteria(Cat.class).add(like("name", "Iz%")).add(gt("weight", new Float(minWeight))).addOrder(asc("age"))
來建立查詢條件。流暢介面風格常見以方法鏈串實作,適合用於組態設定或者建立值物件,在適當的傳回值型態與方法命名下,值物件的建立情境會形成特定語言,也就是一個內部DSL(Internal Domain Specific Language),例如JavaScript中一個有趣專案是JSVerbalExpressions,可使用
VerEx().startOfLine().then('http').maybe('s').then('://').maybe('www.').anythingBut('').endOfLine()
來建立規則表示式(Regular expression),讓規則表示式的建立不再隱晦不明。Martin Fowler說到,這種流暢API的設計,也是對靜態語言在DSL上的一個補充。- 可讀性是流暢API出發點
除了打破命令查詢分離概念來創造可讀性之外,還可以進一步考慮合併命令與查詢方法,用以簡化API。舉例來說,jQuery的
css
方法既是命令方法,像是\$('#some').css('color', 'red')
,也可以是查詢方法,像是\$('#some').css('color')
,在Java中可以用重載(Overload)方法來達到相同目的,就某些程度來看,就像是合併取值方法(Getter)與設值方法(Setter)。在考量到可讀性之後,實作的形式就多元化了,實際上,流暢介面與流暢API不畫上等號,方法鏈串是流暢API常見但不是唯一實現方式,Martin Fowler在2008年對〈FluentInterface〉的補充說明了這點,像是Hamcrest就採用工廠方法等模式實現了
assertThat(numbers, everyItem(lessThan(5)))
之類的風格,然而不用拘泥於名詞,流暢API的重點或許在思考:打破原則或概念是可以創造可讀性,然而分寸在哪呢?大部份設計原則考量的都是職責清晰,降低耦合度,必定優先遵守,以命令查詢分離概念來說,設計API時應該優先考量,其次在一些語義明確或慣例的情境下,若有助於可讀性則可考慮通融,像是堆疊的
pop
方法。如果物件不可變,由於操作總得傳回結果,你可以傳回新物件,以方法鏈串創造流暢性;如果物件可變,且是個值物件,考慮傳回本身,讓查詢方法傳回型態來洩露命令操作的差異,並明確在文件上說明傳回型態是否為本身,作為API使用者也務必確認這點;如果目標是構成一個內部DSL,那在型態與方法名稱上,就得多下點功夫,一切都以可讀性為出發點,API才有流暢的可能性。