违背 C# 中 SOLID 原则的危险

内容纲要

随着编写软件的流程从理论领域发展到实际的工程学科,许多原则也应运而生。 当我提及原则时,我所指的是计算机代码帮助维持代码价值的一项功能。 模式是指常见的代码方案(无论好坏)。

例如,您可能重视在多线程环境中安全工作的计算机代码。 您可能重视在其他位置修改代码时不会崩溃的计算机代码。 您的确可能重视计算机代码中许多有用的品质,但是每天也会遇到糟糕的代码。

首字母缩写词 SOLID 包含一些非常棒的软件开发原则。S 表示 Single responsibility(单一责任);O 表示 Open for extension and closed for modification(扩展时打开和修改时关闭);L 表示 Liskov 替换;I 表示 Interface segregation(接口分隔);D 表示 Dependency injection(依赖关系注入)。 您应该对这些原则有所了解,因为我将展示违背这些原则的各种特定的 C# 模式。 如果对 SOLID 原则不熟悉,则您可能想要先快速地回顾一下这些原则,然后再继续。 此外,我将认为您对结构术语 Model 和 ViewModel 有所了解。

我并非首字母缩写词 SOLID 和其中包含的原则的发起人。 谢谢您,Robert C。 感谢 Martin、Michael Feathers、Bertrand Meyer、James Coplien 以及其他人与我们分享智慧。 其他许多书籍和博文也对这些原则进行了探索和优化。 我希望能够帮助增强这些原则的应用。

通过与许多初级的软件工程师共事及对他们进行培训,我发现初次的专业编码工作和可持续代码之间存在巨大差距。 在本文中,我将尝试通过一种愉悦的方式弥补这种差距。 这些示例似乎有点愚笨,其旨在帮助您意识到您可以将 SOLID 原则应用于各种各样的软件。

对于有志软件工程师而言,专业的开发环境会带来诸多挑战。 您的学校教育教您从自上而下的角度思考问题。 您将采取自上而下的方法应对丰富的、企业规模的软件世界中的首次任务。 不久之后,您将发现您的顶层函数的大小将增长到难以管理。 做出最细小的更改都需要拥有整个系统的全部应用知识,而且很难及时控制。 软件原则指南(此处只提到一部分)将有助于保持结构不超出其基础。

单一责任原则

单一责任原则通常定义如下:一个对象应该只有一个更改的原因;文件或类越长,则越难实现更改。 记住该定义后,请看以下代码:

public IList<IList<Nerd>> ComputeNerdClusters(
  List<Nerd> nerds,
  IPlotter plotter = null) {
  ...
  foreach (var nerd in nerds) {
    ...
    if (plotter != null)
      plotter.Draw(nerd.Location, 
      Brushes.PeachPuff, radius: 10);
    ...
  }
  ...
}

此代码的问题到底出在哪儿? 是否编写或调试了软件? 可能该特定的绘制代码仅适用于调试。 它位于仅可被接口识别的服务中但却不属于该服务,这样很好。 画笔就是一个很好的线索。 如同桃子泡芙一样美丽和普遍,它是特定于平台的。 它超出了此计算模型的类型层次结构。 有许多方法可以将计算和相关的调试实用程序分隔开来。 最起码,您可以通过继承或事件提供必要的数据。 将测试和测试视图分隔开来。

下面是另一个错误示例:

class Nerd {
  public int IQ { get; protected set; }
  public double SuspenderTension { get; set; }
  public double Radius { get; protected set; }
  /// <summary>Get books for growing IQ</summary>
  public event Func<Nerd, IBook> InTheMoodForBook;
  /// <summary>Get recommendations for growing Radius</summary>
  public event Func<Nerd, ISweet> InTheMoodForTwink;
  public IList<Nerd> FitNerdsIntoPaddedRoom(
    IList<Nerd> nerds, IList<Point> boundary)
  {
    ...
  }
}

