重構到更深層的模型

weixin_33763244發表於2018-09-18
\

本文要點

\\
  • 重構有三個層次:程式碼層次微重構,模式重構,以及更深層的模型重構。\\t
  • 無論是對系統還是你的理解來說,做許多小的變更可以形成複雜的大的變更。\\t
  • 這裡提出的案例是Nexia Home Automation的攝像機整合案例。重構之後,開發人員可以更方便地瞭解領域模型,以及系統中的Java和Ruby程式碼。\\t
  • 重構加強了好的DDD實踐,比如說強邊界的上下文,以及跨邊界的顯式轉換。\\t
  • 使用功能開關和階段性發布提供了一個推遲做出決策的選擇,可以讓你在掌握了足夠的資訊之後再做出明智的選擇。\
\\

本文改編自Explore DDD 2017的一個演說

\\

在化學的世界中,你可以提取不同的物質,每個物質本身都處在穩定的狀態,然後你可以將它們組合起來,它們互相發生反應,成為比反應物加起來還要好的物質。相同的,在軟體行業,也會有不同的重構反應物,每個都具有不同的工作量、頻率和能力。當它們與領域驅動的探索發現過程催化劑相互碰撞的時候,這些重構反應物就會產生程式碼的“化學”反應,將程式碼轉換為豐富的領域模型。

\\

本文講述了Nexia Home Intelligence中一個持續很久的攝像機支援系統的重構故事。Nexia是一個大規模Ruby on Rails應用程式,需要支援使用成千上萬個攝像機的客戶叢集需求。

\\

我將從三個層次介紹重構。Martin Fowler的《重構》一書中談到了微重構,就是在程式碼級別不斷進行小的變更,以實現增量提升。好的開發人員會花時間去記憶並養成如何使用重構工具的習慣,所以這些微重構就成為了第二天性。

\\

Joshua Kerievsky在《重構與模式》一書中談到了更高階的模型,比如說策略模式。他在書中還定義了各種“壞味道”,比如霰彈式修改,進行一個小的變更也需要很多額外的其他變更。這讓我很放心,因為我的設計沒有必要一開始就完全正確,我隨時可以開始開發,當碰到一種“壞味道”的時候,我就有了可以重構的工具,但也只在必要的時候進行重構。

\\

我將介紹第三層次的重構,即重構到更深層次的模型,Eric Evans已經在《領域驅動設計》一書中為我們介紹過這一層次。當我第一次閱讀這本書的時候,第三部分吸引了我的注意力。在第三部分中,他談到了一個專案,在專案中模型並不適用,他們就提出了一個新的重構方式模型,它徹底改變了專案。

\\

看一下這三個層次,如果你可以在你的模型中引入新的概念,這就是一次有用的重構。你需要精通於微重構,充分使用模式來重構到更深層次的模型中去。

\\

有關Nexia Home Automation

\\

Nexia Home Automation系統是Ruby語言編寫的,可以幫助你完成各種家庭自動化工作,比如瞭解窗戶是開啟還是關上的,需要系統與運動感測器整合,並連線攝像機。Dan Sharp和我負責攝像機系統,這也是我將介紹的內容。

\\

家庭自動化不像銀行業或保險業等其他領域,它是一個高科技領域,需要處理硬體和韌體。這就意味著客戶並不瞭解很多技術的問題,你不能直接問他們韌體相關的問題。

\\

我們的目標是不斷研究新功能,同時提升對新的攝像機的支援。當新的攝像機面世後,通常需要幾周或幾個月的時間,通過大量霰彈式修改,才能新增Nexia對它的支援。我們希望能大大縮短這個時間。

\\

如果你要完成向Nexia新增攝像機的過程設定,你會注意到它使用的一些術語。比如說,並不是新增攝像機,而是需要註冊一個新的攝像機,之後進行啟用步驟。註冊步驟是想讓Nexia知道相機的存在,需要在連線到Nexia之前完成。

\\

架構處理

\\

安裝在客戶家庭的幾千臺攝像機和許多相機管理器元件通訊。相機管理器是由Java編寫的,通訊是通過HTTP和SSL實現的。當訊息從攝像機進入管理器之後,我們將這些訊息放到Redis作業佇列中。這些訊息被Portal Workers從佇列中去除,在後臺中執行。Portal Workers是由Ruby編寫的。Nexia需要回復攝像機,所以我們在RabbitMQ訊息匯流排上將這些訊息排隊,這些訊息會通過相機管理器處理。圖1展示了這個架構很高層次的一個檢視。

\\

e1ef0084d787f494d7b50721950682e2.jpg
圖1:Nexia架構

\\

