65.9K
CodeProject 正在变化。 阅读更多。
Home

应用程序架构 - 在 SOLID 之前,STUPID 有什么问题

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.40/5 (41投票s)

2017年4月23日

CPOL

16分钟阅读

viewsIcon

53685

downloadIcon

252

本主题将涵盖使用STUPID的糟糕设计实践和使用SOLID的良好设计实践。详细解释单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置(DI)原则。

应用设计原则

软件设计主题的受众 - 应用架构师和开发人员

您将从这些主题中学到什么

本主题将涵盖以下概念: 

  1. 糟糕的STUPID设计
    • 单例
    • 紧耦合
    • 不可测试性
    • 过早优化
    • 描述不清的命名
    • 重复
  2. 良好的SOLID设计
    • 单一职责原则
    • 开闭原则
    • 里氏替换原则
    • 接口隔离原则
    • 依赖倒置原则

让我们深入了解基本概念

等等!……我在说什么?不!今天,我根本不讨论WAIT原则。软件设计和实现应该是快速而简单的。如果它现在能够满足您所有的需求,那么它就已经足够好了。是的,我指的是“足够好”原则。即使一开始我们实现得不好,那也足够好了。我们就是这样学习的。 但未来呢?您应该时刻关注系统的未来。所以,如果我们做了一些愚蠢的事情,那也没关系。但为了未来,我们必须不断进步。

STUPID设计

如果我冒犯了您,请原谅;但我做了和您一样的事情。不过,STUPID这个词到底是什么意思?STUPID代表:

  • Singleton Pattern (单例模式)
  • Tight Coupling (紧耦合)
  • Un-testability (不可测试性)
  • Premature Optimization (过早优化)
  • In-descriptive Naming (描述不清的命名)
  • Duplication (重复)

单例模式

您已经熟悉这种模式。它实例化一个单一的static对象,并确保提供全局访问点。

这也是许多开发人员喜欢的模式之一。所以,我不会对它说任何坏话。我只想说,单例模式本身不是问题;问题在于,如果您不知道——何时、何地以及如何使用它。

问题

  • 单元测试:如果您习惯于单元测试,那么您会知道测试它非常困难,而且通常无法为其创建模拟对象。因为它是紧耦合的。它隐藏了应用程序代码中的依赖项。
  • 全局实例:它隐藏了应用程序代码中的依赖项。许多人认为,如果错误地实现和使用它,它可能是一种反模式。
  • 它违反了单一职责原则,并且它自己控制对象的创建和生命周期。

所以,如果您需要一个单一实例,那么就使用它,并确保它是线程安全的。但要小心,不要到处都全局使用它。

紧耦合

在类图中

  • Customer类需要CheckingAccount类和SavingAccount类来完成其工作。
  • Customer”类在没有CheckingAccountSavingAccount类的情况下无法完成其工作。

在这个例子中,CheckingAccountSavingAccountcustomer类紧密耦合。此实现违反了开闭原则,即类应该是对扩展开放的,但对修改关闭的。不过,忘记原则。只需考虑您的支票账户类在此设计中是固定的,如果您需要另一种支票账户或储蓄账户的实现,您该怎么办?

最后,我们可以说

  • 紧耦合的设计很难重用,也不允许将来进行扩展。
  • 它很难测试。大多数时候,单元测试无法进行对象模拟。

因此,解决方案是它应该松耦合。

不可测试性

根据单元测试的第一条原则,我们“只测试类的逻辑,仅此而已”。

当您要测试一个类时,您不应该依赖数据库、文件、注册表、Web服务等。您应该能够“隔离”地测试任何类,并且它应该被设计成支持完全“隔离”。

根据此原则,模拟所有外部服务和状态,并记住单元测试从不使用

  • 配置文件
  • 数据库
  • 其他应用程序/服务/网络 I/O 或文件系统
  • 日志记录

让我们看一个例子——假设您有一个方法‘IsValidUser’来验证登录的用户名和密码。

