使用简单 C# 示例的 SOLID 架构原则






4.91/5 (481投票s)
在本文中,我们将尝试使用简单的 C# 示例来理解 SOLID 架构原则。
目录
- 什么是 SOLID?
- 理解“S” - SRP(单一职责原则)
- 理解“O” - 开闭原则
- 理解“L” - LSP(里氏替换原则)
- 理解“I” - ISP(接口隔离原则)
- 理解“D” - 依赖反转原则。
- 回顾 SOLID 原则
引言
我知道关于这个主题的文章有数千篇,而且每个月都会有十篇关于 SOLID 的新文章出现。我写这篇文章的目的是通过简单的 C# 示例来理解 SOLID。
对本文的任何改进都可以在下面的评论框中提出。
什么是 SOLID?
SOLID 是有助于创建良好软件架构的五个基本原则。SOLID 是一个首字母缩略词,其中:-
- S 代表 SRP(单一职责原则)
- O 代表 OCP(开闭原则)
- L 代表 LSP(里氏替换原则)
- I 代表 ISP(接口隔离原则)
- D 代表 DIP(依赖反转原则)
那么让我们开始用简单的 C# 示例来理解每个原则。
理解“S” - SRP(单一职责原则)
理解 SOLID 的最佳方式是理解它试图解决的问题。看看下面的代码,你能猜出问题出在哪里吗?(你猜对了也不会得到啤酒 J,因为它太简单了)。
好的,让我给个提示,看看 catch 块代码。
class Customer { public void Add() { try { // Database code goes here } catch (Exception ex) { System.IO.File.WriteAllText(@"c:\Error.txt", ex.ToString()); } } }
上面的客户类正在做 **它不应该做的事情**。客户类应该做客户数据验证,调用客户数据访问层等,但是如果你仔细观察 catch 块,它也正在进行日志记录活动。简单来说,它承担了过多的职责。
所以,如果明天我添加一个像事件查看器这样的新日志记录器,我需要去修改“Customer”类,这非常奇怪。
这就像“JOHN”有问题,为什么我需要检查“BOB”。
这也让我想起了著名的瑞士军刀。如果其中一个需要更换,整个套装都需要被扰乱。我不是说瑞士军刀不好,我是它的忠实粉丝。
但是,如果我们可以将这些项目分开,那么它们就简单、易于维护,一个更改不会影响另一个。同样的原则也适用于软件架构中的类和对象。
所以SRP说一个类应该只有一个职责而不是多个职责。所以如果我们应用SRP,我们可以将日志记录活动移动到另一个只负责日志记录活动的类。
class FileLogger { public void Handle(string error) { System.IO.File.WriteAllText(@"c:\Error.txt", error); } }
现在,客户类可以高兴地将日志记录活动委托给“FileLogger”类,而他可以专注于与客户相关的活动。
class Customer { private FileLogger obj = new FileLogger(); publicvirtual void Add() { try { // Database code goes here } catch (Exception ex) { obj.Handle(ex.ToString()); } } }
现在,架构的思考过程是一个演变。对于一些资深人士来说,看到上面的 SRP 示例可能会反驳说,即使 try catch 也不应该由客户类处理,因为那不是它的工作。
是的,我们可以创建一个全局错误处理程序,它必须在 Global.asax 文件中(假设您正在使用 ASP.NET),并在这些部分处理错误,使客户类完全自由。
所以我将把你可以走多远并让这个解决方案变得更好留给你,但现在我想保持简单,让你的思想自由地将其提升到一个更高的水平。
下面是一个很棒的评论,它讨论了我们如何将这个 SRP 示例提升到新的水平。
https://codeproject.org.cn/Articles/703634/SOLID-architecture-principles-using-simple-Csharp?msg=4729987#xx4729987xx
理解“O” - 开闭原则
让我们继续使用相同的客户类示例。我给这个类添加了一个简单的客户类型属性。这个属性决定了这是“黄金”客户还是“白银”客户。
根据此属性,它计算折扣。看看“getDiscount”函数,它相应地返回折扣。黄金客户为 1,白银客户为 2。
猜猜,下面的代码有什么问题?哈哈哈,看来这篇文章会让你成为一个猜谜冠军;-)。
好的,再给我一个提示,看看“getDiscount”函数中的“IF”条件。
class Customer { private int _CustType; public int CustType { get { return _CustType; } set { _CustType = value; } } public double getDiscount(double TotalSales) { if (_CustType == 1) { return TotalSales - 100; } else { return TotalSales - 50; } } }
问题是,如果我们添加新的客户类型,我们需要在“getDiscount”函数中再添加一个“IF”条件,换句话说,我们需要更改客户类。
如果我们一次又一次地修改客户类,我们需要确保新条件与旧条件一起再次测试,并且引用此类的现有客户端仍然像以前一样正常工作。
换句话说,我们正在为每次更改“修改”当前的客户代码,并且每次修改时,我们都需要确保所有以前的功能和连接的客户端都像以前一样工作。
我们不“修改”而选择“扩展”怎么样?换句话说,每次需要添加新的客户类型时,我们都创建一个新类,如下图所示。这样,当前的代码保持不变,我们只需要测试和检查新类。
class Customer { public virtual double getDiscount(double TotalSales) { return TotalSales; } } class SilverCustomer : Customer { public override double getDiscount(double TotalSales) { return base.getDiscount(TotalSales) - 50; } }
class goldCustomer : SilverCustomer { public override double getDiscount(double TotalSales) { return base.getDiscount(TotalSales) - 100; } }
简单来说,“Customer”类现在对任何新的修改都是关闭的,但当新的客户类型添加到项目中时,它对扩展是开放的。
理解“L” - LSP(里氏替换原则)
让我们继续使用同一个客户。假设我们的系统需要计算询价的折扣。现在,询价不是实际客户,它们只是潜在客户。因为它们只是潜在客户,所以我们暂时不想将它们保存到数据库中。
所以我们创建了一个名为 Enquiry 的新类,它继承自“Customer”类。我们为询价提供了一些折扣,以便它们可以转换为实际客户,并且我们用一个异常覆盖了“Add”方法,这样没有人可以将询价添加到数据库中。
class Enquiry : Customer { public override double getDiscount(double TotalSales) { return base.getDiscount(TotalSales) - 5; } public override void Add() { throw new Exception("Not allowed"); } }
如果你想象一下当前的客户继承层次结构,它看起来如下图所示。换句话说,“Customer”是父类,“Gold”、“Silver”和“Enquiry”是子类。
所以根据多态性规则,我的父“Customer”类对象在运行时可以指向其任何子类对象,即“Gold”、“Silver”或“Enquiry”,没有任何问题。
因此,例如在下面的代码中,你可以看到我创建了一个“Customer”的列表集合,并且由于多态性,我可以将“Silver”、“Gold”和“Enquiry”客户添加到“Customer”集合中,没有任何问题。
由于多态性,我还可以使用父客户对象浏览“Customer”列表并调用“Add”方法,如下面的代码所示。
现在再次让我挠挠你的大脑,这里有一个小问题,思考,思考,思考。
提示: - 观察“FOR EACH”循环中浏览和调用 Enquiry 对象的时间。
List<Customer> Customers = new List<Customer>();
Customers.Add(new SilverCustomer());
Customers.Add(new goldCustomer());
Customers.Add(new Enquiry());
foreach (Customer o in Customers)
{
o.Add();
}
}
根据继承层次结构,“Customer”对象可以指向其任何一个子对象,我们不期望任何异常行为。
但是当调用“Enquiry”对象的“Add”方法时,它会导致以下错误,因为我们的“Enquiry”对象不会将询价保存到数据库,因为它们不是实际客户。
现在请仔细阅读下面的段落以理解问题。如果你不理解下面的段落,请阅读两遍J..
换句话说,“Enquiry”有折扣计算,它看起来像一个“Customer”,但 **它不是一个 Customer。** 所以父类不能无缝地替换子对象。换句话说,“Customer”不是“Enquiry”类的实际父类。“Enquiry”是一个完全不同的实体。
所以 LISKOV 原则说父类应该能够轻松替换子对象。为了实现 LISKOV,我们需要创建两个接口,一个用于折扣,另一个用于数据库,如下图所示。
interface IDiscount { double getDiscount(double TotalSales); } interface IDatabase { void Add(); }
现在“Enquiry”类将只实现“IDiscount”,因为它对“Add”方法不感兴趣。
class Enquiry : IDiscount { public double getDiscount(double TotalSales) { return TotalSales - 5; } }
而“Customer”类将同时实现“IDiscount”和“IDatabase”,因为它也希望将客户持久化到数据库。
class Customer : IDiscount, IDatabase { private MyException obj = new MyException(); public virtual void Add() { try { // Database code goes here } catch (Exception ex) { obj.Handle(ex.Message.ToString()); } } public virtual double getDiscount(double TotalSales) { return TotalSales; } }
现在没有混淆了,我们可以创建一个“IDatabase”接口的列表,并将相关的类添加到其中。如果我们错误地将“Enquiry”类添加到列表中,编译器会抱怨,如下面的代码片段所示。
理解“I” - ISP(接口隔离原则)
现在假设我们的客户类已经成为一个超级热门组件,并且它被 1000 个客户端使用,他们非常高兴地使用这个客户类。
现在,假设一些新客户提出要求,说我们还需要一个方法来帮助我们“读取”客户数据。因此,那些高度热情的开发人员会希望修改“IDatabase”接口,如下图所示。
但是这样做我们做了一件可怕的事情,你能猜到吗?
提示: - 思考一下这个改变对上面图片的影响。
interface IDatabase { void Add(); // old client are happy with these. voidRead(); // Added for new clients. }
如果你想象一下新出现的需求,你有两种类型的客户:-
- 只希望使用“Add”方法的人。
- 另一个希望使用“Add”+“Read”的人。
现在,通过更改当前接口,你正在做一件可怕的事情,打扰了 1000 个满意的现有客户,即使他们对“Read”方法不感兴趣。你正在强迫他们使用“Read”方法。
所以更好的方法是让现有客户在他们自己的美好世界里,并单独服务新客户。
因此,更好的解决方案是创建一个新接口,而不是更新现有接口。我们可以保持当前接口“IDatabase”不变,并添加一个带有“Read”方法的新接口“IDatabaseV1”,“V1”代表版本1。
interface IDatabaseV1 : IDatabase // Gets the Add method { Void Read(); }
你现在可以创建实现“Read”方法的新类,以满足新客户的需求,而你的老客户则不受影响,并对没有“Read”方法的老接口感到满意。
class CustomerwithRead : IDatabase, IDatabaseV1 { public void Add() { Customer obj = new Customer(); Obj.Add(); } Public void Read() { // Implements logic for read } }
所以老客户将继续使用“IDatabase”接口,而新客户可以使用“IDatabaseV1”接口。
IDatabase i = new Customer(); // 1000 happy old clients not touched i.Add(); IDatabaseV1 iv1 = new CustomerWithread(); // new clients Iv1.Read();
理解“D” - 依赖反转原则
在我们的客户类中,如果你还记得,我们创建了一个日志类来满足 SRP。后来,假设又创建了新的日志类。
class Customer { private FileLogger obj = new FileLogger(); public virtual void Add() { try { // Database code goes here } catch (Exception ex) { obj.Handle(ex.ToString()); } } }
为了控制,我们创建了一个通用接口,并使用这个通用接口来创建新的日志类。
interface ILogger { void Handle(string error); }
以下是三种日志类,以后还可以添加更多。
class FileLogger : ILogger { public void Handle(string error) { System.IO.File.WriteAllText(@"c:\Error.txt", error); } }
class EverViewerLogger : ILogger { public void Handle(string error) { // log errors to event viewer } }
class EmailLogger : ILogger { public void Handle(string error) { // send errors in email } }
现在,根据配置设置,在给定时刻将使用不同的日志类。为了实现这一点,我们设置了一个简单的 IF 条件来决定使用哪个日志类,请看下面的代码。
测验时间,这里有什么问题。
提示: - 观察 CATCH 块代码。
class Customer : IDiscount, IDatabase { private IException obj; public virtual void Add(int Exhandle) { try { // Database code goes here } catch (Exception ex) { if (Exhandle == 1) { obj = new MyException(); } else { obj = new EmailException(); } obj.Handle(ex.Message.ToString()); } }
上述代码再次违反了 SRP,但这次的方面不同,它是关于决定应该创建哪些对象。现在,“Customer”对象的工作不是决定创建哪些实例,它应该只专注于与客户类相关的功能。
如果你仔细观察,最大的问题是“NEW”关键字。他承担了创建哪个对象的额外责任。
所以,如果我们将这个责任“反转/委托”给其他人,而不是让客户类来做,那将真的在一定程度上解决问题。
所以这是实现了“反转”的修改后的代码。我们打开了构造函数的“口子”,我们期望别人传入对象,而不是客户类自己创建。所以现在由使用客户对象的客户端来决定注入哪个 Logger 类。
class Customer : IDiscount, IDatabase { private Ilogger obj; public Customer(ILogger i) { obj = i; } }
所以现在客户端将注入 Logger 对象,而 Customer 对象现在摆脱了那些决定注入哪个 Logger 类的 IF 条件。这是 SOLID 依赖反转原则的最后一个原则。
客户类将依赖对象的创建委托给了使用它的客户端,从而使客户类能够专注于自己的工作。
IDatabase i = new Customer(new EmailLogger());
回顾 SOLID 原则
S 代表 SRP(单一职责原则):- 一个类应该只负责一项职责。
O 代表 OCP(开闭原则):- 应该优先考虑扩展而不是修改。
L 代表 LSP(里氏替换原则):- 父类对象在运行时多态性中应该能够无缝地引用子对象。
I 代表 ISP(接口隔离原则):- 如果客户端不需要,则不应强迫其使用接口。
D 代表 DIP(依赖反转原则):- 高层模块不应依赖于低层模块,而应依赖于抽象。
如果你已经完成了这篇文章,那么下一步的逻辑步骤是学习 GOF 设计模式,这里有一篇关于它的文章,希望你喜欢。
如需进一步阅读,请观看以下面试准备和分步教程视频:
- 软件架构面试问题
- C# 设计模式分步教程
- 关联 vs 组合 vs 聚合
- 垃圾回收面试问题
- 栈 vs 堆,装箱 vs 拆箱,值类型 vs 引用类型
- Azure DevOps 分步教程
- SQL Server 教程
- MSBI 安装分步指南
- C# is vs As 关键字
- C# throw vs throw ex
- C# 并发 vs 并行
- C# 字符串是不可变的
访问我的个人资料以获取更多视频。