设计原则之单一职责原则

设计原则之单一职责原则

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

设计原则之单一职责原则

无论我们认为什么是出色的代码,它始终需要一种简单的质量:代码必须是可维护的。正确的缩进,整洁的变量名,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帮助开发人员编写解耦的代码,其中每个类都有其自己的工作。如果此作业的规范发生更改,则开发人员仅更改该特定类。这种更改不太可能破坏整个应用程序,因为其他类当然应该像以前一样继续工作,除非它们当然首先被破坏了。

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