大神教練來鈦坦,鈦坦人耳熟能詳的「coaching」到底在做些什麼?|Part 2:Dev Do & Don’t(開發篇)

㊣港都🦭豹哥㊣
新加坡商鈦坦科技
21 min readMay 15, 2023

--

Created by Midjourney

本篇仍屬 Part 2 文章,但以與程式碼相關的內容為主,Part 2 與團隊運作主題相關的另一篇,可以參見《大神教練來鈦坦,鈦坦人都耳熟能詳的「coaching」到底在做些什麼?|Part 2:Dev Do & Don’t(團隊篇)》一文。

coaching 期間,virtual team 一律是使用 mob programming 的模式以 ATDD 的方式進行開發。

在開發過程中,被 91 發現一些較不好的程式碼寫法以及建議的實踐,有些是有問題的 legacy code、有些是開發新功能的過程中討論所帶出的議題,除此之外團隊成員也被傳授了一些開發門道,豹哥在本篇文章記錄一些值得分享的主題。

Don’t:別再亂做 error handling 啦!

Don’t:系統邊界回傳 IEnumerable、匿名型別溢出當前的 scope,這樣做很母湯!

Do:使用 domain model

Do:使用 decorator 來封裝 feature toggle 邏輯

Do:處理時間的時區問題除了考慮時差,竟然還有…!

Do:何時應重構?不是只有在看到 code smell 的時候…

Coaching D2-D4

Don’t:別再亂做 error handling 啦!

mob programming 開發換手的空檔會進行小小的休息,大約 5 分鐘左右。在某次的休息,一位即將接手開發的團隊成員逛著與本次新功能相關的檔案進行準備時,無意間被 91 瞄到一段程式碼包含了 try-catch 的 error handling 邏輯,一堂 bonus 的 error handling 課程就這麼開始了。

error handling 是一門學問,僅四、五天的 coaching 期間,我們自然是沒有辦法花太多時間重塑一個優雅的 error handling 機制,但 91 在這部分依然費了不少口舌,讓參與 coaching 的團隊成員至少要知道「正確」的 error handling 設計思維,以及怎麼樣是錯誤的使用方式,以避免糟糕的設計持續蔓延也無人警覺,這會造成未來線上 support 人員的痛苦。

首先,C# 在透過 try-catch 處理俗稱「噴錯」的例外(exception)機制上,很特別的是 catch 處理邏輯中 throw 的用法。正確地使用 throw,可以讓錯誤從源頭到拋出點的完整資訊都能精準地被捕捉,後續便能透過適當的 log 機制紀錄下來。

反之若誤用 throw,則會失去真正的錯誤源頭相關資訊,被拋出處理的 exception 將以 catch 處為起點,這種作法沒有顯著的好處,是一種幾乎不會刻意使用的作法,會出現這種狀況還是誤用居多。

此外,濫用 try-catch 的「防禦性編程」也是開發上常見的問題,開發人員害怕 exception 發生在自己負責撰寫的程式碼會被「追究責任」,便到處買保險一般地插滿 try-catch,catch 的邏輯又僅是記錄 log 後便裝沒事地繼續流程以粉飾太平。

擔憂責任歸屬這種組織文化層面的大問題先放一邊不討論,將目光放到充斥 try-catch 的程式碼,不難發現這本身就是令人難以閱讀的存在。重複的 log 行為散落在各處,log 得正確勉強可說是有達到讓 support 人員追溯錯誤的成效,但若 log 也誤用例如只記錄到 exception 的 message 導致重要的 stack trace 都丟了,那將造成除錯人員極大的困擾,套一句 91 真性情的原話就是,「可惡!」

良好的 error handling 設計應該是在最外層(或某一層 middleware)對 exception 進行集中處理,而程式碼中任何會噴錯的地方就應該讓它直接噴錯,讓 exception 得以被被外層攔截,log 錯誤訊息或是針對特殊客製化 exception 的後續行為,都應當在這個 middleware 層進行。

若要在流程中的程式碼插 try-catch,應當要有相當明確的動機,例如:將非得在該 context 下才能獲取的資訊包裝成客製化 exception。需要注意的是,即便是包裝成客製化 exception,仍然要把最原始的 exception 一併包裝進去,並且在最後 log 時完整輸出,鑒於這樣的原則,客製化 exception 必須實現以下三個 constructor 才堪稱為可用的客製化 exception。

public class MyException : Exception
{
public MyException() { }
public MyException(string? message) : base(message) { }
public MyException(string? message, Exception? innerException) : base(message, innerException)
{ }
}

