單一職責原則:偉大代碼的秘訣

已發表: 2022-03-11

不管我們認為什麼是優秀的代碼,它總是需要一個簡單的品質:代碼必須是可維護的。 正確的縮進、整潔的變量名、100% 的測試覆蓋率等等只能帶你到此為止。 任何不可維護且不能相對輕鬆地適應不斷變化的需求的代碼都是等待過時的代碼。 當我們嘗試構建原型、概念證明或最小可行產品時,我們可能不需要編寫出色的代碼,但在所有其他情況下,我們應該始終編寫可維護的代碼。 這應該被視為軟件工程和設計的基本質量。

單一職責原則:偉大代碼的秘訣

在本文中,我將討論單一職責原則和圍繞它的一些技術如何使您的代碼具有這種質量。 編寫出色的代碼是一門藝術,但一些原則總能幫助您的開發工作朝著生成健壯且可維護的軟件所需的方向發展。

模型就是一切

幾乎每一本關於一些新的 MVC(MVP、MVVM 或其他 M**)框架的書都充斥著糟糕代碼的例子。 這些示例試圖展示框架必須提供的功能。 但他們最終也為初學者提供了不好的建議。 像“假設我們的模型有這個 ORM X,我們的視圖有模板引擎 Y,我們將有控制器來管理這一切”這樣的例子除了巨大的控制器之外什麼也沒有。

儘管為了捍衛這些書籍,這些示例旨在展示您可以輕鬆地開始使用它們的框架。 它們不是用來教授軟件設計的。 但是遵循這些示例的讀者直到多年後才意識到,在他們的項目中使用單片代碼塊是多麼適得其反。

模型是應用程序的核心。

模型是應用程序的核心。 如果您將模型與應用程序邏輯的其餘部分分開,那麼無論您的應用程序變得多麼複雜,維護都會變得容易得多。 即使對於復雜的應用程序,良好的模型實現也可以產生極具表現力的代碼。 為了實現這一點,首先要確保你的模型只做它們應該做的事情,而不關心圍繞它構建的應用程序做了什麼。 此外,它不關心底層數據存儲層是什麼:您的應用程序依賴於 SQL 數據庫,還是將所有內容存儲在文本文件中?

隨著本文的繼續,您將意識到關注點分離的重要性。

單一職責原則

您可能聽說過 SOLID 原則:單一職責、開閉、liskov 替換、接口隔離和依賴倒置。 第一個字母 S 代表單一職責原則 (SRP),其重要性怎麼強調都不為過。 我什至會爭辯說,這是好的代碼的充分必要條件。 事實上,在任何寫得不好的代碼中,你總能找到一個有多個職責的類——包含幾千行代碼的 form1.cs 或 index.php 並不罕見,我們所有人可能已經看過或做過。