此代码的问题到底出在哪儿? 其融合了所谓的“学校科目”。您是否记得在学校是如何在不同的课堂上学习不同的主题的? 在代码中保持该分离很重要,不是因为它们完全无关,而是一项组织工作。 通常情况下,不要将以下项中的任意两项放在同一类中:数学、模型、语法、视图、物理或平台适配器、特定于客户的代码等。

您可以看到在学校已了解的有关雕塑、木头和金属等物件的总体分类。 它们需要测量、分析和指导等。 上一个示例混合了数学和模型(FitNerdsIntoPaddedRoom 除外)。 该方法可以轻松地移至实用程序类,甚至是静态类。 您无需实例化数学测试例程中的模型。

下面是另一个多责任示例:

class AvatarBotPath
{
  public IReadOnlyList<ISegment> Segments { get; private set; }
  public double TargetVelocity { get; set; }
  public bool IsReverse { get { return TargetVelocity < 0; } }
  ...
}
public interface ISegment // Elsewhere
{
  Point Start { get; }
  Point End { get; }
  ...
}

这儿到底出了什么问题? 很明显存在由单个对象表示的两个不同的抽象概念。 其中之一与遍历形状相关,另一个表示几何形状本身。 这在代码中很常见。 您拥有一个声明和该声明附带的独立专用参数。

此时,“继承”是个不错的工具。 您可以将 TargetVelocity 和 IsReverse 属性移至继承者,并且在简洁的 IHasTravelInfo 接口中捕捉它们。 或者,您可以向形状中添加一个普通的功能集合。 然后,需要速度的那些将查询功能集合,以查看其是否在特定的形状上进行定义。 此外,您可以使用某些其他集合机制将声明与旅行参数配对。

开闭原则

至此我们进入下一个原则:扩展时打开,修改时关闭。 如何做到? 最好不是如以下内容:

void DrawNerd(Nerd nerd) {
  if (nerd.IsSelected)
    DrawEllipseAroundNerd(nerd.Position, nerd.Radius);
  if (nerd.Image != null)
    DrawImageOfNerd(nerd.Image, nerd.Position, nerd.Heading);
  if (nerd is IHasBelt) // a rare occurrence
    DrawBelt(((IHasBelt)nerd).Belt);
  // Etc.
}

这儿到底出了什么问题? 您需要在客户每次需要显示新增内容时修改此方法,而且,客户始终需要显示新增内容。 几乎每个新软件功能都需要某类 UI 元素。 毕竟,其是提示新功能要求的现有接口中缺少的内容。 此方法中显示的模式是一个不错的线索,但是您可以将那些 if 语句移入它们所保护的方法中,并且无法解决问题。

您需要一个更好的计划,但是如何制定该计划? 它会是什么样子呢? 您拥有某些知道如何绘制特定内容的代码。 这就可以了。 您只需要一个通用程序,用于将这些内容与绘制它们的代码匹配。 最终会得到类似于下面的模式:

readonly IList<IRenderer> _renderers = new List<IRenderer>();
void Draw(Nerd nerd)
{
  foreach (var renderer in _renderers)
    renderer.DrawIfPossible(_context, nerd);
}

有其他添加到呈现器列表的方法。 然而,该代码的要点是编写实施已知接口的绘制类(或有关绘制类的类)。 呈现器必须能够决定其是否可以或应该基于输入内容绘制任何内容。 例如,带式绘制代码可以移动到其自身的“带式呈现器”,用于检查接口并视需要继续执行。

您可能需要将 CanDraw 从 Draw 方法中分离开来,但是这不会违背开闭原则或 OCP。 如果您添加了新的呈现器,则使用呈现器的代码不应发生变化。 就这么简单。 此外,您应能够按照正确的顺序添加新的呈现器。 虽然我用呈现器作为示例,但这也同样适用于处理输入内容、处理数据和存储数据。 该原则具有许多适用于所有类型的软件的应用。 虽然该模式更难在 Windows Presentation Foundation (WPF) 中模拟,但并非不可能。 请参阅图 1,了解一种可能的选择方案。

图 1 将 Windows Presentation Foundation 呈现器合并到单一来源的示例

