重構遺留程式碼(7):識別表示層

EluQ發表於2014-11-28

舊程式碼,醜陋的程式碼,複雜的程式碼,義大利麵條似的程式碼,鬼話廢話……就是四個字:遺留程式碼。這是一個系列文章,將有助於你處理並解決它。

在我們重構教程的第七章,我們將做些不同型別的重構。我們注意到在過去的教程中有個表示層相關的程式碼遍佈在遺留程式碼中。我們將盡我們所能試著識別所有的表示層相關程式碼並且採取必要步驟將其從業務邏輯中分離出來。

驅動力

無論何時我們重構修改程式碼,都是根據一些指導原則這麼做的。這些原則和規則幫助我們識別問題,並且許多情況下,它們在為了使程式碼更好的正確方向上指引著我們。

單一職責原則(The Single Responsibility PrincipleSRP)

SRP是我們非常詳細地在之前教程中談到的一個堅實的原則之一:SOLID: Part 1 – The Single Responsibility Principle。如果你想要了解細節,我建議你讀讀這篇文章,否則就往下繼續閱讀來看看下面單一職責原則的概述。

SRP基本上說的是,任何模組,類或者方法只應有單一的職責。這樣的職責定義為修改的核心。修改的核心是修改的方向,理由。所以,SRP意味著我們類的修改應該有單一的原因。

既然這聽起來很簡單,那麼你怎麼定義“修改的原因”?我們必須從程式碼的使用者的角度來考慮這個問題,無論是普通的終端使用者還是各個軟體部門。這些使用者可以代表演員。當一位演員想要我們修改程式碼,那就是一個決定修改核心的修改理由。這樣的一個要求如果可能的話應該隻影響我們的模組,類或者甚至是方法中的一個。

一個非常顯著的例子可能是,如果我們的UI設計團隊要求我們以某種方式提供需要被呈現的所有資訊,以便我們的應用程式能夠將其傳遞到HTML網頁,來替代我們目前的命令列介面。

正如現在我們的程式碼所表示的,我們可以只將所有的文字傳送給一些能將其傳遞給HTML的額外的智慧物件上。但這可能起效僅僅因為HTML主要是基於文字的。那如果我們的UI團隊想要以有視窗,按鈕和許多表格的桌面UI來呈現我們的益智遊戲會怎麼樣?

如果我們的使用者想要以代表城市街道的虛擬遊戲板,玩家作為在街上走來走去的人們這樣來玩遊戲會怎麼樣?

我們可以定義這些人為UI演員。並且我們必須意識到現在我們程式碼所代表的,可能我們需要修改trivia類和幾乎它的所有方法。如果我想要修正螢幕上文字中一個錯字去修改Game類的wasCorrectlyAnswered()方法或者如果我想要以虛擬遊戲板來展現我們的trivia軟體,這聽起來合乎邏輯嗎?不。答案絕對是不。

 

乾淨的架構

乾淨的架構是Robert C.Martin主要推廣的一個概念。主要說的是我們的業務邏輯應該很好地定義並且與對系統核心功能不想關的其他模組清晰得分隔開。這將帶來解耦和高可測試的程式碼。

你可能在我的教程和課程中已經見過這張圖。我認為它如此重要以至於在我不考慮它的時候從不寫程式碼或者談論程式碼。它完全地改變了我們在Syneto寫程式碼的方式和我們專案的樣子。之前我們所有的程式碼都在MVC框架下,業務邏輯放在Models中。這樣既難理解又難測試。而且業務邏輯與特定的MVC框架完全耦合。而這麼做可能對小寵物專案來說可以起作用,但當涉及到一個公司將來可以賴以依靠,包括在某種程度上所有員工的一個大專案時,你就必須停止再用MVC框架並且必須開始思考如何組織你的程式碼。一旦你這麼做了並且也做對了,你將永遠不再想回到你曾經架構專案的方式上去。

 

察所屬

在之前的一些教程中我們已經開始從表示層分離業務邏輯了。我們有時注意一些列印功能並將它們抽出到同一個Game類的私有方法中。這是我們的潛意識告訴我們在方法層面將表示層從業務邏輯中分離。

現在是時候分析和觀察了。

這是Game.php檔案中所有的變數,方法和函式的列表。標記橘色“f”的是變數。紅色“m”的是方法。如果跟隨著一個綠色的鎖,它就是公開的。如果是跟著紅色的鎖,它就是私有的。從上面的列表中,所有我們所感興趣的是下面的部分。

所有選中的方法有些共同的東西。它們所有的名字都以“display”…開頭。它們是所有和列印到螢幕相關的方法。它們是所有在之前教程中被我們識別並無縫抽取的方法,一次一個。現在我們必須看到它們是屬於一個整體的一組方法。做一件具體的事情,滿足一個單一的職責,在螢幕上顯示資訊的一組方法。

 

