--- tags: 編程哲學 --- # 打造可維護軟體|編寫可維護程式碼的10項法則 (C#版) 讀書筆記 國際標準 ISO/IEC 25010:2011(本書簡稱作 ISO 250101)將軟體品質劃分為八個特徵 :可維護性、功能可適性、效能、兼容性、可用性、可靠性、安全性及可移植性,而本書聚焦於可維護性。 >記住,你不只是在為自己寫程式,還要考慮到後面接手而又缺乏經驗的開發人員,這樣的思維有助於簡化你的編程解決方案。 ## 撰寫簡短的程式碼單元(第2章) > 簡短的程式碼單元(亦即方法與建構式)比較容易分析、測試和重利用。 > 傻瓜也可寫出電腦能懂的程式碼,但優秀的工程師才能寫出人類能夠理解的程式碼。─ Martin Fowler 程式碼要簡短到多短呢?作者的建議是不要超過15行,以下是一段超過15行的範例。 ```csharp= public void Start() { if (inProgress) { return; } inProgress = true; // 如果玩家掛掉,告知所有觀察者: if (!IsAnyPlayerAlive()) { foreach(LevelObserver o in observers) { o.LevelLost(); } } // 如果豆子全部吃光,告知所有觀察者: if (RemainingPellets() == 0) { foreach(LevelObserver o in observers) { o.LevelWon(); } } } ``` ### 重構技巧:提取方法 就是把程式碼拆到不同的Function裡,讓所有的的Function行數都小於15行。 ```csharp= public void Start() { if (inProgress) { return; } inProgress = true; UpdateObservers(); } private void UpdateObservers() { // 如果玩家掛掉,告知所有觀察者: if (!IsAnyPlayerAlive()) { foreach(LevelObserver o in observers) { o.LevelLost(); } } // 如果豆子全部吃光,告知所有觀察者: if (RemainingPellets() == 0) { foreach(LevelObserver o in observers) { o.LevelWon(); } } } ``` 拆得更小,有沒有必要拆到這麼小呢?我覺得可以的,只要每個Function 名字都起得好,程式的可讀性會更好,看得人更容易理解,讀超來就像在讀一本故事書。也更容易測試及重覆利用。 ```csharp= public void UpdateObservers() { UpdateObserversPlayerDied(); UpdateObserversPelletsEaten(); } private void UpdateObserversPlayerDied() { if (!IsAnyPlayerAlive()) { foreach(LevelObserver o in observers) { o.LevelLost(); } } } private void UpdateObserversPelletsEaten() { if (RemainingPellets() == 0) { foreach(LevelObserver o in observers) { o.LevelWon(); } } } ``` ### 重構技巧:以方法物件取代方法 考慮以下超過15行的方法 ```csharp= public Board CreateBoard(Square[, ] grid) { Debug.Assert(grid != null); Board board = new Board(grid); int width = board.Width; int height = board.Height; for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { Square square = grid[x, y]; foreach(Direction dir in Direction.Values) { int dirX = (width + x + dir.DeltaX) % width; int dirY = (height + y + dir.DeltaY) % height; Square neighbour = grid[dirX, dirY]; square.Link(neighbour, dir); } } } return board; } ``` 用**提取方法**重構後,SetLink 參數還是很長 ```csharp= private void SetLink(Square square, Direction dir, int x, int y, int width, int height, Square[, ] grid) { int dirX = (width + x + dir.DeltaX) % width; int dirY = (height + y + dir.DeltaY) % height; Square neighbour = grid[dirX, dirY]; square.Link(neighbour, dir); } public Board CreateBoard(Square[, ] grid) { Debug.Assert(grid != null); Board board = new Board(grid); int width = board.Width; int height = board.Height; for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { Square square = grid[x, y]; foreach(Direction dir in Direction.Values) { SetLink(square, dir, x, y, width, height, grid); } } } return board; } ``` 把方法變成物件 ```csharp= internal class BoardCreator { private Square[, ] grid; private Board board; private int width; private int height; internal BoardCreator(Square[, ] grid) { Debug.Assert(grid != null); this.grid = grid; this.board = new Board(grid); this.width = board.Width; this.height = board.Height; } internal Board Create() { for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { Square square = grid[x, y]; foreach(Direction dir in Direction.Values) { SetLink(square, dir, x, y); } } } return this.board; } private void SetLink(Square square, Direction dir, int x, int y) { int dirX = (width + x + dir.DeltaX) % width; int dirY = (height + y + dir.DeltaY) % height; Square neighbour = grid[dirX, dirY]; square.Link(neighbour, dir); } } ``` ```csharp= public Board CreateBoard(Square[, ] grid) { return new BoardCreator(grid).Create(); } ``` >切勿為了最佳化效能而犧牲可維護性,除非有可靠的效能測試能夠證明效能問題確實存在,而且你的效能最佳化措施真的有效果。 ## 撰寫簡單的程式碼單元(第3章) 程式碼單元包含越少決策點就越容易分析和測試。 就是少一點If , Case 判斷的語句。 > 每個問題都是由一些小問題組成的。— Martin Fowler 考慮以下程式, case 好長, 少打一個break 就出錯了, ```csharp= public IList < Color > GetFlagColors(Nationality nationality) { List < Color > result; switch (nationality) { case Nationality.DUTCH: result = new List < Color > { Color.Red, Color.White, Color.Blue }; break; case Nationality.GERMAN: result = new List < Color > { Color.Black, Color.Red, Color.Yellow }; break; case Nationality.BELGIAN: result = new List < Color > { Color.Black, Color.Yellow, Color.Red }; break; case Nationality.FRENCH: result = new List < Color > { Color.Blue, Color.White, Color.Red }; break; case Nationality.ITALIAN: result = new List < Color > { Color.Green, Color.White, Color.Red }; break; case Nationality.UNCLASSIFIED: default: result = new List < Color > { Color.Gray }; break; } return result; } ``` 用 **Dictionary** 取締 **Case** ```csharp= private static Dictionary < Nationality, IList < Color >> FLAGS = new Dictionary < Nationality, IList < Color >> (); static FlagFactoryWithMap() { FLAGS[Nationality.DUTCH] = new List < Color > { Color.Red, Color.White, Color.Blue }; FLAGS[Nationality.GERMAN] = new List < Color > { Color.Black, Color.Red, Color.Yellow }; FLAGS[Nationality.BELGIAN] = new List < Color > { Color.Black, Color.Yellow, Color.Red }; FLAGS[Nationality.FRENCH] = new List < Color > { Color.Blue, Color.White, Color.Red }; FLAGS[Nationality.ITALIAN] = new List < Color > { Color.Green, Color.White, Color.Red }; } public IList < Color > GetFlagColors(Nationality nationality) { IList < Color > colors = FLAGS[nationality]; return colors ?? new List < Color > { Color.Gray }; } ``` 更進一步, 把每個旗都物件化 ```csharp= public interface IFlag { IList < Color > Colors { get; } } public class DutchFlag: IFlag { public IList < Color > Colors { get { return new List < Color > { Color.Red, Color.White, Color.Blue }; } } } public class ItalianFlag: IFlag { public IList < Color > Colors { get { return new List < Color > { Color.Green, Color.White, Color.Red }; } } } private static readonly Dictionary < Nationality, IFlag > FLAGS = new Dictionary < Nationality, IFlag > (); static FlagFactory() { FLAGS[Nationality.DUTCH] = new DutchFlag(); FLAGS[Nationality.GERMAN] = new GermanFlag(); FLAGS[Nationality.BELGIAN] = new BelgianFlag(); FLAGS[Nationality.FRENCH] = new FrenchFlag(); FLAGS[Nationality.ITALIAN] = new ItalianFlag(); } public IList < Color > GetFlagColors(Nationality nationality) { IFlag flag = FLAGS[nationality]; flag = flag ?? new DefaultFlag(); return flag.Colors; } ``` 考慮以下更複雜的程式碼, 好多 if else 分支 ```csharp= public static int CalculateDepth(BinaryTreeNode < int > t, int n) { int depth = 0; if (t.Value == n) { return depth; } else { if (n < t.Value) { BinaryTreeNode < int > left = t.Left; if (left == null) { throw new TreeException("Value not found in tree!"); } else { return 1 + CalculateDepth(left, n); } } else { BinaryTreeNode < int > right = t.Right; if (right == null) { throw new TreeException("Value not found in tree!"); } else { return 1 + CalculateDepth(right, n); } } } } ``` 重構如下, 早一點 return , 減少 else, ```csharp= public static int CalculateDepth(BinaryTreeNode < int > t, int n) { int depth = 0; if (t.Value == n) { return depth; } if ((n < t.Value) && (t.Left != null)) { return 1 + CalculateDepth(t.Left, n); } if ((n > t.Value) && (t.Right != null)) { return 1 + CalculateDepth(t.Right, n); } throw new TreeException("Value not found in tree!"); } ``` 再進一步, 更容易測試 ```csharp= public static int CalculateDepth(BinaryTreeNode < int > t, int n) { int depth = 0; if (t.Value == n) { return depth; } else { return TraverseByValue(t, n); } } private static int TraverseByValue(BinaryTreeNode < int > t, int n) { BinaryTreeNode < int > childNode = GetChildNode(t, n); if (childNode == null) { throw new TreeException("Value not found in tree!"); } else { return 1 + CalculateDepth(childNode, n); } } private static BinaryTreeNode < int > GetChildNode(BinaryTreeNode < int > t, int n) { if (n < t.Value) { return t.Left; } else { return t.Right; } } ``` ## 不撰寫重複的程式碼(第4章) 任何時候都應避免原始碼重複,因為一旦需要修改,就得處理每一份副本。重複程式碼也是遞迴性臭蟲(regression bug)的滋生根源。 ## 保持單元介面單純(第5章) 程式碼單元(方法和建構式)包含越少參數,就越容易測試及重利用。 >四處分散的相關資料應該被封裝成一個物件。— Martin Flowler 考慮以下6個參數的方法 ```csharp= /// <summary> /// 在指定的長方形上繪製方塊 /// /// <param name="square"> 要繪製的方塊 </param> /// <param name="g"> 要進行繪製的圖形上下文 </param> /// <param name="x"> 開始繪製的 x 位置 </param> /// <param name="y"> 開始繪製的 y 位置 </param> /// <param name="w"> 方塊的寬度(以像素為單位)</param> /// <param name="h"> 方塊的高度(以像素為單位)</param> private void Render(Square square, Graphics g, int x, int y, int w, int h) { square.Sprite.Draw(g, x, y, w, h); foreach(Unit unit in square.Occupants) { unit.Sprite.Draw(g, x, y, w, h); } } ``` 新增**Rectangle**類別, 把參數減到3個 ```csharp= public class Rectangle { public Point Position { get; set; } public int Width { get; set; } public int Height { get; set; } public Rectangle(Point position, int width, int height) { this.Position = position; this.Width = width; this.Height = height; } } /// <summary> /// 在指定的長方形上繪製方塊 /// /// <param name="square"> 要繪製的方塊 </param> /// <param name="g"> 要進行繪製的圖形上下文 </param> /// <param name="r"> 方塊的位置與尺寸 </param> private void Render(Square square, Graphics g, Rectangle r) { Point position = r.Position; square.Sprite.Draw(g, position.X, position.Y, r.Width, r.Height); foreach(Unit unit in square.Occupants) { unit.Sprite.Draw(g, position.X, position.Y, r.Width, r.Height); } } ``` 整個方法更簡潔 ```csharp= private void Render(Square square, Graphics g, Rectangle r) { square.Sprite.Draw(g, r); foreach(Unit unit in square.Occupants) { unit.Sprite.Draw(g, r); } } ``` 考慮另一個方法, 參數更多 ```csharp= public void DoBuildAndSendMail(MailMan m, string firstName, string lastName, string division, string subject, MailFont font, string message1, string message2, string message3) { // 格式化電子郵件地址 string mId = $ "{firstName[0]}.{lastName.Substring(0, 7)}" + $ "@{division.Substring(0, 5)}.compa.ny"; // 根據指定的內容型態與原始訊息進行格式化 MailMessage mMessage = FormatMessage(font, message1 + message2 + message3); // 發送訊息 m.Send(mId, subject, mMessage); } ``` 相關的參數都整到一個類別去 ```csharp= public void DoBuildAndSendMail(MailMan m, MailAddress mAddress, MailBody mBody) { // 建構電子郵件 Mail mail = new Mail(mAddress, mBody); // 發送電子郵件 m.SendMail(mail); } public class Mail { public MailAddress Address { get; set; } public MailBody Body { get; set; } public Mail(MailAddress mAddress, MailBody mBody) { this.Address = mAddress; this.Body = mBody; } } public class MailBody { public string Subject { get; set; } public MailMessage Message { get; set; } public MailBody(string subject, MailMessage message) { this.Subject = subject; this.Message = message; } } public class MailAddress { public string MsgId { get; private set; } public MailAddress(string firstName, string lastName, string division) { this.MsgId = $ "{firstName[0]}.{lastName.Substring(0, 7)}" + $ "@{division.Substring(0, 5)}.compa.ny"; } } ``` 考慮另一個更多參數的方法 ```csharp= public static void DrawBarChart(Graphics g, CategoryItemRendererState state, Rectangle graphArea, CategoryPlot plot, CategoryAxis domainAxis, ValueAxis rangeAxis, CategoryDataset dataset) { // .. } ``` 利用多載添加預設值, 現在C# 也可以直接在參數聲明上直接給定預設值, 呼叫的時候可以省略輸入有預設值的參數, 參考[具名和選擇性引數](https://docs.microsoft.com/zh-tw/dotnet/csharp/programming-guide/classes-and-structs/named-and-optional-arguments) ```csharp= public static void DrawBarChart(Graphics g, CategoryDataset dataset) { Charts.DrawBarChart(g, CategoryItemRendererState.DEFAULT, new Rectangle(new Point(0, 0), 100, 100), CategoryPlot.DEFAULT, CategoryAxis.DEFAULT, ValueAxis.DEFAULT, dataset); } ``` 更進一步, 使用*流暢介面*, 方法return 當前物件 ```csharp= public class BarChart { private CategoryItemRendererState state = CategoryItemRendererState.DEFAULT; private Rectangle graphArea = new Rectangle(new Point(0, 0), 100, 100); private CategoryPlot plot = CategoryPlot.DEFAULT; private CategoryAxis domainAxis = CategoryAxis.DEFAULT; private ValueAxis rangeAxis = ValueAxis.DEFAULT; private CategoryDataset dataset = CategoryDataset.DEFAULT; public BarChart Draw(Graphics g) { // .. return this; } public ValueAxis GetRangeAxis() { return rangeAxis; } public BarChart SetRangeAxis(ValueAxis rangeAxis) { this.rangeAxis = rangeAxis; return this; } // 更多 getter 與 setter } ``` 流暢地呼叫 ```csharp= private void ShowMyBarChart() { Graphics g = this.CreateGraphics(); BarChart b = new BarChart().SetRangeAxis(myValueAxis).SetDataset(myDataset).Draw(g); } ``` ## 不同模組之間的關注點分離(第6章) 鬆散耦合的模組(類別)比較容易修改,並且促成更模組化的系統。 >在既複雜又緊密耦合的系統中,意外無可避免。─ Charles Perrow 的正常意外理論(Normal Accidents) 一開始的時候並沒有太多功能 ```csharp= public class UserService { public User LoadUser(string userId) { // ... } public bool DoesUserExist(string userId) { // ... } public User ChangeUserInfo(UserInfo userInfo) { // ... } } ``` ```csharp= public class UserController: System.Web.Http.ApiController { private readonly UserService userService = new UserService(); // ... public System.Web.Http.IHttpActionResult GetUserById(string id) { User user = userService.LoadUser(id); if (user == null) { return NotFound(); } return Ok(user); } } ``` 日子久了, 添加了一些功能 ```csharp= public class UserService { public User LoadUser(string userId) { // ... } public bool DoesUserExist(string userId) { // ... } public User ChangeUserInfo(UserInfo userInfo) { // ... } public List < NotificationType > GetNotificationTypes(User user) { // ... } public void RegisterForNotifications(User user, NotificationType type) { // ... } public void UnregisterForNotifications(User user, NotificationType type) { // ... } } ``` ```csharp= public class NotificationController: System.Web.Http.ApiController { private readonly UserService userService = new UserService(); // ... public System.Web.Http.IHttpActionResult Register(string id, string notificationType) { User user = userService.LoadUser(id); userService.RegisterForNotifications(user, NotificationType.FromString(notificationType)); return Ok(); } [System.Web.Http.HttpPost] [System.Web.Http.ActionName("unregister")] public System.Web.Http.IHttpActionResult Unregister(string id, string notificationType) { User user = userService.LoadUser(id); userService.UnregisterForNotifications(user, NotificationType.FromString(notificationType)); return Ok(); } } ``` 又增加了一些功能 ```csharp= public class UserService { public User LoadUser(string userId) { // ... } public bool DoesUserExist(string userId) { // ... } public User ChangeUserInfo(UserInfo userInfo) { // ... } public List < NotificationType > GetNotificationTypes(User user) { // ... } public void RegisterForNotifications(User user, NotificationType type) { // ... } public void UnregisterForNotifications(User user, NotificationType type) { // ... } public List < User > SearchUsers(UserInfo userInfo) { // ... } public void BlockUser(User user) { // ... } public List < User > GetAllBlockedUsers() { // ... } } ``` 應該把不相關的功能分開 ```csharp= public class UserNotificationService { public IList < NotificationType > GetNotificationTypes(User user) { // ... } public void Register(User user, NotificationType type) { // ... } public void Unregister(User user, NotificationType type) { // ... } } public class UserBlockService { public void BlockUser(User user) { // ... } public IList < User > GetAllBlockedUsers() { // ... } } public class UserService { public User LoadUser(string userId) { // ... } public bool DoesUserExist(string userId) { // ... } public User ChangeUserInfo(UserInfo userInfo) { // ... } public IList < User > SearchUsers(UserInfo userInfo) { // ... } } ``` ```csharp= public class DigitalCamera { public Image TakeSnapshot() { // ... } public void FlashLightOn() { // ... } public void FlashLightOff() { // ... } } ``` ### 將特定實作隱藏在介面背後 ```csharp= public class SmartphoneApp { private static DigitalCamera camera = new DigitalCamera(); public static void Main(string[] args) { // ... Image image = camera.TakeSnapshot(); // ... } } ``` ```csharp= public class DigitalCamera { public Image TakeSnapshot() { // ... } public void FlashLightOn() { // ... } public void FlaslLightOff() { // ... } public Image TakePanoramaSnapshot() { // ... } public Video Record() { // ... } public void SetTimer(int seconds) { // ... } public void ZoomIn() { // ... } public void ZoomOut() { // ... } } ``` 降低耦合度, 使用了ISimpleDigitalCamera 介面後(line 10), 保證了只用到基本的功能(ISimpleDigitalCamera的 function), 而且就算DigitalCamera 被修改 或者SDK.GetCamera() 返回的是其他Object, SmartphoneApp 也不要修改, 只要返回的object實現了ISimpleDigitalCamera就可以. ```csharp= public interface ISimpleDigitalCamera { Image TakeSnapshot(); void FlashLightOn(); void FlashLightOff(); } public class DigitalCamera: ISimpleDigitalCamera { // ... } public class SmartphoneApp { private static ISimpleDigitalCamera camera = SDK.GetCamera(); public static void Main(string[] args) { // ... Image image = camera.TakeSnapshot(); // ... } } ``` ## 讓架構元件保持鬆散耦合(第7章) 系統的頂層元件之間越是鬆散耦合,就越容易修改,也會導致更加模組化的系統。 ```csharp= public interface ICloudServerFactory { ICloudServer LaunchComputeServer(); ICloudServer LaunchDatabaseServer(); ICloudStorage CreateCloudStorage(long sizeGb); } ``` ```csharp= public class AWSCloudServerFactory: ICloudServerFactory { public ICloudServer LaunchComputeServer() { return new AWSComputeServer(); } public ICloudServer LaunchDatabaseServer() { return new AWSDatabaseServer(); } public ICloudStorage CreateCloudStorage(long sizeGb) { return new AWSCloudStorage(sizeGb); } } public class AzureCloudServerFactory: ICloudServerFactory { public ICloudServer LaunchComputeServer() { return new AzureComputeServer(); } public ICloudServer LaunchDatabaseServer() { return new AzureDatabaseServer(); } public ICloudStorage CreateCloudStorage(long sizeGb) { return new AzureCloudStorage(sizeGb); } } public class ApplicationLauncher { public static void Main(string[] args) { ICloudServerFactory factory; if (args[1].Equals("-azure")) { factory = new AzureCloudServerFactory(); } else { factory = new AWSCloudServerFactory(); } ICloudServer computeServer = factory.LaunchComputeServer(); ICloudServer databaseServer = factory.LaunchDatabaseServer(); } } ``` ```mermaid flowchart TD subgraph 設計從分層到分層的單向依賴 使用者介面-->服務層-->業務邏輯層-->資料抽象層-->資料庫分層 end ``` ```mermaid flowchart TD subgraph 隨著時間,違反依賴原則,直接調用,導致纏結與混亂 使用者介面-->服務層-->業務邏輯層-->資料抽象層-->資料庫分層 使用者介面-->業務邏輯層 服務層-->資料抽象層 使用者介面-->資料抽象層 end ``` ## 保持架構元件平衡(第8章) 平衡度良好的架構擁有不多不少的元件、尺寸一致的程式碼單元,以及最大程度的模組化,並且透過關注點分離,讓修改工作變得很容易。 > 建構封裝邊界是設計軟體架構的重要技巧。— George H. Fairbanks,《Just Enough Architecture》 ## 保持小規模的程式碼基礎(第9章) 大型系統很難維護,因為更多程式碼需要分析、修改及測試,同時,在大型系統中,每一行程式碼的維護效率也比小型系統來得低。 ## 自動化開發與測試(第10章) 自動化測試(亦即,無需人工干預即可執行測試)能夠針對修改是否有效,獲得近乎即時的回饋。手動測試無法形成規模。撰寫乾淨的程式碼(第11章)程式碼基礎存在越多 TODO(待辦事項)、無用程式碼等不相關的遺留物,新加入的團隊成員就越難保持生產力,進而影響維護工作的執行效率。 不同類型的測試需要不同的自動化框架。對單元測試來說,有一些著名的 C# 框架,像是 NUnit(http://nunit.org);對自動化的端到端測試來說,你需要某種模擬使用者輸入並且捕捉輸出的框架。例如,在 Web 開發中相當有名的 Selenium 框架(http://www.seleniumhq.org)。對整合測試來說,這完全依賴你所在的環境及測試的品質特徵。SoapUI(http://www.soapui.org)是聚焦於 web service 和訊息傳遞中間件的整合測試框架,而 Apache JMeter(http://jmeter.apache.org)框架用來測試 C# 應用程式在高負載下的效能。 一般來說,使用 Moq(https://github.com/Moq/moq4)之類的mocking框架最有效率。mocking框架利用 .Net 執行時期環境的特性,自動從普通介面或類別建立 mock 物件,並且提供各種辦法測試 mock 物件的某些方法是否被調用,同時記錄調用引數。有些 mocking 框架還能夠為 mock 物件指定預先定義的行為,讓它們同時具有 stub 和 mock 的特徵。