public abstract class RenderDefinition : ViewModelBase
{
  public abstract DataTemplate Template { get; }
  public abstract Style TemplateStyle { get; }
  public abstract bool SourceContains(object o); // For selectors
  public abstract IEnumerable Source { get; }
}
public void LoadItemsControlFromRenderers(
    ItemsControl control,
    IEnumerable<RenderDefinition> defs) {
  control.ItemTemplateSelector = new DefTemplateSelector(defs);
  control.ItemContainerStyleSelector = new DefStyleSelector(defs);
  var compositeCollection = new CompositeCollection();
  foreach (var renderDefinition in defs)
  {
    var container = new CollectionContainer
    {
      Collection = renderDefinition.Source
    };
    compositeCollection.Add(container);
  }
  control.ItemsSource = compositeCollection;
}

下面是另一个错误示例:

class Nerd
{
  public void WriteName(string name)
  {
    var pocketProtector = new PocketProtector();
    WriteNameOnPaper(pocketProtector.Pen, name);
  }
  private void WriteNameOnPaper(Pen pen, string text)
  {
    ...
  }
}

这儿到底出了什么问题? 此代码的问题是又大又杂。 我要指出的主要问题是没有替换创建 PocketProtector 实例的方法。 类似于此的代码使编写继承者变得不容易。 您有几种处理这种情况的方案可供选择。 您可以将代码更改为:

  • 使 WriteName 方法变为虚拟方法。 这也需要您保护 WriteNameOnPaper,以实现实例化修改后的口袋保护程序的目标。
  • 使 WriteNameOnPaper 方法公开化,但在您的继承者上保留损坏的 WriteName 方法。 除非您舍弃 WriteName,否则这不是一个好的选择(在这种情况下,该选择转为将 PocketProtector 实例传递到该方法中)。
  • 添加一个额外的受保护的虚拟方法,其唯一目的是构建 PocketProtector。
  • 赋予该类一个泛型类型 T(这也是 PocketProtector 的类型),并使用某类别的对象工厂进行构建。 然后,您同样需要注入对象工厂。
  • 通过类的构造函数或通过公共属性将 PocketProtector 实例传递到此类,而不是在类中进行构建。

假设您可以重复使用 PocketProtector,则列出的最后一个选择方案通常是最佳方案。 此外,虚拟创建方法也是一个又好又简单的选择方案。

您应该考虑要虚拟化哪些方法,以适应 OCP。 往往直到最后才能做出决定:“当我需要调用我现在没有的继承者中的方法时,我将使这些方法变为虚拟方法”。其他人可能选择使每个方法变为虚拟方法,希望这能够使扩展程序解决初始代码中的所有疏忽。

两种方法都是错误的。 它们列举了无法实现打开接口的承诺。 拥有太多的虚拟方法会限制您以后更改代码的能力。 缺少您可以替换的方法限制了代码的扩展和重复使用能力。 这限制了它的实用性和生命周期。

下面是违背 OCP 的其他常见示例:

class Nerd
{
  public void DanceTheDisco()
  {
    if (this is ChildOfNerd)
            throw new CoordinationException("Can't");
    ...
  }
}
class ChildOfNerd : Nerd { ... }

这儿到底出了什么问题? Nerd 拥有对其子类型的硬引用。 看到这个错误很苦恼,而且遗憾的是,对于初级开发人员而言,这是一个常见错误。 您可以看到它违背了 OCP。 您需要修改多个类,以加强或重构 ChildOfNerd。

基类决不能直接引用其继承者。 然后,继承者之间的继承者功能不再保持一致。 避免此种冲突的好办法是将某类的继承者放到单独的项目中。 项目结构引用树的方法将驳回此不可取的方案。

此问题不会限制到父子关系。 它也与对等类一起存在。 假设您有如下代码:

class NerdsInAnArc
{
  public bool Intersects(NerdsInAnLine line)
  {
    ...
  }
  ...
}

通常情况下,对象层次结构中的弧线和直线是对等的。 它们不应该知道有关彼此之间的非继承的详尽细节,因为这些细节通常是最优交叉算法所需的。 随时修改其中一个,而无需更改另一个。 这再一次违背了单一责任。 存储弧线,还是分析这些弧线? 将分析操作置于其自己的实用程序类中。