现在,如果您查看第15行和第17行,您会看到‘UserLogIn’类依赖于‘UserDataAccess’类,并且它是紧耦合的。现在的问题是您需要测试‘UserLogIn’类中的‘IsValidUser’方法,并且您必须避免依赖。因为如果您调用‘IsValidUser’,它将调用‘GetUserInfoByUseeName(userName)’方法,并且根据单元测试原则,如果我们依赖另一个类的某个方法,那么我们就必须模拟该对象类。这意味着我们必须向该对象注入一些虚拟数据。在此示例中,我们需要为GetUserInfoByUseName(userName)注入虚拟数据。如果我们这样做,那么我们将能够验证‘UserLogIn’类中‘IsValidUser’方法的逻辑。

因此,为了避免紧耦合的代码,我们需要重构代码以实现松耦合。

过早优化

假设我们正在递增一个计数器值。为了递增该值,我们可以使用前缀递增(++counter)或后缀递增(counter++)。假设前缀递增比后缀递增更有效。那么,我们应该使用哪一个?如果我们使用错误的方式,会发生什么?

优化有不同的级别,从非常高层到低层。为应用程序选择良好的架构,使用松耦合抽象每个层。它还取决于选择正确的数据结构和正确的算法。高效的编码以及许多其他事情都与此相关。所以,现在我避免讨论它,以使其更简单。

描述不清的命名

多多少少,我们都了解清晰代码、命名约定和最佳实践。它可以从您项目的解决方案名称开始,到组件、类、方法、属性、变量等的命名。您是只为自己编写代码吗?如果您不在场,将来有另一个人来,他/她将如何理解您的代码和设计?

例如,我有一个类,它的名字是DaEmp。那么,您怎么知道它的意思?我敢肯定,什么也不知道。因为,您不愿意猜测。

但是,如果我的意思是这个类将用于从数据库的employer表中访问数据。那么,如果我将其重命名为DataAccessForEmployer,那么至少会有意义,您也不需要问我。

再次假设,我有一个int变量c,那么您怎么知道它是做什么用的?但我意思是计数器。所以,将变量名保留为int counter。避免使用缩写,如emp。如果您写employer而不是“emp”,有什么问题吗?不要只为自己写代码,要为他人写代码,并使其易于阅读。抱歉!

重复

在下面的例子中,我们重复了一些属性。不仅在您的模型类中,也可能发生在您的方法、类中的任何地方,或者整个项目中。

在这种特定情况下,解决方案是——假设我们可以为Address创建一个基类,并将其继承到您的模型类中,如GeneralClientCorporateClient类。请注意,如果您的类中只使用Id,那么最佳实践是使用ClassName+Id而不是单独的ID。我的意思是,如果类名是AddressEmployee,那么请使用AddressIdEmployeeId而不是单独的Id

永远要记住——“不要重复自己”原则。

SOLID设计

是时候洗掉设计上的灰尘了。是的,如果您想成为一名优秀的架构师,那么您就必须遵循一些设计模式和原则。可以是您自己的模式和原则,没关系。您需要确保您的设计具有灵活性,例如:

  • 易于更改
  • 难以破坏
  • 易于重用
  • 易于扩展
  • 易于阅读和理解
  • 非常简单、整洁、干净的设计

对于类设计目的,现在我将只讨论SOLID原则。SOLID这个词到底是什么意思?

所以,SOLID代表:

  • Single Responsibility Principle (单一职责原则)
  • Open-Closed Principle (开闭原则)
  • Liskov Substitution Principle (里氏替换原则)
  • Interface Segregation Principle (接口隔离原则)
  • Dependency Inversion Principle (依赖倒置原则)

单一职责原则

一个类应该只有一个引起它变化的原因。

我知道您明白它的意思。但我不确定;所以请跟着我,看看我是否正确。让我们看一个例子:

在类图中,我指向SwitchForFanLight类。它有两个依赖项:lightfan

我故意选择了这个令人困惑的例子来给您设计方面的想法。它介于好设计和坏设计之间。在这种设计场景下,FanLightSwitchForFanLight类是松耦合的。您想看看如何实现吗?好吧,让我们看看下面的SwitchForFanLight类的实现,从技术上讲,它没有问题。我们可以注入ISwitch接口的任何实现。

再次,如果您看到FanLight类的实现,那么我们就做得很好。

好了,废话不多说!直奔主题,我有一个开关,当我打开开关时,它会同时打开风扇和灯。同样,当我关闭开关时,它会同时关闭灯和风扇。我的意思是,一个开关同时控制风扇和灯。我省时省力。一个开关同时控制它们。我是一个好工程师。多么搞笑,嗯?

