舊程式碼,醜陋的程式碼,複雜的程式碼,義大利麵條似的程式碼,鬼話廢話……就是四個字:遺留程式碼。這是一個系列文章,將有助於你處理並解決它。
- 重構遺留程式碼(1):金牌大師
- 重構遺留程式碼(2):魔術字串和常量
- 重構遺留程式碼(3):複雜的條件語句
- 重構遺留程式碼(4):第一個單元測試
- 重構遺留程式碼(5):遊戲的可測試方法
- 重構遺留程式碼(6):進攻複雜的方法
- 重構遺留程式碼(7):識別表示層
是時候談談架構和我們如何組織新建立的程式碼層了。是時候試著將我們的應用程式對映到理論架構設計上了。
整潔架構
這是我們已經在文章和教程中到處可見的。整潔架構。
在一個較高階別上,看起來像上面這樣的結構,我確信你已經熟悉了。它是Robert C. Martin推薦的架構解決方案。
我們架構的核心是業務邏輯。這些是代表我們的應用程式試圖去解決的業務流程的類。這些是代表我們問題域的實體和互動。
接著便是圍繞我們業務邏輯的一些其他型別的模組或者類。這些可看做是簡單的幫助輔助模組。它們有不同的用途並且它們中的大部分是不可或缺的。它們通過一種傳送機制聯絡了使用者和我們應用程式。在我們的例子中,這是個命令列介面。還有另一套輔助類用來連線我們的業務邏輯和表示層及其所有的資料,但在我們的應用程式中沒有這樣的層。還有像工廠和生產者這樣為我們的業務邏輯構造並提供新物件的幫助類。最後,有代表我們系統入口點的類。在我們的例子中,GameRunner可以認為是這樣的類,或者我們所有的測試程式也可以以它們各自的方式作為入口點。
圖中最值得注意的是依賴方向。所有的輔助類都依賴業務邏輯。業務邏輯不依賴任何其它類。如果我們業務邏輯中的所有物件能神奇地呈現,其包含了所有的資料,無論計算機發生了什麼我們都可以直接看見,它們應該能發揮功能。我們的業務邏輯必須能夠在沒有使用者介面或者沒有表示層的情況下發揮功能。我們業務邏輯必須獨立存在,是在邏輯天地裡的一個氣泡。
依賴反轉原則
A.高階別模組不應依賴低階別模組。兩者都應依賴抽象。
B.抽象不應依賴細節。細節應該依賴抽象。
就是它了,最後一個堅實的原則,也許是對你程式碼有最大作用的那個。它既很容易理解也很容易實現。
簡單來說,就是具體的事物應該重視依賴抽象的事物。你的資料庫很具體,所有它應該依賴某些更抽象的。你的使用者介面很具體,所以它應該依賴某些更抽象的。你的工廠又是很具體的。但你的業務邏輯如何,在你的業務邏輯中你應該持續應用這些觀點,以便接近邊界的類依賴更加抽象的、更加接近業務邏輯的核心的類。
一個純粹的業務邏輯,代表了一種抽象的方式,定義域或者業務模型的流程和行為。這樣的業務邏輯不含有細節(具體的事物)如值,金錢,賬戶名,密碼,按鈕的大小或者表單中的數字域。業務邏輯不應關心具體的事物。它應該只關於你的業務流程。
技術訣竅
所以,依賴反轉原則(DIP)說的是,無論何時當程式碼依賴某些具體事物之時,我們應該反轉依賴。目前我們的依賴結構像這樣。
GameRunner,使用RunnerFunctions.php中的函式建立一個Game類然後使用它。另一方面,Game類代表我們的業務邏輯,建立並使用一個Display物件。
那麼,執行者依賴我們的業務邏輯。這是正確的。另一方面,Game類依賴Display類,這就不好了。業務邏輯永遠不應該依賴表示層。
我們能用的最簡單的技術訣竅是在我們的程式語言中使用抽象結構。一個傳統類比一個抽象類更具體,而抽象類比介面更具體。
抽象類是不能被初始化的特殊型別。它只包含定義和部分實現。一個抽象基類通常有多個子類。這些子類從抽象父類繼承共有的部分功能,增加它們自己的擴充套件行為,它們必須實現在抽象父類中定義的所有方法而不是在抽象類中實現。
介面是隻允許方法和變數定義的特殊型別。它是物件導向程式設計中最抽象的結構。任何實現總是必須實現其父類介面的所有方法。一個具體的類可以實現多個介面。
除了C家族的物件導向程式語言,其他像Java或PHP是不允許多繼承的。所以一個具體的類可以擴充套件單一的抽象類但如果需要的話它可以實現多個介面,甚至可以同時實現。或者從另一個角度出發,單一的抽象類可以有許多實現,而許多介面可以有許多實現。
對於DIP一個更全面的解釋,請閱讀獻給這個堅實原則的教程。
使用介面反轉依賴
PHP完全支援介面。從Display類開始作為我們的模型,我們可以定義一個包含對所有類負責的公共方法和有需要實現來顯示資料的介面。
看著Display的方法列表,有12個公開的方法,包括了建構函式在內。這是一個相當大的介面,你應該保持這個數字儘可能低,當客戶端需要它們時才暴露介面。介面隔離原則關於這方面有些不錯的想法。也許我們將在未來的教程中試著處理這個問題。
我們現在想要得到的是類似下面的一個架構。
這種方式,取代了Game類依賴更多Display具體的方法的是,它們都依賴非常抽象的介面。Game類使用介面,而Display類實現它。
命名介面
Phil Karlton說,“電腦科學中只有兩件難事:快取失效和命名事物”。
雖然我們不關心快取,但我們需要命名類,變數和方法。命名介面會是相當大的挑戰。
在匈牙利表示法的舊時代,我們可以以這種方式來做。
關於這張圖,我們使用了實際的類/檔名和實際的大寫。介面以 “Display”簽名加上一個字母“I”而命名為“IDisplay”。實際程式語言中需要這樣為介面命名。我確信一些讀者仍然在使用它們並且此刻笑了。
這種命名模式的問題是錯位關注。介面屬於它們的客戶。我們的介面屬於Game類。因此Game類肯定不知道它在用一個介面或一個真實的物件。Game不必關心它真正得到的實現。從Game類的角度來看,它只是用了“Display”,僅此而已。
這解決了Game類呼叫Display類的命名問題。對實現使用“Impl”字尾比較好些。它有助於消除Game類的關注。
對於我們來說它也更有效。想想Game類現在的樣子。它使用一個Display物件並知道怎麼使用它。如果我們將介面命名為“Display”,我們將減少Game中需要變更程式碼的數量。
但儘管如此,這個命名只是略好於前面的那個。它只允許對Display的一個實現,並且實現的名字不能告訴我們提及的是哪種顯示。
現在相當好了。我們的實現命名為“CLIDisplay”,如同它輸出到CLI。如果我們需要HTML輸出或者Windows桌面使用者介面,我們可以很容易將其新增到我們的架構中。
帖代碼
因為我們有兩種型別的測試程式,慢的金牌大師和快的單元測試,我們想盡可能多得依賴單元測試,儘可能少得依賴金牌大師。所以讓我們將金牌大師標記為跳過而試著依賴單元測試。它們現在是通過的,我們想做些修改讓其能保持通過狀態。但不做上面所有建議的修改,我們怎麼做這樣的事?
有沒有允許我們採取較小步驟的測試方式呢?
模擬儲存這一天
有這樣一種方式。在測試中,這種觀念成為“模擬”。
維基百科這樣定義模擬,“在物件導向程式設計中,模擬物件是以控制的方式模模擬實物件行為的物件”。
這樣的一個物件將為我們帶來很大幫助。事實上,我們甚至不需要複雜得像模擬所有行為的物件。我們需要的是個假的,呆笨的我們能夠送入Game類來取代真實顯示邏輯的物件。
建立介面
讓我們建立一個稱為Display,包含所有當前具體類所有公開方法的的介面。
正如你看到的,舊的Display.php被重新命名為DisplayOld.php了。這只是臨時步驟,這允許我們將其排除出去並專注於介面。
1 2 3 |
interface Display { } |
這就是建立一個介面。你可以看到它被定義為“interface”而不是“class”。讓我們增加方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
interface Display { function statusAfterRoll($rolledNumber, $currentPlayer); function playerSentToPenaltyBox($currentPlayer); function playerStaysInPenaltyBox($currentPlayer); function statusAfterNonPenalizedPlayerMove($currentPlayer, $currentPlace, $currentCategory); function statusAfterPlayerGettingOutOfPenaltyBox($currentPlayer, $currentPlace, $currentCategory); function playerAdded($playerName, $numberOfPlayers); function askQuestion($currentCategory); function correctAnswer(); function correctAnswerWithTypo(); function incorrectAnswer(); function playerCoins($currentPlayer, $playerCoins); } |
是的。介面只是一堆函式的宣告。想象它是C的標頭檔案。沒有實現,只有宣告。它完全不能包含實現。如果你試著實現任何方法,它將導致錯誤。但這些非常抽象的定義允許我們做一些很棒的事。我們的Game類現在依賴它們,而不是一個具體的實現。然而,如果我們是試圖執行測試程式,它們將失敗。
1 |
Fatal error: Cannot instantiate interface Display |
這是因為Gmae類試圖在它的建構函式的第25行中建立一個新的display物件。
我們知道不能那麼做。介面或者抽象類不能被例項化。我們需要一個真實的物件。
依賴注入
在我們的測試中需要一個虛擬物件。一個簡單類,實現所有Display介面的方法,但什麼也不做。讓我們直接把它寫在單元測試中。如果你的程式語言不允許在同一檔案中有多個類,請自由為你的虛擬類建立一個新檔案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
class DummyDisplay implements Display { function statusAfterRoll($rolledNumber, $currentPlayer) { // TODO: Implement statusAfterRoll() method. } function playerSentToPenaltyBox($currentPlayer) { // TODO: Implement playerSentToPenaltyBox() method. } function playerStaysInPenaltyBox($currentPlayer) { // TODO: Implement playerStaysInPenaltyBox() method. } function statusAfterNonPenalizedPlayerMove($currentPlayer, $currentPlace, $currentCategory) { // TODO: Implement statusAfterNonPenalizedPlayerMove() method. } function statusAfterPlayerGettingOutOfPenaltyBox($currentPlayer, $currentPlace, $currentCategory) { // TODO: Implement statusAfterPlayerGettingOutOfPenaltyBox() method. } function playerAdded($playerName, $numberOfPlayers) { // TODO: Implement playerAdded() method. } function askQuestion($currentCategory) { // TODO: Implement askQuestion() method. } function correctAnswer() { // TODO: Implement correctAnswer() method. } function correctAnswerWithTypo() { // TODO: Implement correctAnswerWithTypo() method. } function incorrectAnswer() { // TODO: Implement incorrectAnswer() method. } function playerCoins($currentPlayer, $playerCoins) { // TODO: Implement playerCoins() method. } } |
一旦你說你的類實現了介面,IDE將允許你自動填寫缺失的方法。這使得建立這樣的物件非常快,只要幾秒鐘時間。
現在讓我們通過Game類的建構函式初始化並使用它。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function __construct() { $this->players = array(); $this->places = array(0); $this->purses = array(0); $this->inPenaltyBox = array(0); $this->display = new DummyDisplay(); } |
這使得測試通過,但引入了一個巨大的問題。Game類肯定知道了它的測試程式。我們真不想要這樣。測試程式只是另一個入口點。DummyDisplay只是另一個使用者介面。我們的業務邏輯,Game類,不應該依賴使用者介面。所以讓我們使其只依賴介面。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function __construct(Display $display) { $this->players = array(); $this->places = array(0); $this->purses = array(0); $this->inPenaltyBox = array(0); $this->display = $display; } |
但為了測試Game類,我們需要從測試程式送入虛擬的顯示。
1 2 3 4 5 |
function setUp() { $this->game = new Game(new DummyDisplay()); } |
就是這樣。我們需要修改單元測試中的一行。在setup方法中,我們將把DummyDisplay的一個新例項作為引數傳送到Game類。這就是依賴注入。使用介面和依賴注入是有幫助的,特別是當你在一個團隊工作的時候。我們在Syneto觀察到,指定一個類的介面型別並且注入它,將有助於我們與客戶端程式碼的意圖更好得溝通。任何看著客戶端的人將知道引數中使用的物件型別。並且一個額外的好處是你的IDE將自動完成這些方法,因為它可以確定這些引數的型別。
對金牌大師一個真實的實現
金牌大師測試程式,在真實世界執行我們的程式碼。為了使其通過,我們需要將舊的Display類放入真實的介面實現中,並將其放入我們的業務邏輯。這是這麼做的一種方式。
1 2 3 4 5 |
class CLIDisplay implements Display { // ... // } |
將其重新命名為CLIDisplay並使其實現Display。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function run() { $display = new CLIDisplay(); $aGame = new Game($display); $aGame->add("Chet"); $aGame->add("Pat"); $aGame->add("Sue"); do { $dice = rand(0, 5) + 1; $aGame->roll($dice); } while (!didSomebodyWin($aGame, isCurrentAnswerCorrect())); } |
在RunnerFunctions.php的run()函式中,為CLI建立一個display物件並且當其建立後將其傳遞給Game類。
取消並執行你的金牌大師測試程式。它們將通過。
最後的思考
這種解決方案有效地引向瞭如下圖所示的架構。
所以現在我們的遊戲執行者,作為我們應用程式的入口點,建立了一個具體的CLIDisplay類然後依賴它。CLIDisplay類只依賴在表示層和業務邏輯邊界上的介面。執行者也直接依賴業務邏輯。這就是我們的應用程式投射到文章開始所用的整潔架構上所呈現的樣子。
感謝你的閱讀,別錯過我們下個談論詳細的模擬和類互動的教程。