如果您需要此特定的跨同级能力,则需要引用一个适当的接口。 为了避免跨实体混乱,应遵守以下规则:您应该使用含有抽象概念的“is”关键字,而不是具体的类。 例如,您可能制作 IIntersectable 或 INerdsInAPattern 接口,尽管您可能仍依照某些其他交叉实用程序类来分析该接口上公开的数据。

Liskov 替换原则

Liskov 替换原则定义了适用于维持继承者替换的指南。 传递对象的继承者(代替基类)不应该破坏已调用方法中的所有现有功能。 您应该可以互相替换给定接口的所有实施方案。

C# 不允许修改替换方法中的返回类型或参数类型(即使返回类型是基类中返回类型的继承者也是如此)。 因此,它不用应付最常见的替换违背行为:方法参数的逆变(替换项必须拥有相同的父方法或父方法的基准类型)和返回类型的协变(替换方法中的返回类型必须相同或必须是基类中返回类型的继承者)。 但是,尝试解决此限制很常见:

class Nerd : Mammal {
  public double Diopter { get; protected set; }
  public Nerd(int vertebrae, double diopter)
    : base(vertebrae) { Diopter = diopter; }
  protected Nerd(Nerd toBeCloned)
    : base (toBeCloned) { Diopter = toBeCloned.Diopter; }
  // Would prefer to return Nerd instead:
  // public override Mammal Clone() { return new Nerd(this); }
  public new Nerd Clone() { return new Nerd(this); }
}

这儿到底出了什么问题? 当调用抽象引用时,对象的行为发生变化。 由于克隆方法“new”并非虚拟,因此使用 Mammal 引用时并不执行该方法。 方法声明上下文中的关键字“new”可能是某种功能。 如果您不控制基类,那么,如何保证正确的执行?

C# 包含几个可行的替代方案,尽管有些不太适宜,但仍可行。 您可以使用泛型接口(类似于 IComparable<T>)在每个继承者中明确实施。 但是,您仍需要一个执行实际克隆操作的虚拟方法。 您需要此方法,以使您的克隆内容与派生类型匹配。 此外,在使用事件时,C# 还支持关于返回类型逆变和方法参数协变的 Liskov 标准,但是这不能帮助您通过类继承更改公开的接口。

由该代码推测,您可能认为 C# 包括类方法解析程序使用的方法足迹中的返回类型。 这是错误的,您不能用不同的返回类型进行多次替换,但是可以用相同的名称和输入类型替换。 此外,方法解析也会忽略方法限制。 图 2 显示了语法正确但因方法模糊不清而无法编译的代码的示例。

图 2 模糊的方法足迹

interface INerd {
  public int Smartness { get; set; }
}
static class Program
{
  public static string RecallSomeDigitsOfPi<T>(
    this IList<T> nerdSmartnesses) where T : int
  {
    var smartest = nerdSmartnesses.Max();
    return Math.PI.ToString("F" + Math.Min(14, smartest));
  }
  public static string RecallSomeDigitsOfPi<T>(
    this IList<T> nerds) where T : INerd
  {
    var smartest = nerds.OrderByDescending(n => n.Smartness).First();
    return Math.PI.ToString("F" + Math.Min(14, smartest.Smartness));
  }
  static void Main(string[] args)
  {
    IList<int> list = new List<int> { 2, 3, 4 };
    var digits = list.RecallSomeDigitsOfPi();
    Console.WriteLine("Digits: " + digits);
  }
}

图 3 中的代码显示替换功能可能被破坏的过程。 请看您的继承者。 其中一个可以随机修改 isMoonWalking 字段。 如果发生这种情况,则基类存在失去关键清理部分的风险。 isMoonWalking 字段应该是不公开的。 如果继承者需要知道,则应有提供访问权限(而非修改权限)的受保护的 getter 属性。

图 3 替换功能可能被破坏的过程的示例