但我没有意识到,有一天我会感到太冷,不需要风扇;但需要灯。现在怎么办?如果我关掉风扇,那么我就没有灯了。另一天,外面阳光明媚,照亮了我的房间。所以我不需要灯,我关掉了灯,风扇也停了。但我需要风扇来调节温度。天气太热了。

醒悟

现在我在这个设计上遇到了问题。因为一个开关同时有两个职责。如果我们打开开关,那么fan对象将调用一个方法,light对象将调用另一个方法。同样,如果light类发生任何事情,比如实现错误或编译时错误,那么lightfan都会受到影响。所以,我在SwitchForFanLight类中违反了单一职责原则。

所以,现在我们怎么办。我被迫改变我的实现。现在我把开关分开了,一个开关用于风扇,另一个开关用于灯。酷吧?

我学到了一个类应该只有一个职责,并且只有一个引起它变化的原因。顺便说一句,这就是‘单一职责’原则。

开闭原则

它指出:“模块和方法应该对扩展开放,对修改关闭。

WAIT原则 -

免责声明:我是一个懒惰的开发人员,总是偏爱捷径。所以,我想使用我之前的例子。这样,我既不需要实现另一个项目,也不需要再次解释。我不想给您的大脑增加压力。好吧,现在我们准备继续了。

在我之前的例子中,我们一直在处理SwitchForFanLight类的问题,而且我不想再使用这个类了。但我没有时间去更改它,甚至不知道我在哪里使用了它的引用。如果我现在更改它,我不知道它会受到什么影响。所以,对我来说,最好是把它放在一边,让它过去。

不过,当时,当我遇到问题时,我扩展了我的设计,创建了另外两个类:SwitchForFanSwitchForLight

这对我来说很容易,因为我有一个基接口ISwitch,并且在SwitchForFanSwitchForLight类中,我将ISwitch接口继承为基类。

这意味着我根据当前的需求扩展了SwitchForFanSwitchForLight类,而我根本没有修改旧的SwitchForFanLight类。

现在看看下面的图片。有一个SwitchBoardForBadDesign类,它包含两个方法:TurnOnTurnOff。这两个方法都有一些ifelse-If语句,用于根据SwitchType打开或关闭灯、风扇或两者。

现在回到重点,将来,如果您需要ISwitch的另一种实现,那么您需要在TurnOnTurnOff方法中添加另一个If-Else语句。所以,您就完了。这是对开闭原则的违反。因为它迫使您修改您的代码。

那么,这些问题的解决方案是什么呢??

解决方案

让我们看看下面的abstract switch class

所以我修复了SwitchBoardForGoodDesign类中的那个问题。在这个类中,我继承了abstract Switch class并重写了TurnOnTurnOff方法。

现在,将来,如果我们添加ISwitch的另一种实现,比如GreenLightRedLight,那么我们就不需要修改SwitchBoardForGoodDesign类了。所以,它现在对修改关闭,同时我们也可以在将来扩展我们的ISwitch

这就是开闭原则的工作方式。它帮助我摆脱了那个问题。

里氏替换原则

它指出:“派生类必须可以替换其基类。

确保新派生类在不改变其行为的情况下扩展基类。

我想分享一个我在这条原则上最糟糕的经历。但是,我又是一个懒惰的开发人员。所以,别担心,我不会给您施加压力。

在我日常生活中,我习惯于英国的制度。但现在我在美国。所以我接手了一个项目,我的工作非常简单。我只需要扩展功能。在开发过程中,他们已经告诉我,我将得到一个abstract类,它的名字是‘Switch’。

他们有另一个类kSwitch,我的任务是实现UsaSwitch。请原谅kSwitch。这是一个糟糕的命名约定。但我只是用它来举例。

我包含了类图。

K-switch的实现已经给出。但我不需要看它的实现;因为我知道开关是如何工作的。

对我来说,实现非常简单,我完成了。所以,单元测试之后,我把它送给了QA团队。但当他们开始测试时,他们发现了一个bug。他们告诉我,它没有按他们的预期工作。他们解释说,当他们打开开关时,电力没有通过。同样,当他们关闭开关时,电力也没有停止流动。

然后,我阅读了文档,了解了开关的行为,我明白了,我改变了基类的行为。

UsaSwitch类的实现方式如下:

最后,我了解到这是对里氏替换原则的违反。因此,在美国,Turn-Up表示开始供电。Turn-Down表示停止供电。在英国,Turn-Up表示停止供电。Turn-Down表示开始供电。

所以,在扩展期间不要改变基类的含义。您不会收到任何编译错误,但它可能会在运行时破坏您的代码。

接口隔离原则

它指出:“客户不应该被迫依赖他们不使用的接口。

假设我们有一个IAnimal interface。毫无疑问,MooseRedDeerDogCat都是动物。让我们看看它们的类图。

IAnimal interface具有以下属性:

MooseRedDeer模型类是从IAnimal interface实现的。

同样,DogCat模型类也从IAnimal interface实现的。

现在的问题是MooseRedDeer有角;但是DogCat没有角。IAnimal interface强制实现SizeOfHorns属性。所以,这对DogCat类来说是接口隔离原则的违反。尽管我们对MooseRedDeer类没有异议。

因此,根据该原则,我们必须从IAnimal interface中删除SizeOfHorns,然后DogCat类就会正常。另一方面,我们将SizeOfHorns属性添加到MooseRedDeer的实现类中,或者根据我们的设计计划将其添加到其他地方。

依赖倒置原则

现实生活场景

为了方便理解,假设您有一个不同的数据访问层(DAL)实现。例如,IDataAccess有两个实现,它们是DataAccessForSqlDataAccessForOracle。根据客户,有时您需要DataAccessForSql,有时您需要DataAccessForOracle。但如果您的类是紧耦合的,那么紧耦合设计在这种情况下将无法帮助您。

同样,您有多个支付系统,如PayPal、Visa和MasterCard。您需要根据支付系统实现不同的业务逻辑。例如,假设IPayment有一些实现,如PaymentForMasterCardPaymentForPayPalPaymentForVisa。所以,您需要根据客户的偏好在它们之间切换。紧耦合设计不会为您提供这些功能。

因此,解决方案是依赖倒置。

场景2

看看下面给出的例子,我们正在考虑一种分层架构风格。

表示层(PL)可能包含用户界面层(UI)和表示逻辑层(PLL)。为了简单起见,假设我们有一个Web应用程序,UI是指ASP.NET的*.aspx网页,或者ASP.NET MVC的*.cshtml视图。PLL是指ASP.NET MVC的控制器类,或者ASP.NET的*.aspx.cs文件。

业务层(BL)可能是您的领域层。数据访问层(DAL)。您可以使用任何ORM,如Entity-Framework作为DAL,或者任何您想要的。

假设您从任何网络门户购买了一些产品。现在您想查看您的订单项。因此,当您单击一个按钮来显示您的订单时,视图(UI)将与控制器类(PLL)通信。Controller类将调用BL,然后BL将调用DAL来验证客户账户信息。最后,DAL将订单信息发送给BL,BL将其发送给PL。然后您将看到您的订单列表。

简而言之,各层的工作如下:

  1. PL依赖于BL
  2. BL依赖于DAL

所以,高层依赖于低层。假设它们是紧耦合的层。因此,我们无法在运行时更改这种依赖关系,或者如果我们有DAL或BLL的多个实现,那么会发生什么?我们能改变层之间的紧耦合依赖关系吗?

DI的解决方案

它指出:“高层不应该依赖低层。两者都应该依赖抽象。

所以,我们可以将DAL的任何实现注入到BL,并将BL的任何实现注入到PL。设计方案很棒,不是吗?

查看类图,我们在其中实现了从interface IDataAccess实现的DataAccessForSQLDataAccessForOracle类。我们还实现了从接口IProductBusinessLogic实现的ProductBusinessLogic类。

现在,我们将通过构造函数注入将DataAccessForSQLDataAccessForOracle之一注入到ProductBusinessLogic中。

最后,我们通过构造函数注入创建了dataAccess对象,该对象根据运行时输入是通过DataAccessForSQLDataAccessForOracle创建的,然后dataAccess对象通过构造函数注入到productBl对象中。请看下面的代码。

天哪!您添加了越来越多的描述。好吧,我在这里停止。我甚至没有时间审查它。如果您不理解,请告诉我。我附上了项目和源代码。查找附件。

请记住一件事,这些都只是原则,而不是像神圣书籍那样的硬性规定。所以,放松并尽力而为,设计就会找到自己的方向。

© . All rights reserved.