-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcontent.json
1 lines (1 loc) · 143 KB
/
content.json
1
{"pages":[],"posts":[{"title":"軟體開發中的“無緒”","text":"無緒 (Cluelessness) 由 Martin Rinard 提出。他在演講時指出: 在開發和維護軟體系統時,應該避免讓開發人員深入了解系統。 因為人的大腦可以處理的資訊有限。若要建立一個日益變大的應用程式,就必須學習「如何讓每個開發人員在不了解整個應用程式的情況下,也能完成軟體開發」。 “無緒” 並不是一個貶義詞。它用來區別兩種層次的理解水平。 淺層理解:指對事物的了解程度僅限於掌握使用方法即可。 深層理解:指對掌握了事物背後的原則、規律、原理。 在日常生活中的“無緒”生活中我們通常只需要用到 淺層理解。例如,刷牙不需要知道化學式。不需要理解冰箱原理就可以冷凍食品。當然,也有一些人需要了解更深入的內容。像是冰箱的維修人員就要了解較深入的領域知識。但即便如此,維修人員所需要的知識仍屬於 淺層理解,因為他們也不需要了解事物背後的每個小細節和原理。 當然也可以學習每件事物背後的知識原理,但是必要性與 CP 值通常不大。因此大多數人在日常生活中只需要 淺層理解 就足夠了。 軟體開發中的“無緒”在軟體開發中,“無緒” 也表示大部分的時間中,開發人員只需要 淺層理解 就足以應付工作。這裡的 淺層理解 並不是指開發人員不需要懂得編寫程式。以下舉個例子來說明軟體開發中的 淺層理解 與 深層理解 之間的差異。 「電子商務網站」:某天 PM 告知下一個專案是「電子商務網站」。那麼,實作這個網站有兩種做法: 實作方法一:12345在專案初始階段: 我需要打開 HTTP 協定的文件,解析 HTTP 傳輸格式、研究如何實現 POST、GET 請求....等等。 此外還要閱讀 RFC 文件,並實現文件中的各項內容。 全部都搞定後才能開始打造「電子商務網站」。 實作方法二:12在專案初始階段,在作業系統中下載並安裝 Apache、PHP、MySQL, 設定妥當後即開始打造「電子商務網站」。 相信 實作方法二 才是大家熟悉開發的方式,因為現代軟體都是基於組件組裝出來的,沒有人需要獨自從頭到尾完成所有內容。在一個作業系統上安裝 Web 服務並開始編寫 HTML,對現在的開發人員來說是易如反掌。但事實上光是 Web 服務就已經複雜到極點,應該沒人敢說自己了解 Web 服務的所有內容。這一現象正是 淺層理解 的體現,讓開發人員在大部分的時間中,只需要掌握系統、框架、程式庫的使用方法,即可以將現成的組件應用到自己的應用程式,或是在組件上堆疊自己的應用程式。 設計原則中的“無緒”我們的目標是找到一種軟體開發的實踐方式:讓開發人員不用深入了解所有組件或者模組的實現原理,僅用最少的知識就可以很好地完成自己所需要的功能。這種開發方式被稱為「選擇性無緒(selective cluelessness)」。 選擇性無緒 的思維,正好符合軟體設計原則的 緊湊性。當一個程式具有緊湊性時,即具有「易於理解、使用、組合」等特性。這些特性讓使用者幾乎可以不用閱讀文件,即可自然而然的使用這些程式。但是要注意的是,緊湊性並不等同於「容易學習」。 以下提供一個緊湊與不緊湊的範例: 原生的 JavaScript 的 Date 物件是不緊湊的:123456789// 時間格式轉換var now = new Date(); var year = now.getFullYear(); var month = (\"0\" + (now.getMonth() + 1)).slice(-2); var day = (\"0\" + (now.getDate() + 1)).slice(-2); var hour = (\"0\" + (now.getHours() )).slice(-2); var minute = (\"0\" + now.getMinutes() ).slice(-2); var second = (\"0\" + now.getSeconds() ).slice(-2); year + '/' + month + '/' + day + ' ' + hour + ':' + minute+ ':' + second; moment.js 的 moment 物件是緊湊的:12// 時間格式轉換moment().format('YYYY/MM/DD HH:mm:ss'); 若程式設計得當,維護人員就可以使用 淺層理解 來學習你的程式。若程式設計得不夠緊湊,維護人員往往需要將頭腦切換成 深層理解 的模式來探索你的程式碼,並學習是如何運作的。 因此編寫程式的過程中,應該時時刻刻想著如何讓你的程式變得 “無緒”,讓使用者可以用最少的力氣學習你的程式。這樣的設計思維適用於「函式、類別、模組、類別庫、架構、框架以及系統」的情境。也就是 Martin Rinard 所說的: 軟體設計要盡可能做到 “無緒”。讓開發人員可以在對系統很少了解的前提下,仍然可以完成開發的工作。 延伸閱讀 自學時應該看破“無緒” 文章內容整理自以下書籍並加以歸納與簡化: 軟件框架設計的藝術(Practical API Design) UNIX編程藝術","link":"/blog/Cluelessness/"},{"title":"自學時應該看破“無緒”","text":"上一篇文章 軟體開發中的“無緒” 介紹了無緒的概念,講的是軟體開發的過程中,應該思考如何讓程式的使用者可以無緒。 這篇則是延伸無緒的概念,講解開發人員自學的時候,如何找出新技術背後的知識,並且堆疊知識。 正文開始軟體技術的發展都是基於無緒的,每個無緒的新技術中總會埋藏一些知識。如果每一項新技術都是新的知識,那我們就只能把每項技術都試過一遍,才能將技術轉化成知識。這樣的學習方法沒辦法用過去的經驗來學習新技術,導致較低的學習效率。 幸好人類的大腦有傑出的 抽象能力,也就是我們常說的推理、歸納、一般化、舉一反三等等,意思都差不多。 學習一項新技術時,試著將技術「抽象」一下,排除不相關的細節,找出技術背後的知識。 通常可以透過下列三個問題來找出技術背後的知識: 技術的本質是什麼? 想解決什麼問題? 前置知識是什麼? 挖掘技術背後的知識與思維知識總是會和無關緊要的細節糾纏在一起,當知識和細節糾纏在一起時,知識只能被應用在狹窄的情境。當你從技術歸納出背後的知識時,會發現有許多技術的知識本質是相同的。這幫助你在學習新技術時,能夠看透新技術無緒的表面,推理出新技術內部的原理。理解內部的原理後,新技術也只剩下外面一層皮,學習速度自然加快許多。 學習新知識時也要盡量學習作者的思維。去了解作者為什麼要這樣設計,以及學習作者如何設計;學會這些思維層面的知識,會幫助「未來的你」進行思考。遇到新技術或問題時,會有更多推測與決策的能力。 重視知識的本質對一個開發人員來說非常重要,海量的新技術總是不斷的成長,讓很多人感嘆跟不上新技術。新技術數量的不斷成長的對策,就是 抓住不變的知識。大量的新技術其實只是一層皮,支撐在背後的技術跟知識都是數十年來不變的東西。演算法及資料結構不會過時;程式設計理論1不會過時;開發論及方法論2不會過時;良好的編寫程式習慣3永遠不會過時;分析問題與決策4的能力不會過時… 因此在學習新技術的同時,也別忘了閱讀軟體界大神們的 經典書籍 以及上述 不變的知識。因為這些和思維相關的知識,能夠幫助我們進行更好的決策、學習、判斷與知識堆疊。 文章內容整理自以下書籍並加以歸納與簡化: 軟件框架設計的藝術(Practical API Design) 暗時間 註腳1.程式設計理論: OOP、OOAD ↩2.開發論及方法論: CI/CD、DDD、Clean Architecture、BBD、TDD、ATDD、Specification By Example ↩3.良好的編寫程式習慣: Clean Code、易讀程式之美學、單元測試 ↩4.分析問題與決策: 重構、SOLID 原則、Design Pattern ↩","link":"/blog/cluelessness_in_self_learning/"},{"title":"CodeIgniter 3 框架擴展套件:HMVC","text":"Hierarchical(階層式的)-Model-View-Controller(HMVC)模式,也可以叫做 Layered MVC。 為什麼需要 HMVC單層 MVC 的限制原 MVC 架構中只有單層 MVC,單層 MVC 的設計本身沒問題,但隨著系統功能逐漸變多變複雜時,程式碼卻只能塞進單層 MVC 裡面,程式碼很快就會變得巨大、縱錯複雜、互相耦合、難以維護。試想一下,一個 Controller 內有 7、8 千行程式碼會容易維護嗎。 原 CodeIgniter MVC 架構(單層 MVC)示意圖: 1234567891011121314151617application |- controllers |- controllersA.php |- controllersB.php |- ...(所有 Controller 都只能放在同一層) |- models |- models1.php |- models2.php |- ...(所有 Model 都只能放在同一層) |- views |- views1 |- index.php |- footer.php |- ... |- views2 |- index.php |- footer.php HMVC 帶來的解決方案:擴展 MVC 架構,讓 MVC 底下可以再擴充一層或多層子 MVC,讓單層 MVC 變成階層式 MVC,而這些擴充的 MVC,又稱作為模組、模塊(Modules)。使用模組好處是: 使每個功能都可以獨立出來 因模組變得獨立,降低各個功能模組之間的耦合性 提高程式碼複用性 每個模組都有自己的 MVC 結構 HMVC 架構示意圖: CodeIgniter HMVC 擴展模組後,其結構(階層式 MVC)如下:12345678910111213141516171819202122232425262728293031application |- modules |- moduleA |- controllers |- controllers.php |- models |- models.php |- views |- index.php |- footer.php |- ... |- modules (模組 A 底下還可以有子模組...) |- controllers |- moduleB |- controllers |- controllers.php |- models |- models.php |- views |- index.php |- footer.php |- ... |- modules (模組 B 底下還可以有子模組...) |- controllers |- .... |- controllers |- ... |- models |- ... |- views |- ... 真實使用情境:某系統中有個 表單管理 的功能如下圖,但是 表單管理 底下其實有多個功能,這些功能都屬於 表單管理 的範疇: 只有單層 MVC 架構的情況下,這些功能的程式碼都必須寫在同一個 Controller 裡面。因此 單層 Controller 會在很短的時間內便得龐大又複雜。 在 HMVC 架構中,則可以把這些功能全部拆分成 表單管理 底下的模組。這麼做 減輕了單層 Controller 對每個功能模組的耦合。拆出去的模組也變得高內聚,且模組的功能變得更容易複用。 表單管理 HMVC 模組結構 不斷地抽象、封裝HMVC 可以說是物件導向程式設計的體現。 一個良好的物件導向系統,會隨著程式碼的複雜度上升與變化增加,不斷的進行抽象、封裝。 抽象是將一系列相關的程式碼做歸納,目的是降低人類的認知超載。而封裝是抽象過程中的一種技術,且物件導向開發傾封裝複雜的過程,以便重複利用。 如果不做抽象會怎樣?嘗試一下下面的範例: 1234567891011121314151617181920212223242526272829303132333435// If url is an object, simulate pre-1.5 signatureif (typeof url === \"object\") { options = url; url = undefined;}// Force options to be an objectoptions = options || {};var // Create the final options objects = jQuery.ajaxSetup({}, options),// Callbacks contextcallbackContext = s.context || s,var // Create the final options objects = jQuery.ajaxSetup({}, options),// Callbacks contextcallbackContext = s.context || s,// Context for global events// It's the callbackContext if one was provided in the options// and if it's a DOM node or a jQuery collectionglobalEventContext = callbackContext !== s &&( callbackContext.nodeType || callbackContext instanceof jQuery ) ? jQuery(callbackContext) : jQuery.event,// Deferredsdeferred = jQuery.Deferred(),completeDeferred = jQuery.Callbacks(\"once memory\"),// Status-dependent callbacksstatusCode = s.statusCode || {},// ifModified keyifModifiedKey,// Headers (they are sent all at once)requestHeaders = {},/** * ajax 全長有 380 行程式碼,故省略。 **/ 上面這段程式碼都是「實作」非同步連線功能的程式碼。而 jQuery 將這段程式碼抽象成大家熟悉的 $.ajax。如果每次使用非同步連線功能都要寫 380 行程式碼,那真的會瘋掉。 HMVC 除了封裝以外,又讓模組享有 MVC 的功能HMVC 不只是單純的進行抽象,也讓每個模組享有 MVC 架構的功能! 參考資源 讓Codeigniter支持HMVC架構 什麼是 HMVC HMVC Wiki wiredesignz-codeigniter-hmvc","link":"/blog/codeigniter_3_hmvc/"},{"title":"設計模式起手式:介面(Interface)","text":"在閱讀經典書籍《設計模式 Design Patterns》中,如果你跳過第 1 章引言,那你可能就錯過了設計模式的核心概念了!作者在引言中花了大篇幅講解 介面 在物件導向設計中的定位,以及設計模式如何透過 介面 來解決問題。 介面 可以說是物件導向設計用來處理 軟體複雜度 的利器,因此本文將會講解 介面 在物件導向設計中所扮演的角色為何,以及實務開發中會如何利用介面讓程式碼適應變化! 介面是什麼?類別(Class)可以宣告的公開方法(Public Method),供使用者從外部操作類別的功能。類別的所有公開方法集合起來被稱為「類別的介面」。 介面是物件導向的基本元素,外部環境只能透過介面向類別請求執行功能或存取類別內部的資源。若沒有介面,類別將沒辦法與外部環境互動,外部環境也沒辦法得知類別的內容。 介面可以和實作分離介面的存在,造就了 外部視角與內部視角。介面暴露了類別的對外公開資訊形成外部視角。外部環境只能從外部視角觀察一個類別的基本行為,無法得知類別內部(內部視角)是如何實現介面的行為。外部環境只關注介面的特性,使得介面可以和實現內容分離。因此,一個介面可以被多個不同的類別實現;實現相同介面的兩個類別,可以對介面實現完全不同的行為。 利用介面達到多型(Polymorphism)雖然說介面與實現內容是分離的,但程式碼運行中,介面的行為是由具體的實現內容組成。向一個介面調用同一個方法,會因為不同的具體實現而有不同的行為。程式碼有辦法在調用介面的瞬間,連接到介面當下的具體實現,這個能力被稱為動態綁定(Dynamic binding)。 動態綁定讓程式碼能在調用介面的瞬間才受到具體實現的影響,這種彈性讓開發人員有辦法在系統運行的過程中,動態替換相同介面的具體實現。這種可替換性就被稱為 多型(Polymorphism)。 「設計」要能適應變化以上面的購物車線上付費功能(Pay)為例,若不使用多型來實踐,則所有付款商的連線(connect)、購物(purchase)與付款(send)的實現細節,都將暴露在購物車的付費程式碼中。屆時,程式碼不但變得很長,還會充滿一堆 switch case 或 if else,最慘的是付款商之間的程式碼互相耦合了!一但程式碼依賴實現內容就會出現很多問題,維護起來會變得困難。這也是為什麼,設計模式的作者呼籲開發人員應該要: Program to an interface, not an implementation.讓程式碼關注於介面,而不是實現內容。 開發人員應該要 利用介面來對實現細節進行抽象簡化,將實作細節隱藏在類別的內部。並且利用介面動態替換具體實現的機制(多型),讓程式碼變得容易擴充與適應變化。 當然,需求在一開始的分析階段並不知道會有這麼多種細節,但需求會隨著時間不斷變化和增長。因此程式碼的設計必須隨著需求異動而改變,這也是《設計模式》的目的:提供一系列的良好的「設計」,使程式碼易於管理,亦是系統重構的目標。當系統變得越來越複雜時,開發人員可以不斷地透過重構調整程式碼的「設計」,讓程式碼適應變化! 註:上面購物車付費功能是引入 State 模式 來管理程式碼。 延伸閱讀 物件導向設計原則:開放封閉原則 物件導向設計原則:里氏替換原則","link":"/blog/design_patterns_interface_and_type_is_important/"},{"title":"重構的定義與目的","text":"重構定義: 在不改變軟體外部行為的前提下,改變其內部結構,使其更容易理解且易於修改。 目的:重構的主要目的就是為了提升程式碼品質、提升程式碼的可讀性,以及為了日後有新需求的變化時,程式碼可以 更容易修改或是擴充(提高可維護性)。 優點:1. 改進程式碼的設計:消除重覆的程式碼,每個小功能被歸責到適當的物件中,讓程式碼的職責更清楚就會更容易維護。將雜亂無序的程式碼,重構成一連串的 精心設計 的流程,讓程式碼更容易擴充。 2. 程式碼更容易被理解:重構簡單的講,就是整理程式碼,可以透過 Clean Code 的規範 來整理程式碼,提升可讀性。想想程式碼過一段時間後的第二個讀者,而且這個人常常是你自己。 3. Debug 更容易:重構的過程中,會透過單一職責原則,依照程式碼的工作責任將程式碼整理至責任相同的類別中。有責任清楚的物件,就能更快釐清問題點,除錯速度自然能夠提升。 什麼時候可以開始重構?事實上,重構並不是一項需要額外撥出時間來進行的工作,重構應該是在你的開發過程中持續在發生的事情。 重構的活動,最理想的情況,就是透過持續不斷的整理,掃除那些有礙程式可讀性及可維護性的程式碼,讓程式碼持續盡可能地保持在一定健康的狀態。 既然重構是一個持續進行的活動,但又不是特意安排、特別撥出時間來做的工作,那麼,在什麼樣明確的時間點,應該觸發重構的進行呢?基本上,重構的活動應該伴隨著我們一般開發過程中的主要活動來進行。這些主要活動包括了:增加新功能、修正錯誤、以及程式碼審查的時候,還有三次法則。 三次法則:同樣的事做三次,犯了 Don’t Repeat Yourself 原則,表示重複的邏輯該被重構成唯一且適當的物件或函式了。 新增功能時重構: 當我們試著增加新功能時,便有可能發現舊有的程式碼可以進行一些調整,而達成了必須重構所想要達成的目的。 但如果你在增加功能的時候,發現原設計就足以優雅地讓你將新功能擴充上去,那麼,這意謂著,還不太需要做什麼重構。 除錯時重構:除錯的時候,你不僅會接觸到舊有的程式碼,而且,你通常得搞懂它真正的運作邏輯,當你真的懂了之後,就會知道如何用更清晰、簡潔的方式來改寫這段程式碼。 總結:很多架構良好的程式,並不是一開始就規畫出來的,而是每天不斷不斷的重構、改進。重構也不用刻意安排時間去做,在每次做新功能或修改功能時就順便做重構整理,如果可以也一起加入 Unit Test。童子軍有一條軍規是 讓營地比你來時更乾淨,套用在寫程式上,就是在每次的 checkin ,程式碼應該都是要比 checkout 時更乾淨簡潔。","link":"/blog/refactoring_definition_and_purpose/"},{"title":"淺談物件導向 SOLID 原則對工程師的好處與如何影響能力","text":"前言為了感謝部落格一直以來都有人在閱讀,讓我一直有經營下去的動力。所以想寫一個系列 學習 SOLID 原則 2 年後的心得文章。這心得文章包含自己使用 SOLID 兩年的總結,並且以自己的理解簡化 SOLID 原則,希望幫助新手工程師縮短「SOLID 原則是文字天書」的時間。 從第一次接觸 物件導向 SOLID 原則 至今已經兩年了,一開始覺得「SOLID 原則是文字天書」,到現在 Coding 時常融入 SOLID 的思想來「設計」程式。 所以 SOLID 原則到底是什麼? SOLID 原則是物件導向「設計層面」的思維與定律。 大學時期程式設計課程中所學的物件導向,其實只是在介紹程式語言有提供 物件導向的哪些特性,卻 從未有人教導如何透過物件導向的特性撰寫程式碼,甚至沒人告訴你為什麼要用物件導向開發程式。 然而 SOLID 原則就是物件導向開發的指導方針,若以多個角度來看這些原則,會發現 SOLID 已經指出 物件導向的優點 以及 程序式程式碼隱晦的缺點。但這不代表物件導向沒有缺點,要是沒有妥善運用 SOLID 原則的話,物件導向對專案的傷害絕對不比 程序式程式碼 低!但這留給後續的文章來解釋,首先來看看 SOLID 的好處與重要性。 SOLID 原則對專案的好處?SOLID 原則對專案的影響很大,當專案一點一滴的導入 SOLID 原則的程式碼,不少複雜的程式碼慢慢被簡化,被簡化的程式碼可以降低複雜度,讀懂程式碼的時間從原本需要花 20 分鐘閱讀,到只需要花費 2 分鐘閱讀。縮短閱讀時間對專案來說是一件好事,一般來說工程師「閱讀程式碼」的時間常常大於「新增/修改程式碼」的時間,畢竟要先讀懂才能動手嘛,因此 縮短閱讀程式碼時間 等於 縮短「新增/修改程式碼」程式碼的時間。 優點:降低程式碼複雜度 => 縮短閱讀程式碼的時間 => 減少維護專案程式碼的時間 你可能會覺得,為什麼 SOLID 原則可以降低程式碼的複雜度?因為 物件導向本身的目的就是管理「程式碼複雜度」,這也是為什麼這麼多人推崇使用物件導向開發的原因,然而 SOLID 原則是教導工程師應該如何透過 物件導向的特性 來管理程式碼的複雜度。 SOLID 原則對工程師的好處?由上述可知 SOLID 原則可以降低程式碼的複雜度,這是第一個好處,因為降低工程師開發過程的痛苦值!(應該沒人想一直面對醜陋複雜的程式碼) 再來的好處可多的呢!為什麼這麼說呢?SOLID 原則是踏入資深工程師階段的必學觀念。大部分 軟體開發的進階觀念,都建構在良好的物件導向程式碼之上。要是沒辦法妥善運用物件導向,就沒辦法運用軟體開發的進階觀念/技巧。 》這相當重要,若工程師沒有能力學習進階觀念,很可能就會一直停留在碼農階段。 但是學會 SOLID 原則之後呢?以下列出 SOLID 未來的應用,下列被提及的每個議題都是 進階物件導向重要的基石,很值得花時間投資: 1. 單元測試 用程式碼撰寫測試程式,取代手動測試。 替專案提供回歸測試,時時刻刻執行單元測試,檢查有沒有人改壞程式碼。 符合 SOLID 原則的程式碼可以輕易導入單元測試。 2. 重構 在不改變程式碼外部行為的前提下,修改程式碼內部的結構,提升可讀性與擴充性。 重構必然會搭配測試,避免改壞程式碼。 低階重構:把爛 Code 重構成符合物件導向 SOLID 原則(敏捷開發)。 中階重構:把 SOLID 重構成設計模式(敏捷開發)。 高階重構:把 SOLID 重構成軟體架構(敏捷開發)。 3. 設計模式 進階物件導向應用 學習 物件與物件之間常見的組合模式。用來管理程式碼的複雜度,或解決開發系統中的各種常見問題。 學過設計模式,在寫程式或閱讀程式的時候,會用更高一層的視角去思考。 最後會培養出根深蒂固的抽象觀念。 》但這些議題卻又基於 SOLID 原則之上 因為 SOLID 原則幫助專案建立一個乾淨、穩定、良好的物件導向程式碼,讓這些物件導向程式碼可以引入更進階的概念/技巧。 這裏引用 Uncle Bob 在 物件導向原則、設計模式與C#實踐 這本書說過的話: 我的書裡所教的觀念與技巧,都只對乾淨的 Code 有效益。如果你的程式碼還很雜亂,請先學會怎麼整理程式碼。Uncle Bob(現代軟體界大神) 然而 這本書也是 SOLID 的原點。軟體開發的觀念幾乎就圍繞在上述幾個議題在發展,因此有沒有學會上述議題,基本上就是碼農跟中高階工程師的分水嶺。如果持續努力學習這些議題,馬上就能 凸顯跟大部分工程師的差異性,面試時可以談的條件也會變多。 我的學習路線如下:SOLID > 重構 + 單元測試 > 設計模式 > 測試驅動開發(TDD) > 行為驅動開發(BDD) > 領域驅動開發(DDD) 但是真實的學習過程其實經常交叉學習,不一定是先學完前者才往下學習下一個。因為這些議題都是環環相扣,常常可以在後面的議題學習到前面議題的進階用法。 結尾:以往學習 SOLID 原則時,大部分文章都專注在每個原則的介紹與範例,卻幾乎沒人提及 SOLID 原則與物件導向之間的關係,以及 wSOLID 原則日後的發展為何?因此我想在講解 SOLID 每個原則之前,先花篇幅琢磨在兩個問題上。希望也能替其他人解開疑問。 接下來也將會陸續推出講解 SOLID 每個原則的文章出現,沒意外的話下次 PO 文是探討 單一職責原則。 此文章也會同步到:我的部落格 系列文章: 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力 再談 SOLID 原則,Why SOLID? 物件導向設計原則:單一職責原則,定義、解析與實踐 物件導向設計原則:開放封閉原則,定義、解析與實踐 物件導向設計原則:裡氏替換原則,定義、解析","link":"/blog/why_SOLID_principles_matter_to_you/"},{"title":"再談 SOLID 原則,Why SOLID?","text":"Why SOLID? 在前一篇文章中介紹 SOLID 對一個工程師的影響,這裡再稍微補充一下為什麼軟體開發會需要 SOLID 原則? 軟體複雜的本質專案經常會歷經「商業邏輯調整」和「快速且多變的需求」,這都不是開發環節所能控制的,但是一個專案會「遇到困難」常常是因為 糟糕的程式設計 和其他技術層面導致。 其中 糟糕的程式設計 又可分成三大因素: 程式碼變更 Bug 修復 複雜度控制 在添加功能前應該 閱讀代碼並細心設計,但實際上更常發生的是,開發者選擇直接在程式碼上面堆疊新的程式碼。新舊版本 和 意圖不同 的程式碼錯綜複雜之下,會讓專案難以在不引發連鎖反應的情況下增加功能。最後新的功能引入 Bug,從而導致另一個 Bug,然後一個接著一個,這樣的事情不斷發生。 在 沒有做過可擴展性設計的程式碼 上進行擴展功能,常常是火上加油!那為什麼不把程式「設計」成可擴展就好了呢? 開發者知識普遍不足一個充滿外行人的產業 開發者們來自不同的背景,且有大量不同的方式去解決問題,但是一個開發者應該具備什麼樣的知識體系,卻沒有一個廣泛的共識。 更不幸的是,許多開發者在學校裡面學到的知識是過時的,學校提供的程式設計課程並未給學生做好準備。這些課程通常只關注程式語言的功能,但是 … 掌握一門程式語言並不能使你成為軟體開發者,正如掌握一門自然語言並不能使你成為作家 學校所教的知識,並不是大部分軟體開發者的工作內容。開發者也許能編寫一個可運行的程式碼,但若要回頭去擴展程式碼卻常常是艱難且冒險的。 軟體開發過程中,開發者需要做出無數個選擇,加上每個開發者用不同的方法解決問題,助長了難以維護、難以擴展的程式碼。最終讓 系統設計的思想溝通 變得困難。 工程師必須花費大量時間閱讀程式碼。 不論是開發者還是管理者都需要對 軟體複雜的本質 做管理。為了編寫出更容易應對變化的程式碼,我們必須填補基礎認知方面的空白。 導入設計原則若有一些原則可以指導工程師在 一定的情況下理解每個選擇的所得所失 並做出最佳權衡,則可以統一工程師解決問題的方法,降低 系統設計思想溝通 的困難度。 Uncle Bob 花費多年整理了許多開發人員、研究人員的思想和著作結晶,提出了 5 個設計原則: 單一職責原則(The Single Responsibility Principle, 簡稱 SRP) 開放-封閉原則 (The Open-Close Principle, 簡稱 OCP) 里氏替換原則 (The Liskov Substitution Principle, 簡稱 LSP) 依賴倒置原則 (The Dependency Inversion Principle, 簡稱 DIP) 接口隔離原則 (Interface Segregation Principle, 簡稱 ISP) SOLID 原則目的是讓程式碼在面對改變時,能有一套策略來應對。 SOLID 原則指導開發者們該 如何將函式和資料結構安排到類別中,以及這些 類別該如何互相關聯。 遵循 SOLID 原則的程式碼具有以下特性: 能夠容忍變化 容易擴展新邏輯 容易理解 容易複用 雖然 SOLID 原則是 物件導向 的設計原則,但事實上這些原則與思維一直存在軟體工程中。因此 SOLID 原則的思維可以延伸套用至非物件導向語言、系統架構等等領域。 順帶一提,SOLID 原則並沒有順序關係,單純是 Michael Feathers(1 發了電子郵件告訴 Uncle Bob 可以用 SOLID 這個詞彙將 5 個原則串起來變成口訣方便記憶。所以在學習 SOLID 原則的過程中,可以不用按照順序學習。 備註: Michael Feathers 是 Working Effectively with Legacy Code 的作者,也是一本開發者必讀經典書籍。 此文章也會同步到:我的部落格 系列文章: 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力 再談 SOLID 原則,Why SOLID? 物件導向設計原則:單一職責原則,定義、解析與實踐 物件導向設計原則:開放封閉原則,定義、解析與實踐 物件導向設計原則:裡氏替換原則,定義、解析","link":"/blog/why_we_need_SOLID_principles/"},{"title":"PHP CodeIgniter 3 單元測試日常:建立 PHPUnit 測試環境","text":"本文將帶領讀者建立 CodeIgniter 3 框架的 PHPUnit 測試環境,給 CodeIgniter 3 一個現代化的機會! 本文假設您已經具備 軟體測試自動化 以及 PHPUnit 相關知識,且了解如何撰寫測試案例。若您尚未了解單元測試或軟體測試自動化,這裡提供一些不錯的資源讓您初步了解: otischou.tw:瞭解單元測試 阿川先生:先寫單元測試的12個好處! 除了上面兩個資源外,請您務必花時間認識並學習軟體測試,這可以說是軟體開發技術的核心技能之一。 若您尚未了解 PHPUnit 這裡也有些簡單的文件供您參考: PHPUnit 官方中文文件 Jace Ju:PHPUnit 入門介紹 前言2020 年對於 PHP 界風靡一時的 MVC 框架「CodeIgniter」來說,光環已經被新星 Laravel 搶去。雖然 CodeIgniter 的討論熱度已經消退了,但市佔率仍然相當高,至今仍有許多 PHPer 還在與 CodeIgniter 奮鬥和成長(包括我)。 由於 CodeIgniter 3 框架(以下簡稱 CI3)沒有使用 Namespace 的特性,加上 CI3 統一透過框架內建的 Loader 類別實現 Autoload 機制,造成很多 PHPer 沒辦法在 CI3 框架中使用現代 PHP 的特性來開發系統。若不做點手腳的話,PHPer 的開發思維很容易就會被 CI3 框架綁架,一不小心就將所有業務邏輯全部寫在 CI3 框架中。換句話說所有的程式碼都依賴於框架,物件導向 SOLID 的實現、設計模式、單元測試都不用談了! 為了讓還在與 CI3 奮鬥的同袍們能夠使用現代 PHP 的特性來開發系統,本文將介紹如何在 CI3 框架中建立單元測試的環境,讓 CI3 也能使用並測試現代 PHP 的程式碼,確保 PHPer 能安心地實現各種開發策略和思維。 導入 Composer 擁抱現代 PHP 特性其實 CI3 跟現代 PHP 只差臨門一腳,你可以在 config/Config.php 中找到一個設定為 composer_autoload,只要替 composer_autoload 設定 Composer 目錄底下的 autoload.php 路徑,你的 CI3 框架就可以開始使用 Namespace 搭配 PSR-4 Autoload 機制來開發現代 PHP 特性的程式碼! 編輯 application/config/config.php: 12345678910111213141516171819202122/*|--------------------------------------------------------------------------| Composer auto-loading|--------------------------------------------------------------------------|| Enabling this setting will tell CodeIgniter to look for a Composer| package auto-loader script in application/vendor/autoload.php.|| $config['composer_autoload'] = TRUE;|| Or if you have your vendor/ directory located somewhere else, you| can opt to set a specific path as well:|| $config['composer_autoload'] = '/path/to/vendor/autoload.php';|| For more information about Composer, please visit http://getcomposer.org/|| Note: This will NOT disable or override the CodeIgniter-specific| autoloading (application/config/autoload.php)*/- $config['composer_autoload'] = FALSE;+ $config['composer_autoload'] = __DIR__ . '/../../vendor/autoload.php'; // Composer 的 autoload.php 路徑 安裝 ci-phpunit-test由於 CI3 架構內建的單元測試功能很少,所以我選用整合了 PHPUnit 的 ci-phpunit-test 來當作專案的測試框架。 ci-phpunit-test 除了包含 PHPUnit 測試框架本身的功能以外,還提供模擬 HTTP Request 的整合測試功能,重構前建立回歸測試來說相當方便!不過使用這個套件之前,有一些必要條件: PHP 版本至少高於 5.4.0 CodeIgniter 至少要有 3.0 至少要安裝 PHPUnit 4.3 以上的版本 透過 Composer 下載 ci-phpunit-test到你的專案目錄下使用 Composer 下載 ci-phpunit-test 12$ cd /path/to/codeigniter/$ composer require kenjis/ci-phpunit-test --dev 透過 install.php 安裝 ci-phpunit-test下載完成後,需要執行一次 install.php 注意,必須要在專案的根目錄執行安裝指令安裝指令每次都會建立並覆蓋整個 application/tests 目錄 12$ cd /path/to/codeigniter/$ php vendor/kenjis/ci-phpunit-test/install.php 安裝完成後,application 目錄下會出現 tests 目錄,以後測試程式都要放在這個 tests 目錄底下: 1234567891011121314codeigniter/├── application/│ └── tests/│ ├── _ci_phpunit_test/ ... 裡面是 ci-phpunit-test 框架的原始碼│ ├── Bootstrap.php ... PHPUnit 的 Bootstrap 設定檔│ ├── DbTestCase.php ... │ ├── TestCase.php ... 測試案例的基底類別│ ├── controllers/ ... 把你的 Controller 測試程式放進來(整合測試)│ ├── libraries/ ... 把你的 Library 測試程式放進來(整合測試或單元測試)│ ├── mocks/│ │ └── libraries/ ... 把 Mock 模擬物件放進來│ ├── models/ ... 把你的 Model 層測試程式放進來│ └── phpunit.xml ... PHPUnit 的設定檔└── vendor/ 第一次執行 ci-phpunit-test在執行 ci-phpunit-test 之前,一定要確保兩件事: 你已經安裝 PHPUnit。 讓 PHPUnit 可以在系統任一地方使用,可直接參考全局安裝教學。 (以下為 OSX 系統安裝 PHPUnit 步驟) 12345$ wget https://phar.phpunit.de/phpunit-7.0.phar$ chmod +x phpunit-7.0.phar$ sudo mv phpunit-7.0.phar /usr/local/bin/phpunit$ phpunit --versionPHPUnit x.y.z by Sebastian Bergmann and contributors. 這麼做是因為 ci-phpunit-test 要求一定要在 application/tests 目錄執行 phpunit: 1234567$ cd /path/to/codeigniter/$ cd application/tests/$ phpunitPHPUnit 7.5.18 by Sebastian Bergmann and contributors....Time: 341 ms, Memory: 5.50Mb 撰寫測試案例:到目前為止,你的 CI3 框架已經完全整合了 PHPUnit,並且可以利用 Composer 的 Autoload 機制引入使用現代 PHP 特性的受測程式碼。但是本篇主題是建立測試環境,若還想知道更多測試案例的寫法,可以直接參考 ci-phpunit-test 作者提供的線上手冊:How to Write Tests 和電子書 CodeIgniter Testing Guide。 我也會陸續新增幾篇 CodeIgniter 撰寫整合測試、單元測試的文章供大家參考。 CI3 + HMVC 測試環境配置因為 CodeIgniter + HMVC 的結構較為特殊,安裝 ci-phpunit-test 之後還需手動做一些修改,才能在 HMVC 的環境中執行ci-phpunit-test 測試框架。 接下來的修改是參考 ci-phpunit-test 作者針對 Github issue 34:Any luck with ci-phpunit-test working with HMVC? 的回應,對 CI3 做調整。這些調整的目的是改變 CI3 載入 HMVC 套件的順序,讓測試環境取得 HMVC 的物件,而不是原生 CI 的物件。 步驟一:修改 Modules.php編輯 ci/application/third_party/MX/Modules.php: 12345678910<?php (defined('BASEPATH')) OR exit('No direct script access allowed');(defined('EXT')) OR define('EXT', '.php');global $CFG;+if (ENVIRONMENT === 'testing')+{+ $CFG =& load_class('Config');+} 步驟二:修改 Loader.php編輯 ci/application/third_party/MX/Loader.php: 1234567891011public function initialize($controller = NULL){+ if (ENVIRONMENT === 'testing')+ {+ // Rest CI::$APP+ CI::$APP = CI_Controller::get_instance();+ if ( ! CI::$APP->lang instanceof MX_Lang) CI::$APP->lang = new MX_Lang;+ } /* set the module name */ $this->_module = CI::$APP->router->fetch_module(); 步驟三:設定 PHPUnit 預設設定檔案在執行 ci-phpunit-test 之前,記得要將 PHPUnit 的設定檔案預設為: 1ci/application/tests/phpunit.xml 否則 ci-phpunit-test 會因為沒載入 Autoload 而無法順利執行。 注意事項禁止使用 header() 跳頁整合測試中,ci-phpunit-test 只接受 CI3 的 Output 類別輸出結果,若在程式運行中途對 header 設定做任何修改,測試框架將會出現錯誤訊息。為了讓測試案例可以運作,應盡量在系統中使用 CI3 內建的 redirect 進行轉址,若沒辦法避免使用 header(),也可以用 ENVIRONMENT 環境變數進行跳脫。 123456-header('Location: ' . base_url(). 'admin');+if (ENVIRONMENT === 'testing') {+ redirect(base_url(). 'admin');+} else {+ header('Location: ' . base_url(). 'admin');+} 推薦安裝:Codeception\\SpecifyCodeception\\Specify 供測試環境使用 BDD 語法。Codeception\\Specify 可以在測試案例中隔離測試情境,減少很多因為情境產生的測試 function。 透過 Composer 安裝 Codeception\\Specify 1$ composer require codeception/specify 使用方法:直接在測試程式中引入 Codeception\\Specify Trait: 1234<?phpclass UserTest extends PHPUnit\\Framework\\TestCase {+ use Codeception\\Specify; 使用範例:簡化多個測試案例情境 結尾這篇文章其實「CodeIgniter 3 單元測試日常」系列文的揭幕,這系列文的目的除了分享經驗以外,也想讓更多仍在維護 CodeIgniter 框架(或非現代 PHP 框架)的開發人員知道,原來手頭上的框架也可以變得現代化!希望可以給維護舊框架的開發人員帶來一絲希望,不要被舊框架或專案嚇跑了!","link":"/blog/codeigniter_3_unit_test_installation/"},{"title":"Git Commit Message 這樣寫會更好,替專案引入規範與範例","text":"其實寫 Commit Message 有時候跟寫程式註解還蠻像的,最好可以寫下「為什麼」你要作這樣的異動,而不是單單只記錄下你做了「什麼」異動, Commit Message 最好兼俱 Why 及 What,讓日後進行維護人員更快進入狀況。 Commit Message 這樣寫會更好: 做 issue 的時候,不應該一次 Commit 所有異動!應該獨立 Commit 每個不同意義的異動,這樣 commit 訊息才會跟異動的程式碼有關聯。 每次 Commit 都是針對異動的檔案做說明:Why & What。這樣的 Commit Message 能讓日後的維護人員更快進入狀況 每次 Commit 都加上 issue 編號,方便追蹤相關的程式異動原因。 若 Commit Message 寫得妥當,在閱讀追蹤程式碼的意圖會相當容易。如果只把 Git 當作版本控制,隨意撰寫 Commit Message 就太可惜了! 不能只把 Git 當作程式碼的 FTP,這樣太可惜了,要把 Git 當作歷史查閱的工具才拿發揮 Git 的功能。 好與不好的真實案例用一個小插曲證實 Commit 訊息的重要性上面 PPT 是我在工作中遇到的兩個案例,範例中包含「好的 Commit Message」與「不良的 Commit Message」。在範例中可見: 良好的 Commit Message: 如何在「一年後」讓維護人員進入狀況 不良的 Commit Message: 如何在「一個月內」讓維護人員找不出程式異動的原因。 Commit Message 之規範在撰寫 Git 與 SVN 等版本控制軟體 Commit Message 時,可以參照國外 AngularJS 團隊的規範: AngularJS Git Commit Message Conventions 以下為這套訊息規範的展示與說明:Commit Message 規範範例:Commit Message 規範範例解析: Commit Message 規範組成:12345678910111213Header: <type>(<scope>): <subject> - type: 代表 commit 的類別:feat, fix, docs, style, refactor, test, chore,必要欄位。 - scope 代表 commit 影響的範圍,例如資料庫、控制層、模板層等等,視專案不同而不同,為可選欄位。 - subject 代表此 commit 的簡短描述,不要超過 50 個字元,結尾不要加句號,為必要欄位。Body: 72-character wrapped. This should answer: * Body 部份是對本次 Commit 的詳細描述,可以分成多行,每一行不要超過 72 個字元。 * 說明程式碼變動的項目與原因,還有與先前行為的對比。Footer: - 填寫任務編號(如果有的話). - BREAKING CHANGE(可忽略),記錄不兼容的變動, 以 BREAKING CHANGE: 開頭,後面是對變動的描述、以及變動原因和遷移方法。 type: subject 是簡述不要超過 50 個字元 type 只允許使用以下類別: feat: 新增/修改功能 (feature)。 fix: 修補 bug (bug fix)。 docs: 文件 (documentation)。 style: 格式 (不影響程式碼運行的變動 white-space, formatting, missing semi colons, etc)。 refactor: 重構 (既不是新增功能,也不是修補 bug 的程式碼變動)。 perf: 改善效能 (A code change that improves performance)。 test: 增加測試 (when adding missing tests)。 chore: 建構程序或輔助工具的變動 (maintain)。 revert: 撤銷回覆先前的 commit 例如:revert: type(scope): subject (回覆版本:xxxx)。 Type 是用來告訴進行 Code Review 的人應該以什麼態度來檢視 Commit 內容。例如: 看到 Type 為 fix,進行 Code Review 的人就可以用「觀察 Commit 如何解決錯誤」的角度來閱讀程式碼。 若是 refactor,則可以放輕鬆閱讀程式碼如何被重構,因為重構的本質是不會影響既有的功能。 利用不同的 Type 來決定進行 Code Review 檢視的角度,可以提升 Code Review 的速度。因此開發團隊應該要對這些 Type 的使用時機有一致的認同。 Commit 訊息範例範例 fix:123456789101112131415fix: 自訂表單新增/編輯頁面,修正離開頁面提醒邏輯問題:1. 原程式碼進入新增頁面後,沒做任何動作之下,離開頁面會跳提醒2. 原程式碼從新增/編輯頁面回到上一頁後(表單列表頁面),離開頁面會跳提醒原因:1. 新增頁面時,頁面自動建立空白題組會調用 sort_item,造成初始化 unload 事件處理器。2. 回到上一頁後,就不需要監聽 unload 事件,應該把 unload 事件取消。調整項目:1. 初始化 unload 事件處理器:排除新增表單時,頁面自動建立空白題組調用 sort_item 的情境2. 回到上一頁後,復原表單被異動狀態且清除 unload 事件處理器issue #1335 12345678910111213fix: 意見反應,信件看不到圖片問題問題:1. 客戶反應:意見反應的信件都看不到圖片。原因:1. 目前程式碼都會要求先登入後才可查看使用者上傳的檔案, 造成在信件上會看不見圖片的問題。調整項目:1. File.php,經討論後,開放讓意見反應頁面上傳的檔案,不用登入就可以查看/下載。issue #1229 範例 feat:1234567891011121314feat: message 信件通知功能因應新需求做調整: 通知和 message 都要寄發每日信件, 通知和 message 都用放在同一封信裡面就好, 不然信件太多可能也不會有人想去看。調整項目:1. mail_template.php,新增 message 區塊。2. Send_today_notify_mail.php,新增 取得每日 Message 邏輯。3. Message_model_api.php,新增 $where 參數,以便取得每日訊息。4. Message_api.php、Message_group_user_model_api.php,新增 **取得訊息使用者** 邏輯,以便撈取每日訊息。issue #863 123456789101112feat: 表單統計,多顯示計畫名稱欄位因應需求做調整:1. 列表資訊多加「計畫名稱」欄位,以利後續匯出資料處理。調整項目:1. Assessment_form.php,匯出表單統計時,新增訓練計畫名欄位。2. customize.php,表單統計查詢時,多顯示訓練計畫名欄位。3. Complex_assessment_form_api.php、Complex_assessment_form_model_api.php: - 取得表單統計資料時,多取得計畫名稱。issue #1200 範例 chore:12345chore: 更新 testing 環境更新 ci-phpunit-test 套件 0.16 => 0.17for Request GET 帶參數功能。 123456789101112131415chore:調整單元測試環境調整項目:1. MX/Modules將客製化 Testing 的邏輯移除,否則在測試環境中無法正確存取檔案。2. 加入 tests/unit 與 tests/integration 目錄,並將測試檔案移至合宜的位置。3. AdminTestCase.php,繼承 TestCase,實作登入邏輯、setUp 與 tearDown,供其他測試案例繼承使用。4. Bootstrap.php,引入 AdminTestCase.php 共測試案例繼承用。5. Login.php,因測試案例中不能有 header 的設定,更動系統登入邏輯,在測試環境中改用 redirect 轉址。6. phpunit.xml,取消嚴謹宣告覆蓋模式,避免造成測試不通過(若需知道你的測試案例覆蓋了哪些類別或邏輯,可自行打開)。## 備註: unit 與 integration 目錄分別為「單元測試目錄」與「整合測試目錄」,單元測試目錄負責測試 Api 與 Model,整合測試目錄則負責測試 Controller。issue #709 範例 style:123456789101112style: message 頁面,對 Component 做 Beautifier經 IE 瀏覽器測試後發現 Component 裡面仍然夾帶 ES6 語法,但是目前 Component 的程式碼都被壓縮成一行,為了日後修改程式方便,故先對所有被壓縮的程式碼做 Beautifier調整項目:1. 針對所有被壓縮的程式碼做 Beautifier2. 移除被註解的程式碼,原本被註解的程式碼應該是壓縮前的程式碼,但是經測試後發現這些被註解的程式碼都是舊 Code,故移除。issue #1219issue #1028 123style: 統一換行符號 CRLF to LF統一換行符號 123style: 調整 HTML 縮排issue #964 範例 refactor:1234567891011121314refactor: 重構取得「簽核流程種類名稱」邏輯原程式碼取得流程名稱的邏輯散落在多個檔案,為了讓未來新增/修改種類名稱時,不必到多個檔案找查程式,現在統一透過 Process::get_type_name($process_type) 方法,取得流程種類名稱。調整項目:1. Process.php,新增 get_type_name() 方法,供取得流程名稱稱用。2. workflow_type_name.php,此 View 檔案只是為了取得流程名稱,現在以 Process:: get_type_name() 取代,故刪除。3. Workflow_api.php,get_process_name() 方法是為了取得流程名稱,現在以 Process:: get_type_name() 取代,故刪除。4. 其他檔案:改用 Process:: get_type_name() 取得流程名稱。issue #1253 1234567refactor: 表單統計,語意化調整匯出表單時的表單答題資料,應該是以表單 $assessment_result 為基準做統計,故更改變數名稱 $user => $assessment_resultissue #1200 123456789101112131415161718refactor: 每日通知信件,重構程式結構考量將來可能會需要寄送多種資訊給使用者,故重構程式結構,讓未來擴充功能時比較方便。調整內容:1. Send_today_notify_mail: - 把取得「系統通知」邏輯搬移至 System_notify_handler.php - 把取得「站內訊息」邏輯搬移至 Message_handler.php - 引入 Pipeline,把取得各種系統資訊的邏輯注入進 Pipeline。 - 透過 Pipeline 取得每日通知信件內容,並建立信件 HTML2. Daily_email 介面: - 定義 每日信件處理器 Xxx_handler 的方法 - 之後要擴充新的功能,必須按照 Daily_email 介面的定義,實作方法。3. message.php、system_notify.php: - 將「系統通知」與「站內訊息」的 Email 頁面獨立出來。issue #1308 範例 perf:12345678910111213perf: 評核表單列表,優化取得受評者速度原本取得受評者的邏輯會造成載入頁面緩慢(開發機約 52 秒),故做優化。調整方式:原程式碼每個表單迴圈進入 DB 取得受評者資料。改成進 DB 一次撈取全部受評者資料,再回到 PHP 分配資料。結果:開發機載入頁面時間 52 秒 => 5秒issue #1272 範例 docs:1docs: 新增註解 123docs: 修正型別註解讓 IDE 可以讀取到正確的類別 123docs: 移除過期的註解issue #1229","link":"/blog/git_commit_message_best_practice/"},{"title":"探討單元測試和整合測試的涵蓋範圍","text":"本篇文章紀錄自己導入 測試驅動開發(Test Driven Design) 過程中,曾經沒辦法分辨自己所寫的測試案例到底是“單元測試”還是“整合測試”,與同儕討論後發現其他人也有相同的困擾,於是看了幾本書與文章才釐清自己的問題所在。為方便與其他人進行交流討論,故將自己理解的資訊整理下來並做個總結。 單元測試的涵蓋範圍很模糊?單元測試(Unit Test)是軟體開發中很重要的環節,替 TDD 提供重構的保護網,也是軟體測試(Software Testing)中測試金字塔(Test Pyramid)的最低測試層級。 但是,一個「單元測試」所涵蓋的範圍到底有哪些,卻讓國外網友議論紛! 大家在初學單元測試一定會看到的定義如下: 以程式碼的最小單位來進行正確性檢驗的測試工作,最小單位包括「類別與方法」。 若按此定義來寫測試案例,一個單元測試只能包含一個類別。且受測類別的依賴都必須透過測試替身或 Mock 技術進行隔離,才能確保測試的目標是最小且不可分割的邏輯。 但是隨著 Mock 的詬病被發掘(參考:Mock 不是測試的銀彈),為避免 Mock 使測試案例變成開發人員的快樂表(測試通過,正式環境卻出現錯誤),開始有人提倡使用 Spy 來替代 Mock,以及依賴若是自己的開發團隊所寫,而非第三方函式庫,則可直接使用依賴。 這時,一個單元測試會執行的範圍已經從 一個類別 變成 一個類別加上該類別的依賴。換句話說,一個單元測試除了受測程式外,也會執行到其他類別的程式碼: 123456789101112131415describe('AddGroupToRange', function () { it('空的統計範圍, 將題組「questionGroups1」新增至空的統計範圍中, 統計範圍包含題組「questionGroups1」', function () { // @given 空的統計範圍 var range = new StatisticsRange(); var pipeline = new Pipeline(range); // @when 將題組「questionGroups1」新增至空的統計範圍中 pipeline.setRange(range); pipeline.addCommand(new AddGroupToRange('questionGroups1')); pipeline.run(); // @then 統計範圍包含題組「questionGroups1」 expect(range.questionGroups).toEqual(['questionGroups1']); });}); 如上範例所見,此測試案例已包含多個類別的邏輯。 但是,按照一開始所學的「單元測試定義」,我開始懷疑自己寫的測試案例到底算不算單元測試呢? 原來單元測試涵蓋範圍有兩派?為解決疑慮,我到開始找人討論、爬文試圖找出單元測試的涵蓋範圍。最後在 Martin Fowler 的文章 UnitTest 找到答案,原來單元測試的涵蓋範圍有兩派! 孤立型(Solitary)or 社交型(Sociable) Martin Fowler1 認為,在撰寫單元測試時,搞清楚自己的測試案例屬於 孤立型(Solitiary) 還是 社交型(Sociable) 很重要! 如果你喜歡使用 孤立型的單元測試,那麼 受測物件將不會使用真實的依賴類別。因為依賴類別發生錯誤,也會造成單元測試無法通過!為了確保受測程式不被影響,孤立型單元測試 會利用測試替身(Test Doubles)模擬並隔離依賴(如圖一右方)。 如果你喜歡 社交型的單元測試,則 受測物件會直接使用真實的依賴類別,讓測試案例真實地執行一個完整的行為。 Martin Folwer 也提及,社交型單元測試的作法可能會因「單元測試的定義」而被抨擊。但他覺得這並不是什麼問題,他認為: because these tests are tests of the behavior of a single unit. 單元測試是對一個行為的測試。 我們在測試一個行為時,也會「假設」受測行為以外的功能都是正常的。這種「假設」本質上與 孤立型的單元測試 是一樣的! (題外話:Martin Fowler 在文章中表明自己偏好社交型的單元測試) TDD/BDD 是社交型單元測試嗎?在《修改軟件的藝術》第 10 章測試先行,作者提及 TDD 的單元測試與狹義的單元測試不同,TDD 是以 一個行為 作為一個單元: 一個獨立、可驗證的行為。這個行為會對系統產生可觀察的影響,且不和系統的其他行為耦合。 這個單元測試的定義意味著:每個可觀察到的行為都應該要有一個相對應的測試。 另外在《Growing Object-Oriented Software, Guided by Tests》第五章節也指出,應該針對行為進行單元測試,而非針對方法。 這下真相大白了!如果你是 BDD 或 TDD 的實踐者,那麼你的單元測試就可能是跨多個類別的 社交型單元測試,因為測試的對象是 一個行為,而非一個類別。 TDD 並不能取代品質保證TDD 所編寫的測試,目的是為 系統重構(Refactoring) 提供支持。本質上與 QA 團隊做的軟體品質測試並不相同,因此狹義、細粒度 以品質保證為目標的單元測試 仍然有其存在的價值。 兩種單元測試的差異: 項目 QA 的單元測試 TDD/BDD 的單元測試 目的 檢驗軟體基本組成單位的正確性 建立回歸測試,讓系統支持重構 單元的定義 最小且不可分割的邏輯 獨立、可驗證的行為 測試粒度 一個類別或一個函式 一個類別或一群依賴關係緊密的類別 社交型單元測試也算整合測試嗎?曾經我也有這個疑問,以為自己寫的單元測試其實是整合測試吧?! 會有這種錯覺,也是來自下面這條整合測試的定義: 對不同模組之間的交互作用進行測試 但是測試案例成為整合測試的關鍵點是:測試案例是否包含與外部環境交互的邏輯,如時間、Session、Cookie、資料庫,硬體,網路等等不受程式控制的因素。 簡單來說,若測試案例無與外部環境交互的邏輯,則可以將測試案例視為單元測試: 反之,若測試案例中包含與外部環境交互的邏輯,那麼這個測試案例就是一個整合測試: [補充]Uncle Bob 對 TDD 單元測試的看法:單元測試的定義有兩個版本,在國外好像越來越被接受了,但是國內卻還不是很明確。 2017 年,Uncle Bob 在 Twitter 有對網友說明 TDD 單元測試的對象是一個“行為”,而非一個“方法”: 原文連結:Uncle Bob 在 Twitter 的發言 最後,Uncle Bob 在後續留言還有補充 TDD 單元測試的測試案例應該寫在哪個層級: 總結TDD/BDD 與軟體品質(QA)的單元測試很容易混淆,但兩者的目的與涵蓋範圍並不相同。 若對兩種單元測試的本質不夠了解,就容易在寫測試案例的時候陷入進退兩難的窘境,因此釐清自己正在使用哪一種單元測試相當重要!若是帶領一個開發團隊,一定要在動手開發之前讓團隊要有一個統一的語言和定義。否則,做出來的結果可能相當不一樣呢! 推薦閱讀: Victor:Defining Test Boundaries Martin Fowler:UnitTest 修改軟件的藝術1.《重構:改善既有程式的設計》作者 ↩","link":"/blog/unit_test_and_integrate_test_definition/"},{"title":"物件導向設計原則:里氏替換原則,定義、解析","text":"里氏替換原則(Liskov Substitution Principle) 定義 Subtypes must be substitutable for their base types.-子類別必須能取代父類別 里式替換原則是從 開放封閉原則 延伸出來的原則,若對開放封閉原則還不了解,建議先去瞭解開放封閉原則如何透過引入抽象來擴充程式碼的行為,再來學習里氏替換原則! 目的 讓開發人員確實地按照「介面」的定義進行實作,確保程式碼名符其實,避免發生無法預料的事情。 程式碼在編譯階段可以檢查出型別錯誤,卻不能檢查出開發人員犯傻。 因此里式替換原則要求開發人員確實地按照「介面」的定義進行實作,否則程式的行為將變得「不可預測」。換句話說,程式碼雖然可以“繞過”型別檢查使編譯成功,但有可能產生不可預知且不容易察覺的 Bugs。 解析在開始講解之前,必須先引用 Uncle Bob 在 2017 年《Clean Architecture》對里氏替換原則的補充: 物件導向革命的最初幾年,里氏替換原則被用來指導「繼承的使用」。然而,多年以來里氏替換原則已經涉及到介面與實作,演變成了更廣泛的軟體設計原則。 引用這段是為了讓讀者知道,里氏替換原則不但適用於 繼承,也適用於 介面實作。 接下來將會講解為什麼里氏替換原則可以同時套用到 繼承 與 介面實作,以及里氏替換原則對物件導向開發的影響。 物件跟「抽象」與「介面」息息相關 「抽象」是人類處理複雜事物的方式。 人的大腦可以接收的訊息有限,因此在現實生活中,人類往往會對複雜的事物進行簡化,或將類似的事物歸納成同一類。對事物進行「抽象」雖然會忽略某些細節,但也讓人類更易於溝通、學習與管理。舉例來說,向餐廳大廚點一份炒高麗菜就是利用「簡化」進行抽象,我們不會告訴大廚怎麼切菜、火要多大以及料理的順序;學校常見的告示牌“教室內不能喝飲料”則是透過「歸納」進行抽象,不可能將綠茶、奶茶、果汁、啤酒 …等等全部寫到告示牌上。 開發人員也會透過物件「封裝」的功能對程式碼進行抽象,把複雜的流程或業務規則隱藏到物件的內部。當程式碼被抽象成為物件後,就可以透過「外部視角」和「內部視角」來觀察一個物件:從「外部視角」觀察物件時,只能看見程式碼被簡化成一系列的 抽象行為。從內部觀察物件時,則可以看見每個行為的實作內容。 在外部視角中,只能得到物件公開(Public)的資訊,包含:公開屬性、常數、方法簽名(Signature,指方法名稱與其參數)。我們會將這些物件公開的資訊統稱為「介面」,所以很多物件導向設計(OOAD)的書籍提到介面時,可能同時是在講 Interface、類別 和 抽象類別。 開發人員常常透過「介面」描述一個業務邏輯的基本特徵,包含要實現的功能目標與涉及範圍。並忽略介面的實際結構與行為實作內容。 「繼承」是為了共用父類別的介面為了促使程式碼遵循 開放封閉原則,開發人員可以透過物件導向的繼承技術,繼承父類別的「介面」來擴充業務規則的邏輯。 不論是繼承或是介面,目的都是利用多型的特性來擴充業務規則的邏輯。這也是為什麼里氏替換原則可以同時適用於繼承與介面實作。 「繼承」不是為了共用父類別的程式碼若只是想要共用父類別的邏輯,應該使用組合,而不是使用繼承。雖然沒有人會限制開發人員隨意地使用繼承,但如果使用繼承的目的不是為了「多型」,不但沒有讓繼承功能派上用場,還會迫使子類別公開父類別的「介面」。 契約式設計(Design by Contract)里氏替換原則延伸出契約式設計,契約式設計用了三個條件來規範開發人員應該如何遵循「介面」的實作: 前置條件(pre-conditions)實作「介面」的實體物件,必須包含並保留所有「介面」的公開資訊。確保依賴「介面」的程式可以調用「介面」提供的功能。只有前置條件達成時,程式碼才會執行後置條件的邏輯。 後置條件(post-conditions)實作「介面」的實體物件,在執行完「介面」提供的功能後,必須回傳「介面」指定的回傳型別(Return Type)。約束開發人員要按照介面的定義實作功能。 不變性(invariants)若 前置條件 或 後置條件 任一項條件沒有達成,程式碼就會報錯。 這三個條件就是物件導向語言中的 Interface 的限制條件,因此 Interface 也經常被稱作契約(Contract)。 範例接下來利用 系統通知信件 示範違反與符合里氏替換原則的案例。 某系統有通知信件的功能,可以因應多種情境寄送對應的通知信件內容: 1234567891011121314151617181920212223242526272829303132class EmailSender{ private $mail; private $emails; /** * 加入信件 * * @param string $address * @param EmailMaker $emailMaker 用於建立信件內容 */ public function addEmail($address, EmailMaker $emailMaker) { $email = [ 'address' => $address, 'emailHTML' => $emailMaker->makeEmailHTML(), ]; array_push($this->emails, $email); } /** * 寄送信件 */ public function send() { foreach ($this->emails as $email) { $this->mail->setAddress($email['address']); $this->mail->setBody($email['emailHTML']); $this->mail->Send(); } }} 在這個系統中,所有情境的通知信件都是透過 EmailSender 來寄送信件。從上面程式碼中可以發現,開發人員希望透過 多型 來建立不同情境的信件樣板,因此在 addEmail 方法中引入一個專門用來建立信件樣板的介面 EmailMaker: 12345678interface EmailMaker{ /** * 建立信件 HTML 內容 * @return string */ public function makeEmailHTML(): string;} 到目前為止,EmailSender 已經建立起 開放封閉原則 的 Plugin 架構,開發人員只需要新增實作 EmailMaker 介面的類別,就能替系統建立全新的通知信件種類(開放擴充)。完全不需要更改 EmailSender 的程式碼(關閉修改)。 里氏替換原則就像一個審查機制,監督開發人員在實作 開放封閉原則 Plugin 架構的介面(EmailMaker)時,讓程式碼的行為符合介面的定義。目的是確保開放封閉原則的核心業務邏輯(EmailSender)可以安全地使用 Plugin 來擴充邏輯。 違反里氏替換原則1234567891011121314151617181920212223242526272829/** * 上課遲到通知信件 HTML 產生器 */class LateForClassEmailHTML implements EmailMaker{ public function __construct($studentId, $classInfo) { $this->studentId = $studentId; $this->classInfo = $classInfo; } /** * 建立信件 HTML 內容 * @return string */ public function makeEmailHTML(): string { // 建立 上課遲到通知信件 HTML 樣板 $this->template = new Template('emails'); $template = $this->template->load('emails/template/lateForClass', $this->classInfo); // 扣除學生課程總成績 $studentCourse = StudentCourse::where(['studentId' => $this->studentId, 'classId' => $this->classInfo['classId']); $studentCourse->totalScore = $studentCourse->totalScore - 1; $studentCourse->save(); return $template; }} 在這個案例中,需求為「若學生上課遲到就寄送遲到通知信件,並扣除學生的課程總成績 1 分」。 開發人員新增 LateForClassEmailHTML 類別並實作 EmailMaker 介面替系統新增「學生上課遲到」通知信件內容。 但是上面的範例違反了里氏替換原則,因為 EmailMaker 介面明確定義 makeEmailHTML 的目的是「建立信件 HTML 內容」,但開發人員卻將「扣除學生的課程總成績」邏輯寫在 makeEmailHTML 函式中。雖然程式碼仍然會通過型別檢查(Type Hint),但卻會增加維護系統的困難度。 這些「不符合介面定義的程式碼」被放在不合理的地方,就會成為系統的技術債,開發人員會需要更多時間找碴程式碼,例如,從 Controller 層根本看不出「扣除學生的課程總成績」的邏輯在哪裡被執行: 12345678// Controller 層public function StudentLateForClass { /** ...省略 */ $emailMaker = new LateForClassEmailHTML($student->id, $classInfo); $emailSender = new EmailSender(); $emailSender->addEmail($student->email, $emailMaker); $emailSender->send();} 符合里氏替換原則開發人員在實作「介面」的時候,應該完全按照介面的「定義」來撰寫功能,而且要不多也不少: 1234567891011121314151617181920212223/** * 上課遲到通知信件 HTML 產生器 */class LateForClassEmailHTML implements EmailMaker{ public function __construct($studentId, $classInfo) { $this->classInfo = $classInfo; } /** * 建立信件 HTML 內容 * @return string */ public function makeEmailHTML(): string { // 建立 上課遲到通知信件 HTML 樣板 $this->template = new Template('emails'); $template = $this->template->load('emails/template/lateForClass', $this->classInfo); return $template; }} 「介面」不只是定義了一個類別的職責,也畫出類別的邊界。如果程式碼不符合「介面」所定義的範圍,就要將不符合定義的程式碼從介面中搬移到適合的地方: 12345678910111213// Controller 層public function StudentLateForClass { /** ...省略 */ $emailMaker = new LateForClassEmailHTML($student->id, $classInfo); $emailSender = new EmailSender(); $emailSender->addEmail($student->email, $emailMaker); $emailSender->send(); // 扣除學生的課程總成績 $studentCourse = StudentCourse::where(['studentId' => $this->studentId, 'classId' => $this->classInfo['classId']); $studentCourse->totalScore = $studentCourse->totalScore - 1; $studentCourse->save();} 結論開放封閉原則必須透過 統一的抽象介面 來擴充核心業務規則的邏輯,因此《設計模式》作者們提出 “Program to an interface, not an implementation.”,將需求的問題域定義成抽象介面,系統才能安全地的擴展程式碼。搭配里氏替換原則對開發人員的限制,確保程式碼的行為符合「介面」的定義與期望結果,讓開放封閉原則可以信任實作「介面的程式碼」,最終讓系統可以用「增量式開發」的方式進行迭代釋出。 系列文章: 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力 再談 SOLID 原則,Why SOLID? 物件導向設計原則:單一職責原則,定義、解析與實踐 物件導向設計原則:開放封閉原則,定義、解析與實踐 物件導向設計原則:裡氏替換原則,定義、解析","link":"/blog/solid_lsp_definition_and_practices/"},{"title":"物件導向設計原則:開放封閉原則,定義、解析與實踐","text":"開放封閉原則(Open-Closed Principle) 定義: Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.--軟體中的類別、模組、函式等等應該開放擴充,但是封閉修改。 白話版本為: 當系統需要擴充功能時,應該藉由 增加新的程式碼 來擴充系統的功能,而 不是藉由修改原本已經存在的程式碼 來擴充系統的功能。 開放封閉原則為軟體開發的 首要原則,很多軟體開發原則都是建構在這短短一句話之上,因此可以通過此原則引伸出其他原則。很多時候一個程式具有良好的設計,往往說明它是符合開放封閉原則。 目的 隔離業務邏輯與附加邏輯,使業務邏輯更易於擴充,以便因應需求變化。 解析什麼是業務邏輯?附加邏輯?一個系統總有幾個極具價值的核心邏輯,這些核心邏輯實現了企業或專案的業務規則(Business Rule)與 Know How。通常可以從核心邏輯延伸出更多功能,提供使用者的便利性,以下將這些核心業務邏輯簡稱為「業務邏輯」。也就是說系統中有可能 20% 是業務邏輯,剩下的 80% 是圍繞著業務邏輯延伸出來的附加邏輯。 舉例來說,一個診所掛號系統一開始只有「掛號與叫號」功能。但若需要的話,也可以延伸出「叫號時發送簡訊提醒患者」功能。掛號系統的案例中業務邏輯是「掛號與叫號」;而「叫號時發送簡訊提醒患者」則是 隨著時間與新需求延伸出來的附加邏輯。 為什麼要隔離 業務邏輯 與 附加邏輯?和軟體複雜的特質 軟體熵(Software entropy) 有關,指系統在經過修改後,程式碼的無序程度(意圖流失程度)與複雜程度皆會上昇。 需求變更和除錯是系統修改的主因,系統會隨著時間不斷衍生出新需求。這些需求可能是工程浩大的新功能;也可能是為了某個特定案例只使用一次的需求。甚至客戶往往在看見實際功能後,才想到有更好的解決方案或缺少哪些細項。於是剛釋出的功能馬上又進入重工(Rework)階段。 若開發人員不懂得將業務邏輯與附加邏輯分開,往往為了完成新需求,把附加邏輯寫在業務邏輯裡面,替業務邏輯擴充行為。這種做法一但遇到需求不停出現時,業務邏輯 與 附加邏輯 會漸漸地糊在一起變成一個大泥團導致程式脆弱化。新增需求和除錯更容易引入新的 Bug,解決新的 Bug 又引入更新的 Bug…。 (圖一)中的程式碼在專案中隨處可見,當 附加邏輯 與 業務邏輯 耦合在一起時,業務邏輯 會變得很難除錯、重複使用以及擴充,這些因素都會拉長開發時程,增加維護系統的成本。 因此開發人員應該要有個認知: 雖然需求並不是程式設計環節能控制的,但是程式碼應該要能夠適應快速多變的需求。 業務邏輯本身只需要關心業務規則(Business Rule),不應該和附加邏輯耦合在一起。一定要隔離業務邏輯與附加邏輯,才能確保業務邏輯的彈性。一旦業務邏輯有了彈性,程式就較容易面對需求變化。 開放擴充點,由外部注入附加邏輯新需求不斷出現,修改業務邏輯來擴充附加功能卻會促進 軟體熵 成長,增加維護系統的困難度。為了避免 軟體熵 的問題,開放封閉原則指導開發人員在面對需求變化時應該要: 盡可能減少對既有程式碼的修改,並開放擴充點,讓新需求可以從外部擴充業務邏輯。 實際上 開放封閉原則的設計思維 早在物件導向技術出現之前就存在,並且被廣泛應用在各種層面,從程式設計乃至框架、系統層級: 程式設計層面:jQuery ajax透過 $.ajax 的 done, fail, always 等公開函式從外部注入閉包,擴充 $.ajax 行為: 1234567891011121314$.ajax({ method: \"POST\", url: \"some.php\", data: { name: \"John\", location: \"Boston\" }}) .done(function() { alert(\"success\"); }) .fail(function() { alert(\"error\"); }) .always(function() { alert(\"complete\"); }); 框架層面:Laravel Controller透過繼承 MVC 框架內建的 Controller 類別,擴充 Controller 層的行為: 123456789101112<?phpnamespace App\\Http\\Controllers;use Illuminate\\Http\\Request;class HelloController extends Controller{ public function index(Request $request){ return 'Hello World!'; }} 框架層面:React.js透過繼承 React.Component 類別,擴充 Component 的行為: 12345class Welcome extends React.Component { render() { return <h1>Hello, {this.props.name}</h1>; }} 其他範例: JavaScript 透過註冊 event 事件,擴充瀏覽器行為。 瀏覽器透過安裝擴充套件,擴充瀏覽器行為。 手機透過安裝 APP,擴充手機 OS 行為。 … 上述這些耳熟能詳的範例中,每個技術都被應用到成千上萬個不同的需求。這些高彈性技術的共通點是:至少有一個開放的擴充點,讓開發人員可以寫入自己的邏輯來完成功能。 開放封閉原則 讓開發人員不需要修改已經造好的輪子,就可以完成自己所需的功能。 這也是為什麼軟體技術能夠以海量增長的原因。但是開放封閉原則的原理是什麼呢? 原理:利用抽象隔離不相關的程式 解除耦合的方法,就是讓程式碼不知道彼此的存在。 程式碼可以透過繼承、引入介面或注入閉包等技術,讓附加邏輯可以”共用公開的介面“。業務邏輯在需要擴充的時機,則須透過 統一的公開介面 來調用附加邏輯。 這其實是利用 多型的特性,在業務邏輯和附加邏輯之間引入一個抽象(繼承、介面、閉包等): 對業務邏輯來說,原本寫死在業務邏輯裡面的附加邏輯將被 抽象的變數 取代。只有等程式碼運行中,藉由 當時實作抽象介面的實體(類別、閉包) 來決定附加邏輯的行為。 對附加邏輯來說,只需要按照 抽象介面 的定義,實作完成新需求所需的程式。最後注入業務邏輯中,以便擴充業務邏輯。 找出業務邏輯與附加邏輯的邊界 開發人員必須懂得如何找出業務邏輯與附加邏輯的邊界,才能從中開放擴充點引入抽象隔離彼此。 簡單有效的方法是,把重要與不重要的事情分開。例如 UI 介面所需的邏輯與業務規則無關,所以它們之間應該要有一個邊界。也可以 已變化為軸的地方 繪製邊界,邊界另一側的元件將以不同的速率以及不同的原因改變: 附加邏輯 與 業務邏輯 相比,彼此在不同的時間以不同的速率改變,因此它們之間應該有個邊界; 附加邏輯 與 其他附加邏輯 相比,每個附加邏輯都在不同的時間和不同的原因改變,所以它們之間應該也要有邊界。 說到底,其實一直都是 單一職責原則 指導我們應該如何切割邊界。 引入抽象後,業務邏輯與附加邏輯 只能透過抽象介面與彼此互動。如此一來,業務邏輯可以專注於本身的業務規則(Business Rule),而附加邏輯則可以隨時被多個不同的實作替換掉,並且業務邏輯完全不需要關心這些事。 一但建立起開放封閉原則的架構(圖四),就能擁有一個安全的防火牆。程式碼之間的變動不會傳播出去。附加邏輯的變動不會影響到業務邏輯。 事實上,軟體開發技術的歷史就是「如何方便地建立 Plugin 來奠定可擴展和可維護的系統架構」的故事 - Uncle Bob. 《Clean Architecture》 實踐:每日信件功能從原理中可以發現,開放封閉原則能夠解除業務邏輯與附加邏輯之間的耦合,並且保持業務邏輯的彈性。接下來將透過一個「每日信件功能」的案例,講解如何讓開放封閉原則落地。 某校園系統中,有一個寄信排程會在每天凌晨寄送「每日信件」,最初的需求為: 1. 最初需求:寄送使用者昨天收到的系統通知。1234567891011121314151617181920212223242526class Send_today_mail extends MX_Controller{ public function index() { /** 1. 撈取信件的內容,並產生信件 HTML */ // 取得所有使用者昨天收到的系統通知 $system_notifies = $this->notify_api->get_yesterday_notify(); // 依照收件者的 email 分群通知訊息 $system_notifies = $this->group_system_notify_by_email($system_notifies); // 產生信件 HTML 內容 $mail_contents = $this->make_mail_contents($system_notifies); /** 2. 寄送信件 */ $this->send_mail($mail_contents); } /** 建立系統通知信件 */ private function get_yesterday_notify() {/** ... */} private function group_system_notify_by_email($system_notifies) {/** ... */} private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */} private function make_mail_contents($system_notifies){/** ... */} private function send_mail($mail_contents) {/** ... */}} 第一版本的程式碼中可以看見寄信功能主要分兩個部分: 撈取信件的內容,並產生信件 HTML 寄送信件 Send_today_mail 的最初版本中,總共只有 93 行程式碼。 2. 第二需求:寄送使用者昨日收到的 Messenger 訊息123456789101112131415161718192021222324252627282930313233343536class Send_today_mail extends MX_Controller{ public function index() { /** 1. 撈取信件的內容,並產生信件 HTML */ // 取得所有使用者昨天收到的系統通知 $system_notifies = $this->notify_api->get_yesterday_notify(); // 依照收件者的 email 分群通知訊息 $system_notifies = $this->group_system_notify_by_email($system_notifies); // 取得 Messenger 使用者、對話群組 id list($message_users, $group_ids) = $this->message_api->get_all_message_users(); // 取得昨日的 Messages $messages = $this->get_yesterday_message($group_ids); // 產生信件 HTML 內容 $mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users); /** 2. 寄送信件 */ $this->send_mail($mail_contents); } /** 建立系統通知信件 */ private function get_yesterday_notify() {/** ... */} private function group_system_notify_by_email($system_notifies) {/** ... */} private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */} /** 建立 Messenger 訊息信件 */ private function get_yesterday_message() {/** ... */} private function message_filter($messages, $group_id) {/** ... */} private function make_message_template_variables($messages, $message_users, $tplVar) {/** ... */} /** 合併信件內容並寄送信件 */ private function make_mail_contents($system_notifies, $messages, $message_users){/** ... */} private function send_mail($mail_contents) {/** ... */}} 第二版本加入了新需求,Send_today_mail 的程式碼一下子從 93 行增加到 295 行。為了產生 系統通知 和 Messages 的信件 HTML 內容,make_mail_contents() 函式已經開始出現耦合。 3. 第三需求:寄送明日課程內容給教師1234567891011121314151617181920212223242526272829303132333435363738394041424344454647class Send_today_mail extends MX_Controller{ public function index() { /** 1. 撈取信件的內容,並產生信件 HTML */ // 取得所有使用者昨天收到的系統通知 $system_notifies = $this->notify_api->get_yesterday_notify(); // 依照收件者的 email 分群通知訊息 $system_notifies = $this->group_system_notify_by_email($system_notifies); // 取得 Messenger 使用者、對話群組 id list($message_users, $group_ids) = $this->message_api->get_all_message_users(); // 取得昨日的 Messages $messages = $this->message_api->get_yesterday_message($group_ids); // 取得明日的課程資訊 $tomorrow_course = $this->get_tomorrow_course(); // 取得課程教師資訊 $course_ids = array_column($tomorrow_course, 'course_id'); $teachers = $this->course_api->get_course_teachers($course_ids); // 產生信件 HTML 內容 $mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers); /** 2. 寄送信件 */ $this->send_mail($mail_contents); } /** 建立系統通知信件 */ private function get_yesterday_notify() {/** ... */} private function group_system_notify_by_email($system_notifies) {/** ... */} private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */} /** 建立 Messenger 訊息信件 */ private function get_yesterday_message() {/** ... */} private function message_filter($messages, $group_id) {/** ... */} private function make_message_template_variables($messages, $message_users, $tplVar) {/** ... */} /** 建立 明日課程 信件 */ private function get_tomorrow_course() {/** ... */} private function get_course_teachers(course_ids) {/** ... */} private function make_course_start_template_variables() {/** ... */} /** 合併信件內容並寄送信件 */ private function make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers){/** ... */} private function send_mail($mail_contents) {/** ... */}} 第三個版本,Send_today_mail 的總行數來到 504 行,make_mail_contents() 函式的耦合更加嚴重。 到目前為止,Send_today_mail 已經變得不太容易維護,這個 Controller 裡面包含了 12 個函式,其中好幾個函式卻都是在做一樣的事情:「撈取信件的內容,並產生信件 HTML」。 為了避免 Send_today_mail 因新需求的出現不斷膨脹,接下來將開始替 Send_today_mail 進行一次重構。這次重構的目的將是引入抽象,拆散 隨著時間增加的附加邏輯。 第一次重構:拆散職責1234567891011121314151617181920class Send_today_mail extends MX_Controller{ /** * 寄送系統每日收到的所有通知訊息 */ public function index() { /** 1. 撈取信件的內容,並產生信件 HTML */ $email_maker = new Today_email_maker(); $email_maker->add_handler(new System_notify_handler()); $email_maker->add_handler(new Message_handler()); $email_maker->add_handler(new Course_start_handler()); $mail_contents = $email_maker->make_mail_contents(); /** 2. 寄送信件 */ $this->send_mail($email_contents); } private function send_mail($mail_contents) {/** ... */}} 上面是重構後的結果,Send_today_mail 的程式碼大幅減少,可讀性也有提高。 這樣拆分職責的邏輯是「已變化為軸的地方劃分界限」:Send_today_mail 從第一次發佈以來就一直新增 信件種類,這些 信件種類 最後都需要透過 make_mail_contents() 產生信件內容。那麼隨著新需求冒出來的信件種類,就是容易變動的地方,也就是 附加邏輯;負責產生信件 HTML 內容的 make_mail_contents() 則是在流程中不變的邏輯,故可視為 業務邏輯。 找出 業務邏輯 與 附加邏輯 後,即可將邏輯拆分成下面結構: 將產生多個信件 HTML 內容的 make_mail_contents() 搬移至 Today_email_maker 類別。 負責 撈取各種信件種類內容 的邏輯則拆散至各自的類別: System_notify_handler Message_handler Course_start_handler 具體細節如下: 在(圖五)結構圖中可以看見業務邏輯和附加邏輯之間引入一個抽象介面(Daily_email)。業務邏輯 透過公開 add_handler(Daily_email $handler) 函式,讓 Controller 層可以從外部注入 附加邏輯。附加邏輯則須按照 Daily_email 介面的定義,實作完成新需求所需的程式碼。 這是利用多型的特性,讓 add_handler(Daily_email $handler) 可以接收任何有實作 Daily_email 介面的物件。這也是為什麼 Controller 層可以對 Today_email_maker 注入多個附加邏輯類別的原因。 下面附上重構後的範例程式碼: 1234567891011121314151617181920212223242526272829303132interface Daily_email{ /** 取得今日信件內容 */ public function get_email_content(); /** 建立 Email HTML 樣板變數 */ public function make_email_template_variables(); /** 建立 Email HTML 內容 */ public function make_email_content();}class Today_email_maker{ /** @var Daily_email[] */ private $handlers = array(); public function add_handler(Daily_email $handler) { array_push($this->handlers, $handler); } public function make_mail_contents() { $mail_contents = array(); foreach ($this->handlers as $handler) { $handler->get_email_content(); $handler->make_email_template_variables(); array_push($mail_contents, $handler->make_email_content()); } return $mail_contents; }} 附加邏輯如下: 1234567891011121314151617181920212223242526class System_notify_handler implements Daily_email{ public function get_email_content() { /** ... */} public function make_email_template_variables() { /** ... */} public function make_email_content() { /** ... */} private function xxxx() { /** ... */} /** ... */}class Message_handler implements Daily_email{ public function get_email_content() { /** ... */} public function make_email_template_variables() { /** ... */} public function make_email_content() { /** ... */} private function xxxx() { /** ... */} /** ... */}class Course_start_handler implements Daily_email{ public function get_email_content() { /** ... */} public function make_email_template_variables() { /** ... */} public function make_email_content() { /** ... */} private function xxxx() { /** ... */} /** ... */} 重構前,只要每新增一種信件,make_email_content 就會耦合新的信件種類資料,以便產生信件 HTML 內容。 12345678910111213141516171819/** 重構前 Send_today_mail.php */private function make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers){/** ... */}{ // 建立 Notifies 信件樣板變數 $tplVar = $this->make_notifies_template_variables($notifies); // 建立 Messages 信件樣板變數 $tplVar = $this->make_message_template_variables($messages, $message_users, $tplVar); // 建立 明日課程 信件樣板變數 $tplVar = $this->make_tomorrow_course_template_variables($tomorrow_course, $teachers, $tplVar); // 建立信件樣板 $mail_contents = []; foreach ($tplVar as $target_mail => $template_data) { // 以使用者的 email 做區隔 $mail_contents[$target_mail] = $this->load->view('send_today_notify_mail/mail_template', $template_data, true); } return $mail_contents;} 重構後,不管再新增多少種類的信件,Today_email_maker 都不需修改任何程式碼(封閉修改)。只需新增實作 Daily_email 介面的附加邏輯即可完成新需求(開放擴充)。而且還可以隨時移除任何一種信件種類。這就是利用開放封閉原則的成果,讓程式碼可以適應需求變化。 1234567891011121314151617/** 重構後 Today_email_maker.php */public function add_handler(Daily_email $handler){ array_push($this->handlers, $handler);}public function make_mail_contents(){ $mail_contents = array(); foreach ($this->handlers as $handler) { $handler->get_email_content(); $handler->make_email_template_variables(); array_push($mail_contents, $handler->make_email_content()); } return $mail_contents;} 接受第一次愚弄你可能已經發現了,引入抽象後程式碼變得比重構前還要複雜。若每個新功能都要符合開放封閉原則,系統結構會變得極其複雜,而且還會有很多抽象沒有實質效益。 因此 Uncle Bob 建議可以接受不合理的程式碼帶來的第一次愚弄。在最初寫程式的時候,可以先假設變化永遠不會發生,這有利於我們迅速完成需求。當變化發生並且對我們接下來的工作造成影響的時候,再回過頭來封裝這些變化的地方。確保未來不會掉進同一個坑里。 結論在寫程式的時候,可以把開放封閉原則當作目標,因為設計良好的程式通常都經得起開放封閉原則的考驗。也有人說設計模式就是幫良好的設計取個名字,因為設計模式幾乎都是遵守開放封閉原則的。開放封閉原則延伸出單一職責原則、依賴倒置原則等其他設計原則,其實都只是為了完成開放封閉原則這個目標的過程。 開放封閉原則是終極目標,很少人可以百分之百做到,但只要朝著原則的方向努力,就可以不斷改善系統的架構,讓程式碼可以“擁抱變化“。 系列文章: 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力 再談 SOLID 原則,Why SOLID? 物件導向設計原則:單一職責原則,定義、解析與實踐 物件導向設計原則:開放封閉原則,定義、解析與實踐 物件導向設計原則:裡氏替換原則,定義、解析 推薦閱讀: Clean Architecture 無瑕的程式碼-整潔的軟體設計與架構篇","link":"/blog/solid_ocp_definition_and_practices/"},{"title":"物件導向設計原則:單一職責原則,定義、解析與實踐","text":"單一職責原則(Single responsibility principle) 定義: A class should have only one reason to change.以一個類別來說,應該只有一個引起它變化的原因。 ”等等,這是在說人話嗎?還是我理解能力不夠好?“ 這是我第一次讀到 SRP 原則定義的反應,當時覺得 SOLID 每個原則都是文字天書。若你也有跟我一樣的反應,不要緊張,大家都經歷過這個過程。我將會在本文中以自己的體悟來講解觀念,實務經驗演示如何實踐 SOLID 原則。 單一職責原則是 SOLID 原則中看起來最容易明白,卻也是最容易讓人混淆的原則。因為很多人並不清楚 職責 是什麼,甚至誤以為一個類別只能做一件事。接下來的文章中會依序講解原則的目的;解決什麼問題;如何實踐。 目的: 提高程式碼的內聚性,讓程式碼更易於管理和重複使用。 解析:什麼是內聚?在英文辭典中 內聚(Cohesion) 的同義詞為一致性、凝聚、結合等等,描述相關的事務如何聯繫在一起。在軟體開發中,高質量程式碼通常是高內聚性的,內聚 程式碼的特徵為: 每個程式碼片段都只關注一件事情 當每段程式碼只關注一件事情時,程式碼會更容易被理解和處理,且相較 低內聚 的程式碼來說更容易編寫。 什麼情況會造成低內聚?當程式碼包含一個以上「互不相關」的邏輯或意圖時,程式碼的內聚性就會降低。(一般而言,低內聚的程式碼代表高耦合1) 程式碼的內聚性一但降低,閱讀與維護程式碼的難度就會提升。當你必須從一個大函式裡面修改其中一小段邏輯,若不先花時間讀懂函式中每段程式碼之間的關係就直接修改程式碼,很容易破壞函式原先可正常運作的程式碼。為了避免對程式碼造成破壞,開發人員開發過程總是變成:花費 80% 時間閱讀程式碼,真正編寫程式碼的時間卻只有 20%。對於維護一個系統來說,這種情況除了相當浪費成本以外,也相當折磨開發人員的心情,更糟的是,每次回來維護又要重新讀一次程式碼。 單一職責原則對專案的重要性?單一職責原則乍看之下好像很簡單,但實踐過程其實困難重重。現實狀況常常是:專案起初幾個版本的程式碼意圖都相當簡單明瞭,但是當需求隨著時間增長再加上開發時程短促,讓開發人員不斷在原本的程式碼上堆疊新的程式碼。最後 舊程式碼與新程式碼糾纏在一起,使得程式碼的意圖和邊界漸漸變得模糊且互相耦合。若在意圖模糊的程式碼上繼續擴充或修改,則會使程式碼的意圖逐漸流失並且擴大影響範圍,最後變成 技術債 折磨維護專案的人員。 因此,單一職責原則指導開發人員在建立新功能時,不應該把意圖不同的程式碼擺放在一起。 讓每段程式碼的意圖保持清晰,確保程式碼的意圖不會隨著需求或時間增長而流逝。 為什麼意圖如此重要呢?維護專案最怕的就是修改程式碼時 不知道當初的開發者為什麼要這樣設計程式。要是冒然修改程式碼,就容易使功能發生錯誤。這種不確定的感覺會變成開發人員心中的恐懼,對無知的恐懼常在開發人員的心中作祟:「如果程式碼能好好運作,就別碰了吧!」這也是為什麼專案中經常會存在一段醜陋的程式碼,卻沒人去整理的原因。意圖模糊的程式碼一但被留下來就會成為專案長久的痛處,只要新需求和這些程式碼相關,開發時程就會變得緩慢且難以估計。 若程式碼的意圖有被保留下來,這些程式碼就有機會被改善。保持程式碼意圖的方法就是盡量隔離意圖不同的程式碼,避免意圖不相同的程式碼耦合在一起,造成程式碼的意圖與界線都變得模糊。 隔離意圖也是解除耦合單一職責原則並不只有保持程式碼意圖這項優點而已,因為隔離意圖的過程中,也會解除不經意耦合的程式碼。開發的過程中常常 為了共用某些變數或邏輯,將意圖不相同的程式碼安排在一起;或單純只是因為處理的資料相同而被擺放在一起。雖然程式碼可以運作,卻也造成不同意圖的程式碼互相耦合。耦合的程式碼對維護專案來說是相當致命的。 意圖不相同的程式碼,通常也意味著修改的時機與頻率不相同。 新舊程式碼因為共用變數或邏輯而被安排在一起,常常會因為需求異動,只需要調整 其中一小段程式碼。但是開發人員卻需要花費很多時間閱讀與 當前需求 不相關的程式碼,只怕程式碼的異動會造成其他程式碼無法正常運行。 以(圖ㄧ)為例,不同意圖的程式碼共用一個 Foreach 迴圈,讓開發人員很難判斷修改任一變數後,會不會造成其他程式碼發生錯誤;如果將每個意圖隔離開來,每段程式碼只需要維護自己的小迴圈,即可減少開發人員閱讀程式碼的時間與發生錯誤的機率。 實踐接下來的章節將進入實作練習「如何導入單一職責原則」的階段。練習過程中,會先建立一個功能,並且隨著新需求不斷加入新的程式碼。最後再藉由單一職責原則,隔離不同意圖的程式碼,使每段程式碼的意圖得以保持清晰、且不互相耦合。 如何隔離意圖?隔離意圖前,須先學會找出可能發生「意圖糾纏」的地方導入單一職責原則的過程中,較困難的部分是如何發現意圖不同的程式碼。可能有多個需求都是在處理同一種資料,開發人員也習慣性地將處理相同資料的程式碼擺放在一起。如此一來,不同意圖的程式碼就容易堆疊在一起。因此,除了把處理相同資料的程式碼擺放在一起外,還必須做到隔離不同意圖的程式碼。 接下來以一個簡單的範例來「意圖糾纏」的程式碼是如何產生的: 範例:學生列表某系統最原始的版本中,有一個「學生列表」的功能,其需求為:顯示某班級的所有學生。 其程式碼分為 StudentController 與 StudentModel 兩部份。Controller 負責接收 HTTP 參數,並返回學生資料。Model 負責從資料庫撈取學生資料。 1234567891011121314151617181920212223242526class StudentController extends Controller{ /** var StudentModel **/ private $model; public function studentList() { $classId = $this->input->get('classId'); return $this->model->studentList($classId); }}class StudentModel extends Model{ private $db; public function studentList($classId) { $this->db->select('*'); $this->db->from('students'); $this->db->where('students.classId', $classId); return $this->db->get()->resultArray(); }} 新需求:已完成作業的學生列表隨著新需求新增,老師想要有一個「已完成作業的學生列表」的畫面。因此開發人員在 Controller 新增一個 studentListByHomeworkStatus() 函式,並且調整 StudentModel 的 studentList() 函式以便撈取對應的查詢條件: 1234567891011121314151617181920212223242526272829303132333435363738class StudentController extends Controller{ /** var StudentModel **/ private $model; /** 顯示某班級的所有學生 **/ public function studentList($classId) {/** ...省略 */} /** 已完成作業的學生列表 **/ public function studentListByHomeworkStatus() { $classId = $this->input->get('classId'); $homeworkId = $this->input->get('homeworkId'); return $this->model->studentList($classId, $homeworkId); }}class StudentModel extends Model{ private $db; public function studentList($classId, $homeworkId = null) { $this->db->select('*'); $this->db->join('homeworks', 'students.id = homeworks.studentId'); $this->db->from('students'); if ($homeworkId != null) { $this->where('homework.id', $homeworkId); $this->where('homeworks.status', 'done'); } $this->db->where('students.classId', $classId); return $this->db->get()->resultArray(); }} 臨時需求:尚未繳交 108 學年度腳踏車證費用的學生列表突然有臨時的需求,校務人員需要匯出「尚未繳交 108 學年度腳踏車證費用的學生列表」,於是開發人員又做了以下變動: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647class StudentController extends Controller{ /** var StudentModel **/ private $model; /** 顯示某班級的所有學生 **/ public function studentList($classId) {/** ...省略 */} /** 已完成作業的學生列表 **/ public function studentListByHomeworkStatus() {/** ...省略 */} /** 尚未繳交 108 學年度腳踏車證費用的學生列表 **/ public function studentListThatNotPaidBicyclePassFee() { $classId = $this->input->get('classId'); $bicyclePassYear = 108; return $this->model->studentList($classId, null, $bicyclePassYear); }}class StudentModel extends Model{ private $db; public function studentList($classId, $homeworkId = null, $bicyclePassYear = null) { $this->db->select('*'); $this->db->join('homeworks', 'students.id = homeworks.studentId'); $this->db->leftJoin('bicyclePass', 'students.id = bicyclePass.studentId'); $this->db->from('students'); if ($homeworkId != null) { $this->where('homework.id', $homeworkId); $this->where('homeworks.status', 'done'); } if ($bicyclePassYear != null) { $this->db->where('bicyclePass.year', $bicyclePassYear); $this->db->where('bicyclePass.payStatus', false); } $this->db->where('students.classId', $classId); return $this->db->get()->resultArray(); }} 新需求只會不斷地出現隨著時間的推移,功能也會不斷出現新需求,需求幾乎是無限上綱的。例如:以性別撈取學生、以戶籍地址撈取學生、撈取沒繳午餐費的學生、撈取午餐吃素的學生、已經繳交學雜費撈取學生 …等。 當這些需求都被寫在同一個功能裡面時,就能發現 studentList() 函式中充滿意圖不同的程式碼: 從(圖二)可看到 studentList() 總共包含了 8 個意圖的程式碼,其中 6.撈取沒繳午餐費的學生 和 7.撈取午餐吃素的學生 還共用同一段 Join 邏輯,產生了不經意的耦合。 這樣的程式碼會有下列問題: 不易閱讀與維護:為了避免改壞其他意圖的程式碼,每次進來改程式碼都要先讀過所有與 當前需求 不相關的程式碼。 額外的工作:不同意圖的程式碼被耦合在一起,造成部分意圖被迫執行不同意圖的程式碼。除了讓功能變得不穩定以外,日積月累還有可能成為系統的效能瓶頸。 studentList() 範例中,部分意圖被迫執行其他意圖的 Join 邏輯。 修改不能局部化:每個意圖共用同一個函式,當某個意圖不小心寫入嚴重錯誤 Bug,會連同其他意圖的功能也跟著發生錯誤。 不同的變動率:每個意圖會以不同的時機與頻率修改程式碼,讓原本正常運作的功能變得不穩定,隨時會被改成壞掉的。 學生列表的範例相當簡單,看起來影響不大,很容易解決。但實際上 意圖交纏 的問題常常出現在系統各處,而且每個問題的耦合程度與複雜度都不相同。通常等你意識到程式碼很難修改時,耦合的問題也已經很嚴重了。 因此,每個開發人員都應該學會如何隔離意圖。 隔離意圖:功能插件化 的思維解決意圖耦合最快的方式就是將 功能插件化,藉由 增加新的程式碼 來擴充系統的功能,而 不是藉由修改原本已經存在的程式碼 來擴充系統的功能,其原理為: 將「核心的邏輯」與「附加功能的邏輯」隔離開來,讓附加功能擴充核心功能的邏輯。 實務上可以從觀察程式碼中發現,會隨著需求增長的程式碼,通常是附加功能的邏輯;不會隨著需求被改變的程式碼,通常是核心邏輯。 這種開發思維對不熟悉物件導向的人來說應該覺得很奇怪,但是將 功能插件化 早在軟體開發領域隨處可見,應用層面從程式開發、框架、系統層級都有: JavaScript 透過註冊 event 事件,擴充瀏覽器行為。 MVC 框架透過繼承 Controller 或 Model 擴充框架的行為,以便完成功能。 瀏覽器透過安裝擴充套件,擴充瀏覽器行為。 手機透過安裝 APP,擴充手機行為。 以「學生列表」功能為例,核心的邏輯為:撈取學生列表;附加功能的邏輯為:其他完成新需求的程式碼。 接下來將 導入介面(Interface) 讓「學生列表」功能插件化,隔離意圖不同的程式碼。 Note: 因本篇文章探討的是物件導向設計原則,故以介面(Interface)來實踐功能插件化,但並不表示功能插件化只可以透過介面或物件導向的方式實踐。 導入介面(Interface)實踐 功能插件化一共有 4 步驟: 找出核心邏輯 開放擴充點,供核心邏輯隨時可以使用插件。 當有需求時,按照擴充點的定義,實作新的插件以便完成需求。 將新的插件注入核心邏輯中。 1. 找出核心邏輯,並開放擴充介面各種「學生列表」功能中,最常被執行的功能為 StudentModel->studentList(),因此我們可以斷定核心邏輯應該在這個函式裡面,並做了些調整: 123456789101112131415161718192021222324252627282930// ConditionPlugin:擴充 DB 查詢條件的介面interface ConditionPlugin { public function setWhereCondition($db);}class StudentModel extends Model{ private $db; /** @var ConditionPlugin */ private $plugin = null; /** 開放從外面注入擴充邏輯 */ public function setConditionPlugin(ConditionPlugin $plugin) { $this->plugin = $plugin; } public function studentList($classId) { $this->db->select('*'); $this->db->from('students'); $this->db->where('students.classId', $classId); // 執行擴充邏輯 if ($this->plugin) { $this->plugin->setWhereCondition($this->db); } return $this->db->get()->resultArray(); }} 這個步驟中,首先要找出核心邏輯。您可以發現 studentList() 只被保留了最核心的邏輯,也就是隨著需求與時間不變的邏輯。其他的邏輯暫時被忽略了,它們都是附加功能的邏輯,等等會再提及。 找出最核心的邏輯後,下個步驟是開放擴充點。範例中我做了四件事,讓 studentList() 開放了擴充點: 新增一個 ConditionPlugin 介面,這個介面接收一個 $db 參數,用來動態調用 $db 物件 StudentModel 新增私有屬性:$plugin StudentModel 新增公開方法:setConditionPlugin(),其參數型別為 ConditionPlugin 介面。供外部可以注入插件。 StudentModel->studentList() 方法中,調用外部注入插件($this->plugin)的setWhereCondition() 方法來擴充核心邏輯的行為。 其中,ConditionPlugin 是插件需要實作的介面,實作的內容即為:擴充核心邏輯,以便完成需求。只要類別有實作 ConditionPlugin 介面,都可以透過 setConditionPlugin 函式將插件注入到 StudentModel 中。這樣的作法是利用物件導向 多型 的特性,讓程式碼可以隨著 $plugin 變數運作時的真實物件,會引發不同的動作,達到擴充核心邏輯的效果。 接下來我們將依照 ConditionPlugin 介面的定義,實作各種「學生列表」功能的插件: 2. 實作插件介面,並於注入插件為了縮短範例的長度,此步驟只挑出「尚未繳交 108 學年度腳踏車證費用的學生列表」的需求來講解,其餘的需求則先帶過: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849// 1. 新增一個類別(插件)並實作 ConditionPlugin 介面:/** *「學生列表」插件:撈取指定學年度與符合付款狀態的學生 */class StudentListPluginThatNotPaidBicyclePassFee implements ConditionPlugin{ private $bicyclePassYear; private $payStatus; public function __construct($bicyclePassYear, $payStatus) { $this->bicyclePassYear = $bicyclePassYear; $this->payStatus = $payStatus; } // 2. 將原本放在 `StudentModel->studentList()` 的邏輯搬移至此 public function setWhereCondition($db) { $db->leftJoin('bicyclePass', 'students.id = bicyclePass.studentId'); $db->where('bicyclePass.year', $this->bicyclePassYear); $db->where('bicyclePass.payStatus', $this->payStatus); }}// 3. 修改 StudentController,從 Controller 配置 StudentModel 的擴充插件class StudentController extends Controller{ /** var \\StudentModel **/ private $model; /** 顯示某班級的所有學生 **/ public function studentList($classId) { /** ...省略 */} /** 已完成作業的學生列表 **/ public function studentListByHomeworkStatus(){ /** ...省略 */ } /** 尚未繳交 108 學年度腳踏車證費用的學生列表 **/ public function studentListThatNotPaidBicyclePassFee() { $classId = $this->input->get('classId'); $bicyclePassYear = 108; $payStatus = false; $ConditionPlugin = new StudentListPluginThatNotPaidBicyclePassFee($bicyclePassYear, $payStatus); $this->model->setConditionPlugin($ConditionPlugin); return $this->model->studentList($classId); }} 這個步驟中,我們建立了一個名稱為 StudentListPluginThatNotPaidBicyclePassFee 的插件,這個插件裡面的邏輯,其實就是把原本寫在 StudentModel->studentList() 的邏輯搬移過來而已。其他的「學生列表」功能也要以此類推,把當初寫在 StudentModel->studentList() 的邏輯搬移到自己的插件中。這麼一來,就已經把核心邏輯與附加邏輯拆開了。 最後在每個需求的 Controller 層,透過 StudentModel 的公開方法setConditionPlugin() 將插件注入 StudentModel 裡面。StudentModel 在撈取學生時就可以透過被注入的插件來擴充核心邏輯。 3. 每個插件都只負責一個職責上一步驟中,將每個附加邏輯與核心邏輯隔離後,即可產生新的結構: (圖三)中,每個插件都只負責執行一個需求的程式碼;StudentModel->studentList() 函式則專注於撈取學生列表。兜了這麼大一圈,這才是 單一職責原則 要我們做的事情: 隔離 核心邏輯 與 附加功能邏輯 當使用 核心邏輯 的情境不同時,就應該隔離該使用情境的程式碼 每個類別最多只負責一個情境的程式碼,避免造成耦合,或意圖模糊 單一職責原則,其實是以更高一層的角度在看程式碼。寫程式碼的時候,應該時時刻刻注意當前的程式碼會不會跟 當前需求 不相關的程式碼寫在一起。若有的話表示 核心邏輯 和 附加功能邏輯 可能已經混在一起了,這時就可以考慮導入單一職責原則,隔離 核心邏輯 和 附加邏輯,並且確保每個類別只負責一個需求的程式碼,避免程式碼的耦合越來越深。 所以單一職責的職責到底是什麼?很多人被單一職責原則的名字給混淆了,以為一個類別只可以做一件事情。但事實上「一次只做一件事」是函式層級的原則。 單一職責原則在類別層級中,用來劃分介面和型別的邊界2,將不同意圖、不同使用情境、不同需求、不同修改時機的功能劃分為各自獨立的「職責」,最後由類別來實現這些被獨立的職責。因此當一個職責的需求異動時,也表示只有 負責實現該職責的類別 需要被異動(修改局部化)。 為了讓類別容易被維護,一個類別應該盡可能減少負責的職責,這就是單一職責原則想傳達的概念: 「A class should have only one reason to change.」以一個類別來說,應該只有一個引起它變化的原因。 「only one reason to change」,其實就是在說一個類別應該只負責 一個意圖 或 一個使用情境,也就是上述的「職責」。 這意味著系統功能會由許多小巧且高內聚的類別組成,且每個類別只專注於實現單一的職責。 單一職責做得很好時,每個類別都只有一個唯一的目的。因此需要進行功能修改的時候可以更容易地專注在一個或特定幾個類別。不但加快找查程式碼的速度,也讓系統的修改可以局部化,降低維護系統的困難度。因此在一個高內聚性的系統中,程式碼可讀性及復用的可能性都會提高,儘管程式複雜,但容易被管理。 系列文章: 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力 再談 SOLID 原則,Why SOLID? 物件導向設計原則:單一職責原則,定義、解析與實踐 物件導向設計原則:開放封閉原則,定義、解析與實踐 物件導向設計原則:裡氏替換原則,定義、解析 註腳1.耦合:將許多功能封裝在同一個類別、介面、方法,但這些功能彼此的意圖卻不相同。 ↩2.邊界:明確定義一個類別、函式要實作的功能目標與涉及範圍。 ↩","link":"/blog/solid_srp_definition_and_practices/"},{"title":"2019 回顧工作與學習歷程","text":"2019 年可以說是既充實又偷懶的一年。為什麼呢?因為花了很多時間在學習理論知識,包含:重構、整潔架構、單元測試、領域驅動開發、行為驅動開發 …等等。雖然每個知識看起來像是完全獨立的領域,但對 2019 的我來說,它們都是應付 Legacy Application 的利器。 為什麼又說偷懶?部落格和作品幾乎停擺!學習知識理論不像學習新技術,能把應用新技術的步驟分享到部落格上。因為理論知識必須經過堆疊和內化才能在真實專案中落地實踐。再加上這一年很榮幸受邀到高雄科技大學,帶領一批資工系的學生學習 PHP 式設計課程,備課與上課的時間幾乎吃掉了三個月的休閒時間,也就是平常能用來自學或練習的時間 …。 至於工作呢?這份工作是我搬來高雄的第一份工作,工作內容主要是維護一個大型的 Legacy Application。雖然說是維護,但仍不斷有新需求一直進來。這也是為什麼我必須花費很多時間閱讀如何處理 Legacy Application 的相關書籍和技術。很慶幸我也有機會將學到的知識導入專案中,增加部分功能的彈性;或是阻止部分功能繼續腐敗。改善程式碼品質的成就感讓我更多的動力繼續學習。 可惜 2019 對我來說是一眨眼就結束,到年尾了還來不及紀錄下學習與實踐的東西。所以想先以流水帳的形式紀錄一下 2019 年所學的知識和工作中實踐的技術。 自學主題DDD《領域驅動設計》在 1 月準備系統重構流程的過程中,覺得自己對於「領域模型」不夠瞭解,因此決定拜讀一下《領域驅動設計》,增進自己對領域模型的認知。 DDD 是一個開發論,涵蓋的領域很多,可以說是納入各個程式設計層次的最佳實踐,包含:設計模式、DDD 四層架構(戰術設計)到多個系統間如何劃分邊界(戰略設計)。也因為 DDD 以多種層次來探討程式應該如何「設計」,讓我的思維直接上升好幾個層次,看待程式碼的視角變得更廣、更高階。 DDD 可以說是繼 SOLID 原則後影響我很深的知識,以前的思維只停留在物件與物件之間, DDD 讓我見識到系統架構如何應用「良好的程式設計」來應對變化,引起了我對系統架構的興趣。不過 DDD 對當時的我來說仍然有很多看不懂的部分(尤其是戰略設計),目前規劃 2020 年會再回來鑽研一次! 《Clean Architecture》《Clean Architecture》本來並不在 2019 的書單裡面,但是在學習 DDD 的過程中發現不斷有人拿 Clean Architecture 的架構來跟 DDDLite 架構做比較,這才引起我對 Clean Architecture 的興趣。 很幸運地,這又是一本可以讓程式設計思維提升好幾個層次的好書!Clean Architecture 可以說是物件導向設計 SOLID 原則的延伸,如果說 SOLID 是指導開發人員如何設計物件,那麼 Clean Architecture 就是指導開發人員如何將多個物件組織起來,並且用一套清晰易懂的規則讓物件與物件彼此協作,完成系統需求所需的功能。 整潔架構中,依照程式的重要程度來劃分層級: 越核心的邏輯越往內圈靠,容易變化的附加邏輯則越往外層靠。 越內圈的邏輯層次就越高(層次高表示彈性高),最內圈屬於業務規則的核心策略;外圈則是圍繞著核心業務規則並隨著需求不斷變化的 機制。這樣劃分的原因是,系統必須保持核心業務邏輯的彈性來因應多變的需求。內圈的程式碼彈為了保持彈性,也會引入較多的抽象;外圈的程式碼則是按需求所需,透過實作內圈公開的抽象來存取或擴充核心業務規則,以完成需求所需的功能。 除了分層以外,還需要遵循「相依性規則」: 原始碼依賴關係只能指向內部,朝向更高層級的策略。 這裏指的是,內圈不能知道外圈的存在。包含外圈的變數、類別、函式。只要在內圈看到 import 或是 using 引入外圈的東西就代表違反了規定。這樣的好處是可以將 策略 與 機制 隔離開來。身為一個開發人員必須意識到,將 策略 和 機制 寫在一起是很致命的錯誤!因為 機制(如 UI 介面)的需求變動頻率很大,而且有很大的機率修改到 策略(核心業務邏輯)的程式碼。為了避免 策略 被影響,應該隔離 策略 與 機制,這麼一來被隔離開的程式碼會以不同的速率和原因被修改,並且不會影響到彼此的程式碼。 另外,Clean Architecture 架構的同心圓並不一定要是 4 圈,4 圈只是作者為了方便講解層次而已。 最重要的是要做到「隔離策略與機制」和「遵循相依性規則」! 開發人員必須防止程式碼腐敗手頭上的 Legacy Application 到處都可以看見“參數很多且內容包含大量 if 或 switch 的大函式”,例如: 123456789101112131415161718192021222324252627282930class StudentModel extends Model{ private $db; /** 取得學生列表 */ public function studentList($classId, $homeworkId = null, $bicyclePassYear = null, /** ... 省略 */) { $this->db->select('*'); $this->db->join('homeworks', 'students.id = homeworks.studentId'); $this->db->leftJoin('bicyclePass', 'students.id = bicyclePass.studentId'); $this->db->from('students'); // 回家作業繳交狀況列表 if ($homeworkId != null) { $this->where('homework.id', $homeworkId); $this->where('homeworks.status', 'done'); } // 腳踏車證繳費記錄列表 if ($bicyclePassYear != null) { $this->db->where('bicyclePass.year', $bicyclePassYear); $this->db->where('bicyclePass.payStatus', false); } /** ...省略 */ $this->db->where('students.classId', $classId); return $this->db->get()->resultArray(); }} StudentModel->studentList() 是一個正在腐敗的程式碼,因為它的 策略 與 機制 被寫在一起了。這種安排程式的方式往往會在遇到新需求時,就替函式新增幾個參數或 if 來完成新需求的功能。雖然這個案例看起來很小又不複雜,但是,如果開發人員不懂得將策略與機制隔離,不用一下子專案中就會充滿又臭又長的大函式!而且這些大函式常常又是 策略 與 機制 完全攪在一起,開發人員根本難以辨認函式最原始的邏輯是什麼! 隔離策略與機制,防止程式碼腐敗在我維護的專案中,有一個核心功能為「簽核公文」。使用者需要有一個「簽核歷史紀錄」的畫面來追蹤公文狀態: 「簽核歷史紀錄」功能的資料是經由 Workflow 類別的 process() 函式撈取的: 1234567891011121314151617class Workflow{ /** @var array 所有簽核 */ protected $process = array(); /** @var Workflow_repository 資料庫操作層 */ private $repo; /** * 取得 所有簽核資料 * * @return array */ public function process() { return $this->repo->get_process($this->workflow_id) } // ... 省略 但是目前 Workflow->process() 只有撈取「簽核資料」是不夠的,因為客戶要求「簽核歷史紀錄」畫面需要更多的資訊: 隱藏匿名簽核成員的名稱 異常的流程須標記成紅色 顯示異常流程的原因 到目前為止可以看到,「簽核歷史紀錄」是屬於與 UI 相關的新需求。在 Clean Architecture 架構的同心圓中,UI 被分類在外圈,也就是 機制。Workflow 類別則是系統的核心功能,故屬於同心圓的內圈,也就是 策略。 如果開發人員直接擴充 Workflow->process() 的程式碼來完成「簽核歷史紀錄」的功能,等同於“把機制的程式碼寫進策略中”,不但使程式碼變得不易維護,還讓系統中其他調用 Workflow->process() 的功能都被迫執行「簽核歷史紀錄」的程式碼! 因此應該遵循 Clean Architecture 的「隔離策略與機制」原則,我們必須隔離 Workflow->process() 與「簽核歷史紀錄」的程式碼。做法很簡單,只需要讓 Workflow 引入一個抽象介面,讓外部可以注入 機制 的程式碼,就是做到隔離 機制 和 策略: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253/** * Interface Decorator * * 用於擴充領域模型撈取資料邏輯 for UI 介面 */interface Decorator { /** * 擴充領域模型的資料 * * 先利用領域模型取得資料, * 再取得 UI 層的所需資料 * * @param $domain_object * @return mixed */ public function decorator($domain_object);}class Workflow{ /** @var Decorator[] 資料裝飾器 */ private $decorators = []; /** @var array 所有簽核 */ protected $process = array(); /** @var Workflow_repository 資料庫操作層 */ private $repo; /** * 引入資料裝飾器,讓資料可以隨著需求擴充 * * @param Decorator $decorator 資料裝飾器 */ public function add_decorator(Decorator $decorator) { $this->decorators[] = $decorator; } /** * 取得 簽核流程 * * @return array */ public function process() { $this->process = $this->repo->get_process($this->workflow_id); // 如果有配置裝飾器,則執行裝飾器擴充資料邏輯 foreach ($this->decorators as $decorator) { $this->process = $decorator->decorator($this->process); } return $this->process; } 上面這段範例程式碼,已經完全將策略和機制隔離了: 新增了一個用來擴充資料的抽象介面 Decorator 在 Workflow 新增一個公開的 add_decorator() 函式,提供外部將實作 Decorator 抽象介面的實體物件注入 Workflow。 調整 Workflow->process() 函式,調用被注入的 Decorator 實體物件來擴充資料。 這一個步驟的核心觀念是 策略必須開放擴充點,讓機制從外部擴充策略的邏輯,只有這麼做才能讓策略應對千變萬化的需求。 開放擴充點後,只需要依照「簽核歷史紀錄」的需求,並新增 實作 Decorator 的類別 來擴充 Workflow 的邏輯就好了: 從上圖可以發現,Workflow 只能透過抽象介面來使用 機制,換句話說 Workflow 根本不知道 Hide_anonymous_user_name.php 和 Mark_error_process.php 的存在!這正是「相依性規則」所謂的“內圈不能知道外圈的存在”。抽象介面就像一道邊界,隔離了策略與機制。而邊界的兩邊將以不同的速率和原因被改變。 一直都是開放封閉原則 較有經驗的開發人員應該會發現這不正是 開放封閉原則 嗎?沒錯,Uncle bob 在書中也說了: 事實上,軟體開發技術的歷史就是「如何方便地建立 Plugin 來奠定可擴展和可維護的系統架構」的故事 -《Clean Architecture》 《Clean Architecture》一書揭露了軟體開發從設計物件到組織架構,其實都是應用「隔離策略與機制」的思維來控制軟體複雜度,至於如何做到隔w離策略和機制則是按照不同的情境有不同的作法。 工作實踐經驗替專案導入 Commit Message 規範打開新公司的程式碼,發現這裡的開發人員沒有統一撰寫 Git Commit Message 的格式,這對一個長期維護的系統來說並不是一件好事!打開 Git History 幾乎找不出程式碼異動的意圖與原因,往往只有原作者知道自己的程式碼在做什麼…。 為了解決這個問題,我向開發團隊提議可以導入 Commit Message 規範,規範的詳細內容已經被記錄在「Git Commit Message 這樣寫會更好,替專案引入規範與範例 」 文章中。 實際案例1. 同仁導入規範前的 Commit Message: 2. 導入規範後,開始會紀錄異動原因與內容: 雖然過了好幾個月才讓大部分的開發人員確實按規範撰寫 Commit Message,但是開發團隊中已經養成一個優良的文化!當良好 Commit Message 持續被 Commit 進程式庫,新進人員也會乖乖遵循前人的格式撰寫 Commit Message。 替專案導入單元測試時間拉回到剛搬到高雄的 2018 末。當時我才剛學會重構和單元測試。面試時,公司的技術顧問說公司想要導入單元測試和重構,希望我一定要進公司幫忙。我也答應了!真的很感謝有這個機會可以實踐導入重構與單元測試(讓我成長很多)。 這間公司的專案使用 CodeIgniter 3 框架(後面簡稱 CI3),CI3 本身並不是倡導物件導向開發風格的框架,專案的程式碼自然也是偏向義大利麵風格。加上專案是維護多年的大系統,在這樣的環境下導入單元測試變得很困難! 雖然說導入單元測試很困難,但說真的,除非專案使用物件導向風格開發以及有持續重構的習慣,不然不可能輕易導入單元測試吧!而且從身邊碼農朋友的經驗看來,幾乎 100% 的專案沒辦法輕易導入單元測試。所以我把這次的困難當作挑戰,練習如何讓單元測試在一個 Legacy Application 落地。 建立測試框架由於 CI3 架構內建的單元測試功能很少,所以我選用整合了 PHPUnit 的 ci-phpunit-test 來當作專案的測試框架。建立測試框架其實是導入單元測試最快最簡單的步驟,因為其他開發人員並不懂單元測試的相關知識,所以一定要盡可能地簡化撰寫單元測試的複雜度,才能讓單元測試“較有機會”導入開發團隊… 隔離測試環境對 DB 的副作用為了避免測試環境的行為污染 DB 的資料,因此要隔離單元測試與 DB。當時第一個想法是:”在測試環境下一律使用 SQLite”,因為 SQLite 可開啟 In Memory 模式,在記憶體中操作資料庫。當程式關閉後,記憶體內的 SQLite 資料庫也會清空,相當適合測試環境使用(無副作用)。 做法很簡單,只要把 MySQL 的備份檔案轉成 SQLite,再到 CI3 的設定檔案中將測試環境的 DB 驅動器改成 SQLite 就可以了…。 但是實際運作起來,總有一些功能會發生錯誤。原本以為是 MySQL 的資料在轉換成 SQLite 的過程中有失真。經過百般測試後發現,原來是專案很多功能是用手寫的字串來組織 SQL 指令,而不是透過 CI3 的 QueryBuilder 來建立 SQL 指令。手寫的 MySQL 指令讓測試環境中的 SQLite 驅動器編譯失敗。我也因此學到一個教訓: 專案應該盡可能用 QueryBuilder 或 ORM 等資料庫操作層來編寫 SQL,否則專案要切換資料庫種類的時候,會有很高的機率遇到編譯失敗的窘境。 若您的專案也經常使用手寫字串組織 SQL 指令,請不要花時間研究如何使用 SQLite 當作測試環境的資料庫了! 利用 DB 交易機制避免副作用SQLite In Memory 的方式失敗了,只好找用第二方案:資料庫交易處理機制(Transaction),在測試案例中加入交易機制,測試一結束就執行滾回(Rollback)。為了引入交易機制,建立一個客製化的類別,並繼承 TestCase 類別來銜接 CI3 內建的 DB 交易邏輯,如此一來測試環境的行為就不會對資料庫造成影響了: 123456789101112131415161718192021/** * 功能三:將目前帳號加入至設備可切換帳號 * 1. 將帳號加至 DB 中,where token = Cookie.token */public function test_add_switchable_account(){ $this->trans_begin(); // @given admin 帳號 $this->init_switcher(self::COOKIE_STATE); $account = 'admin'; // @when 將 admin 帳號加入至設備可切換帳號 $this->switcher->add_switchable_account($account); // @then 帳號 admin 已存至設備可切換帳號 DB 中 $switchable_account = $this->get_switchable_account_from_db($this->state->token); $this->assertEquals(json_encode([$account]), $switchable_account['switchable_account']); $this->trans_rollback();} 當然,這個方案不夠好,要是資料庫裡面沒有我需要的測試資料呢?更何況單元測試本來就不應該依賴外部環境(如資料庫)不是嗎?因此後來我嘗試了好幾種建立測試資料的方案,例如 Seeds 或 Faker 等等…。但是公司專案的資料其實非常難建立,若為了測試環境而花費大把時間維護 Seeds 或 Faker 似乎也不是一個好辦法。最後終於在《Specification By Example》這本書找到最佳解決方案! 拷貝「具有象徵性的真實資料」當作測試資料《Specification By Example》中提及,若專案的較難建立就可以拷貝「具有象徵性的真實資料」,並且把被拷貝的資料存放在某個檔案中,進行測試的時候再將這些檔案轉譯成測試資料。這個方法讓我不再煩惱如何建立測試資料,現在只需要透過 PHP 的 var_export() 或 json_encode() 就可以將需要多道程序才能建立的資料拷貝起來,然後將拷貝的資料放在專門提供單元測試資料的類別(Test Data Provider),供測試案例使用: 12345678910111213141516171819202122232425262728/** * feat: 表單填寫頁面,選擇題允許輸入文字時,必填時也要檢查輸入文字 input */public function test_check_form_with_options_that_text_writable(){ $this->describe('必填「多選題」允許輸入文字,沒有填寫 **所有已挑選選項** 的文字時,不可通過必填檢查', function () { // @given 必填「多選題」允許輸入文字 // @given 尚未填寫所有已挑選的選項之文字方塊 list($question_block, $input, $ai_id, $role_id) = $this->data_provider->form_with_checkbox_that_text_writable_and_not_filled_all_text(); // @when 進行表單必填檢查 $required_notice = $this->answer_checker->check_form($ai_id, $question_block, $input, $role_id); // @then 不過表單必填檢查 $this->assertEquals(1, count($required_notice)); }); $this->describe('必填「多選題」允許輸入文字,有填寫 **所有已挑選選項** 的文字時,可通過必填檢查', function () { // @given 必填「多選題」允許輸入文字 // @given 有填寫所有已挑選的選項之文字方塊 list($question_block, $input, $ai_id, $role_id) = $this->data_provider->form_with_checkbox_that_text_writable_and_filled_all_text(); // @when 進行表單必填檢查 $required_notice = $this->answer_checker->check_form($ai_id, $question_block, $input, $role_id); // @then 通過表單必填檢查 $this->assertEquals(0, count($required_notice)); });} 替 Bug 撰寫測試案例值得一提的是,拷貝資料的做法除了提供 TDD 或 BDD 測試資料以外,也可以應用在 Debug。每當遇到 Bug 時,就可以把具有「Bug 特徵的輸入值」Copy 起來,並且為這個測試資料建立一個 Bug fix 測試案例。 接著我們要修正程式碼讓 Bug fix 的測試案例通過。當測試案例通過的時候,表示我們把 Bug 修正了。之後遇到新的問題的時候,不但要通過新的 Bug fix 測試案例,也需要通過所有「舊的 Bug fix 測試案例」,才能確保新的 Bug fix 不會破壞原本正常運作的邏輯! 提出系統重構流程“導入系統重構”是技術顧問在面試中拜託我要幫忙的事項。 由於公司早期的開發人員沒有重構的習慣,所以平常有非常大量的技術債可以讓我進行重構,小至意義不明的變數名稱,大至超多層巢狀 if、foreach,有時候看到巨獸等級的義大利麵還會被嚇到心頭揪一下呢! 雖然平常我都會隨手進行重構一下 issue 會觸及的程式碼,但是一個團隊中只有一個人進行重構,對多年累積下來的 技術債 來說是不痛不癢的。所以我開始在公司內部的 Wiki 分享重構的知識;觀察專案中的核心功能有哪些;制定重構的 SOP 流程。最後將這些資訊整理一份簡易的簡報,用來跟經理和技術顧問討論要怎麼讓重構落地: 系統重構流程.pdf 簡報大綱指出系統現有的問題: 專案使用的 MVC 框架在面對數量龐大的模組和多變的需求下,容易變得耦合與臃腫。 專案中充滿著重複的程式碼邏輯,開發人員難以學習系統。 提出的解決方案: 導入領域模型層,讓程式碼抽象化,降低開發人員學習系統的難度,以及更容易應變變化。 導入 Wiki 文件系統,讓開發人員共同維護文件。 也提供實際操作範例: 如何撰寫整合測試案例,供重構時回歸測試用。 如何利用重構建立領域模型。 如何撰寫領域模型的單元測試。 這份簡報的原意只是想用來討論 SOP,不過技術顧問看完後就開始調動人員準備執行了! 持續重構,利用設計模式”擁抱變化“基本型別偏執(Primitive Obsession)手頭上的專案每次遇到要匯出 Excel 的需求都會很頭痛,因為必須透過大量且複雜的 Array 來建立匯出資料。這是 Primitive Obsession(基本型別偏執)的壞味道,大量使用基本型別(如 Array)來組織一個有意義的結構。但是大量的基本型別閱讀起來跟大泥團沒什麼兩樣,因此做了 Replace Array with Object 重構: 這次重構簡直是一勞永逸,之後遇到匯出 Excel 都是小菜一碟!其他開發人員也用得開心^^。 Flyweight 享元模式在 Excel 解決了 基本型別偏執 臭味道後,又引入了新的問題:記憶體耗盡(memory exhausted)。為什麼會發生記憶體耗盡呢?因為對 Excel 物件來說,一欄一列都需要產生一個 Row 或 Cell 物件來乘載資料。一旦要匯出資料量很大,就有可能發生記憶體耗盡的問題: 為了避免記憶體耗盡的問題,導入了享元模式。享元模式的核心思想其實就是建立快取(Cache)。所有要匯出的資料只需通過快取的 Row 或 Cell 物件來渲染資料即可,不必再為每個 Row 與 Cell 建立獨立的物件。 123456789101112131415161718192021222324252627282930313233343536373839404142class Excel{ /** * Excel Row 物件 * * 使用享元模式,避免 new 太多列物件 * * @var Row $row */ private $row; /** * 新增一列 * * @param Cell|string|array $cells 欄位內容(可複數) * @param array $styles */ public function addRow(array $cells, $styles = array()) { $this->flyweightRow(); $this->row->clearCells(); foreach ($cells as $cell) { $this->row->addCell($cell, $styles); } $this->stack($this->row); return $this; } /** * 建立享元 Row 物件 * * @return Row; */ private function flyweightRow(): void { if (!$this->row) { $this->row = new Row(); } } 加入快取機制後,即使要建立一個上萬筆資料的 Excel 活頁,也只需要一個 Row 與 Cell 物件就好了! (雖然後來發現其實是迴圈邏輯寫錯才造成記憶體耗盡,但享元模式仍被保留下來了。) State 狀態模式使用者希望系統提供「切換帳號功能」,讓使用者不必登出重打帳號密碼即可切換同一設備上曾經登入過的帳號,。 原先的設計是建立一個 Switcher 類別,讓每個設備產生獨立的 Token 並儲存在 Cookie 裡面,系統再由 Cookie 的 Token 撈取設備曾經登入過哪些帳號。 但是過沒多久,另一個客戶希望可以藉由 user_id 從舊系統的人事資料庫撈取多個可切換的帳號,但這個切換帳號的需求較適合用 Session 來實現。 為了讓系統可以同時使用 Cookie 與 Session 來切換帳號,我在 Switcher 類別中引入 Switchable_state 介面,並把原本 Cookie 的邏輯全部搬移至新類別 Cookie_storage: 現在 Switcher 是透過 Switchable_state 介面來存取資料來源,因此我只需新增一個實作 Switchable_state 介面的類別來實現 Session 版本的切換帳號功能,即可無縫接軌地讓系統多出 Session 切換帳號功能。 Pipeline 流水線模式系統有個每日信件功能,該功能會在每日凌晨啟動排程寄送當日的系統信件給使用者。最初的需求為: 11. 寄送每日系統通知 沒多久後,每日信件又有新的需求,要寄送新的信件種類: 121. 寄送每日「系統通知」2. 寄送每日「站內訊息」 沒意外!每日信件馬上又出現第三個需求: 1231. 寄送每日「系統通知」2. 寄送每日「站內訊息」3. 寄送「明日上課資訊」給教師 每日信件功能原本的設計是,將所有建立信件內容的邏輯寫在同一個 Controller 上。但是隨著需求不斷增加,Controller 變得又龐大又複雜。 考量到將來可能會需要寄送多種資訊給使用者,故重構程式結構,讓未來擴充每日信件功能比較方便: 引入 Pipeline,把取得各種系統資訊的邏輯注入進 Pipeline。 透過 Pipeline 取得每日通知信件內容,並建立信件 HTML。 把建立「系統通知」信件邏輯搬移至 System_notify_handler.php 把建立「站內訊息」信件邏輯搬移至 Message_handler.php 把建立「開課資訊」信件邏輯搬移至 Course_start_handler.php 導入 Event Sourcing 概念紀錄系統日誌其他經驗高雄科技大學講授 PHP 程式設計課程技術顧問對 1 月份提出的 系統重構流程 相當有興趣,故將重構計畫整合至校外課堂中,帶領一批學生一起參與重構計畫,希望透過讓學生閱讀企業級的程式碼,栽培學生寫出「成熟」的程式。在這個計畫中,公司由經理和我每個禮拜中抽出兩天的時間到高雄科技大學替學生上課,上課的主題是網站系統開發,包含: Gitlab 基本操作 Commit Commit Message 規範 Push Pull Merge Request 概念與用意 HTML5 CSS JavaScript PHP CodeIgniter 3 如何閱讀程式碼 另外也可以看當時設計給學生的 訓練菜單,我認為這份也很適合給較沒開發經驗的新進人員當做教材學習。 翻轉教室每週兩天的上課時間,對網站開發沒概念的學生來說實在是太少了,因此必須設計一套適合的課程來解決時間太少的問題。最後遵循一套名為「翻轉教室」的教學方式,設計出讓學生在非上課時間也能夠自主學習的課程。 翻轉教育的課程必須事先幫學生定製一系列的主題與範圍(詳情可見:訓練菜單),再給予每個主題的學習資源,讓學生以概括的方式快速了解主題。最後每個主搭配一些題目或情境,讓學生以解決問題導向的方式進行學習。另外,為了因應學生素質不一致的問題,教學資源和題目包含淺至深的議題,讓進度較快的學生不必等其他同學的進度,就能持續往下練習。 開始上課前幾週,只需要幫學生起個頭,簡介一下主題的內容與主題之間的關聯,並且指派每週作業。下課後學生就會自行找出實踐作業的相關知識,從中學習知識並且得到解決問題的成就感。 當學生熟悉翻轉教室的形式後,每次到學校上課都是與學生探討在作業中遇到常見錯誤、困難,並且提出良好的解決方案或學習方向。 Gitlab 當作回饋機制翻轉教室主要是讓學生透過自主學習的方式進行學習,因此必須有個回饋機制讓學生知道自己學得正不正確。原本翻轉教室是利用每次上課時間,讓學生分享作業;以及講師帶領學生探討寫作業過程中遇到的問題來當作回饋。 我和經理選擇導入更適合開發人員的 Gitlab 來實踐回饋機制: 利用 Milestone 要求學生將作業繳交至 Gitlab。 issue 功能則像 StackOverflow,讓學生可以公開討論自己的問題,個性羞怯的學生也能在 Gitlab 與講師或同學進行交流。 Merge Request 機制用來審視學生的作業,將學生做得不好的地方與建議即時回饋給學生。 利用 Merge Request 導入良好的開發觀念Merge Request 讓我變成學生的個人教練,學生上傳程式碼後會立刻得到錯誤報告與改善建議。發現常見問題時,也可以 tag 所有學生一起討論並學習解決方案。 我和經理在 Merge Request 階段進行嚴謹的 Code Review,審查內容包含: Commit Message 規範 Coding Style 重構 Clean Code 觀念 …等 只要不合格就退回並給予改善建議,詳細內容可以看 PHP 作業常見問題與建議。前幾個作業中,學生都被多次退回才通過 Code Review。 不到一個月,學生的程式碼變得相當成熟,不再有意義不明的變數名稱,也不會寫出巢狀 if,並且有能力釐清程式碼的職責。甚至能力較好的學生還會回頭重構前幾次的作業呢! Code Review 除了訓練學生效果很好以外,我認為一個 IT 公司也應該好好利用 Code Review 持續培養開發人員良好的開發習慣與思維!","link":"/blog/2019_review/"},{"title":"PHPStorm 如何遠端執行單元測試","text":"我的公司在開發階段中,習慣以 rsync 將本機上的程式碼同步至 測試機 上運行。 因此替功能撰寫測試案例後,必須經過一些設定才能 遠端執行測試案例。 以下將提供 PHPStorm 遠端執行測試案例 配置步驟。 1. 新增遠端部署(Development) 設定第一個步驟是設定遠端部署,主要目的有兩個: 讓 PHPStorm 可以透過 ssh 連接至遠端伺服器(測試機),執行測試案例。 設定專案路徑配對,讓 PHPStorm 可以對應「你的本機程式碼位置」與「測試機程式碼位置」。 只要配對好 Development 設定,PHPStorm 就可以直接在測試機上執行 你所指定的測試案例(後面會示範)。 1.1 打開遠端部署(Development)設定介面遠端部署(Development)的打開步驟依序為:Tools>Development>Configuration: 1.2 設定 ssh 連線資訊打開遠端部署(Development)設定介面後,你會看見預設第一個頁籤為 Connection。 Connection 頁籤要設定的是如何讓 PHPStorm 連線登入至遠端伺服器(測試機),以下是必要設定說明: Type:選擇 SFTP Host:要連接的 Domain Name User name:登入遠端伺服器的 User Authentication:ssh 認證方式(可參考 SSH 公開金鑰認證) Private key path:私鑰位置 (可參考 SSH 公開金鑰認證) Root path:遠端伺服器中,專案的根目錄路徑(此為遠端伺服器上的絕對路徑)。 此設定相當重要,用來對映本地與遠端程式碼。若沒做好對映,後續 PHPStorm 會沒辦法辨認你要執行哪個測試案例。 Web server URL:遠端伺服器的對外 URL 1.3 設定專案檔案對映(Mappings)資訊Connection 頁籤設定完成後,接著進入第二個頁籤 Mappings。 Mappings 頁籤用來設定「本機專案」與「遠端專案」的檔案對映關係。 必須設定的項目有: Local path: 專案根目錄路徑(本機的絕對路徑) Development path: 遠端主機的專案根目錄(注意,此為 Connection 頁籤之 Root path 的相對路徑) 完成檔案對映資訊後,PHPStorm 就能辨認你的本機檔案是對映至遠端伺服器的哪個檔案。 2. 新增遠端 PHP CLI 執行器(Interpreter)注意:執行此步驟前,必須元成第一個步驟 1.新增遠端部署(Development)設定,否則無法進行下去。 此一步驟的目的是:讓 PHPStorm 調用遠端伺服器的 PHP 執行檔 來啟動 測試案例。 2.1 打開 PHP 設定頁面 Mac:PHPStorm 左上角,依序點擊 PHPStorm>Preferences>Language & Framework>PHP Windows:左上角,依序點擊 >Files>Settings>Language & Framework>PHP 接著就能看見 PHP 設定頁面: 2.2 打開新增 PHP-CLI Interpreter 的介面於 2.1 打開 PHP 設定頁面後,依照下圖順序,打開新增 PHP-CLI Interpreter 的介面: 2.3 設定 Remote PHP Interpreter若按照步驟 2.2 操作,即可 PHPStorm 跳出新的視窗「Configuration Remote PHP Interpreter」。 這一步驟即需要完成兩項設定: 設定 PHPStrom 如何連線至遠端伺服器 配置遠端伺服器的 PHP 執行檔 當作 CLI Interpreter。 若您已經完成 1.新增遠端部署(Development)設定,則可以選擇 Development Configuration 選項來替 PHPStrom 設定連線至遠端伺服器的方式。 最後 PHP Interpreter path 則需填入 遠端伺服器的 PHP 執行檔 路徑(實體路徑)。 設定完成後,點擊 OK。 此時回到原本 PHP 設定頁面,若出現一個 Remote PHP xxx 的 CLI 設定檔,即表設定成功。 3. 新增一個遠端調試器(Debugger)最後一個步驟,替測試案例新增一個遠端調試器(Debugger)。 3.1 打開 Debugger 設定畫面打開 Debugger 設定畫面,並點擊 Edit Configuration: 3.2 打開測試框架的 Interpreter 設定畫面請一下圖順序,打開新增 PHPUnit 測試框架的 Remote Interpreter 畫面。 3.3 設定 PHPUnit Remote Interpreter若您已經完成 步驟2. 新增遠端 PHP CLI 執行器(Interpreter),則可以在 Interpreter 選單中選擇剛剛建立的 Remote PHP xxx。 注意:上圖中的 Default configuration file 是遠端伺服器的 phpunit.xml 路徑 3.4 設定完成 全部設定完成!執行測試後,即可看見當前是在遠端伺服器中執行測試:","link":"/blog/PHPStorm_remote_testing_setting/"}],"tags":[{"name":"Clean Code","slug":"Clean-Code","link":"/blog/tags/Clean-Code/"},{"name":"自學技巧","slug":"自學技巧","link":"/blog/tags/%E8%87%AA%E5%AD%B8%E6%8A%80%E5%B7%A7/"},{"name":"PHP","slug":"PHP","link":"/blog/tags/PHP/"},{"name":"CodeIgniter3","slug":"CodeIgniter3","link":"/blog/tags/CodeIgniter3/"},{"name":"設計模式(Design Patterns)","slug":"設計模式(Design-Patterns)","link":"/blog/tags/%E8%A8%AD%E8%A8%88%E6%A8%A1%E5%BC%8F%EF%BC%88Design-Patterns%EF%BC%89/"},{"name":"重構","slug":"重構","link":"/blog/tags/%E9%87%8D%E6%A7%8B/"},{"name":"SOLID 原則","slug":"SOLID-原則","link":"/blog/tags/SOLID-%E5%8E%9F%E5%89%87/"},{"name":"單元測試","slug":"單元測試","link":"/blog/tags/%E5%96%AE%E5%85%83%E6%B8%AC%E8%A9%A6/"},{"name":"測試驅動開發","slug":"測試驅動開發","link":"/blog/tags/%E6%B8%AC%E8%A9%A6%E9%A9%85%E5%8B%95%E9%96%8B%E7%99%BC/"},{"name":"PHPStorm","slug":"PHPStorm","link":"/blog/tags/PHPStorm/"}],"categories":[{"name":"程式設計思維","slug":"程式設計思維","link":"/blog/categories/%E7%A8%8B%E5%BC%8F%E8%A8%AD%E8%A8%88%E6%80%9D%E7%B6%AD/"},{"name":"設計模式(Design Patterns)","slug":"程式設計思維/設計模式(Design-Patterns)","link":"/blog/categories/%E7%A8%8B%E5%BC%8F%E8%A8%AD%E8%A8%88%E6%80%9D%E7%B6%AD/%E8%A8%AD%E8%A8%88%E6%A8%A1%E5%BC%8F%EF%BC%88Design-Patterns%EF%BC%89/"},{"name":"重構","slug":"程式設計思維/重構","link":"/blog/categories/%E7%A8%8B%E5%BC%8F%E8%A8%AD%E8%A8%88%E6%80%9D%E7%B6%AD/%E9%87%8D%E6%A7%8B/"},{"name":"CodeIgniter 3 單元測試日常","slug":"CodeIgniter-3-單元測試日常","link":"/blog/categories/CodeIgniter-3-%E5%96%AE%E5%85%83%E6%B8%AC%E8%A9%A6%E6%97%A5%E5%B8%B8/"},{"name":"單元測試","slug":"程式設計思維/單元測試","link":"/blog/categories/%E7%A8%8B%E5%BC%8F%E8%A8%AD%E8%A8%88%E6%80%9D%E7%B6%AD/%E5%96%AE%E5%85%83%E6%B8%AC%E8%A9%A6/"}]}