我们已经看到了构成面向对象语言的构建块,但是我们如何编写我们的类使它们有意义呢?什么时候继承,一个类里面放什么,我们需要多少接口?这些问题可以用所谓的固体原理来部分回答。固体是首字母缩略词,每个字母代表一个原则。
单一责任原则
开放封闭原则(OCP)
利斯科夫替代原则
界面分离原理
依赖性反演原理
在这一章中,我们将看所有这些。了解这些原则可以帮助您编写易于维护和扩展的模块化代码。罗伯特·C·马丁在他 2006 年的著作《c#【1】中描述了这些原则。
单一责任原则,或称 SRP,规定一个类应该对单一功能负责。一个功能可以被认为是一个改变的理由。例如,将错误记录到数据库中的类可能会因为数据库通信应该更改,或者因为日志输出的格式需要更改而更改。所以更一般地说,一个类应该只有一个改变的理由。这有点棘手;毕竟,每一行代码都可以随时更改。事实上,我遇到过一些程序员,他们认为一个类应该只包含一个方法。然而,这真的不是 SRP 的意义所在!
我们来看一个具体的例子。假设您正在构建一个数据库模块。现在您已经有了一个类,它可以建立到数据库的连接,获取和发送数据,最后关闭连接。如果我们定义一个接口,它可能如下所示:
代码清单 23:可能违反 SRP
public interface IDatabase
{
void Connect(string connectionString);
void Close();
object GetData();
void SendData(object data);
}
所以这可能真的是你的类的接口,对吗?不幸的是,这个类正在做两件事。该课程涉及打开和关闭连接以及数据通信。棘手的部分来了,这可能是个问题,也可能不是。你应该问自己这样一个问题:应用程序会改变连接或数据通信,但不会两者都改变吗?这很重要,因为当时间到了,你意识到你需要改变这两个功能中的一个时,不需要改变的功能就会碍事。阻碍的功能越多,就越有可能破坏应用程序的这些部分。
当应用 SRP 时,您的类将变得更小,更容易维护,但是您将有更多的类要处理。200 行代码做多件事的类和 100 行代码只做一件事的类,你更愿意改变哪一个?
如你所见,SRP 中没有对错。不幸的是,这有点直觉。如果你觉得一个班级太臃肿,试着确定不同的职责,给每个人一个自己的班级。
毫无疑问,最难把握和正确的原则之一是开放封闭原则,即 OCP 原则。“开放关闭”意味着一个类应该开放扩展,但是关闭修改。这意味着您的类的消费者应该能够定制它的用法,但实际上并不改变它的代码。除非你的同事和你的类的消费者在同一个解决方案中工作,否则即使他们也不可能改变你的类的源代码。
假设您已经构建了我们刚才谈到的那个数据库类。从某种意义上说,它已经是可扩展的,因为每个人都可以从它继承并添加方法和属性。这样做几乎肯定会导致 SRP 的破坏,因为类的核心功能已经在基类中定义了,我们不能改变这一点。您可以将基类中的每个方法都虚拟化,我已经看到了这种情况,但是这并不是真正的好做法,因为消费者现在可能会破坏您的类。例如,如果它们重写 Connect 并将其留空,将不会建立任何连接,并且对您的类的每个后续方法调用都将失败。此外,即使方法被正确重写,基类现在也只是坐在那里什么也不做,这感觉不对。
我提出了一个不同的解决方案,这是众多方案之一。让我们将之前提出的接口分成两个独立的接口。您现在可能需要更多的方法,但是让我们暂时保持简单。
代码清单 24:OCP 的 SRP
public interface IConnectionManager
{
void Connect(string connectionString);
void Close();
}
public interface IDataManager
{
object GetData(IConnectionManager connManager);
void SendData(IConnectionManager connManager, object data);
}
现在假设我想更改连接到数据库的方式。我可以实现自己的 IConnectionManager,并将其传递给我的 IDataManager。在不接触原始代码的情况下,我现在能够扩展类的功能,而无需实际破坏已经存在的类!同样,如果我想改变获取或发送数据的方式,我可以简单地实现我自己的 IDataManager,并将其与已经存在的 IConnectionManager 实现一起使用。
不过,会好起来的。依赖于 IConnectionManager 和/或 IDataManager 的每个类现在都是可扩展的,因为它可以使用你能想象到的任何数据库!理论上来说。
稍后我们将看看设计模式,它可以帮助你设计符合 OCP 的类。
利斯科夫替代原则,简称 LSP,是关于继承的。它规定子类和基类必须是可替换的。这意味着任何使用任何基本类型的对象的方法都不能知道它使用的是基类还是子类。这里有一个你不会做的事情的例子(尽管我必须承认我以前也写过这样的代码):
代码清单 25:违反 LSP
bool TestConnection(IConnectionManager connMngr)
{
if (connMngr is SqlServerConnectionManager)
{
// Do something...
}
else if (connMngr is OracleConnectionManager)
{
// Do something else...
}
else
{
// ...
}
}
TestConnection 是一个接收 IConnectionManager 类型的对象的函数,该对象可以是任何连接管理器。在这种情况下,该方法检查 connMngr 的类型,并相应地采取行动。这意味着,对于 IConnectionManager 的每个新实现,您都需要更改这个方法。此外,您刚刚违反了 OCP,因为这个函数现在无法测试新的连接类型。有很多方法可以解决这个问题,比如使用策略模式,但是我们将在后面的章节中讨论这个问题。在上面的方法中,调用 Connect()或 Close()方法应该总是足够好的,不管您得到什么样的 IConnectionManager。
LSP 的一个更微妙的违反是当子类的行为不同于它们的基类时。经典的例子是从矩形类继承而来的正方形类。正方形是矩形(宽度和高度相等),所以这种继承看起来似乎是合理的。不幸的是,在某些场景中,正方形的行为可能与矩形不同,并且可能会打断矩形类的用户,取而代之的是正方形。
代码清单 26:用正方形分隔
void TestSquareMeters(Rectangle rect)
{
rect.Height = 2;
rect.Width = 3;
Assert(rect.Height * rect.Width == 6);
}
接口隔离原则,或称 ISP,有点像接口的单一责任原则。严格来说,我们不是在讨论类似抽象类的接口,而是你的类的公共方法。然而,在 C#实践中,我们主要通过接口来实现 ISP。让我澄清一下。让我们考虑一下我们的数据库示例。我们有以下界面:
代码清单 27:一个数据库接口
public interface IDatabase
{
void Connect(string connectionString);
void Close();
object GetData();
void SendData(object data);
}
正如我在关于 SRP 的部分中提到的,这可能是也可能不是 SRP 防护,这取决于您的要求。假设我们的例子可以,但是让我们采取另一种方法,即继承。更具体地说,我将为连接管理创建一个基类,并为我们的数据管理继承它。
代码清单 28:使用继承的数据库管理器
public class ConnectionManager
{
public void Connect(string connectionString)
{
// ...
}
public void Close()
{
// ...
}
}
public class DataManager : ConnectionManager
{
public virtual object GetData()
{
// ...
}
public virtual void SendData(object data)
{
// ...
}
}
public class DatabaseManager : DataManager
{
// Needs ConnectionManager...
}
因此,我们的数据库管理器通过数据管理器继承了连接管理器,当然是为了利用它的基本实现,但也是为了利用多用途功能,比如前面定义的测试连接。我们能确定我们所有的数据管理器都需要连接管理器吗?当然,数据库管理器需要它,但是我们的文件数据管理器呢?
代码清单 29:文件数据管理器
public class FileDataManager : DataManager
{
// Doesn't need ConnectionManager...
}
现在我们的文件数据管理器有了一个胖接口。这取决于它不需要的课程!请注意,如果我们只是创建了两个接口并实现了它们,这是不会发生的。我们甚至可以使用组合在其他数据(基础)管理器中重用连接管理器。这样做的缺点是,文件数据管理器现在依赖于连接管理器,尽管它并不需要它。对连接管理器的任何更改现在都可能会破坏文件数据管理器!在可能的情况下,应避免类与类之间的这种耦合。
依赖倒置原则,简称 DIP,简单地说你应该依赖抽象而不是具体的类型。这是一个你会看到很多的原则,但是很难付诸实践(正确)。让我们再次考虑我们的测试连接方法。我们可以为我们的 SqlDatabaseManager 创建一个,如下例所示:
代码清单 30:违反 DIP
class Program
{
//...
bool TestConnection(SqlConnectionManager connMngr)
{
// ...
}
}
public interface IConnectionManager
{
void Close();
void Connect(string connectionString);
}
public class SqlConnectionManager : IConnectionManager
{
// ...
}
很明显,TestConnection 现在只能用于 SqlConnectionManager。我们制造了两个问题。首先,测试连接和使用它的程序现在依赖于 SqlConnectionManager。其次,我们必须为将要编写的每个连接管理器编写一个测试连接。我们说 SqlConnectionManager 是抽象类型 IConnectionManager 的具体类型。让我们解决这个问题,这样我们就可以依赖符合 DIP 的抽象类型。
代码清单 31:修复了 DIP
bool TestConnection(IConnectionManager connMngr)
{
// ...
}
你可能认为这是一个非常蹩脚的解决方案,你甚至可以说这将破坏你现有的代码,因为测试连接依赖于不属于图标连接管理器接口的方法。如果是这样,你应该问自己三件事。TestConnection 实现是否正确,是否真的只测试连接?我的 SqlConnectionManager 真的是 IConnectionManager 吗?最后,IConnectionManager 的定义是否正确?
为什么这个原则如此重要?它与扩展和改变现有系统有关。如果依赖于抽象,切换实现会更容易。理论上,您可以切换您的数据库(或者至少是连接管理器),并且测试连接仍然会像预期的那样运行。我们之前已经看到过日志记录程序,依赖于 ILogger,您可以毫无问题地从文件日志记录切换到数据库日志记录!
正如我所说,你会经常看到 DIP。也许你以前听说过依赖注入?有很多“DI 框架”可以帮助你将 DIP 付诸实践。前面的例子很简单,将我们的方法签名改为使用接口而不是具体的类型。但是任何方法在运行时仍然会得到一个具体的类型,那么这是从哪里来的呢?我们将不得不在某个地方打破这个“全抽象类型”的原则!这就是直接投资的作用。
对于下一个例子,我将使用 Unity,这是微软开发的阿迪库,现在由其他人维护。您可以使用 Visual Studio 中的“获取包管理器”来获取它。只需转到工具>获取软件包管理器>管理获取解决方案软件包,然后搜索“统一”将它安装到您的(保存的)项目中,您就可以开始了。
Unity 提供的“DI 容器”是一种存储库,抽象类型映射到具体类型。然后,任何代码都可以向存储库请求抽象类型的实例,存储库将返回您映射到它的任何具体类型。除了映射类型的部分之外,您的整个代码库现在可以依赖于抽象。听起来很整洁,所以让我们看看一些代码!
我稍微修改了之前的 IConnectionManager 示例:
代码清单 32:图标连接管理器
public interface IConnectionManager
{
void Close();
void Connect();
}
public class SqlConnectionManager : IConnectionManager
{
public void Close()
{
Console.WriteLine("Closed SQL Server connection...");
}
public void Connect()
{
Console.WriteLine("Connected to SQL Server!");
}
}
public class OracleConnectionManager : IConnectionManager
{
public void Close()
{
Console.WriteLine("Closed Oracle connection...");
}
public void Connect()
{
Console.WriteLine("Connected to Oracle!");
}
}
下面是测试连接的定义:
代码清单 33:测试连接的定义
static bool TestConnection(IConnectionManager connMngr)
{
connMngr.Connect();
connMngr.Close();
return true;
}
使用 Unity,我们可以向 IConnectionManager 注册 SqlConnectionManager 或 OracleConnectionManager:
代码清单 34:向 Unity 注册一个类型
class Program
{
static void Main(string[] args)
{
// Create a new DI Container...
IUnityContainer container = new UnityContainer();
// ...And register a type!
container.RegisterType<IConnectionManager, SqlConnectionManager>();
}
}
在代码的其他地方,我们现在可以请求一个图标连接管理器并调用测试连接:
代码清单 35:用 Unity 解析一个类型
IConnectionManager connMngr = container.Resolve<IConnectionManager>();
bool success = TestConnection(connMngr);
// Do something with the result...
您现在应该看到“已连接到 SQL Server!”和控制台窗口中的“已关闭的 SQL Server 连接...”。现在更改对 RegisterType 的调用,使其注册为 OracleConnectionManager。测试连接现在将打印甲骨文,而不是 SQL Server。想象一下:通过更改这一行代码,您可以将整个应用程序更改为使用 Oracle 而不是 SQL Server!
现在假设您有一些动态用户界面,屏幕上显示的控件取决于一些设置和/或配置。假设我们在数据库中定义了一些 HTML 表单,我们希望生成 HTML 服务器端。我会让你自己解决这个问题,但我很肯定你会明白的。
代码清单 36: HTML 生成器
class Program
{
static void Main(string[] args)
{
IUnityContainer container = new UnityContainer();
container.RegisterType<IHtmlCreator, HtmlInputTextCreator>("text");
container.RegisterType<IHtmlCreator, HtmlInputCheckboxCreator>("checkbox");
var someDbFields = new[]
{
new
{
Text = "Please enter your name:",
Type = "text"
},
new
{
Text = "Do you wish to receive
offers?",
Type = "checkbox"
}
};
// Generate the HTML...
StringBuilder builder = new StringBuilder();
foreach (var dbField in someDbFields)
{
builder.AppendLine($"<p>{dbField.Text}</p>");
IHtmlCreator html = container.Resolve<IHtmlCreator>(dbField.Type);
builder.AppendLine(html.CreateHtml());
}
Console.WriteLine(builder.ToString());
Console.ReadKey();
}
}
public interface IHtmlCreator
{
string CreateHtml();
}
public class HtmlInputTextCreator : IHtmlCreator
{
public string CreateHtml()
{
return "<input type=\"text\" />";
}
}
public class HtmlInputCheckboxCreator : IHtmlCreator
{
public string CreateHtml()
{
return "<input type=\"checkbox\" />";
}
}
看看这是如何为您省去了一个笨拙的 switch/if 语句?最棒的是,您可以添加新的 IHtmlCreators 和类型,而无需更改生成 HTML 的代码!所以你刚刚创建了符合 OCP 和 DIP 的代码(除了
标签,但是你可以理解这一点)!
这不是关于使用 Unity 的教程,但它确实展示了 DIP 和 DI 容器的基本原理。本节中的示例演示了什么叫做接口注入,但是也可以使用指定的构造函数创建对象,称为构造函数注入,并在属性上自动设置对象,称为属性或设置器注入。其他 DI 框架也存在,比如 Ninject 和 Spring.NET。在这个例子中,我们已经编写了设置容器的代码,但是其他框架也使用(XML)配置文件。所有这些都遵循相同的原则,并且依赖于抽象。
经常与 DI 一起被提及的是控制反转,或 IoC。你甚至可以看到这些术语可以互换使用。IoC 的基本原则是“更高”的代码依赖于抽象。我们已经在测试连接中看到了这一点。这是另一个有趣的事实:DI 实际上是 IoC 的一种形式(尽管不一定是反过来的)!
一般来说,SOLID Principles 可以以额外的复杂性为代价,带来良好的、可维护的、可扩展的和可测试的软件设计。然而,它们对许多开发者来说并不自然。老实说,很难提前想好,所以如果你现在需要一个 SqlConnectionManager,为什么还要麻烦一些以后可能需要修改的 IConnectionManager 呢?简单地创建一个 SqlConnectionManager 并在整个代码中使用它要容易得多。然而,你无法预测未来,即使你认为你现在永远不会切换到除了 SQL Server 之外的东西,未来可能会证明不是这样。当你不得不转换到其他事情时,你会希望你已经实践了依赖倒置原则。同样,当你的内部应用编程接口突然不得不公开时,你会希望你已经实践了开放关闭原则。这种事情确实会发生。“那永远不会发生”我听了太多次了,所以真的,提前想好,扎实练习!