提取重構

Martin Fowler在重構-改善既有程式碼的設計中對於提取類重構最好的例子和解釋是,在你意識到你的類要做的工作應該由兩個類來完成之後,你應該去構築兩個類。對著來說有特定的機制,正如下文中從上述的書中引用的解釋:

  • 確定如何分割類的職責
  •  建立一個新類來表述分隔開的職責。

○   如果舊類的職責不在與其類名匹配,就重新命名舊類。

  • 從舊類到新類做個連線。

○   你可能需要一個雙向連線。直到你發現你需要它為止,不要建立反向連線。

  • 對每個你想移動的欄位使用移動欄位。
  • 在每次移動之後編譯並測試。
  • 使用移動方法將舊類的方法移動到新類中。從低等級方法(被呼叫而不是呼叫)開始並向高等級構築。
  • 在每次移動之後編譯並測試。
  • 複審並削減每個類的介面。

○   如果你確實有雙向連線,檢檢視看是否可以改成單向的。

  • 確定是否暴露新類。如果你暴露了新類,確定是否將其作為引用物件或者不可變的值物件來暴露。

 

應用提取類

不幸的是,在寫這篇文章的時候,在PHP上沒有一個IDE可以只通過選擇一組方法並從選單選擇一個選項來完成提取類。

因為了解處理程式碼的流程機制永遠不會有壞處,所以我們將採取上面的步驟,一個一個將方法重構到我們的程式碼中。

 

確定如何分割職責

我們已經知道這個了。我們想要將表示層從業務邏輯分離出來。我們想要將輸出,顯示功能和其他的程式碼移動到其他地方去。

 

建立一個新類

我們第一步是建立一個新的,空的類。

是的,這就是目前能做的。並且為其尋找一個合適的名字合適相當容易的。Display是代表我們開始感興趣的所有方法的詞。這是它們名字的共同點。這是關於它們共同行為一個相當強大的建議,根據行為我們定義新的類。

如果你願意並且你的程式語言支援,PHP是支援的,你可以在舊類的相同檔案中建立一個新的類。或者,你可以從一開始就為其建立一個新的檔案。我個人發現沒有明確的理由按照哪種方式或者禁止任何一種方式。所以這取決於你。只要決定了就繼續吧。

建立從舊類到新類的連線

這一步可能聽起來不是很熟悉。它的含義是,在舊類中宣告一個類變數,並將其作為新類的例項。

簡單。不是嗎?在Game的建構函式中,我們只初始化了一個和新類同樣名字的私有類變數,display。我們也需要將Display.php引入我們的Game.php檔案。我們還沒有自動裝載器。也許未來的教程中如果需要的話我們可能會引入一個。

和往常一樣,別忘了執行你的測試程式。單元測試在這個階段足夠了,只要保證在新加的程式碼中沒有錯字。

 

移動域和編譯/測試

讓我們一次做這兩步。從Game到Display我們能識別什麼域?

只通過觀察列表…

…我們找不到任何變數/欄位必須屬於Display的。也許過段時間一些變數會浮現出來。所以這步什麼都不做。至於測試程式,剛才我們已經執行過了。繼續吧。

 

移動方法到新的類

這本身,是另一個重構。你可以以好幾種方式來做,也將在我們之前談論的相同書中發現精妙的定義。

正如上面提及的,我們應該從最低等級的方法開始。就是那些沒有呼叫其他方法的,而被呼叫的方法。

displayPlayersNewLocation()似乎是個不錯的候選方法。讓我們分析下它做了什麼。

我們可以看到在Game類中它並沒有呼叫其他方法。取而代之的,它使用了三個域:players,currentPlayer和places。這些可以變成2或3個引數。到目前為止相當不錯。但我們方法中唯一的函式呼叫echoln()怎麼辦?echoln()是從哪兒來的呢?

它在Game.php檔案的頂部,在Game類本身的外面。

它確實如其表達的那麼實行了。重複一個字串並以一個新行結束。並且這是純表示。他應該放入Display類中。那麼讓我們將其拷貝到那裡。

再次執行我們的測試程式。直到我們完成提取所有的表示層程式碼到新的Display類中之前,我們可以使金牌大師測序保持禁用。任何時候,如果你感到輸出可能已被修改,那麼也要再次執行金牌大師測試程式。從這點來說,就通過拷貝函式到新的地方來說,測試程式將證明我們沒有引入錯字或者重複方法宣言,或者任何其它錯誤。

現在,去刪除Game.php檔案中的echoln()吧,執行我們的測試程式,並期望他們失敗。

幹得漂亮!我們的測試程式在這兒幫了大忙。它執行得很快並且告訴了我們問題的準確位置。我們去看55行。

 

