个性化阅读
专注于IT技术分析

单一责任原则:伟大准则的秘诀

本文概述

无论我们认为什么是出色的代码, 它始终需要一种简单的质量:代码必须是可维护的。正确的缩进, 整洁的变量名, 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对象, 发送电子邮件等。对于单个类来说, 这简直是太多工作。对于每一个小的更改, 开发人员都需要更改整个控制器的代码。并且, 以防万一另一个Controller也需要创建订单的情况, 通常情况下, 开发人员将诉诸于复制粘贴代码。控制器应仅控制整个过程, 而不能真正容纳过程的所有逻辑。

但是今天是我们停止编写这些庞大控制器的一天!

让我们首先从控制器中提取所有业务逻辑, 然后将其移至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;
    }
}
}

每当创建订单时, 都会创建特殊事件类OrderCreated并生成事件, 而不是直接从OrderService类发送电子邮件。在应用程序中的某个地方将配置事件处理程序。其中之一将向客户发送电子邮件。

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类被故意标记为可序列化。我们可以立即处理此事件, 或将其序列化存储在队列中(Redis, ActiveMQ或其他), 并在与处理Web请求的进程/线程分开的进程/线程中进行处理。在本文中, 作者详细解释了什么是事件驱动的体系结构(请不要关注OrderController中的业务逻辑)。

有人可能会争辩说, 现在很难理解创建订单时的情况。但这与事实不符。如果你有这种感觉, 只需利用IDE的功能即可。通过在IDE中查找OrderCreated类的所有用法, 我们可以看到与该事件关联的所有动作。

但是, 什么时候应该使用依赖注入, 什么时候应该使用事件驱动的方法?回答这个问题并不总是那么容易, 但是可以帮助你的一个简单规则是对应用程序中的所有主要活动使用依赖注入, 对所有辅助操作使用事件驱动的方法。例如, 将Dependecy Injection与诸如使用IOrderRepository在OrderService类中创建订单, 以及将不是主要订单创建流程的关键部分的电子邮件委托给某个事件处理程序的操作结合使用。

总结

我们从一个非常沉重的控制器开始, 只有一个类, 最后是精心制作的类集合。这些变化的优势从示例中显而易见。但是, 仍有许多方法可以改进这些示例。例如, 可以将OrderService.Create方法移至其自己的类:OrderCreator。由于订单创建是遵循”单一职责原则”的业务逻辑的独立单元, 因此自然而然会有一个具有自己的依赖关系集的类。同样, 订单删除和订单取消可以分别在自己的类中实现。

当我编写高度耦合的代码时(类似于本文的第一个示例), 对需求的任何细微更改都可能轻易导致代码其他部分的许多更改。 SRP帮助开发人员编写解耦的代码, 其中每个类都有其自己的工作。如果此作业的规范发生更改, 则开发人员仅更改该特定类。这种更改不太可能破坏整个应用程序, 因为其他类当然应该像以前一样继续工作, 除非它们当然首先被破坏了。

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

赞(0)
未经允许不得转载:srcmini » 单一责任原则:伟大准则的秘诀

评论 抢沙发

评论前必须登录!