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






4.40/5 (41投票s)
本主题将涵盖使用STUPID的糟糕设计实践和使用SOLID的良好设计实践。详细解释单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置(DI)原则。
应用设计原则
软件设计主题的受众 - 应用架构师和开发人员
您将从这些主题中学到什么
本主题将涵盖以下概念:
- 糟糕的STUPID设计
- 单例
- 紧耦合
- 不可测试性
- 过早优化
- 描述不清的命名
- 重复
- 良好的SOLID设计
- 单一职责原则
- 开闭原则
- 里氏替换原则
- 接口隔离原则
- 依赖倒置原则
让我们深入了解基本概念
等等!……我在说什么?不!今天,我根本不讨论WAIT原则。软件设计和实现应该是快速而简单的。如果它现在能够满足您所有的需求,那么它就已经足够好了。是的,我指的是“足够好”原则。即使一开始我们实现得不好,那也足够好了。我们就是这样学习的。 但未来呢?您应该时刻关注系统的未来。所以,如果我们做了一些愚蠢的事情,那也没关系。但为了未来,我们必须不断进步。
STUPID设计
如果我冒犯了您,请原谅;但我做了和您一样的事情。不过,STUPID这个词到底是什么意思?STUPID代表:
- Singleton Pattern (单例模式)
- Tight Coupling (紧耦合)
- Un-testability (不可测试性)
- Premature Optimization (过早优化)
- In-descriptive Naming (描述不清的命名)
- Duplication (重复)
单例模式
您已经熟悉这种模式。它实例化一个单一的static
对象,并确保提供全局访问点。
这也是许多开发人员喜欢的模式之一。所以,我不会对它说任何坏话。我只想说,单例模式本身不是问题;问题在于,如果您不知道——何时、何地以及如何使用它。
问题
- 单元测试:如果您习惯于单元测试,那么您会知道测试它非常困难,而且通常无法为其创建模拟对象。因为它是紧耦合的。它隐藏了应用程序代码中的依赖项。
- 全局实例:它隐藏了应用程序代码中的依赖项。许多人认为,如果错误地实现和使用它,它可能是一种反模式。
- 它违反了单一职责原则,并且它自己控制对象的创建和生命周期。
所以,如果您需要一个单一实例,那么就使用它,并确保它是线程安全的。但要小心,不要到处都全局使用它。
紧耦合
在类图中
Customer
类需要CheckingAccount
类和SavingAccount
类来完成其工作。- “
Customer
”类在没有CheckingAccount
和SavingAccount
类的情况下无法完成其工作。
在这个例子中,CheckingAccount
和SavingAccount
与customer
类紧密耦合。此实现违反了开闭原则,即类应该是对扩展开放的,但对修改关闭的。不过,忘记原则。只需考虑您的支票账户类在此设计中是固定的,如果您需要另一种支票账户或储蓄账户的实现,您该怎么办?
最后,我们可以说
- 紧耦合的设计很难重用,也不允许将来进行扩展。
- 它很难测试。大多数时候,单元测试无法进行对象模拟。
因此,解决方案是它应该松耦合。
不可测试性
根据单元测试的第一条原则,我们“只测试类的逻辑,仅此而已”。
当您要测试一个类时,您不应该依赖数据库、文件、注册表、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
创建一个基类,并将其继承到您的模型类中,如GeneralClient
和CorporateClient
类。请注意,如果您的类中只使用Id
,那么最佳实践是使用ClassName+Id
而不是单独的ID
。我的意思是,如果类名是Address
,Employee
,那么请使用AddressId
,EmployeeId
而不是单独的Id
。
永远要记住——“不要重复自己”原则。
SOLID设计
是时候洗掉设计上的灰尘了。是的,如果您想成为一名优秀的架构师,那么您就必须遵循一些设计模式和原则。可以是您自己的模式和原则,没关系。您需要确保您的设计具有灵活性,例如:
- 易于更改
- 难以破坏
- 易于重用
- 易于扩展
- 易于阅读和理解
- 非常简单、整洁、干净的设计
对于类设计目的,现在我将只讨论SOLID原则。SOLID这个词到底是什么意思?
所以,SOLID代表:
- Single Responsibility Principle (单一职责原则)
- Open-Closed Principle (开闭原则)
- Liskov Substitution Principle (里氏替换原则)
- Interface Segregation Principle (接口隔离原则)
- Dependency Inversion Principle (依赖倒置原则)
单一职责原则
一个类应该只有一个引起它变化的原因。
我知道您明白它的意思。但我不确定;所以请跟着我,看看我是否正确。让我们看一个例子:
在类图中,我指向SwitchForFanLight
类。它有两个依赖项:light
和fan
。
我故意选择了这个令人困惑的例子来给您设计方面的想法。它介于好设计和坏设计之间。在这种设计场景下,Fan
和Light
与SwitchForFanLight
类是松耦合的。您想看看如何实现吗?好吧,让我们看看下面的SwitchForFanLight
类的实现,从技术上讲,它没有问题。我们可以注入ISwitch
接口的任何实现。
再次,如果您看到Fan
和Light
类的实现,那么我们就做得很好。
好了,废话不多说!直奔主题,我有一个开关,当我打开开关时,它会同时打开风扇和灯。同样,当我关闭开关时,它会同时关闭灯和风扇。我的意思是,一个开关同时控制风扇和灯。我省时省力。一个开关同时控制它们。我是一个好工程师。多么搞笑,嗯?
但我没有意识到,有一天我会感到太冷,不需要风扇;但需要灯。现在怎么办?如果我关掉风扇,那么我就没有灯了。另一天,外面阳光明媚,照亮了我的房间。所以我不需要灯,我关掉了灯,风扇也停了。但我需要风扇来调节温度。天气太热了。
醒悟
现在我在这个设计上遇到了问题。因为一个开关同时有两个职责。如果我们打开开关,那么fan
对象将调用一个方法,light
对象将调用另一个方法。同样,如果light
类发生任何事情,比如实现错误或编译时错误,那么light
和fan
都会受到影响。所以,我在SwitchForFanLight
类中违反了单一职责原则。
所以,现在我们怎么办。我被迫改变我的实现。现在我把开关分开了,一个开关用于风扇,另一个开关用于灯。酷吧?
我学到了一个类应该只有一个职责,并且只有一个引起它变化的原因。顺便说一句,这就是‘单一职责’原则。
开闭原则
它指出:“模块和方法应该对扩展开放,对修改关闭。”
WAIT原则 -
免责声明:我是一个懒惰的开发人员,总是偏爱捷径。所以,我想使用我之前的例子。这样,我既不需要实现另一个项目,也不需要再次解释。我不想给您的大脑增加压力。好吧,现在我们准备继续了。
在我之前的例子中,我们一直在处理SwitchForFanLight
类的问题,而且我不想再使用这个类了。但我没有时间去更改它,甚至不知道我在哪里使用了它的引用。如果我现在更改它,我不知道它会受到什么影响。所以,对我来说,最好是把它放在一边,让它过去。
不过,当时,当我遇到问题时,我扩展了我的设计,创建了另外两个类:SwitchForFan
和SwitchForLight
。
这对我来说很容易,因为我有一个基接口ISwitch
,并且在SwitchForFan
和SwitchForLight
类中,我将ISwitch
接口继承为基类。
这意味着我根据当前的需求扩展了SwitchForFan
和SwitchForLight
类,而我根本没有修改旧的SwitchForFanLight
类。
现在看看下面的图片。有一个SwitchBoardForBadDesign
类,它包含两个方法:TurnOn
和TurnOff
。这两个方法都有一些if
和else
-If
语句,用于根据SwitchType
打开或关闭灯、风扇或两者。
现在回到重点,将来,如果您需要ISwitch
的另一种实现,那么您需要在TurnOn
和TurnOff
方法中添加另一个If
-Else
语句。所以,您就完了。这是对开闭原则的违反。因为它迫使您修改您的代码。
那么,这些问题的解决方案是什么呢??
解决方案
让我们看看下面的abstract switch class
。
所以我修复了SwitchBoardForGoodDesign
类中的那个问题。在这个类中,我继承了abstract Switch class
并重写了TurnOn
和TurnOff
方法。
现在,将来,如果我们添加ISwitch
的另一种实现,比如GreenLight
或RedLight
,那么我们就不需要修改SwitchBoardForGoodDesign
类了。所以,它现在对修改关闭,同时我们也可以在将来扩展我们的ISwitch
。
这就是开闭原则的工作方式。它帮助我摆脱了那个问题。
里氏替换原则
它指出:“派生类必须可以替换其基类。”
确保新派生类在不改变其行为的情况下扩展基类。
我想分享一个我在这条原则上最糟糕的经历。但是,我又是一个懒惰的开发人员。所以,别担心,我不会给您施加压力。
在我日常生活中,我习惯于英国的制度。但现在我在美国。所以我接手了一个项目,我的工作非常简单。我只需要扩展功能。在开发过程中,他们已经告诉我,我将得到一个abstract
类,它的名字是‘Switch
’。
他们有另一个类kSwitch
,我的任务是实现UsaSwitch
。请原谅kSwitch
。这是一个糟糕的命名约定。但我只是用它来举例。
我包含了类图。
K-switch的实现已经给出。但我不需要看它的实现;因为我知道开关是如何工作的。
对我来说,实现非常简单,我完成了。所以,单元测试之后,我把它送给了QA团队。但当他们开始测试时,他们发现了一个bug。他们告诉我,它没有按他们的预期工作。他们解释说,当他们打开开关时,电力没有通过。同样,当他们关闭开关时,电力也没有停止流动。
然后,我阅读了文档,了解了开关的行为,我明白了,我改变了基类的行为。
UsaSwitch
类的实现方式如下:
最后,我了解到这是对里氏替换原则的违反。因此,在美国,Turn-Up表示开始供电。Turn-Down表示停止供电。在英国,Turn-Up表示停止供电。Turn-Down表示开始供电。
所以,在扩展期间不要改变基类的含义。您不会收到任何编译错误,但它可能会在运行时破坏您的代码。
接口隔离原则
它指出:“客户不应该被迫依赖他们不使用的接口。”
假设我们有一个IAnimal interface
。毫无疑问,Moose
、RedDeer
、Dog
和Cat
都是动物。让我们看看它们的类图。
IAnimal interface
具有以下属性:
Moose
和RedDeer
模型类是从IAnimal interface
实现的。
同样,Dog
和Cat
模型类也从IAnimal interface
实现的。
现在的问题是Moose
和RedDeer
有角;但是Dog
和Cat
没有角。IAnimal interface
强制实现SizeOfHorns
属性。所以,这对Dog
和Cat
类来说是接口隔离原则的违反。尽管我们对Moose
和RedDeer
类没有异议。
因此,根据该原则,我们必须从IAnimal interface
中删除SizeOfHorns
,然后Dog
和Cat
类就会正常。另一方面,我们将SizeOfHorns
属性添加到Moose
和RedDeer
的实现类中,或者根据我们的设计计划将其添加到其他地方。
依赖倒置原则
现实生活场景
为了方便理解,假设您有一个不同的数据访问层(DAL)实现。例如,IDataAccess
有两个实现,它们是DataAccessForSql
和DataAccessForOracle
。根据客户,有时您需要DataAccessForSql
,有时您需要DataAccessForOracle
。但如果您的类是紧耦合的,那么紧耦合设计在这种情况下将无法帮助您。
同样,您有多个支付系统,如PayPal、Visa和MasterCard。您需要根据支付系统实现不同的业务逻辑。例如,假设IPayment
有一些实现,如PaymentForMasterCard
、PaymentForPayPal
和PaymentForVisa
。所以,您需要根据客户的偏好在它们之间切换。紧耦合设计不会为您提供这些功能。
因此,解决方案是依赖倒置。
场景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。然后您将看到您的订单列表。
简而言之,各层的工作如下:
- PL依赖于BL
- BL依赖于DAL
所以,高层依赖于低层。假设它们是紧耦合的层。因此,我们无法在运行时更改这种依赖关系,或者如果我们有DAL或BLL的多个实现,那么会发生什么?我们能改变层之间的紧耦合依赖关系吗?
DI的解决方案
它指出:“高层不应该依赖低层。两者都应该依赖抽象。”
所以,我们可以将DAL的任何实现注入到BL,并将BL的任何实现注入到PL。设计方案很棒,不是吗?
查看类图,我们在其中实现了从interface IDataAccess
实现的DataAccessForSQL
、DataAccessForOracle
类。我们还实现了从接口IProductBusinessLogic
实现的ProductBusinessLogic
类。
现在,我们将通过构造函数注入将DataAccessForSQL
或DataAccessForOracle
之一注入到ProductBusinessLogic
中。
最后,我们通过构造函数注入创建了dataAccess
对象,该对象根据运行时输入是通过DataAccessForSQL
或DataAccessForOracle
创建的,然后dataAccess
对象通过构造函数注入到productBl
对象中。请看下面的代码。
天哪!您添加了越来越多的描述。好吧,我在这里停止。我甚至没有时间审查它。如果您不理解,请告诉我。我附上了项目和源代码。查找附件。
请记住一件事,这些都只是原则,而不是像神圣书籍那样的硬性规定。所以,放松并尽力而为,设计就会找到自己的方向。