看啊!那兒有個echoln()呼叫。測試程式從不撒謊。讓我們通過呼叫$this->display->echoln()來修正它吧。

這使得測試程式通過55行但在56行失敗了。

解決方案很明顯。這是一個繁瑣的過程,但至少它是容易的。

這真的使最初的三個測試程式通過了,也告訴我們我們下一個要修改的呼叫的地方。

那是在wrongAnswer()中。

修正這兩個呼叫,將錯誤向下推到了228行。

一個display方法!也許這是我們最先應該移動的方法。我們試著在這兒做點測試驅動開發(TDD)。當測試失敗時,我們不允許寫任何對讓測試通過來說不是完全必須的產品程式碼。而所需的僅僅是修改echoln()呼叫直到我們的單元測試通過。

你可以通過使用IDE或者編輯器的查詢和替換功能來加速這一過程。只要在你完成了這個替換後,執行所有的包括金牌大師在內的測試程式。我們的單元測試沒有覆蓋所有的程式碼,和所有的echoln()呼叫。

我們可以處理最初的候選方法,displayCurrentPlayer()。將它複製到Display中並執行你的測試程式。

然後,在Display類中將其設為public,在Game類的displayCurrentPlayer()中用呼叫$this->display->displayCuttentPlayer()來替代直接用echoln()。最後,執行你的測試程式。

它們將失敗。但通過這種方式的修改,我們確定只改了一樣可能失敗的程式碼。所有其他的方法仍然在呼叫Game類的displayCurrentPlayer()。並且這是對Display的委託。

我們的方法使用類欄位。這些需要作為函式的引數。如果你跟蹤測試程式的錯誤,你應該看到Game中以下面這樣結束的程式碼。

在Display中。

用Display中的本地方法來替換對Game類的呼叫。也不要忘了將引數提高一個層級。

最後,從Game類中移除沒有用到的方法。並且執行測試程式保證一切都沒問題。

這是一個繁瑣的過程。通過一次採取多種方法和使用無論你的IDE能做的來幫助在類之間移動和替換程式碼,你可以提點速。剩下的方法將留作你的練習,或者你可以閱讀本章更多高亮顯示的過程。本文所附的完成程式碼將包含完整的Display類。

啊,別忘了Game類沒有被提取到“display”方法中的程式碼。你可能直接移動那些echoln()呼叫到display中。我們的目標是完全不呼叫Game類中的echoln(),並在Display類中將其設為private。

在做了短短半小時這樣的工作之後,Display開始變得精妙起來。

 

所有來自Game類的顯示方法都放到Display類中了。現在我們也可以看看仍然留在Game類中的所有echoln呼叫並且移動它們了。當然,測試是通過的。

但我們一面對askQuestion()方法,我們就意識到它也只是表示層程式碼。這意味著各個問題陣列也應該放到Display類中。

這看起來是恰當的。問題只是字串,我們展示它們並且它們在這兒也適應得更好。當我們做這種型別的重構時,對於新移動的程式碼來說也是個重構的好機會。我們定義宣告欄位的初始值,將它們設為私有,並建立一個需要被執行的方法以便它不只是徘徊在建構函式中。取而代之的,它被藏在類的底部,並不礙事。

在抽取了如下兩個方法之後,我們意識到在Display類中,不以“display”開頭來命名它們更好。

隨著我們測試程式通過並且做得很好,現在可以重構並重新命名方法了。PHPStorm可以很好地處理重新命名重構。它會根據Game類重新命名函式呼叫。下面就是這段程式碼。

仔細看看選中的119行。那看起來像是我們不久前在Display類中抽取的方法。

但如果我們呼叫它而不是程式碼,測試將失敗。是的!那有個錯字。也不是!不該修正它。我們在重構。我們必須保證功能不變,即使有個缺陷。

方法剩下的部分顯示出沒有特殊的挑戰。

 

複審並削減介面

現在所有的表示層功能都在Display類中了,我們必須複審方法並保持它們在Game類中呼叫的方法為公開的。這一步驟也是出於我們過去教程中談到的介面隔離原則

在我們的例子中,識別什麼方法需要為公開或者私有最簡單的方式是一次將每一個方法都設為私有的,執行測試程式,如果它們失敗了就將其轉為公開的。

因為金牌大師測試執行得慢,我們也可以依賴IDE來加速這一過程。PHPStorm足夠智慧而能分清方法是否沒有用到。如果我們設一個方法私有的,而它突然變得未使用,很明顯它是在Display類外使用的,需要將其改為公開。

最後,我們可以重新組織Display類以便私有方法放在類的最後。

 

最後的思考

現在,提取類重構原則的最後一步在我們的例子中是不相關的。因此,總結下教程,但還不是這個系列的總結。請繼續關注下一片文章,我們將進一步得到一個乾淨的架構,並且反轉依賴。

相關文章