class GrooveControl: Control {
  protected bool isMoonWalking;
  protected override void OnMouseDown(MouseButtonEventArgs e) {
    isMoonWalking = CaptureMouse();
    base.OnMouseDown(e);
  }
  protected override void OnMouseUp(MouseButtonEventArgs e) {
    base.OnMouseUp(e);
    if (isMoonWalking) {
      ReleaseMouseCapture();
      isMoonWalking = false;
    }
  }
}

聪明且有时过于学究式的程序员将会更进一步探讨此项内容。 封装鼠标处理程序(或其他任何依赖或修改私有状态的方法),并且允许继承者使用事件或其他为非必须调用方法的虚拟方法。 可采纳需要基础函数调用的模式,但不是很理想。 我们有时都会忘记调用预期的基础函数方法。 切勿让继承者打破封装状态。

此外,Liskov 替换原则还要求继承者不要引发新的异常类型(尽管基类中已引发异常的继承者是好的)。 C# 无法强制这一点。

接口分隔原则

每个接口都应具有特定的用途。 当您的对象没有共享该用途时,不应强制您实施某个接口。 推而广之,接口越大,其越可能包含并非所有实施程序都可以获取的方法。 这就是接口分隔原则的本质。 请看 Microsoft .NET Framework 中常见的旧接口对:

public interface ICollection<T> : IEnumerable<T> {
  void Add(T item);
  void Clear();
  bool Contains(T item);
  void CopyTo(T[] array, int arrayIndex);
  bool Remove(T item);
}
public interface IList<T> : ICollection<T> {
  T this[int index] { get; set; }
  int IndexOf(T item);
  void Insert(int index, T item);
  void RemoveAt(int index);
}

这些接口仍然有些用处,但是暗含的假设是,如果您要使用这些接口,则您希望修改集合。 通常,无论谁创建这些数据集合都不希望任何人修改这些数据。 实际上,这样对于将接口分成来源和使用者非常有帮助。

许多数据存储希望共享常见的、可索引的非可写接口。 考虑使用数据分析或数据搜索软件。 通常在一个大型日志文件或数据库表格中读取它们,以便于分析。 修改数据从来都不属于日程安排的一部分。

不可否认,IEnumerable 接口预期成为最小的只读接口。 通过其他的 LINQ 扩展方法,其已开始履行使命。 此外,Microsoft 已经意识到可索引集合接口中的差距。 公司已在添加了 IReadOnlyList<T>的 .NET Framework 4.5 版本中解决了此问题(现在由许多框架集合实施)。

您将会记住旧的 ICollection 接口中的这些亮点:

public interface ICollection : IEnumerable {
  ...
  object SyncRoot { get; }
  bool IsSynchronized { get; }
  ...
}

换句话说,在迭代该集合前,您必须首先潜在地锁住其 SyncRoot。 甚至一些继承者明确实施这些特定的项只是为了隐藏不得不实施这些项的尴尬之举。 多线程方案中的预期变为,在您使用集合(而非 SyncRoot)的每个地方都锁住该集合。

大部分人希望封装集合,这样就可以以一种线程安全的方式访问这些集合。 不使用 foreach,则必须封装多线程数据存储,并且只提供替代代理的 ForEach 方法。 幸运的是,现在适用于 .NET Framework 4.5(通过 NuGet)的更新的集合类(如 .NET Framework 4 或不可变集合中的并发集合)已经消除了大部分这种麻烦。

NET 流抽象概念的错误相同(过大),包括可读和可写的元素以及同步标志。 然而,它确实包括决定可写性的属性:CanRead、CanWrite、CanSeek 等。 将 if (stream.CanWrite) 与 if (stream is IWritableStream) 进行对比。 对于您所创建的不可写的流,很显然,支持后者。

现在,请看一下图 4 中的代码。

图 4 不必要的初始化和清理示例

// Up a level in the project hierarchy
public interface INerdService {
  Type[] Dependencies { get; }
  void Initialize(IEnumerable<INerdService> dependencies);
  void Cleanup();
}
public class SocialIntroductionsService: INerdService
{
  public Type[] Dependencies { get { return Type.EmptyTypes; } }
  public void Initialize(IEnumerable<INerdService> dependencies)
  { ... }
  public void Cleanup() { ... }
  ...
}