在流程的 catch 邏輯中沒有 throw, 也並非什麼不可觸犯的天條,但開發人員必須很清楚地知道,這代表的是在此地就已經把針對該 exception 的錯誤處理都處理完成了,並且不需要再交由外部處理;相反地,有 throw 將 exception 往外拋,代表此地所能進行的處理已完成,但仍需要交由外部繼續做處理。

這一段其實與本次 sprint 要開發的項目完全無關,如前所述,它只是恰好在休息時間路過的 legacy code 之一,但 91 的 coaching 往往不會只著眼在完成當週的 item 這種小目標,而是更關注在與團隊成員貼身一起作戰的過程中,發掘團隊不足之處再加以補強。

這種調整團隊體質的作法,長遠地看效益將遠大於把 item 衝完。也因此,91 總是會把握各種時機進行適當的機會教育,本次的 error handling 就是一個例子。

機會教育之前,91 也會先確認團隊成員的認知,是否真的需要花時間進行更多的說明,他採用的方式相當值得參考——請所有人輪流針對目前有問題的程式碼,說出自己覺得有問題之處為何,以及應如何改善。透過這樣的方式,便得以探詢每個成員對此議題的先備知識深度。

團隊中的 junior 成員對於 error handling 的認知尚不完備,91 邀請他們上台就現有程式碼做出評論。

Don’t:系統邊界回傳 IEnumerable、匿名型別溢出當前的 scope,這樣做很母湯!

在開發進度推展到某一支取資料的關鍵 API 時,赫然發現該 API 回傳的型別是 IEnumerable 而非 ToList() 後的結果,91 提醒這樣的作法有機會遇到非預期問題,使用上要特別注意。

這是由於 .NET 的 IEnumerable 有延遲性,它是等待真正需要使用到內部值的時刻(對於 API 來說,就是準備進行資料 JSON 序列化以回傳的那一刻),才真正去執行先前所累積的各種操作。

雖然這個特性可以節省記憶體空間,但把定型的動作留到系統邊界才觸發,若累積操作的整串方法 stack 中有參照到一些外部的 field 或變數,執行的當下有可能參考值已經不是預期的結果了。

有以上的不確定性,再加上序列化動作是有可能在處理到某一筆資料才出錯,如此 exception 的發生點會是在 .NET 框架自動進行序列化的時機點,而非真正有問題的 production code,兩個不確定性疊加就會造成線上除錯麻煩。

因此,91 基於過去的實務經驗,建議系統邊界在處理 request / response 時避免使用可能導致以上問題的 IEnumerable,當然平時使用 IEnumerable 也要注意是否會因其延遲性有帶來非預期錯誤的可能。

同一段 API,還被發現有不當使用匿名型別的狀況,讓團隊成員被 91 訓了一頓。該匿名型別原先是被用在 API 中的一段 LINQ Select,該 Select 的目的是準備稍後要回傳的多個物件,而其中有一個內層物件只有三個屬性,當初應是開發人員不想再額外開 class 裝載,因此使用匿名型別來呈現這個小物件,結構如以下示意程式碼:

var results = listX.Select(x => new QueryResult
{
// other properties
// ...

PropertyA = listY.Where(/* filter logic */)
.Select(y => new // 不想另開 class 來維護的資料結構
{
y.Name,
y.Code,
Status = y.Status.ToString()
})
});

匿名型別的用法本身沒什麼問題,為了方法層級的小範圍需求而開 class 確實是沒必要的。但這一部份的問題在於,QueryResult 作為一個 class,其中的 PropertyA 因此被迫成為 object 的型別,這就大有問題了。

public class QueryResult
{
// other properties
// ...

// 因外部使用的是匿名型別,導致生長出此 property 時無法自動推斷型別,IDE 只能自動生成 object 型別的 property
public object PropertyA { get; set; }
}

匿名型別的影響在此已經溢出方法層級,甚至影響到其它 class。除了 object boxing & unboxing 的成本問題,更大的問題是往後使用到 PropertyA 的人要怎麼使用這個屬性?後人怎麼知道這其實是個列舉型的屬性?只能從名稱推測?這對於程式碼的可維護性來說是莫大的傷害。

91 指出這個小地方彰顯 code review 機制可能沒有落實,或是團隊缺乏紀律而有這種便宜行事的作法出現,他相當嚴厲地要團隊成員正視這個問題。

犯錯事小,即便是 senior 成員也會有犯錯的時候,而犯錯的人一定不是刻意的,他就是不知道這樣做是錯的才會這麼做。91 在這個環節想要帶給團隊的是,senior 成員應對於這種明顯有問題的程式碼的敏銳度,以及團隊應有的導正機制和 coding 紀律。

