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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (481投票s)

2013年12月30日

CPOL

11分钟阅读

viewsIcon

2057977

在本文中,我们将尝试使用简单的 C# 示例来理解 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 设计模式,这里有一篇关于它的文章,希望你喜欢。

如需进一步阅读,请观看以下面试准备和分步教程视频:

访问我的个人资料以获取更多视频。

© . All rights reserved.