这里到底出了什么问题? 您的服务初始化和清理应遍历通常适用于 .NET Framework 的一个非常不错的控制反转 (IoC) 容器,而非重新创建。 例如,没有人关注初始化和清理(service manager/­container/boostrapper 除外)加载这些服务的任何代码。 那是关注的代码。 您不希望其他任何人永久地调用清理。 C# 含有有助于解决此问题的机制,称为显式实施。 您可以像以下所示更明确地实施该服务:

public class SocialIntroductionsService: INerdService
{
  Type[] INerdService.Dependencies { 
    get { return Type.EmptyTypes; } }
  void INerdService.Initialize(IEnumerable<INerdService> dependencies)
  { ... }
  void INerdService.Cleanup() {       ... }
  ...
}

一般而言,您希望出于其他目的设计您的接口,而非单纯地提取单个具体类。 这为您提供了组织和扩展的方法。 不过,至少有两个明显的例外情况。

首先,接口与其具体的实施相比,变化频率更低。 您可以利用这一情况,为您带来益处。 将接口置于单独的程序集中。 让使用者仅引用接口程序集。 这有助于提高编译速度。 可帮助您避免将属性置于非隶属的接口上(因为不适当的属性类型无法适用于适当的项目层次结构)。 如果相应的抽象概念和接口位于同一文件中,则存在某种错误。 接口位于项目层次结构中,作为其实施程序的父级和使用它们的服务(或服务的抽象概念)的同级。

第二,按照定义,接口没有任何依赖关系。 因此,它们适用于通过对象模拟/代理框架的简单单元测试。 至此,我们进入下一个,也是最后一个原则。

依赖关系注入原则

依赖关系注入表示依赖抽象概念,而非具体的类型。 此原则和已探讨的其他原则之间存在许多重叠部分。 前面的许多示例包括无法依赖抽象概念的错误。

Eric Evans 在“领域驱动设计”(Addison-Wesley Professional,2003 年)一书中,概述了一些对于探讨依赖关系注入非常有用的对象分类。 总结该书有助于您将您的对象分为以下三组之一:值、实体或服务。

值指的是对象通常不含有短暂和不可变的依赖关系。 它们通常不是抽象的,您可以随心所欲地实例化它们。 不过,将它们抽象化也不会出现任何问题,尤其是您可以获得抽象所有益处的情况下。 随着时间的推移,某些值会变成实体。 实体是指您的企业 Model 和 ViewModel。 它们由值类型和其他实体构建而成。 对这些项目进行抽象化很有用处,尤其是您拥有一个代表 Model 多个不同变量的 ViewModel 的情况,反之亦然。 服务指的是包含、组织和使用实体的类。

牢记此分类,依赖关系注入主要处理服务和需要这些服务的对象。 特定于服务的方法应始终可以在接口中捕获。 无论您需要在什么位置访问该服务,您都通过该接口访问它。 除了构建服务的位置,切勿在代码中的其他任何位置使用具体的服务类型。

总体而言,这些服务依赖于其他服务。 一些 ViewModel 依赖服务,尤其是容器和工厂类型服务。 因此,服务通常难于针对测试进行实例化,因为您需要完整的服务系列。 将它们的本质抽象为接口。 然后,服务的所有引用都应通过该接口进行,这样就可以轻松地模拟它们,以便用于测试。

您可以在代码的任何级别上创建抽象概念。 当您发现您在思考“哇,对于 A 而言,支持 B 的接口会很麻烦;同样对于 B 而言,支持 A 的接口也会很麻烦”,此时正是在其中引入新的抽象概念的最佳时机。 创建可用的接口,并依赖这些接口。

适配器和调节器模式可以帮助您遵循首选的接口。 听上去像是额外的抽象概念会带来额外的代码,但是总体而言这不正确。 执行互操作性的部分步骤可以帮助您组织为 A 和 B 互相通信存在的代码。