Do:使用 domain model

primitive obsession(基礎型別偏執),是一種經常出現在程式碼中的 code smell,過度以基礎型別(例如:decimal、string 等)來作為重要資訊的載體,導致程式碼經常很忙地對這些沒有 domain 意義的基礎型別做處理、組裝,而那些組裝和處理的邏輯本身具有 domain 意義,就是這類 code smell 的明顯徵兆。與 domain 強烈相關的邏輯暴露在並不需要知道這些細節的使用端,這會使得程式碼職責不清,也會變得難以維護。

domain model 一般很難打一開始就精準設計,大多數時候是發覺以上的 code smell 後重構而得。在團隊開發過程中,91 發現產品程式碼中經常對名為 amount 的屬性作處理,甚至還有 extension method 是專門做這件事的,會需要處理則是基於系統內部與外部的 amount 規則不同,在系統內部使用前需要轉換規則這樣的 domain knowledge,這就是一種基礎型別偏執。

在這個議題上,91 教導團隊成員對 domain model 的敏感度,第一,這種 domain knowledge 應該要封進一個 domain model 中而不是攤在呼叫端(extension method 也只是把程式碼從呼叫端挪到別處,但無法凝聚出 domain 意義)。

第二,這種內外部轉換的邏輯,應該要在系統邊界就處理好,所謂邊界是例如 proxy 這類的位置,而在系統內部就一律使用 domain model 來存取這個重要資訊。

domain model 只關注 domain logic,因為看不到外部,商業邏輯的測試應當要是好撰寫的。在程式碼中使用適當的 domain model 會讓一切閱讀起來是那麼地理所當然,不僅提升開發體驗也減少維護成本和 bug 發生的機會,可說是有 domain model 才能更進一步地實現 OOP 帶來的好處。

Do:使用 decorator 來封裝 feature toggle 邏輯

本次開發的新功能因為會參雜在舊有的流程中,在部門與團隊以 trunk-based 來實踐持續整合的環境下,程式碼推上共享的 codebase 前應當都要有「feature toggle」來保護,以避免開發中功能在部署到正式環境後被意外地執行,造成功能提前曝光甚至是系統錯誤。

這種使用 feature toggle 來做新功能隔離的需求是相當常見的,因為持續整合在做的就是頻繁地將新的程式碼加入到既有的 codebase,永遠都會有開發中的程式碼被推上正式環境。

開發人員使用 feature toggle 經常會遵循直覺,直接在既有程式碼流程中插入 if-else 來做邏輯分流。然而這種做法會讓核心邏輯中漸漸累積各式各樣的 feature toggle if-else 判斷,導致程式碼的意圖表達力和整潔程度大幅下降、方法邏輯中被排列組合出愈來愈多可能路線,以致完整測試新邏輯變得困難、未來寫該方法的單元測試,都需要考量 feature toggle 的 given 狀況相當麻煩等等,直覺的作法帶來未來維護上的困難。

當然,這一切只要拔除不再需要的 feature toggle 就可以解決了,但因為這些 if-else 座落在核心方法並與核心邏輯糾纏,實務上的狀況往往是沒有任何一個開發人員敢去消除這些過時的判斷。

91 說明如何用 decorator 來實現 feature toggle 分流的功能。

在討論著應在何處加上 feature toggle 的時刻,一位曾經上過 91 公開課《DI 與 AOP 進階實戰》的團隊成員,可能因為 91 在場觸發了過去上課的印象,突然想起了可以使用課堂上教過的「裝飾者模式(decorator pattern)」來實現本次的新功能分流,並提議這麼做。

virtual team 並非所有人都上過這門課,於是 91 花了點時間來向大家解釋這個搭配依賴注入(DI)的手法:透過建立額外的 decorator 並將 feature toggle 的判斷封入,由 decorator 來判斷 toggle on / off 的時候應如何應對,on 時就要採用含有新功能的流程、off 時則走舊流程,這可以帶來的好處是舊有的流程完全不需更動,這將讓整個開發過程符合 SOLID 中的 OCP 開閉原則 — 對於舊的程式碼儘可能減少更動,透過新增程式碼來實現新的功能。

// IMyService 是本次新服務的介面,
// 既有流程中會呼叫 IMyService 中的 GetEndDateTime 方法,
// 透過 decorator 來分流 feature toggle 開/關時該方法的行為。

public interface IMyService {
DateTime? GetEndDateTime(QueryResult queryResult, DateTime now);
}

