单一职责原则:伟大代码的秘诀

已发表: 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 帮助开发人员编写解耦的代码,其中每个类都有自己的工作。 如果此作业的规范发生更改,则开发人员仅对该特定类进行更改。 这种变化不太可能破坏整个应用程序,因为其他类应该仍然像以前一样做他们的工作,当然除非它们一开始就被破坏了。

使用这些技术预先开发代码并遵循单一职责原则似乎是一项艰巨的任务,但随着项目的发展和开发的继续,这些努力肯定会得到回报。