讓我們看一個 C# 中的示例(ASP.NET MVC 和實體框架)。 即使您不是 C# 開發人員,只要有一些 OOP 經驗,您就可以輕鬆地跟上。

 public class OrderController { ... public ActionResult CreateForm() { /* * View data preparations */ return View(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } using (var context = new DataContext()) { var order = new Order(); // Create order from request context.Orders.Add(order); // Reserve ordered goods …(Huge logic here)... context.SaveChanges(); //Send email with order details for customer } return RedirectToAction("Index"); } ... (many more methods like Create here) }

這是一個常見的 OrderController 類,顯示了它的 Create 方法。 在這樣的控制器中,我經常看到 Order 類本身用作請求參數的情況。 但我更喜歡使用特殊請求類。 再次,SRP!

單個控制器的作業太多

請注意,在上面的代碼片段中,控制器對“下訂單”的了解太多了,包括但不限於存儲 Order 對象、發送電子郵件等。對於單個類來說,這簡直是太多的工作。 對於每一個微小的更改,開發人員都需要更改整個控制器的代碼。 萬一另一個控制器也需要創建訂單,開發人員通常會求助於復制粘貼代碼。 控制器應該只控制整個過程,而不是實際容納過程的每一點邏輯。

但是今天是我們停止編寫這些龐大的控制器的日子!

讓我們首先從控制器中提取所有業務邏輯並將其移至 OrderService 類:

 public class OrderService { public void Create(OrderCreateRequest request) { // all actions for order creating here } } public class OrderController { public OrderController() { this.service = new OrderService(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } this.service.Create(request); return RedirectToAction("Index"); }

完成此操作後,控制器現在只做它打算做的事情:控製過程。 它只知道視圖、OrderService 和 OrderRequest 類——它完成其工作(管理請求和發送響應)所需的最少信息集。

這樣,您將很少更改控制器代碼。 視圖、請求對象和服務等其他組件仍然可以更改,因為它們與業務需求相關聯,但控制器不會。

這就是 SRP 的意義所在,並且有很多技術可以編寫符合這一原則的代碼。 一個例子是依賴注入(這對於編寫可測試的代碼也很有用)。

依賴注入

很難想像一個沒有依賴注入的基於單一職責原則的大型項目。 讓我們再看一下我們的 OrderService 類:

 public class OrderService { public void Create(...) { // Creating the order(and let's forget about reserving here, it's not important for following examples) // Sending an email to client with order details var smtp = new SMTP(); // Setting smtp.Host, UserName, Password and other parameters smtp.Send(); } }

此代碼有效,但不是很理想。 為了理解 OrderService 類的創建方法是如何工作的,他們不得不理解 SMTP 的複雜性。 而且,複製粘貼是在任何需要的地方復制這種 SMTP 使用的唯一方法。 但是通過一些重構,這可以改變:

 public class OrderService { private SmtpMailer mailer; public OrderService() { this.mailer = new SmtpMailer(); } public void Create(...) { // Creating the order // Sending an email to client with order details this.mailer.Send(...); } } public class SmtpMailer { public void Send(string to, string subject, string body) { // SMTP stuff will be only here } }

已經好多了! 但是,OrderService 類仍然知道很多關於發送電子郵件的知識。 它完全需要 SmtpMailer 類來發送電子郵件。 如果我們將來想改變它怎麼辦? 如果我們想將發送到特殊日誌文件的電子郵件內容打印出來,而不是在我們的開發環境中實際發送它們怎麼辦? 如果我們想對 OrderService 類進行單元測試怎麼辦? 讓我們通過創建接口 IMailer 繼續重構:

 public interface IMailer { void Send(string to, string subject, string body); }

SmtpMailer 會實現這個接口。 此外,我們的應用程序將使用 IoC 容器,我們可以對其進行配置,以便 IMailer 由 SmtpMailer 類實現。 然後可以按如下方式更改 OrderService:

 public sealed class OrderService: IOrderService { private IOrderRepository repository; private IMailer mailer; public OrderService(IOrderRepository repository, IMailer mailer) { this.repository = repository; this.mailer = mailer; } public void Create(...) { var order = new Order(); // fill the Order entity using the full power of our Business Logic(discounts, promotions, etc.) this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); } }

現在我們正在取得進展! 我藉此機會也做出了另一個改變。 OrderService 現在依賴 IOrderRepository 接口與存儲我們所有訂單的組件進行交互。 它不再關心該接口是如何實現的以及為其提供動力的存儲技術。 現在 OrderService 類只有處理訂單業務邏輯的代碼。

這樣,如果測試人員在發送電子郵件時發現某些行為不正確,開發人員就可以確切地知道在哪裡查找:SmtpMailer 類。 如果折扣出了問題,開發人員再次知道去哪裡尋找:OrderService(或者如果您已經完全接受了 SRP,那麼它可能是 DiscountService)類代碼。

事件驅動架構

但是,我仍然不喜歡 OrderService.Create 方法:

 public void Create(...) { var order = new Order(); ... this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); }

發送電子郵件並不是主要訂單創建流程的一部分。 即使應用程序無法發送電子郵件,訂單仍會正確創建。 另外,想像一下您必須在用戶設置區域中添加一個新選項的情況,該選項允許他們在成功下訂單後選擇不接收電子郵件。 要將其合併到我們的 OrderService 類中,我們需要引入一個依賴項 IUserParametersService。 將本地化添加到組合中,您還有另一個依賴項 ITranslator(以用戶選擇的語言生成正確的電子郵件)。 其中一些操作是不必要的,尤其是添加這麼多依賴項並最終得到一個不適合屏幕的構造函數的想法。 我在 Magento 的代碼庫(用 PHP 編寫的流行電子商務 CMS)中找到了一個很好的例子,該類有 32 個依賴項!

不適合屏幕的構造函數

有時很難弄清楚如何分離這種邏輯,而 Magento 的班級可能是其中一種情況的受害者。 這就是為什麼我喜歡事件驅動的方式:

 namespace <base namespace>.Events { [Serializable] public class OrderCreated { private readonly Order order; public OrderCreated(Order order) { this.order = order; } public Order GetOrder() { return this.order; } } }

每當創建訂單時,不是直接從 OrderService 類發送電子郵件,而是創建特殊事件類 OrderCreated 並生成一個事件。 應用程序中的某處將配置事件處理程序。 其中一個將向客戶發送電子郵件。

 namespace <base namespace>.EventHandlers { public class OrderCreatedEmailSender : IEventHandler<OrderCreated> { public OrderCreatedEmailSender(IMailer, IUserParametersService, ITranslator) { // this class depend on all stuff which it need to send an email. } public void Handle(OrderCreated event) { this.mailer.Send(...); } } }

類 OrderCreated 被故意標記為 Serializable。 我們可以立即處理此事件,或者將其序列化存儲在隊列(Redis、ActiveMQ 或其他)中,並在與處理 Web 請求的進程/線程分開的進程/線程中處理它。 在這篇文章中作者詳細解釋了什麼是事件驅動架構(請不要關注 OrderController 內部的業務邏輯)。

有些人可能會爭辯說,現在很難理解創建訂單時發生了什麼。 但這與事實相去甚遠。 如果您有這種感覺,只需利用 IDE 的功能即可。 通過在 IDE 中查找 OrderCreated 類的所有用法,我們可以看到與事件相關的所有操作。

但是什麼時候應該使用依賴注入,什麼時候應該使用事件驅動的方法呢? 回答這個問題並不總是那麼容易,但一個可能對您有所幫助的簡單規則是對應用程序中的所有主要活動使用依賴注入,對所有次要操作使用事件驅動的方法。 例如,將 Dependecy Injection 與諸如使用 IOrderRepository 在 OrderService 類中創建訂單之類的事情一起使用,並將電子郵件的發送(這不是主要訂單創建流程的關鍵部分)委託給某個事件處理程序。

結論

我們從一個非常重的控制器開始,只有一個類,最後得到了一個精心設計的類集合。 從這些例子中可以看出這些變化的好處。 但是,仍有許多方法可以改進這些示例。 例如,OrderService.Create 方法可以移動到它自己的一個類:OrderCreator。 由於訂單創建是遵循單一職責原則的業務邏輯的獨立單元,因此它擁有自己的類和自己的一組依賴項是很自然的。 同樣,訂單刪除和訂單取消都可以在它們自己的類中實現。

當我編寫高度耦合的代碼時,類似於本文中的第一個示例,對需求的任何微小更改都可能很容易導致代碼其他部分的許多更改。 SRP 幫助開發人員編寫解耦的代碼,其中每個類都有自己的工作。 如果此作業的規範發生更改,則開發人員僅對該特定類進行更改。 這種變化不太可能破壞整個應用程序,因為其他類應該仍然像以前一樣做他們的工作,當然除非它們一開始就被破壞了。

使用這些技術預先開發代碼並遵循單一職責原則似乎是一項艱鉅的任務,但隨著項目的發展和開發的繼續,這些努力肯定會得到回報。