這個應用程式本身是Rails應用程式,程式碼庫的部分內容如圖2所示。如果你不熟悉Rails開發,models資料夾通常不是控制器所在的地方,所以不要認為它是富領域模型。我特別展示了一些我提到的workers,以及和自動化相關的camera檔案。比如在日落時執行一些工作,或是在指定時間調暗燈光,都是Nexia的自動化例子。

\\

dfc32a73b082dd9178b77ca9be4e3c7b.jpg
圖2:Rails應用程式架構

\\

三個主要挑戰

\\

我們遇到的第一個挑戰是程式碼很難推斷。Java 相機管理器過度架構,它們使用了有許多抽象的元架構,讓它可以和任何與Nexia連線的任何型別攝像機一起工作。實際上大多數攝像機都非常類似,比如說都使用SSL和HTTP,我們不需要額外的抽象層。

\\

舉一個系統性問題的例子,圖3展示的是handleRequest()方法的一部分。任何DDD從業者都會對這段程式碼的語言表達產生疑問。91行引入了Zombie一詞,什麼是Zombie?93行提到了“如果沒有進行授權(isAuthorized)”,但是94行的註釋提到的行的註釋提到的認證(authenticated)和它並不是同一個東西。更糟糕的是,98行將一個變數宣告為auth,這既能代表前者,也能代表後者。雖然這僅僅是個小例子,但是這段程式碼也能代表我們相機管理器程式碼庫中遇到的一些問題了。

\\

34be0e395ac886830d4f99017f8fa5eb.jpg
圖3:相機管理器handleRequest()程式碼示例

\\

在Ruby端,網站工作人員對於攝像機的支援隨著時間推移而增長。由於大多數工作都是由不同的開發人員(主要是外包)按照需要完成的,所以過多地傾向於職責實現了,而未進行有目的地建模。

\\

Ruby程式碼的優點是十分簡潔,可以在幾行內表達很多內容。但是,CameraWorker情況不同,它負責驗證並關閉攝像機的連結。先宣告一下,由於不能本文中展示超過130多行程式碼,因此圖4中展示了部分程式碼。在多個地方,worker需要對攝像機物件進行狀態修改,而不是宣告所需的行為。我們還碰到了一些不太好的命名,比如89行的start_motion呼叫,看上去像是開始行動的命令,但其實並不是。

\\

2401bff77cb71d09d9d2bc53ee7d9dff.jpg
圖4:CameraWorker程式碼示例

\\

和那段Java程式碼類似,這只是其中的一個小片段,但它也可以代表系統性問題了。這些都造成了程式碼很難推算。

\\

遇到的第二個挑戰是相機管理器與裝置管理器過於耦合了。想要理解這個問題,就得先了解一些架構的歷史了。相機管理器(CM)是從通用裝置管理器(DM)發展而來的,後者可以管理各種型別的裝置。這就導致需要和其他Nexia的部分共享核心。這成為了一個重大的部署問題,這意味著基本上我們就不能升級Java了。最終,我們認識到這種耦合是沒有必要的。儘管攝像機是個裝置,但是它和其他裝置沒有很多類似之處,比如門鎖等等。

\\

第三個挑戰真正涉及到了DDD,領域知識出現在錯誤的位置。大多數領域邏輯是在Java相機管理器程式碼之中,這就代表著增加新的功能會很複雜、耗時、容易發生錯誤以及很難測試。同時,修改程式碼代表著要對所有東西進行霰彈式修改。

\\

DDD關注點

\\

我列出所有問題,不僅僅是吐槽糟糕的程式碼,而是要明確它們並不是不可克服的挑戰。此外,DDD提供了可以在很大程度上改善這種情況的技術。首先回顧一下DDD的四大關注點。

\\

首先,我們希望在程式碼中演進並表達深層的領域模型。第二,我們希望將程式碼重構為通用的語言,內容一致易於理解,程式碼意圖清晰。第三,我們要清楚地描述模型和模組的邊界和職責。很難在沒有明確邊界的情況下實現高內聚和鬆耦合。最後,我們需要嚴格遵守模型邊界(即有界的上下文),跨邊界時進行顯式轉換。

\\

從何開始?

\\

當你遇到這樣的程式碼的時候你會怎麼做?現在有一些選擇,我知道現在一些人已經試過某些選擇了,但並沒有成功。一個選擇是“清除積水”,嘗試刪除所有舊的程式碼,重新開始。人們可能需要空出幾個禮拜進行重大重構,直到“修復”的時候再解脫出來。第二種選擇是將問題甩給別人,自己不做任何處理。

\\

