作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
阿德尔·费兹拉赫马诺夫的头像

Adel Fayzrakhmanov

Adel (MCE)有15年以上的软件开发经验,专注于web技术和质量架构. PHP and .NET are his forté.

Previously At

Cornell University
Share

不管我们认为什么是优秀的代码, 它总是要求一个简单的质量:代码必须是可维护的. 适当的缩进、整齐的变量名、100%的测试覆盖率等等,这些只能带您到这里. 任何不可维护的代码,不能相对容易地适应不断变化的需求,都是等待过时的代码. 当我们试图构建原型时,我们可能不需要编写很棒的代码, 概念证明或最小可行产品, 但是在所有其他情况下,我们应该总是编写可维护的代码. 这应该被认为是软件工程和设计的基本品质.

单一职责原则:编写优秀代码的秘诀

In this article, 我将讨论单一职责原则和围绕它的一些技术如何使您的代码达到这种质量. 编写优秀的代码是一门艺术, 但是,一些原则总是可以帮助您的开发工作朝着生成健壮且可维护的软件的方向前进.

Model Is Everything

几乎每本书都介绍了一些新的MVC (MVP), MVVM, 或其他M**)框架充斥着糟糕代码的例子. 这些示例试图展示该框架必须提供的功能. 但它们最终也为初学者提供了糟糕的建议. 例如“假设我们的模型有这个ORM X, 模板引擎Y为我们的视图,我们将有控制器来管理这一切“实现除了巨大的控制器.

尽管是为了维护这些书, 这些示例旨在演示您可以轻松地开始使用他们的框架. 它们不是用来教软件设计的. 但是跟随这些例子的读者会意识到, only after years, 在他们的项目中使用单块代码会产生多大的反效果.

模型是应用程序的核心.

模型是应用程序的核心. 如果您的模型与应用程序逻辑的其余部分分离, 维护将会容易得多, 不管您的应用程序变得多么复杂. 即使对于复杂的应用程序,良好的模型实现也可以产生极具表现力的代码. And to achieve that, 首先要确保您的模型只做它们应该做的事情, 不要关心应用围绕它构建了什么. Furthermore, 它不关心底层数据存储层是什么:你的应用是否依赖于SQL数据库, 或者它将所有内容存储在文本文件中?

在我们继续本文的过程中,您将意识到代码在很大程度上是关于关注点分离的.

单一责任原则

你可能听说过坚实原则:单一责任, open-closed, liskov substitution, 接口隔离和依赖倒置. The first letter, S, 代表单一责任原则(SRP),其重要性怎么强调都不为过. 我甚至认为这是好的代码的必要和充分条件. In fact, 在任何写得不好的代码中, 你总能找到一个有多个职责的类——form1.cs or index.包含几千行代码的PHP并不罕见,我们可能都见过或做过.

让我们看一个用c# (ASP)编写的例子.. NET MVC和实体框架). Even if you are not a C# developer,有了一些面向对象的经验,你就能很容易地跟上.

公共类OrderController
{
...

    	public ActionResult CreateForm()
    	{
        	/*
        	* View data preparations
        	*/

        	return View();
    	}

    	[HttpPost]
    	公共ActionResult创建(OrderCreateRequest请求)
    	{
        	if (!ModelState.IsValid)
        	{
            	/*
             	* View data preparations
            	*/

            	return View();
        	}

        	使用(var context = new DataContext())
        	{
                   var order = new Order();
                    //根据请求创建订单
                    context.Orders.Add(order);

                    // Reserve ordered goods
                    …(Huge logic here)...

                   context.SaveChanges();

                   //向客户发送带有订单详细信息的电子邮件
        	}

        	返回RedirectToAction(“指数”);
    	}

... (这里有更多类似Create的方法)
}

这是一个常见的OrderController类,其Create方法如图所示. In controllers like this, 我经常看到将Order类本身用作请求参数的情况. 但我更喜欢使用特殊要求类. Again, SRP!

单个控制器上的任务太多

