話說在前頭,這篇文章所提出的質疑不一定適用所有的情境,請依據讀者自己的情境酌量採用。
這次聊聊一個幾乎每個軟體工程師都會掛在嘴邊,但實際上寫程式是否還記得的原則:Single Responsibility。從 Wikipedia 第一行的說明
every module or class should have responsibility over a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated by the class, module or function.
很多人會覺得,這原則很簡單啊,但實際上卻有點模糊,什麼功能應該放在哪個類別 (模組) 或是不該放在那個類別 (模組) 的邊界卻很難拿捏。這邊說個就學時的故事,當年研究所課程助教幫我的作業做 design review,那時我把專案從檔案讀取和寫入檔案的 method 放在專案的類別中
當時覺得這樣的設計很合理,畢竟我不想替私有的成員加 setters,但助教建議我將讀取和寫入的邏輯移出,那時和助教有過熱烈的討論,最後被說服的理由便是 single responsibility,但助教的說法是 single responsibility 的另一種解讀:
A class should have only one reason to change.
如果要改變檔案的結構,或是改成讀寫 JSON 檔案,又或是要支援多種檔案格式,那要修改哪個類別?但這是Project
這個類別的責任嗎?想想確實,這類別的責任應該是管理元件與之間的連結,其他都不是這個類別的責任,而且為了讀取 XML,Project
類別和 JDOM 有了相依性。
看到這,我想應該有人會認同助教的說法,於是我們來看下個例子,這應該很常見在 Java 處理 JSON 的轉換程式中看到:
而且我相信很多人這樣寫的時候,並不會想到 single responsibility 的原則,因為這樣寫很方便,提高生產力,同樣的類型,再看一個例子:
這寫法幾乎快變成 Java 後端的標準起手式了,畢竟回頭寫 XML mapping 的人應該越來越少了,當初第一次學 JPA 時覺得這個超方便的,而且程式和 mapping 放在同一個檔案,很容易發現 mapping 不一致的問題,但這樣寫真的符合 single responsibility 嗎?更何況不一致是雙向的,改 DB 欄位若沒想到回頭改 mapping 也是會不一致。
這情況不是 Java 獨有,自從 Swift 導入Codable
介面後,實作Codable
也幾乎變成標準寫法了 (若把 fullName
宣告成 computed property,可以不用寫init(from decoder)
和encode(to encoder)
,這是刻意寫的例子),不這樣寫還會被嫌,但若反問為什麼這樣寫比較好,到目前為止,我並沒有聽到任何決定性的優點讓我非這麼寫不可,我也沒那麼喜歡這樣寫。
至少,我會拆成兩個檔案:Customer.swift
和Customer+JSON.swift
,可能有人會疑惑,程式碼明明沒有差 (其實有差,visibility 不一樣),為什麼要拆成兩個檔案,如果專案只有一個模組那確實沒太大差別,但如果有拆模組,例如Core
和WebService
模組,那Customer.swift
會放在Core
模組,另一個檔案Customer+JSON.swift
會放在WebService
模組,由於 extension 的 visibility 是 internal,所以在Core
模組是看不到 JSON 相關的內容,任何和 JSON 相關的修改也不會動到Customer
類別和Core
模組,而是限縮在另一個模組裡,某種程度上還算是符合 single responsibility 原則。
因為官方提供了Codable
,所以就一定要這樣寫?我個人是覺得沒必要,事實上我個人並不介意寫像這樣的程式碼跟相對應的測試:
若真的要嚴格說,domain 類別的責任主要是封裝 domain knowledge,不在 domain 中的東西,都可以用 creator 或 pure fabrication 類別處理,而不用放在 domain 類別中。但有些東西就很難說,例如 create time 和 update time,通常在分析 problem domain 時,不會列出這兩個時間戳記,但幾乎在所有的商業應用都可以看到他們在 domain 類別中,但若仔細想,為什麼會需要這兩個時間戳記甚至是誰改的?主要是為了稽核這個目的,如果是這樣,這是各別 entity 的責任嗎?難道沒有其他的解法?
事實上是有的,雖然在 GSS 待不長,卻也跟當初的架構師學了一些技巧,那時用 Spring framework 的 AOP 寫一個攔截器,搭配自訂 annotation,攔截所有需要稽核的 Restful API end points,只要呼叫 API,攔截器便會被呼叫,此時可取得是誰呼叫 API 以及請求的內容,並在 API 結束後,攔截器再次被呼叫,取得回傳的內容,如此,就可以將所有 API 的呼叫記錄下來,甚至可以知道誰在幾點幾分下載了什麼檔案,或修改了什麼資料,如此一來在每個 entity 中加入 create time 及 update time 就不是必要的,雖然這樣做是最方便的 (但只能記住最後一次修改的時間)。
剛剛的 create time 及 update time,還能算作是 application logic,勉強能稱上是 domain 的一種,但因為使用的 framework 導入的東西,大多數都是實作層級的。除了剛剛的 annotation 外,因為導入 ORM 內建的樂觀鎖 (Optimistic Offline Lock),在物件中加入 version,也是一個當初我剛接觸時,不喜歡卻也離不開的東西,有了 version 資訊,ORM 工具就可以協助判斷這次存入的變動是根據哪個版本所做的,若 DB 其實已經有更新的版本時,ORM 會以例外的方式告知這個變動可能是過時的,這非常有用,畢竟後端要思考到多個 request 編輯同一份資料的情況,但總是覺得好像違反 single responsibility,若以 DDD 的角度,Aggregate 本身負責交易邊界,似乎還說得過去。
近期,不少程式語言 (Objective C、C#、Swift 和 Kotlin) 有 extension 的機制,在不修改原有類別 (像是其他函式庫的類別,無法修改其原始檔)的情況下擴充類別的功能,這機制十分強大,我也很常用,但這幾年,我開始覺得這機制也助長違反 single responsibility 的現象,因為方便,開始為某些類別加入 extension methods,而且越加越多。
我只能說,拜託在加入前請認真思考一下,這真的是這個類別的責任嗎?像是 derived information 用 extension 就很合適,例如先前例子中的 full name、或是用生日推算出年紀的函式,如果一開始沒有在類別中,用 extension 擴充就很不錯。但特定情境下才適用的 derived information,例如 UI 才適用的資訊,比起 extension 放在 view model 也許更合適。
另外,用 extension method 的可讀性真的比用 static utility method 高嗎?某些情況下真的比較高,像是在設計 DSL 時,可以寫出較接近句子的程式,但不是每個情況都是如此。
實際上 Kotlin extension method 轉成的 byte code 就是一個第一個參數是目標類別的 static method,然後提供一個語法糖衣。
但不同類別之間的轉換,用 extension 加到任何一邊都很怪,因為轉換這件事到底應該是誰的責任?雖然說,extension 可以把程式分到不同檔案,加上可以透過一些語言機制,讓某些 extension 的函式不被看見,但還是把功能加到原先的類別中,更何況不是所有的語言都有這類機制。
這也是我在讀過《Clean Architecture》後,重新思考的問題,不同 layer 之間資料的轉換,該是誰的責任?理想狀況是加入一個第三者:
但如果不加入第三者,我目前比較喜歡把責任放在接近 I/O 的類別身上:
小結一下,若真的要符合 single responsibility,通常會得到很多很小的類別或是函式,各別完成一個小的功能,然後在某個地方被聚合起來完成一個使用案例 (use case),而不是一個很大的類別,包山包海,然後最後變成一個狀態超複雜,超級難測試的類別。也許是因為 dependency 難管理,所以很多工程師喜歡大類別,而不喜歡很多的小類別。
所以下次,當想把某個函式或屬性加到某個類別前,也許可以先想想 single responsibility,有沒有更合適的地方或是新增一個第三者類別。至於,那些為了提高生產力的寫法,到底在整個軟體開發週期中減少多少時間,我就不去討論了 (近一年都在寫 Node.js,沒有那些 annotations,也不會覺得有失去很多生產力)。
後記:現在想想,會對 JSON annotation 或是 serialization 有些堅持,真的跟當初與當時的助教 Teddy 熱烈討論後,留下深刻的印象有關。
題外話:這裡要小小抱怨一下《Kotline in Action》的中文翻譯,為什麼要把 extension 翻譯成繼承?我通常一本書不會一次看到完,有時會隔個幾週再回去看,然後看到繼承這個術語時,都還要根據上下文來判斷是類別之間的繼承還是 extension,我想超過 90% 以上學過 OOP 語言的人,看到繼承兩個字絕對不會聯想到 extension,翻譯成繼承真是爛透了。
系列索引
上一篇:《閒談軟體設計:架構師難尋?》
下一篇:《閒談軟體設計:Switch 壞味道》