我喜歡選擇進行試驗,看看你能做什麼。我喜歡2012年夏季奧運會英國自行車隊獲得金牌的故事。這個團隊進行了許多試驗,找了很多方法,從小的地方開始改進,並做了許多改變。英國自行車隊負責人Sir Dave Brailsford說:“我覺得我們應該從小處進行改變,通過小收益的累積,採取不斷提升的哲學思想。拋棄完美,關注事情的進展,嘗試各種改進。”對於敏捷軟體開發人員來說,持續改進的思想並不陌生。

\\

一小步

\\

在我們的專案裡,我們嘗試了各種不同的事情,但大多數都沒有用。在2014年3月,我們嘗試了“一小步”,我們意識到攝像機的概念實際上需要完成兩個不同的任務。它充當了物理裝置,也叫實體。但同時它還需要作為命令處理程式,提供向物理裝置傳送命令和查詢的介面。

\\

首先看一下Ruby程式碼,我們發現這兩個任務都在攝像機物件中。它是裝置的子類,有許多問題。由於沒有進一步的子類,所有的邏輯都在巨大的“上帝”物件攝像機中。

\\

我們首先決定新增新的領域服務,而不是改變攝像機物件,這遵循了開放和修改的原則。這個新的Camera::CommandService存放所有的攝像機指令和查詢。由於我們將它作為擴充套件來寫,我們可以用好的測試驅動開發和結對程式設計實踐來實現它,在不破壞其他東西的情況下創造更高質量的設計工作。我們有一個很好的測試元件,它覆蓋了controllers、 workers和collaborators,我們可以更放心地進行更新。這一小步實現了雖然小,但是不可忽視的改進。

\\

尋找接縫

\\

Martin Fowler在其《修改程式碼的藝術》一書中聊到了尋找程式碼的接縫,也就是你可以加入新東西。我們召開了事件風暴會,瞭解裝置註冊Nexia的不同方法,這有助於視覺化這些工作流中的相似之處。通過檢視Java程式碼,我們發現相機管理器元件太過“智慧”,它僅需要管理攝像機會話就可以了,但卻做了太多其他事情。我們希望讓相機管理器成為通用的http代理,由於所有指令都是HTTP呼叫,並整合Ruby的所有攝像機邏輯。

\\

我們在接縫相機管理器加入了新的通用send_url()指令。我們將http代理模型運用在連線管理、驗證、攝像機到入口訊息傳遞和日誌記錄上(來幫助故障排除以及未來計劃)。在Ruby端,我們可以在前一年的進展上,使用Camera:CommandService向攝像機傳送任何指令。

\\

遷移領域邏輯

\\

在推行了一小步並發現新的接縫一年之後,我們可以將攝像機的領域邏輯從Java端遷移到Ruby端。我們將Camera::CommandService指令(例如Pan-Tilt)遷移到使用通用的相機管理器介面。這個方法最棒的地方是不需要修改Java程式碼。作為Ruby端的內部重構,我們可以只使用一個指令進行測試,並迭代它直到可以執行。

\\

我聊到這個故事的時候,通常會被問一個問題:“你怎麼驗證這些重構?”我指出,我們正在繼續交付應用程式的功能,這是我們隨時可以進行的工作。同時,這些小的步驟也獲得了一些進展。由於我們為攝像機提供通用的URLs,可以從Ruby傳送指令,因此我們可以在所有安裝的相機上批量升級韌體。此外,我們可以在Ruby端簡單、快速地進行變更,這代表著我們可以進行試驗,發現其他的邊界改進。在這之前,需要Java和Ruby合作才能完成變更。

\\

我想再次強調小進展的重要性。非技術利益相關者並不關心你是否重構程式碼。我相信一般來說,他們相信你是專業人士,會竭盡最大努力寫可維護的程式碼。這代表著你必須建立信任和信譽,可以通過實現小的進展來完成這一點。

\\

我們還發現,重構可以幫助清理程式碼。在Camera::CameraWorker中就有三個驗證。首先,在重新連線的時候驗證已存在的攝像機。其次,處理新的攝像機的建立和驗證。第三,去掉“殭屍”攝像機,就是已經連線但沒有驗證的攝像機。通過重構到更深層的模型,程式碼可以更容易地推斷,正如圖5中的8-14行所示。

\\

e8790d48d8c0b9f27c8e6ba142f168d3.jpg
圖5:驗證的三個不同方向

\\

在我們引入了更多的領域邏輯之後,Ruby程式碼佔據了Nexia普遍用的語言的比重更高了。我們不需要給攝像機物件進行許多修改,並設定許多屬性,我們發現工廠模式更加適合。普遍使用的語言包括心跳的概念,對於這個系統來說就是攝像機連線到Nexia,就像它們或者一樣。之後我們創造了名為update_from_heartbeatcreate_from_heartbeat的工廠方法,來分別處理現有的攝像機和新的攝像機。