注意,在上面的代码片段中,控制器对“下订单”知道得太多了。, 包括但不限于存储Order对象, sendings emails, etc. 对于一个阶层来说,这实在是太多的工作了. 对于每一个小的改变,开发者都需要改变整个控制器的代码. 以防另一个Controller也需要创建订单, more often than not, 开发人员将采用复制粘贴代码的方式. 控制器应该只控制整个过程, 实际上并没有包含整个过程的所有逻辑.

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

让我们首先从控制器中提取所有业务逻辑,并将其移动到OrderService类中:

public class OrderService
{
    创建(OrderCreateRequest)
    {
        //所有创建订单的动作
    }
}

公共类OrderController
{
    public OrderController()
    {
        this.service = new OrderService();
    }
    
    [HttpPost]
    公共ActionResult创建(OrderCreateRequest请求)
    {
        if (!ModelState.IsValid)
        {
            /*
             * View data preparations
            */

            return View();
        }

        this.service.Create(request);

        返回RedirectToAction(“指数”);
   }

完成这些后,控制器现在只做它想做的事情:控制流程. It knows only about views, OrderService和OrderRequest类—完成其工作所需的最少信息集, 哪一个是管理请求和发送响应.

通过这种方式,您将很少更改控制器代码. 其他组件,如视图, 请求对象和服务在链接到业务需求时仍然可以更改, but not controllers.

这就是SRP的意义所在,并且有许多编写符合这一原则的代码的技术. 这方面的一个例子是依赖注入(它也适用于 writing testable code).

Dependency Injection

很难想象一个没有依赖注入的基于单一职责原则的大型项目. 让我们再看一下OrderService类:

public class OrderService
{
   public void Create(...)
   {
       //创建订单(让我们忘记这里的预订), 这对于下面的例子并不重要)
       
       //发送邮件到客户端与订单细节
       var smtp = new SMTP();
       // Setting smtp.主机、用户名、密码等参数
       smtp.Send();
   }
}

这段代码可以工作,但不是很理想. 要了解OrderService类的创建方法是如何工作的, 他们被迫去理解复杂的 SMTP. 同样,复制-粘贴是在需要时复制SMTP的唯一方法. 但只要稍微重构一下,情况就会改变:

public class OrderService
{
    私人SmtpMailer邮件;
    public OrderService()
    {
        this.mailer = new SmtpMailer();
    }

    public void Create(...)
    {
        // Creating the order
        
        //发送邮件到客户端与订单细节
        this.mailer.Send(...);
    }
}

public class SmtpMailer
{
    发送(string to, string subject, string body)
    {
        // SMTP将只在这里
    }
}

Much better already! 但是,OrderService类仍然知道很多关于发送电子邮件的事情. 它需要SmtpMailer类来发送电子邮件. 如果我们将来想改变它怎么办? 如果我们想要将发送到一个特殊日志文件的电子邮件的内容打印出来,而不是在我们的开发环境中实际发送它们,该怎么办呢? 如果我们想对OrderService类进行单元测试呢? 让我们通过创建一个接口IMailer来继续进行重构:

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

SmtpMailer将实现这个接口. Also, 我们的应用程序将使用一个ioc容器,我们可以配置它,以便IMailer由SmtpMailer类实现. OrderService可以修改如下:

公共密封类OrderService: IOrderService
{
    私有IOrderRepository存储库;
    private IMailer mailer;
    public OrderService(IOrderRepository, IMailer)
    {
        this.repository = repository;
        this.mailer = mailer;
    }

    public void Create(...)
    {
        var order = new Order();
        //使用我们的业务逻辑(折扣、促销等)的全部力量填充订单实体.)
        this.repository.Save(order);

        this.mailer.Send(, , );
    }
}

现在我们有点进展了! 我利用这个机会也做了另一个改变. OrderService现在依赖于IOrderRepository接口来与存储我们所有订单的组件进行交互. 它不再关心接口是如何实现的,以及是什么存储技术为它提供动力. 现在OrderService类只有处理订单业务逻辑的代码.

This way, 如果测试人员发现发送电子邮件的行为不正确, 开发者知道去哪里看:SmtpMailer类. 如果折扣出了什么问题, developer, again, 知道在哪里查找:OrderService(或者如果您已经完全接受了SRP), 那么它可能是DiscountService)类代码.

