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 的大函式”,例如:
1 | class StudentModel extends Model |
StudentModel->studentList()
是一個正在腐敗的程式碼,因為它的 策略 與 機制 被寫在一起了。這種安排程式的方式往往會在遇到新需求時,就替函式新增幾個參數或 if 來完成新需求的功能。雖然這個案例看起來很小又不複雜,但是,如果開發人員不懂得將策略與機制隔離,不用一下子專案中就會充滿又臭又長的大函式!而且這些大函式常常又是 策略 與 機制 完全攪在一起,開發人員根本難以辨認函式最原始的邏輯是什麼!
隔離策略與機制,防止程式碼腐敗
在我維護的專案中,有一個核心功能為「簽核公文」。使用者需要有一個「簽核歷史紀錄」的畫面來追蹤公文狀態:
「簽核歷史紀錄」功能的資料是經由 Workflow 類別的 process()
函式撈取的:
1 | class Workflow |
但是目前 Workflow->process()
只有撈取「簽核資料」是不夠的,因為客戶要求「簽核歷史紀錄」畫面需要更多的資訊:
- 隱藏匿名簽核成員的名稱
- 異常的流程須標記成紅色
- 顯示異常流程的原因
到目前為止可以看到,「簽核歷史紀錄」是屬於與 UI 相關的新需求。在 Clean Architecture 架構的同心圓中,UI 被分類在外圈,也就是 機制。Workflow 類別則是系統的核心功能,故屬於同心圓的內圈,也就是 策略。
如果開發人員直接擴充 Workflow->process()
的程式碼來完成「簽核歷史紀錄」的功能,等同於“把機制的程式碼寫進策略中”,不但使程式碼變得不易維護,還讓系統中其他調用 Workflow->process()
的功能都被迫執行「簽核歷史紀錄」的程式碼!
因此應該遵循 Clean Architecture 的「隔離策略與機制」原則,我們必須隔離 Workflow->process()
與「簽核歷史紀錄」的程式碼。做法很簡單,只需要讓 Workflow
引入一個抽象介面,讓外部可以注入 機制 的程式碼,就是做到隔離 機制 和 策略:
1 | /** |
上面這段範例程式碼,已經完全將策略和機制隔離了:
- 新增了一個用來擴充資料的抽象介面
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 交易邏輯,如此一來測試環境的行為就不會對資料庫造成影響了:
1 | /** |
當然,這個方案不夠好,要是資料庫裡面沒有我需要的測試資料呢?更何況單元測試本來就不應該依賴外部環境(如資料庫)不是嗎?因此後來我嘗試了好幾種建立測試資料的方案,例如 Seeds 或 Faker 等等…。但是公司專案的資料其實非常難建立,若為了測試環境而花費大把時間維護 Seeds 或 Faker 似乎也不是一個好辦法。最後終於在《Specification By Example》這本書找到最佳解決方案!
拷貝「具有象徵性的真實資料」當作測試資料
《Specification By Example》中提及,若專案的較難建立就可以拷貝「具有象徵性的真實資料」,並且把被拷貝的資料存放在某個檔案中,進行測試的時候再將這些檔案轉譯成測試資料。這個方法讓我不再煩惱如何建立測試資料,現在只需要透過 PHP 的 var_export()
或 json_encode()
就可以將需要多道程序才能建立的資料拷貝起來,然後將拷貝的資料放在專門提供單元測試資料的類別(Test Data Provider),供測試案例使用:
1 | /** |
替 Bug 撰寫測試案例
值得一提的是,拷貝資料的做法除了提供 TDD 或 BDD 測試資料以外,也可以應用在 Debug。每當遇到 Bug 時,就可以把具有「Bug 特徵的輸入值」Copy 起來,並且為這個測試資料建立一個 Bug fix 測試案例。
接著我們要修正程式碼讓 Bug fix 的測試案例通過。當測試案例通過的時候,表示我們把 Bug 修正了。之後遇到新的問題的時候,不但要通過新的 Bug fix 測試案例,也需要通過所有「舊的 Bug fix 測試案例」,才能確保新的 Bug fix 不會破壞原本正常運作的邏輯!
提出系統重構流程
“導入系統重構”是技術顧問在面試中拜託我要幫忙的事項。
由於公司早期的開發人員沒有重構的習慣,所以平常有非常大量的技術債可以讓我進行重構,小至意義不明的變數名稱,大至超多層巢狀 if、foreach,有時候看到巨獸等級的義大利麵還會被嚇到心頭揪一下呢!
雖然平常我都會隨手進行重構一下 issue 會觸及的程式碼,但是一個團隊中只有一個人進行重構,對多年累積下來的 技術債 來說是不痛不癢的。所以我開始在公司內部的 Wiki 分享重構的知識;觀察專案中的核心功能有哪些;制定重構的 SOP 流程。最後將這些資訊整理一份簡易的簡報,用來跟經理和技術顧問討論要怎麼讓重構落地:
簡報大綱
指出系統現有的問題:
- 專案使用的 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 建立獨立的物件。
1 | class Excel |
加入快取機制後,即使要建立一個上萬筆資料的 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 流水線模式
系統有個每日信件功能,該功能會在每日凌晨啟動排程寄送當日的系統信件給使用者。
最初的需求為:
1 | 1. 寄送每日系統通知 |
沒多久後,每日信件又有新的需求,要寄送新的信件種類:
1 | 1. 寄送每日「系統通知」 |
沒意外!每日信件馬上又出現第三個需求:
1 | 1. 寄送每日「系統通知」 |
每日信件功能原本的設計是,將所有建立信件內容的邏輯寫在同一個 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 持續培養開發人員良好的開發習慣與思維!