\\

Java端也得益於重構。較之前在圖3中部分展示的handleRequest()方法,變成了圖6中的5行程式碼。對一些提取的方法進行重構,功能變得更加容易理解。

\\

bccd50ff6cfb4792cfd6efc2ad8f9dff.jpg
圖6:新Java程式碼示例(與圖3相比較)

\\

攝像機類很大,因此你經常會跑到這段程式碼裡。小貼士,處理這種情況並不需要通過程式碼重構使它更加清晰。當程式碼雜亂不堪時,簡單地重新排列一下程式碼會很有效,雖然它只是個簡單的設計技巧。看一看模式,把類似的方法放在一起,這將緩解你在處理龐大程式碼域時的認知負擔。

\\

重構到更深層次

\\

處理一個龐大、混亂的程式碼庫就像霧中漫步,你不能看到周圍的一切,你可能看到的只是一棵樹,或是一座山。當你做了一些小的變更之後(如重組織程式碼,提取方法),這些小的收益會累積,霧開始消散。實現微重構和Fowler和Kerievsky提到的模式能產生累積的效果,因此可以對模型有更深層次的瞭解。

\\

比如說,在Ruby端,我們發現我們正在向攝像機傳送指令。所以我們按照Kerievsky的建議,使用命令模式,使之大大簡化了。我們為指令設定了基類,以及標準的execute()方法。之後我們建立了camera/command資料夾,開始寫每個指令,實現這個基類。此外,我們還引入了功能開關,幫助舊程式碼繼續執行,直到相應的指令已經轉換。我強烈推薦使用功能開關來幫助你安全地進行重構。

\\

我推薦的另一個方法是階段性發布。我們希望避免每臺攝像機突然斷開連線的情況,大多數現有的客戶群的產品都有共同的目標,就是不要影響到所有客戶。在第一個月我們僅僅部署到Nexia IP地址,讓QA、開發人員和支援人員在部署到客戶之前先嚐試新系統。第二個月我們加大部署,部署到一部分客戶,但只使用一個生產伺服器。直到第三個月我們才會部署到所有的攝像機、所有的客戶和所有的生產伺服器上。這比我職業生涯中參與的其他生產環境釋出都要順利。

\\

功能開關和階段性發布的另一個好處是它們提供了有價值的選擇。我推薦《Commitment: Novel about Managing Project Risk》一書,介紹了實際選擇權的概念。通常,當某人在會議中說“我們需要作出決定”的時候,就會有兩個選擇,一個是根據有限的知識作出選擇,要麼不做選擇。實際選擇權指出還有第三個選項,在我們更好地理解之前,戰略性地推遲決策。功能開關和階段性發布都可以幫助你推遲做出決定,直到你可以做出更好的決定為止,這非常關鍵。

\\

回顧

\\

回看一開始的時候,我們已經獲得了很大的成就。之前,新增新的攝像機需要幾周甚至幾個月。現在,我們可以在幾小時內新增新的攝像機。我們不再需要在Java和Ruby中都作出變更,並保持程式碼的同步,我們只需要在Ruby中進行修改。儘管舊程式碼在一些方面不一致,但新的程式碼更加內聚,也很容易推斷,因為上下文很清晰。我們移除了相機管理器裝置管理器之間粗糙的依賴,所以我們可以更新Java了。

\\

根據這些經驗,有一些通用的重構技巧。不要只選擇一個變更的實現方法,比如說命名新的東西,嘗試至少三種語言和/或模型選項。在你的日常工作中,同樣要注意一些小的收益。我們往往太過於高估大變更的效果,卻低估了小的累積的變化的力量。

\\

有關作者

\\

ad6dd468522ce92a88f0ba0a97c64f5c.jpgPaul Rayner 是開發者、教練、導師、培訓師以及國際流行會議講師。他在各種行業擁有超過25年的軟體開發經驗,他是經驗豐富的軟體設計教練和領導力導師,幫助團隊點亮他們的設計技巧。他的諮詢公司Virtual Genius LLC為敏捷團隊提供軟體設計的指導和培訓。Rayner生在澳大利亞珀斯,他在科羅拉多丹佛和妻子及兩個孩子生活、工作和玩耍。他在推特@ThePaulRayner上用澳大利亞英語發表推文,並在thepaulrayner.com釋出部落格。

\\

檢視英文原文Refactoring to a Deeper Model

\\

感謝冬雨對本文的審校。

相關文章