Event Driven Architecture

然而,我仍然不喜欢OrderService.Create method:

    public void Create(...)
    {
        var order = new Order();
        ...
        this.repository.Save(order);

        this.mailer.Send(, , );
    }

发送电子邮件并不是主要订单创建流程的一部分. 即使应用程序发送电子邮件失败,订单仍然是正确创建的. Also, 想象一下这样一种情况:您必须在用户设置区域中添加一个新选项,允许他们在成功下订单后选择不接收电子邮件. 将其合并到OrderService类中, 我们需要引入一个依赖项, IUserParametersService. 将本地化加入其中, 你还有另一个依赖项, ittranslator(以用户选择的语言生成正确的电子邮件信息). 其中一些操作是不必要的, 尤其是添加了这么多依赖关系,最终构造函数无法显示在屏幕上的想法. I found a great example 在Magento的代码库(一个用PHP编写的流行电子商务CMS)中,有32个依赖项的类!

不适合屏幕显示的构造函数

有时候很难弄清楚如何区分这种逻辑, Magento的班级可能就是其中一个案例的受害者. 这就是为什么我喜欢事件驱动的方式:

namespace .Events
{
[Serializable]
public class OrderCreated
{
    private readonly Order;

    public OrderCreated(订单)
    {
        this.order = order;
    }

    public Order GetOrder()
    {
        return this.order;
    }
}
}

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

namespace .EventHandlers
{
public class OrderCreatedEmailSender : IEventHandler
{
    public OrderCreatedEmailSender(IMailer, IUserParametersService, ittranslator)
    {
        //这个类依赖于发送电子邮件所需的所有东西.
    }

    public void Handle(OrderCreated事件)
    {
        this.mailer.Send(...);
    }
}
}

OrderCreated类被有意地标记为Serializable. 我们可以立即处理这件事, 或者将其序列化存储在队列中(Redis), ActiveMQ或其他东西)并在与处理web请求的进程/线程分开的进程/线程中处理它. In this article 作者详细解释了什么是事件驱动架构(请不要注意OrderController中的业务逻辑)。.

有些人可能会争辩说,现在很难理解创建订单时发生了什么. 但这与事实相去甚远. 如果您有这种感觉,只需利用IDE的功能即可. 通过在IDE中找到OrderCreated类的所有用法, 我们可以看到与事件相关的所有动作.

但是什么时候应该使用依赖注入,什么时候应该使用事件驱动的方法? 回答这个问题并不总是那么容易, 但有一个简单的规则可能会对你有所帮助,那就是对应用程序中的所有主要活动使用依赖注入, 以及所有次要操作的事件驱动方法. For example, 在使用IOrderRepository的OrderService类中创建订单时使用依赖注入, 委派发送邮件, 不是主要订单创建流程的关键部分, to some event handler.

Conclusion

我们从一个非常沉重的控制器开始, just one class, 最后得到了一系列精心设计的课程. 这些变化的好处从这些例子中非常明显. 然而,仍然有许多方法可以改进这些示例. For example, OrderService.Create方法可以移动到它自己的类中:OrderCreator. 由于订单创建是遵循单一职责原则的业务逻辑的独立单元, 对于它来说,拥有自己的类和自己的依赖集是很自然的. 同样,订单删除和订单取消也可以在各自的类中实现.

当我写高耦合代码的时候, 类似于本文的第一个示例, 对需求的任何微小更改都很容易导致代码其他部分的许多更改. SRP帮助开发人员编写解耦的代码,其中每个类都有自己的工作. 如果此作业的规范发生更改,则开发人员仅对该特定类进行更改. 更改不太可能破坏整个应用程序,因为其他类仍然可以像以前一样工作, 当然,除非它们一开始就坏了.

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

聘请Toptal这方面的专家.
Hire Now
阿德尔·费兹拉赫马诺夫的头像
Adel Fayzrakhmanov

Located in Kazan, Tatarstan, Russia

Member since October 19, 2015

About the author

Adel (MCE)有15年以上的软件开发经验,专注于web技术和质量架构. PHP and .NET are his forté.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Previously At

Cornell University

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.