public class MyServiceDecorator : IMyService
{
private readonly IFeatureToggleService _featureToggleService;
private readonly IMyService _myService;

// constructor 注入可取得 feature toggle 的 serivce 以及真正核心的 MyService
public MyServiceDecorator(
IFeatureToggleService featureToggleService,
IMyService myService)
{
_featureToggleService = featureToggleService;
_myService = myService;
}

// 實作 IMyService 的 decorator 一定要實作此方法,
// 內部邏輯只需要簡單地定義 toggle on / off 的行為為何即可。
public DateTime? GetEndDateTime(QueryResult queryResult, DateTime now)
{
if (!_featureToggleService.IsEnabled("new_feature_toggle_name"))
{
// 本次開發的新功能,只要回傳 null 就可以視為此功能未開放。
return null;
}

// toggle 開啟時,就要真正去實現新的 MyService 中的新邏輯。
return _myService.GetEndDateTime(queryResult, now);
}
}

使用這種方法,未來可以相當輕便且安全地移除掉 feature toggle 以及舊版(或新版)的流程,因為新舊流程沒有糾纏,自然很容易個別處理。這個不繁複但十分有用的技巧讓在場沒學過的夥伴眼睛為之一亮,原來 feature toggle 還能這樣加!

為既有的 interface 生出 decorator,在不需要特別處理的方法上只需要實作「轉拋」的邏輯 — 直接呼叫內部 decoratee 的同名方法。這部分如果是用手動實現相當浪費時間也很不方便,因為每個方法內部都是毫無邏輯的程式碼,91 在 coaching 結束後幾天,也在第一時間向我們分享他發現的 IDE 功能,可以在幾秒內就實現這件事,具體作法可以參考 91 敏捷開發之路 的文章

至於實現 decorator 的方式,.NET / .NET Core 可以透過安裝 Scrutor 套件輕易在 DI 透過 serviceCollection.TryDecorate 來註冊;即便不透過套件,直接在 DI 時將 IMyService 的實作註冊為 MyServiceDecorator 也可以達成一樣的效果。

Do:處理時間的時區問題除了考慮時差,竟然還有…!

在第四天,也就是團隊的 sprint review 日,只剩下上午一小段開發時間。本次實作的新功能已經大致都打通,專案間的串接皆已完成,從資料庫取值做邏輯運算的核心也已完成,僅剩下顯示給使用者看到的時間部分需要處理時區問題,因為從資料表取得的時區與前端要顯示給使用者看的時區不一致。

原以為這算是個簡單的工作,只要簡單地轉換時區即可,不料在透過 TimeZoneInfo 在系統邊界進行時區轉換時,透過 end to end 的測試,計算出的時間卻總是對不上預期數字,從資料庫資料和程式碼來看也看不出什麼明顯的錯誤。花了一點點時間 debug 排查問題,才發現原來我們透過 .NET 內建方法以 UTC 時差 -4 採用 .First() 取用到的目標時區地區「亞松森」,在我們開發的當日是正處於日光節約時間的!因此這一天實際上的時差是 3 小時,並非我們預期的 4 小時。

這再度彰顯出 ATDD 的優勢,因為時時刻刻都採用 ATDD 的方式為新功能逼出小小的進展,因此在第一時間就發現這個預期以外的問題,並及早修復。若將 end to end 的整合測試工作留到最後才做,就難以發現這種程式碼沒問題但實際運作不如預期的錯誤。

Do:何時應重構?不是只有在看到 code smell 的時候…

一般重構可能發生在幾個時間點,一個是 TDD 開發循環中「紅燈變為綠燈」以後,立即在不弄壞測試的前提下把新寫的程式碼重構成表達力更強、沒有顯著 code smell 的樣貌;另一個則是實現新功能的過程,發現其所經過的 legacy code 中有明顯的設計問題並且會阻礙到我們新功能的開發,那我們會在補上基本的測試後,對造成阻礙的部分進行重構。

無論是何者,重構行為都是伴隨著「開發新功能」一起發生。因為重構是有風險的,重構完也需要再進行完整測試,在開發新功能時,對 item 做測試很容易涵蓋到重構之處,便容易在第一時間發現不慎改壞的部分。因此重構應跟著新 item 發生,而非特意拉一段時間來做某區段的重構,後者的作法相對來說是較危險的。

值得一提的是這次開發過程中有發生一次比較特別的重構,在完成一段取得時間的核心演算法功能之後,91 看了看覺得雖然程式碼沒有明顯的 code smell 或 anti-pattern,但程式碼中針對不同情境的邏輯比例在視覺上過於不平衡,權重應該是並重的情境 A 的區塊卻遠大於情境 B & C 的區塊,如下圖所示:

public DateTime? GetEndDateTime()
{
// case-A and related logic
// ...
// ...
// ...
// ...
// ...
// ...
// ...
// ...
// ...
// ...
// ...
// ...

// case-B and related logic
// ...
// ...

// case-C and related logic
}

考量以上的程式碼樣貌可能會有表達力不足的問題,未來查看這一段程式碼的讀者很難在第一時間意識到這裡其實有 A、B、C 三種並重的情境,91 決定要對這段程式碼進行重構,他坐了下來直接示範這邊可以怎麼做。

91 不只作為教練在旁指導,也經常會坐到電腦和鍵盤前示範一些技巧。

重構完成後,該段程式碼變成一種類似策略模式的樣貌,雖然比起原先直接攤平邏輯的作法,在程式碼的結構上是變得複雜了一點,不過表達力卻大幅提升。並且這樣的結構,未來有 OperatorD、OperatorE、OperatorF 的 case 都可以保持外層程式碼的一致性與整潔,透過新增 class 和微調 GetEndDateTime 內容即可實現功能擴充。

public DateTime? GetEndDateTime(DateTime now)
{
var operatorA = new OperatorA();
var operatorB = new OperatorB();
var operatorC = new OperatorC();
var operators = new IOperator[] { operatorA, operatorB };

// 把原本的判斷 OperatorA、OperatorB 以及取得 EndTime 的邏輯都封到 class 中,
// 如此設計,在 public 方法層級看到的就是一致的邏輯。
foreach (op in operators) {
if (op.IsMatch(now)) {
return op.EndTime;
}
}

// 若既不是 OperatorA 也不是 OperatorB,就是需要 return null 的 OperatorC,
return operatorC.Time;
}


public interface IOperator
{
bool IsMatch(DateTime now);
DateTime? EndTime { get; }
}


public class OperatorA : IOperator {
public bool IsMatch(DateTime now) { /* 判定是否為 OperatorA 的邏輯 */ }
public DateTime? EndTime => { /* OperatorA 取得回傳 EndTime 的邏輯 */ }
}

public class OperatorB : IOperator {
public bool IsMatch(DateTime now) { /* 判定是否為 OperatorB 的邏輯 */ }
public DateTime? EndTime => { /* OperatorB 取得回傳 EndTime 的邏輯 */ }
}

public class OperatorC : IOperator {
public bool IsMatch(DateTime now) { return true; }
public DateTime? EndTime => null; // OperatorC 的回傳 EndTime 必須為 null
}

過去會覺得重構應該是要把複雜的寫法變成簡單的寫法,但在這次的經驗中發現,原本就很簡單的實現方法也可能因為過於簡單粗暴反而讓意圖表達力下降。團隊成員們因此學習到,刻意讓程式碼結構變得更複雜但更具意圖表達力,也是一種值得實行的重構。

coaching 期間關於程式開發上的學習,不只技術乾貨還有決策思維,皆可謂讓 virtual team 的團隊們大開眼界,有許多過去不知道可以這麼做的作法,也有過去已知但卻還沒徹底落地的知識,當然也有習得一些教訓。

virtual team 團隊成員來自各團隊,日後將作為種子,把這幾日的所學所知帶回團隊中渲染給其餘的小夥豹們。91 也建議,本次有被 coach 的人,在日後工作期間一次帶一兩個未 coach 的人一起做 item,漸進式地讓所學發揮影響力,讓團隊戰力能逐步增強,也讓 coaching 能持續發酵發揮更大的價值。

延伸閱讀

大神教練來鈦坦,鈦坦人耳熟能詳的「coaching」到底在做些什麼?|Part 1:PBR x SP2 x ATDD
大神教練來鈦坦,鈦坦人耳熟能詳的「coaching」到底在做些什麼?|Part 2:Dev Do & Don’t(團隊篇)

📢【鈦坦熱烈徵才中】

在鈦坦沒有Boss,但要有自組織的Guts ❗❗

想要投入 #彈性工時 #自主升遷 #薪資透明 的工作文化還有~~豐富的內外訓教育課程隨你申請、專為敏捷團隊設計的開放式辦公空間、上班隨時前往酒吧暢飲放鬆一下…

🙋‍♂️ 職缺看這邊~
👉 https://gotica.io/鈦坦職缺/Blog

--

--

㊣港都🦭豹哥㊣
新加坡商鈦坦科技

從北方的南港登陸南方的海港,沐浴在港都的陽光下,想要一展豹負。豹持著 Never Stop Improving 的精神,在此跟大家分享哥的開發日常😎