多年以前,我读到这样的说法:开发人员应“始终重复使用代码”。那时候看上去很简单。 我无法相信如此简单的一种说法会使“意大利面条式”的复杂代码洒满我的整个屏幕。 但是,随着时间的推移,我懂了。 请看此处的代码:

private readonly IRamenContainer _ramenContainer; // A dependency
public bool Recharge()
{
  if (_ramenContainer != null)
  {
    var toBeConsumed = _ramenContainer.Prepare();
    return Consume(toBeConsumed);
  }
  return false;
}

是否看到任何重复的代码? 两次读取了 on _ramenContainer。 从技术角度而言,编译器将通过名为“常见的子表达式消除”的优化消除此代码。为了便于讨论,假设您在多线程情况中运行,而且编译器实际上重复执行了方法中的类字段读取。 您可能碰到以下风险:您的类变量甚至会在使用前更改为 null。

如何解决此问题? 在 if 语句上方引入一个本地引用。 此重新安排要求您在外围或外围上方添加新的项。 该原则在项目组织中也适用! 重复使用代码或抽象概念时,您最终到达项目层次结构中的有用范围。 让依赖关系驱动项目间引用层次结构。

现在,请看以下代码:

public IList<Nerd> RestoreNerds(string filename)
{
  if (File.Exists(filename))
  {
    var serializer = new XmlSerializer(typeof(List<Nerd>));
    using (var reader = new XmlTextReader(filename))
      return (List<Nerd>)serializer.Deserialize(reader);
  }
  return null;
}

是否依赖抽象概念?

不,不依赖。 它始于静态引用文件系统。 正在使用硬编码的反序列化程序和硬编码的类型引用。 它希望在类的外部进行异常处理。 不通过附带的存储代码,无法对该代码进行测试。

通常,您会将此代码移至下面的两个抽象概念:一个适用于存储格式,另一个适用于存储媒介。 存储格式的一些示例包括 XML、JSON 和 Protobuf 二进制数据。 存储媒介包括磁盘和数据库中的直接文件。 此外,第三个抽象概念也是此类型系统中的典型:代表将存储对象的某类很少更改的备忘。

请考虑以下示例:

class MonsterCardCollection
{
  private readonly IMsSqlDatabase _storage;
  public MonsterCardCollection(IMsSqlDatabase storage)
  {
    _storage = storage;
  }
  ...
}

在这些依赖关系中能否看到错误? 线索在依赖关系名称中。 它是特定于平台的。 该服务并不是特定于平台的服务(或至少它尝试通过使用外部存储引擎避免平台依赖关系)。 在这种情况下,您需要使用适配器模式。

当依赖关系为特定于平台时,则依赖者以其自己特定于平台的代码结尾。 您可以使用一个额外的层避免这种情况。 该额外的层将帮助您按照平台特定实现存在于其自己的特殊项目(包含其全部的平台特定引用)中的方式组织项目。 您只需要通过启动应用程序项目来引用包含所有平台特定代码的项目。 平台封装程序一般较大;请根据需要进行复制,不要超过必要大小。

依赖关系注入原则将本文讨论的所有原则整合到一起。 它使用简洁的、有目的性的抽象概念。这些概念可以通过具体的实施程序进行填写,而且不会破坏基本的服务状态。 这就是目标所在。

事实上,在对可持续计算机代码的影响上,SOLID 原则一般有重叠的地方。 大量的中间(表示可轻松进行反编译的)代码非常棒,因为它们能够揭示您可能扩展对象的最大程度。 随着时间的推移,大量的 .NET 库项目逐渐消失了。 这并非因为该理念是错误的;它们只是无法安全地满足未来预期不到的且多变的需求。 您的代码必须要使您自己满意。 应用 SOLID 原则,您将会看到您代码的生命周期延长了。

给TA打赏
共{{data.count}}人
人已打赏
.NET

.NET 应用练习 - 读取和写入文件

2022-8-24 16:50:20

.NET

Asp.Net 5.0简介

2022-8-25 10:15:54

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
今日签到
有新私信 私信列表
搜索