三、架构原则

本章重点介绍基本的体系结构原则,而不是设计模式。这背后的原因很简单:这些原则是现代软件工程的基础。此外,我们在整本书中都应用了这些原则,以确保到最后,我们能够编写更好的代码并做出更好的软件设计决策。

在本章中,我们将介绍以下主题:

  • 坚实的原则及其重要性
  • 干燥原理
  • 关注点分离原则

坚实的原则

SOLID 是一个首字母缩略词,代表五条原则,它们扩展了 OOP 的基本概念:抽象、封装、继承和多态性。他们增加了关于做什么和如何做的更多细节,指导开发人员进行更健壮的设计。

同样重要的是要注意,它们是要不惜一切代价遵守的原则,而不是规则。根据您正在构建的内容权衡成本。如果您正在构建一个小型工具,那么与设计一个业务关键型应用相比,缩短它的时间是可以的。对于后一种情况,你可能想考虑更严格一些。然而,不管应用的大小,遵循它们通常是一个好主意,这是在开始深入研究设计模式之前在这里介绍它们的主要原因。

实心首字母缩略词表示以下内容:

  • S单一责任原则
  • O笔/闭合原理
  • L伊斯科夫代换原理
  • I界面分离原理
  • D依附倒置原理

通过遵循这些原则,您的系统应该更易于测试和维护。

单一责任原则(SRP)

本质上,SRP 意味着一个类应该承担一个且只有一个责任,这让我引述如下:

“一个类改变的原因不应该超过一个。”

–罗伯特·C·马丁

好吧,但是为什么呢?在给出答案之前,我希望您考虑一下每次添加、更新或删除规范的情况。然后,想一想,如果系统中的每个类都只有一个责任:一个改变的理由,那么事情会变得多么容易。

我不知道你是否清楚地看到了这一点,但我可以在脑海中想出一些可以从这一原则中获益的项目。软件可维护性问题可能是由程序员、经理或两者共同造成的。我认为没有什么是黑色或白色的,大多数情况是灰色的;有时,它是深灰色或浅灰色,但仍然是灰色。在设计软件时,这种绝对性的缺失也是真实的:尽力而为,从错误中吸取教训,保持谦虚。

让我们回顾一下这一原则存在的原因:

  • 应用生来就是要改变的。
  • 使我们的类更加可重用,并创建更加灵活的系统。
  • 帮助维护应用。由于您知道一个类在更新它之前所做的唯一一件事,因此您可以快速预见它对系统的影响,这与承担许多责任的类不同,更新一个类可能会破坏一个或多个其他部分。
  • 使我们的类更具可读性。更少的责任导致更少的代码,更少的代码更容易在几秒钟内可视化,从而更快地理解该软件。

让我们在行动中尝试一下。我写了一些违反一些原则的可怕代码,包括 SRP。让我们从分析代码开始,对其进行部分修复,使其不再违反 SRP。

下面的是写得很差的代码的一个例子:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    private static int _lastId = 0;
    public static List<Book> Books { get; }
    public static int NextId => ++_lastId;
    static Book()
    {
        Books = new List<Book>
        {
            new Book    
            {
                Id = NextId,
                Title = "Some cool computer book"
            }
        };
    }
    public Book(int? id = null)
    {
        Id = id ?? default(int);
    }
    public void Save()
    {
        // Create the book if it does not exist, 
        // otherwise, find its index and replace it 
        // by the current object.
        if (Books.Any(x => x.Id == Id))
        {
            var index = Books.FindIndex(x => x.Id == Id);
            Books[index] = this;
        }
        else
        {
            Books.Add(this);
        }
    }
    public void Load()
    {
        // Validate that an Id is set
        if (Id == default(int))
        {
            throw new Exception("You must set the Id to the Book Id you want to load.");
        }
        // Get the book
        var book = Books.FirstOrDefault(x => x.Id == Id);
        // Make sure it exist
        if (book == null)
        {
            throw new Exception("This book does not exist");
        }
        // Copy the book properties to the current object
        Id = book.Id; // this should already be set
        Title = book.Title;
    }
    public void Display()
    {
        Console.WriteLine($"Book: {Title} ({Id})");
    }
}

该类包含该超小型控制台应用的所有职责。还有Program类,它包含一个快速而肮脏的用户界面,Book类的使用者。

该计划提供以下选项:

图 3.1–程序的用户界面

让我们看一下的代码:

class Program
{
    static void Main(string[] args)
    {
        // ...
    }

我省略了Main方法代码,因为它只是一个包含Console.WriteLine调用的大型switch。当用户做出选择时,它将用户输入分派给其他方法(稍后解释)。参见https://net5.link/jpxa 了解更多关于Main方法的信息。接下来,当用户选择 1 时调用的方法:

    private static void FetchAndDisplayBook()
    {
        var book = new Book(id: 1);
        book.Load();
        book.Display();
    }

FetchAndDisplayBook()方法加载的id等于1book实例,并在控制台中显示。接下来,当用户选择 2 时调用的方法:

    private static void FailToFetchBook()
    {
        var book = new Book();
        book.Load(); // Exception: You must set the Id to the Book Id you want to load.
        book.Display();
    }

FailToFetchBook()方法加载book实例而不指定id,导致加载数据时抛出异常;参考Book.Load()方法(前面的代码块,第一个突出显示)。接下来,当用户选择 3 时调用的方法:

    private static void BookDoesNotExist()
    {
        var book = new Book(id: 999);
        book.Load();
        book.Display();
    }

BookDoesNotExist()方法加载一个不存在的book实例,导致加载数据时抛出异常;参考Book.Load()方法(前面的代码块,第二个突出显示)。接下来,当用户选择 4 时调用的方法:

    private static void CreateOutOfOrderBook()
    {
        var book = new Book
        {
            Id = 4, // this value is not enforced by anything and will be overriden at some point.
            Title = "Some out of order book"
        };
        book.Save();
        book.Display();
    }

CreateOutOfOrderBook()方法创建一个手动指定idbook实例。该 ID 可以被Book类的自动增量机制覆盖。这些行为是程序设计中问题的良好指标。接下来,当用户选择 5 时调用的方法:

    private static void DisplayTheBookSomewhereElse()
    {
        Console.WriteLine("Oops! Can't do that, the Display method only write to the \"Console\".");
    }

DisplayTheBookSomewhereElse()方法指出了该设计的另一个问题。由于Book类拥有显示机制,我们不能在控制台以外的任何地方显示书籍;参考Book.Display()方法。接下来,当用户选择 6 时调用的方法:

private static void CreateBook()
    {
        Console.Clear();
        Console.WriteLine("Please enter the book title: ");
        var title = Console.ReadLine();
        var book = new Book { Id = Book.NextId, Title = 
        title };
        book.Save();
    }

CreateBook()方法让我们创作新书。它使用Book.NextId静态属性,即增加Id。这破坏了封装并将创建逻辑泄露给消费者,这是另一个与设计相关的问题,我们将在稍后修复。接下来,当用户选择 7 时调用的方法:

    private static void ListAllBooks()
    {
        foreach (var book in Book.Books)
        {
            book.Display();
        }
    }
}

ListAllBooks()方法显示我们在程序中创建的所有书籍。

在进一步讨论之前,我希望你们思考一下Book类中的错误以及违反 SRP 的责任有多少。完成后,请继续阅读。

好的,让我们从隔离功能开始:

  • 该类是一个数据结构,表示一本书(IdTitle)。
  • 它保存和加载数据,包括保留所有现有书籍的列表(BooksSave()Load())。
  • 它通过公开NextId 属性来“管理”自动递增的 ID,该属性将该功能破解到程序中。
  • 它扮演演示者的角色,使用其Display()方法在控制台中输出一本书。

从这四点中,我们可以提取哪些角色?

  • 这是一本书。
  • 它进行数据访问(管理数据)。
  • 它通过在控制台中输出自己来向用户呈现书籍。

这三个元素是责任,这是划分Book类的一个很好的起点。让我们看看这三个类:

  • 我们可以保留Book类,并使其成为表示一本书的简单数据结构。
  • 我们可以创建一个BookStore类,其角色是访问数据。
  • 我们可以创建一个BookPresenter类,在控制台上输出(呈现)一本书。

以下是这三门课:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
}
public class BookStore
{
    private static int _lastId = 0;
    private static List<Book> _books;
    public static int NextId => ++_lastId;
    static BookStore()
    {
        _books = new List<Book>
            {
                new Book
                {
                    Id = NextId,
                    Title = "Some cool computer book"
                }
            };
    }
    public IEnumerable<Book> Books => _books;
    public void Save(Book book)
    {
        // Create the book when it does not exist, 
        // otherwise, find its index and replace it 
        // by the specified book.
        if (_books.Any(x => x.Id == book.Id))
        {
            var index = _books.FindIndex(x => x.Id == book.Id);
            _books[index] = book;
        }
        else
        {
            _books.Add(book);
        }
    }
    public Book Load(int bookId)
    {
        return _books.FirstOrDefault(x => x.Id == bookId);
    }
}
public class BookPresenter
{
    public void Display(Book book)
    {
        Console.WriteLine($"Book: {book.Title} ({book.Id})");
    }
}

这还不能解决所有问题,但至少是一个好的开始。通过提取责任,我们实现了以下目标:

  • FailToFetchBook()用例已经固定(参见Load()方法)。
  • 现在取书更优雅、更直观。
  • 我们还打开了一个关于DisplayTheBookSomewhereElse()用例的可能性(稍后将重新讨论。

从 SRP 的角度来看,我们仍然有一两个问题:

  • 自动递增的 ID 仍然公开,并且BookStore没有管理它。
  • Save()方法是处理书籍的创建和更新,这似乎是两个职责,而不是一个。

在接下来的更新中,我们将重点关注这两个共享协同效应的问题,使它们更容易单独修复,而不是一起修复(在方法之间划分责任)。

我们将要做的是:

  1. 隐藏BookStore.NextId属性以修复封装(不是 SRP,但它是必需的)。
  2. BookStore.Save()方法分为Create()Replace()两种方法。
  3. 更新我们的用户界面:Program.cs

隐藏NextId属性后,我们需要将该特性移动到BookStore类中。最符合逻辑的地方应该是Save()方法(尚未一分为二),因为我们希望每本新书都有一个新的唯一标识符。以下是变化:

public class BookStore
{
    ...
    private static int NextId => ++_lastId;
    ...
    public void Save(Book book)
    {
        ...
        else
        {
            book.Id = NextId;
            _books.Add(book);
        }
    }
}

自动递增的标识符仍然是一个不成熟的特性。为了进一步改进它,让我们将Save()方法分成两部分。通过查看生成的代码,我们可以想象处理这两个用例更容易编写。对于将来可能接触到该代码的任何开发人员来说,它也更易于阅读和使用。你自己看看:

public void Create(Book book)
{
    if (book.Id != default(int))
    {
        throw new Exception("A new book cannot be created with an id.");
    }
    book.Id = NextId;
    _books.Add(book);
}
public void Replace(Book book)
{
    if (_books.Any(x => x.Id == book.Id))
    {
        throw new Exception($"Book {book.Id} does not exist!");
    }
    var index = _books.FindIndex(x => x.Id == book.Id);
    _books[index] = book;
}

现在我们开始有所进展。我们已经成功地将职责划分为三类,并且还将Save()方法划分为三类,这样两种方法都只处理一个操作。

Program方法现在看起来如下:

private static readonly BookStore _bookStore = new BookStore();
private static readonly BookPresenter _bookPresenter = new BookPresenter();
//...
private static void FetchAndDisplayBook()
{
    var book = _bookStore.Load(1);
    _bookPresenter.Display(book);
}
private static void FailToFetchBook()
{
    // This cannot happen anymore, this has been fixed automatically.
}
private static void BookDoesNotExist()
{
    var book = _bookStore.Load(999);
    if (book == null)
    {
        // Book does not exist
    }
}
private static void CreateOutOfOrderBook()
{
    var book = new Book
    {
        Id = 4, 
        Title = "Some out of order book"
    };
    _bookStore.Create(book); // Exception: A new book cannot be created with an id.
    _bookPresenter.Display(book);
} 
private static void DisplayTheBookSomewhereElse()
{
    Console.WriteLine("This is now possible, but we need a new Presenter; not 100% there yet!");
}
private static void CreateBook()
{
    Console.Clear();
    Console.WriteLine("Please enter the book title: ");
    var title = Console.ReadLine();
    var book = new Book { Title = title };
    _bookStore.Create(book);
}
private static void ListAllBooks()
{
    foreach (var book in _bookStore.Books)
    {
        _bookPresenter.Display(book);
    }
}

除了自动修正FailToFetchBook方法外,我发现代码更容易阅读。让我们比较一下FetchAndDisplayBook方法:

// Old
private static void FetchAndDisplayBook()
{
    var book = new Book(id: 1);
    book.Load();
    book.Display();
}
// New
private static void FetchAndDisplayBook()
{
    var book = _bookStore.Load(1);
    _bookPresenter.Display(book);
}

结论

在考虑 SRP 时,有一件事需要注意,那就是不要过多地划分类。系统中的类越多,组装系统就越复杂,调试或遵循执行路径就越困难。另一方面,许多分离良好的职责应该会导致一个更好、更可测试的系统。

不幸的是,如何描述“一个原因”或“一个单一的责任”是不可能定义的,我在这里没有一个硬性的指导方针。在没有其他人帮助您解决设计难题的情况下,如果您有疑问,我建议您将其拆分。

当您不知道如何命名元素时,可以很好地指示 SRP 冲突。这通常是一个很好的指针,元素不应该驻留在那里,应该被提取,或者应该被分割成多个较小的部分。

另一个好的指标是当一个方法变得太大时,可以选择使用许多if语句或循环。在这种情况下,您应该将该方法拆分为多个较小的方法。这将使代码更易于阅读,并使初始方法的主体更干净。它还经常帮助你摆脱无用的评论。请清楚地命名您的私有方法,否则将不利于可读性(请参见命名示例部分)。

你怎么知道一个班级什么时候变得太少了?再一次,你不会喜欢它的。我也没有任何硬性的指导方针。但是,如果您正在查看您的系统,并且您的所有类中只有一个方法,那么您可能滥用了 SRP。也就是说,我并不是说只有一个方法的类是错误的。

命名示例

在这个示例中,我们提取了一些方法,通过将 SRP 应用到下面的 long 方法中,来了解命名这些方法是如何很好地提高可读性的。让我们先看看 AUTT1 类的 AUTT0.方法:

namespace ClearName
{
    public class OneMethodExampleService : IExampleService
    {
        private readonly IEnumerable<string> _data;
        private static readonly Random _random = new Random();
        public OneMethodExampleService(IEnumerable<string> data)
        {
            _data = data ?? throw new ArgumentNullException(nameof(data));
        }
        public RandomResult RandomizeOneString()
        {
            // Find the upper bound
            var upperBound = _data.Count();
            // Randomly select the index of the string to return
            var index = _random.Next(0, upperBound);
            // Shuffle the elements to add more randomness
            var shuffledList = _data
                .Select(value => new { Value = value, Order = _random.NextDouble() })
                .OrderBy(x => x.Order)
                .Select(x => x.Value)
            ;
            // Return the randomly selected element
            var randomString = shuffledList.ElementAt(index);
            return new RandomResult(randomString, index, shuffledList);
        }
    }
}

有了所有的注释,我们可以分离出一些需要进行的操作,以便找到那个随机字符串。这些行动如下:

  • 找到_data字段的上界(anIEnumerable<string>
  • 生成下一个随机索引,我们的项目应该在该索引处执行。
  • 洗牌项目列表以添加更多随机性。
  • 返回结果,包括索引和无序数据,以便于以后显示。

一旦我们隔离了这些操作,我们就可以为每个操作提取一个方法。

笔记

我不建议系统地提取单行方法,因为它会创建大量不一定有用的代码。也就是说,如果您发现提取一行方法可以使代码更具可读性,那么一定要这样做。

考虑到这一点,我们可以提取一个方法并使其更易于阅读,就像CleanExampleService类中的方法一样。这样做导致在类中应用 SRP 以提高可读性,如我们在这里所见:

public RandomResult RandomizeOneString()
{
    var upperBound = _data.Count();
    var index = _random.Next(0, upperBound);
    var shuffledData = ShuffleData();
    var randomString = shuffledData.ElementAt(index);
    return new RandomResult(randomString, index, shuffledData);
}

在这个生成的方法中,我们甚至删除了注释,因此通过从RandomizeOneString方法中提取洗牌责任,我们使代码比以前更易于阅读。此外,通过使用描述性变量名,可以更容易地使用该方法,而无需注释。

我们只研究了代码的一小部分,但是完整的代码示例可以在 GitHub 上获得。在Startup.cs中,您可以注释掉第一行#define USE_CLEAN_SERVICE,使用OneMethodExampleService类代替CleanExampleService;他们做同样的事情。

完整样本也展示了 ISP 和 DIP 的作用;我们很快就会讨论这两个原则。阅读本章后,您应该回到这个示例(完整的源代码)。

开/关原理(OCP)

让我们引用 BertrandMeyer 的话开始本节:

软件实体(类、模块、函数等)应为扩展打开,但为修改关闭

好的,但这意味着什么,你可以问自己?这意味着类的行为应该可以从外部(也称为调用方代码)进行更新。与手动重写类内方法的代码不同,您应该能够从外部更改类行为,而不必更改代码本身。

实现这一点的最佳方法是使用多个设计良好的代码单元组装应用,并使用依赖项注入将其缝合在一起。

为了说明这一点,让我们玩一个忍者、一把剑和一个 shuriken;小心,这里的地面很危险!

以下是所有示例中使用的IAttackable接口:

public interface IAttackable { }

让我们从一个不遵循 OCP 的示例开始:

   public class Ninja : IAttackable
   {
       //…
       public AttackResult Attack(IAttackable target)
       {
           if (IsCloseRange(target))
           {
               return new AttackResult(new Sword(), this, target);
           }
           else
           {
               return new AttackResult(new Shuriken(), this, target);
           }
       }
        //…
    }

在本例中,我们可以看到Ninja类根据其目标距离选择武器。这背后的想法并没有错,但当我们向忍者的武器库添加新武器时会发生什么?此时,我们需要打开Ninja类并更新其Attack方法。

让我们通过外部设置武器和抽象Attack方法来重新思考Ninja类。我们可以在内部管理装备的武器,但为了简单起见,我们是从消费代码管理它。

我们的空壳现在看起来像这样:

public class Ninja : IAttackable
{
    public Weapon EquippedWeapon { get; set; }
    // ...
    public AttackResult Attack(IAttackable target)
    {
        throw new NotImplementedException();
    }
    // ...
}

现在,忍者的攻击与他装备的武器直接相关;例如,投掷一把舒利肯剑,同时使用一把剑进行近距离打击。OCP 规定攻击应该在其他地方处理,允许在不改变代码的情况下修改忍者的行为。

我们想要做的就是所谓的组合,而实现这一点的最佳方式就是策略模式,我们在第 6 章中对其进行了更详细的探讨,了解了策略、抽象工厂和单体设计模式以及第 7 章深入依赖注入。现在,让我们忘掉这些细节,让我们玩一些遵循 OCP 的代码。

Attack方法如下:

public AttackResult Attack(IAttackable target)
{
    return new AttackResult(EquippedWeapon, this, target);
}

它现在做的和最初一样,但是我们可以添加武器,设置EquippedWeapon属性,程序应该使用新武器;不再需要为此更改Ninja类。

好吧,让我们诚实点;那代码什么都不做。它只允许我们打印出程序中发生的事情,并展示如何在不修改类本身的情况下修改行为。然而,我们可以从那里开始创建一个小的忍者游戏。我们可以管理忍者的位置来计算他们之间的实际距离,并强制每个武器的最小和最大射程,但这远远超出了本节的范围。

现在,让我们来看看 AutoT0.类。我们可以看到它是一个不包含任何行为的小数据结构。我们的程序使用它来输出结果:

public class AttackResult
{
    public Weapon Weapon { get; }
    public IAttackable Attacker { get; }
    public IAttackable Target { get; }
    public AttackResult(Weapon weapon, IAttackable attacker, IAttackable target)
    {
        Weapon = weapon;
        Attacker = attacker;
        Target = target;
    }
}

Startup类中的程序代码显示为如下:

// Setup the response
context.Response.ContentType = "text/html";
// Create actors
var target = new Ninja("The Unseen Mirage");
var ninja = new Ninja("The Blue Phantom");
// First attack (Sword)
ninja.EquippedWeapon = new Sword();
var result = ninja.Attack(target);
await PrintAttackResult(result);
// Second attack (Shuriken)
ninja.EquippedWeapon = new Shuriken();
var result2 = ninja.Attack(target);
await PrintAttackResult(result2);
// Write the outcome of an AttackResult to the response stream
async Task PrintAttackResult(AttackResult attackResult)
{
    await context.Response.WriteAsync($"'{attackResult.Attacker}' attacked '{attackResult.Target}' using'{attackResult.Weapon}'!<br>");
}

运行程序时,使用dotnet run命令,浏览到https ://localhost:5001/时,我们应该有以下输出:

'The Blue Phantom' attacked 'The Unseen Mirage' using 'Sword'!
'The Blue Phantom' attacked 'The Unseen Mirage' using 'Shuriken'!

在更复杂的应用中,组合和依赖项注入相结合将允许从一个地方(称为组合根)将行为更改应用到整个程序,而无需更改现有代码;“开放供扩展,关闭供修改。”要添加新武器,我们可以创建新类,而不需要修改任何现有类。

所有这些新术语一开始都可能是压倒性的,但我们将在后续章节中更详细地介绍它们,并在本书中广泛使用这些技巧。

一点历史

OCP 的第一次出现是在 1988 年,当时它指的是继承,从那时起 OOP 已经进化了很多。大多数情况下,您应该选择组合而不是继承。继承仍然是一个有用的概念,但在使用它时应该小心;这是一个容易被误用的概念,在类之间创建了直接耦合。

利斯科夫替代原理(LSP)

LSP 起源于 80 年代末的芭芭拉·利斯科夫(Barbara Liskov),并在 90 年代被利斯科夫和珍妮特·荣格(Jeannette Wing)重新审视,以创造出我们今天所知道和使用的原则。它也类似于 Bertrand Meyer 的合同设计

LSP 的重点是保持子类型行为,这将导致系统的稳定性。在继续之前,让我们先从 Wing 和 Liskov 引入的正式定义开始:

假设是关于 T 类型的对象的可证明属性。那么,对于 S 类型的对象应该为真,其中 S 是 T 的子类型。

这意味着您应该能够将 T 类型的对象与 S 类型的对象交换,其中 S 是 T 的子类型,而不会破坏程序的正确性。

如果不付出一些努力,你就不能违反 C#中的以下规则,但它们仍然值得一提:

  • 子类型中方法参数的逆变。
  • 子类型中返回类型的协方差。

打破逆差的一种方法是测试特定的子类型,例如:

public void SomeMethod(SomeType input)
{
    if (input is SomeSubType)
    // …
}

打破协方差的一种方法是将超类型作为子类型返回,这需要开发人员进行一些工作。

然后,为了证明子类型的正确性,我们必须遵循以下几条规则:

  • 在超类型中实现的任何先决条件都应该在其子类型中产生相同的结果,但子类型对它的要求可以不那么严格,永远不会更严格。
  • 在超类型中实现的任何后置条件都应该在其子类型中产生相同的结果,但是子类型可以对其更严格,而不是更少。
  • 子类型必须保持超类型的不变性;换句话说,超类型的行为不能改变。

最后,我们必须将“历史约束”添加到该规则列表中,这表明在超类型中发生的事情必须仍然发生在子类型中。虽然子类型可以添加新的属性和方法(换句话说,新的行为),但它们不能以任何新的方式修改超类型状态。

好的,在这一点上,你觉得这相当复杂是对的。请放心,这是这些原则中不太重要的,但更为复杂,我们正在尽可能远离继承,因此这不应该经常适用。

也就是说,我将通过执行以下操作来恢复之前的所有复杂性:

  • 在子类型中,添加新行为;不要改变现有的。

您应该能够通过一个子类交换一个类,而不会破坏任何东西。

需要注意的是,“不破坏任何东西”包括不在子类型中抛出新异常。父类型引发的子类型异常是可以接受的,因为现有代码应该已经处理了这些异常,并且如果子类型异常已经处理了,那么就捕获它们。

作为旁注,在开始处理 LSP 之前,先应用继承中的“is-a”规则;如果子类型不是超类型,则不要使用继承。

做一个乐高®的类比:LSP 就像用一个 4x2 的蓝色积木替换一个 4x2 的绿色积木:结构的完整性和积木的作用都没有改变,只是改变了它的颜色。

提示

实施这些行为约束的一个很好的方法是自动化测试。您可以编写一个测试套件,并针对特定超类型的所有子类运行它,以确保保留行为。

让我们跳进一些代码,在实践中将其可视化。

项目-Hallofame

现在,让我们看看这在代码中是什么样子。对于这一个,我们探索一个我们正在开发的虚拟游戏的名人堂功能。

功能描述:游戏应该累积在游戏期间杀死的敌人的数量,如果你杀死了至少 100 个敌人,你的忍者应该到达名人堂。名人堂应该从最好的分数排列到最差的分数。

我们创建了以下自动测试来执行这些规则,sut(测试对象)的类型为HallOfFame。下面是HallOfFame类的空实现:

public class HallOfFame
{
    public virtual void Add(Ninja ninja)
        => throw new NotImplementedException();
    public virtual IEnumerable<Ninja> Members
        => throw new NotImplementedException();
}

笔记

我没有遵循我在上一章中提到的约定,因为我需要继承来重用代码的三个版本的测试套件。使用嵌套类不可能做到这一点。

Add()方法应该添加杀死 100 以上敌人的忍者:

public static TheoryData<Ninja> NinjaWithAtLeast100Kills => new TheoryData<Ninja>
{
    new Ninja { Kills = 100 },
    new Ninja { Kills = 101 },
    new Ninja { Kills = 200 },
};
[Theory]
[MemberData(nameof(NinjaWithAtLeast100Kills))]
public void Add_should_add_the_specified_ninja(Ninja expectedNinja)
{
    // Act
    sut.Add(expectedNinja);
    // Assert
    Assert.Collection(sut.Members,
        ninja => Assert.Same(expectedNinja, ninja)
    );
}

Add()方法不应多次添加忍者:

[Fact]
public void Add_should_not_add_existing_ninja()
{
    // Arrange
    var expectedNinja = new Ninja { Kills = 200 };
    // Act
    sut.Add(expectedNinja);
    sut.Add(expectedNinja);
    // Assert
    Assert.Collection(sut.Members,
        ninja => Assert.Same(expectedNinja, ninja)
    );
}

Add()方法应验证忍者在将其添加到被测HallOfFame实例的Members集合之前至少有 100 次死亡:

[Fact]
public void Add_should_not_add_ninja_with_less_than_100_kills()
{
    // Arrange
    var ninja = new Ninja { Kills = 99 };
    // Act
    sut.Add(ninja);
    // Assert
    Assert.Empty(sut.Members);
}

HallOfFame类的Members属性应返回其忍者,按其击杀次数排序,从最多到最少:

[Fact]
public void Members_should_return_ninja_ordered_by_kills_desc()
{
    // Arrange
    sut.Add(new Ninja { Kills = 100 });
    sut.Add(new Ninja { Kills = 150 });
    sut.Add(new Ninja { Kills = 200 });
    // Act
    var result = sut.Members;
    // Assert
    Assert.Collection(result,
        ninja => Assert.Equal(200, ninja.Kills),
        ninja => Assert.Equal(150, ninja.Kills),
        ninja => Assert.Equal(100, ninja.Kills)
    );
}

HallOfFame类的实现如下所示:

public class HallOfFame
{
    protected HashSet<Ninja> InternalMembers { get; } = new HashSet<Ninja>();
    public virtual void Add(Ninja ninja)
    {
        if (InternalMembers.Contains(ninja))
        {
            return;
        }
        if (ninja.Kills >= 100)
        {
            InternalMembers.Add(ninja);
        }
    }
    public virtual IEnumerable<Ninja> Members
        => new ReadOnlyCollection<Ninja>(
            InternalMembers
                .OrderByDescending(x => x.Kills)
                .ToArray()
        );
}

现在,我们已经完成了我们的功能和推动我们的变化,我们演示名人堂给我们的客户。

更新 1

在演示之后,一个想法出现了:为什么不为没有资格进入名人堂的玩家添加英雄堂?经过审议,我们决定实施该功能。

功能描述:游戏应该累积游戏中被杀死的敌人的数量(已经完成),并将所有忍者添加到英雄大厅中,无论分数如何。结果应该先按最佳分数排序,按降序排列,每个忍者只能出现一次。

快速实现此功能的第一个想法是重用名人堂代码。第一步,我们决定创建一个继承HallOfFame类的HallOfHeroes类,并重写Add()方法以支持新规范。

经过思考,你认为这种改变会打破 LSP 吗?

在给出答案之前,让我们来看看这个 T0 T0 类:

namespace LSP.Examples.Update1
{
    public class HallOfHeroes : HallOfFame
    {
        public override void Add(Ninja ninja)
        {
            if (InternalMembers.Contains(ninja))
            {
                return;
            }
            InternalMembers.Add(ninja);
        }
    }
}

由于 LSP 声明子类可以对前提条件不那么严格,因此删除 kill 前提条件的数量应该是可以接受的。

现在,如果我们使用HallOfHeroes来运行为HallOfFame构建的测试,唯一失败的测试是与我们的前提条件相关的测试,因此子类没有改变任何行为,所有用例仍然有效。

为了更有效地测试我们的特性,我们可以将所有共享测试封装到一个基类中,但只为HallOfFame测试保留Add_should_not_add_ninja_with_less_than_100_kills

有了它来验证我们的代码,我们就可以开始探索 LSP 的作用,因为我们可以在程序需要HallOfFame实例的地方使用HallOfHeroes的实例,而不会破坏它。

以下是完整的BaseLSPTest课程:

namespace LSP.Examples
{
    public abstract class BaseLSPTest
    {
        protected abstract HallOfFame sut { get; }
        public static TheoryData<Ninja> NinjaWithAtLeast100Kills => new TheoryData<Ninja>
        {
            new Ninja { Kills = 100 },
            new Ninja { Kills = 101 },
            new Ninja { Kills = 200 },
        };
        [Fact]
        public void Add_should_not_add_existing_ninja()
        {
            // Arrange
            var expectedNinja = new Ninja { Kills = 200 };
            // Act
            sut.Add(expectedNinja);
            sut.Add(expectedNinja);
            // Assert
            Assert.Collection(sut.Members,
                ninja => Assert.Same(expectedNinja, ninja)
            );
        }
        [Theory]
        [MemberData(nameof(NinjaWithAtLeast100Kills))]
        public void Add_should_add_the_specified_ninja(Ninja expectedNinja)
        {
            // Act
            sut.Add(expectedNinja);
            // Assert
            Assert.Collection(sut.Members,
                ninja => Assert.Same(expectedNinja, ninja)
            );
        }
        [Fact]
        public void Members_should_return_ninja_ordered_by_kills_desc()
        {
            // Arrange
            sut.Add(new Ninja { Kills = 100 });
            sut.Add(new Ninja { Kills = 150 });
            sut.Add(new Ninja { Kills = 200 });
            // Act
            var result = sut.Members;
            // Assert
            Assert.Collection(result,
                ninja => Assert.Equal(200, ninja.Kills),
                ninja => Assert.Equal(150, ninja.Kills),
                ninja => Assert.Equal(100, ninja.Kills)
            );
        }
    }
}

HallOfFameTest类比类简单,如下所示:

using LSP.Models;
using Xunit;
namespace LSP.Examples
{
    public class HallOfFameTest : BaseLSPTest
    {
        protected override HallOfFame sut { get; } = new HallOfFame();
        [Fact]
        public void Add_should_not_add_ninja_with_less_than_100_kills()
        {
            // Arrange
            var ninja = new Ninja { Kills = 99 };
            // Act
            sut.Add(ninja);
            // Assert
            Assert.Empty(sut.Members);
        }
    }
}

最后,HallOfHeroesTest类的几乎为空:

using LSP.Models;
namespace LSP.Examples.Update1
{
    public class HallOfHeroesTest : BaseLSPTest
    {
        protected override HallOfFame sut { get; } = new HallOfHeroes();
    }
}

这项新功能已经实现,但我们还没有完成。

更新 2

后来,游戏使用了这些类。然而,另一位开发人员 Joe 决定在新功能中使用HallOfHeroes,但他需要知道何时添加了重复的忍者,因此他决定用throw new DuplicateNinjaException()替换return;语句。他为自己的长相感到自豪,并向团队展示了这一点。

你认为乔的更新会破坏 LSP 吗?

更改后,该类如下所示:

using LSP.Models;
using System;
namespace LSP.Examples.Update2
{
    public class HallOfHeroes : HallOfFame
    {
        public override void Add(Ninja ninja)
        {
            if (InternalMembers.Contains(ninja))
            {
                throw new DuplicateNinjaException();
            }
            InternalMembers.Add(ninja);
        }
    }
    public class DuplicateNinjaException : Exception
    {
        public DuplicateNinjaException()
            : base("Cannot add the same ninja twice!") { }
    }
}

是的,它违反了 LSP。此外,如果我们的工程师已经运行了测试,那么很明显有一个测试失败了!

你认为什么违反了 LSP?

所有现有代码都不希望HallOfFame实例在任何地方抛出DuplicateNinjaException,因此可能会造成运行时崩溃,可能会破坏游戏。根据 LSP,禁止在子类中抛出新异常。

更新 3

为了纠正错误并使符合 LSP,我们的工程师决定在HallOfHeroes类中添加AddingDuplicateNinja事件,然后订阅该事件,而不是捕获DuplicateNinjaException

这能解决之前的 LSP 违规问题吗?

更新后的代码如下所示:

using LSP.Models;
using System;
namespace LSP.Examples.Update3
{
    public class HallOfHeroes : HallOfFame
    {
        public event EventHandler<AddingDuplicateNinjaEventArgs> AddingDuplicateNinja;
        public override void Add(Ninja ninja)
        {
            if (InternalMembers.Contains(ninja))
            {
                OnAddingDuplicateNinja(new AddingDuplicateNinjaEventArgs(ninja));
                return;
            }
            InternalMembers.Add(ninja);
        }
        protected virtual void OnAddingDuplicateNinja(AddingDuplicateNinjaEventArgs e)
        {
            AddingDuplicateNinja?.Invoke(this, e);
        }
    }
    public class AddingDuplicateNinjaEventArgs : EventArgs
    {
        public Ninja DuplicatedNinja { get; }
        public AddingDuplicateNinjaEventArgs(Ninja ninja)
        {
            DuplicatedNinja = ninja ?? throw new ArgumentNullException(nameof(ninja));
        }
    }
}

是的,该修复允许现有代码在添加 Joe 所需的新功能的同时平稳运行。发布event而不是抛出Exception只是解决我们虚构问题的一种方法。在现实生活中,您应该选择最适合您的问题的解决方案。

上一个示例的重要部分是引入一个新的异常类型似乎是无害的,但会造成很大的危害。其他违反 LSP 的情况也是如此。

结论

再一次,这只是一项原则,而不是一项法律。一个很好的提示是将 LSP 的违反视为代码气味。从这里开始,进行一些分析,看看您是否有设计问题以及可能产生的影响。在个案的基础上使用你的分析技能,并得出结论,在特定情况下,打破 LSP 是否可以接受。

我认为我们可以也将这一原则命名为向后兼容性原则,因为之前以某种方式工作的所有东西在替换后都应该至少以同样的方式工作,这就是为什么这一原则很重要。

我们越是进步,就越是远离继承,就越不需要这个原则。请不要误解我的意思,如果您使用继承,请尽最大努力应用 LSP,这样做很可能会给您带来回报。

接口隔离原则(ISP)

让我们从罗伯特·C·马丁的另一句名言开始:

“许多特定于客户端的接口比一个通用接口要好。”

这是什么意思?它的意思是:

  • 您应该创建接口。
  • 您应该更加重视小型接口。
  • 您不应该尝试创建一个多用途接口,作为“一个管理所有接口的接口”

接口在这里可以指类接口(类的所有公开元素),但我更喜欢关注 C#接口,因为在本书中我们经常使用它们。如果你知道 C++,你可以看到一个接口作为头文件。

什么是接口?

接口是 C#box 中最有用的工具之一,可以创建灵活且可维护的软件。我将试图给你一个接口是什么的清晰定义,但别担心;从解释中很难理解和掌握接口的威力。

  • 接口的作用是定义内聚契约(公共方法、属性和事件);接口中没有代码;这只是一份合同。
  • 一个接口应该是小型的(ISP),它的成员应该朝着一个共同的目标(内聚)对齐,并分担一个单一的责任(SRP)。
  • 在 C#中,一个类可以实现多个接口,通过这样做,一个类可以公开这些公共契约的多个,或者更准确地说,可以作为它们中的任何一个(多态性)。

老实说,这个定义仍然有点抽象,但请放心,我们在本书中大量使用接口,因此到最后,接口不应该为您保留很多秘密。

另一个更基本的问题

类不从接口继承;它实现了一个接口。但是,一个接口可以从另一个接口继承。

项目–门锁

查看合同的一种方式是将其视为钥匙和锁。每把钥匙配一把锁,这是基于一个特定的合同,合同规定了钥匙的工作方式。如果我们有多个锁遵循相同的合同,一把钥匙应该适合所有这些锁,而多把钥匙也可以适合相同的锁,只要它们是相同的。

界面背后的理念是相同的;接口描述什么是可用的,实现决定它是如何做的,让使用者期望行为(通过遵循契约)发生,而忽略它在后台(通过实现)是如何做的。

我们的关键合同如下所示:

/// <summary>
/// Represents a key that can open zero or more locks.
/// </summary>
public interface IKey
{
    /// <summary>
    /// Gets the key's signature.
    /// This should be used by <see cref="ILock"/> to decide whether or not the key matches the lock.
    /// </summary>
    string Signature { get; }
}

而我们的锁合同是这样出现的:

/// <summary>
/// Represents a lock than can be opened by zero or more keys.
/// </summary>
public interface ILock
{
    /// <summary>
    /// Gets if the lock is locked or not.
    /// </summary>
    bool IsLocked { get; }
    /// <summary>
    /// Locks the lock using the specified <see cref="IKey"/>.
    /// </summary>
    /// <param name="key">The <see cref="IKey"/> used to lock the lock.</param>
    /// <exception cref="KeyDoesNotMatchException">The <see cref="Exception"/> that is thrown when the specified <see cref="IKey"/> does not match the <see cref="ILock"/>.</exception>
    void Lock(IKey key);
    /// <summary>
    /// Unlocks the lock using the specified <see cref="IKey"/>.
    /// </summary>
    /// <param name="key">The <see cref="IKey"/> used to unlock the lock.</param>
    /// <exception cref="KeyDoesNotMatchException">The <see cref="Exception"/> that is thrown when the specified <see cref="IKey"/> does not match the <see cref="ILock"/>.</exception>
    void Unlock(IKey key);
    /// <summary>
    /// Validate that the key's <see cref="IKey.Signature"/> match the lock.
    /// </summary>
    /// <param name="key">The <see cref="IKey"/> to validate.</param>
    /// <returns><c>true</c> if the key's <see cref="IKey.Signature"/> match the lock; otherwise <c>false</c>.</returns>
    bool DoesMatch(IKey key);
}

正如您所看到的,合同是明确的,并且添加了一些细节来描述在使用它或在实施它时预期会发生什么。

笔记

这些规范可以通过验证实现是否尊重其契约来帮助实施 LSP 的扩展视图,从而允许使用者安全地使用接口的任何实现。

请注意,很少像我这样在接口级别定义异常。在我们的案例中,我觉得这样做更有意义,使合同的描述清晰明了,而不是返回可能误导的bool。此外,返回bool会导致对故障源缺乏反馈。我们可以返回一个对象或选择另一个解决方案,但这会给代码示例增加不必要的复杂性。在本书的后面部分,我们正在探索类似问题的替代方案。

让我们来看看基本的密钥和锁实现。

基本实现

物理钥匙和锁易于可视化。钥匙有凹口和脊,长度和厚度由特定的材料制成,使其具有颜色,等等。另一方面,锁由销和弹簧组成。当您将正确的钥匙插入正确的锁时,您可以锁定或解锁它。

在我们的例子中,为了保持简单,我们使用IKey接口的Signature属性来表示物理密钥的属性,而锁可以按照自己的意愿处理密钥。

我们最基本的密钥和锁实现如下所示:

public class BasicKey : IKey
{
    public BasicKey(string signature)
    {
        Signature = signature ?? throw new ArgumentNullException(nameof(signature));
    }
    public string Signature { get; }
}
public class BasicLock : ILock
{
    private readonly string _expectedSignature;
    public BasicLock(string expectedSignature)
    {
        _expectedSignature = expectedSignature ?? throw new ArgumentNullException(nameof(expectedSignature));
    }
    public bool IsLocked { get; private set; }
    public bool DoesMatch(IKey key)
    {
        return key.Signature.Equals(_expectedSignature);
    }
    public void Lock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        IsLocked = true;
    }
    public void Unlock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        IsLocked = false;
    }
}

如您所见,这些实现正在执行接口及其///注释所描述的操作,使用名为_expectedSignature的私有字段验证密钥的签名。

为了简洁起见,我没有在这里复制所有的测试,但是这个示例的大部分代码都包含在单元测试中,您可以在 GitHub 上浏览或本地克隆。下面是一个例子,涵盖了DoesMatch方法的规范:

using Xunit;
namespace DoorLock
{
    public class BasicLockTest
    {
        private readonly IKey _workingKey;
        private readonly IKey _invalidKey;
        private readonly BasicLock sut;
        public BasicLockTest()
        {
            sut = new BasicLock("WorkingKey");
            _invalidKey = new BasicKey("InvalidKey");
            _workingKey = new BasicKey("WorkingKey");
        }
        public class DoesMatch : BasicLockTest
        {
            [Fact]
            public void Should_return_true_when_the_key_matches_the_lock()
            {
                // Act
                var result = sut.DoesMatch(_workingKey);
                // Assert
                Assert.True(result, "The key should match the lock.");
            }
            [Fact]
            public void Should_return_false_when_the_key_does_not_match_the_lock()
            {
                // Act
                var result = sut.DoesMatch(_invalidKey);
                // Assert
                Assert.False(result, "The key should not match the lock.");
            }
        }
        //...
    }
}

我们可以看到,DoesMatch的测试是接口///注释的直接表示:

<returns><c>true</c> if the key's <see cref="IKey.Signature"/> match the lock; otherwise <c>false</c>.</returns>.

在深入研究 ISP 之前,让我们先了解更多的实现。

多锁实现

为了证明小型、定义良好的接口很重要,让我们实现一种特殊类型的锁:MultiLock类包含其他锁:

public class MultiLock : ILock
{
    private readonly List<ILock> _locks;
    public MultiLock(List<ILock> locks)
    {
        _locks = locks ?? throw new ArgumentNullException(nameof(locks));
    }
    public MultiLock(params ILock[] locks)
        : this(new List<ILock>(locks))
    {
        if (locks == null) { throw new ArgumentNullException(nameof(locks)); }
    }
    public bool IsLocked => _locks.Any(@ lock => @ lock.IsLocked);
    public bool DoesMatch(IKey key)
    {
        return _locks.Any(@ lock => @ lock.DoesMatch(key));
    }
    public void Lock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        _locks
            .Where(@ lock => @ lock.DoesMatch(key))
            .ToList()
            .ForEach(@ lock => @ lock.Lock(key));
    }
    public void Unlock(IKey key)
    {
        if (!DoesMatch(key))
        {
            throw new KeyDoesNotMatchException(key);
        }
        _locks
            .Where(@ lock => @ lock.DoesMatch(key))
            .ToList()
            .ForEach(@ lock => @ lock.Unlock(key));
    }
}

这个新类允许使用者创建一个由其他锁组成的锁。MultiLock保持锁定状态,直到所有锁解锁,并在任何锁锁定后立即锁定。

作为旁注

MultiLock类实现了本书后面讨论的复合设计模式。

撬锁

现在我们有了安全锁,有人需要创建一个撬锁,但是我们将如何创建它?你认为撬锁是一种IKey吗?

也许是另一种设计;在我们这里,不,撬锁不是钥匙。因此,与其包装IKey接口的使用,不如创建一个定义 picklock 的IPicklock接口:

/// <summary>
/// Represent a tool that can be used to pick a lock.
/// </summary>
public interface IPicklock
{
    /// <summary>
    /// Create a key that fits the specified <see cref="ILock"/>.
    /// </summary>
    /// <param name="lock">The lock to pick.</param>
    /// <returns>The key that fits the specified <see cref="ILock"/>.</returns>
    /// <exception cref="ImpossibleToPickTheLockException">
    /// The <see cref="Exception"/> that is thrown when a lock cannot be picked using the current <see cref="IPicklock"/>.
    /// </exception>
    IKey CreateMatchingKeyFor(ILock @lock);
    /// <summary>
    /// Unlock the specified <see cref="ILock"/>.
    /// </summary>
    /// <param name="lock">The lock to pick.</param>
    /// <exception cref="ImpossibleToPickTheLockException">
/// The <see cref="Exception"/> that is thrown when a 
lock cannot be picked using the current <see 
        cref="IPicklock"/>.
    /// </exception>
    void Pick(ILock @lock);
}

再一次,我在界面上使用///编写规范,包括异常。

初始实现基于IKey.Signature的集合。该集合被注入构造函数,以便我们可以重用我们的 picklock。我们可以将其视为预定义的钥匙集合,一种钥匙环:

public class PredefinedPicklock : IPicklock
{
    private readonly string[] _signatures;
    public PredefinedPicklock(string[] signatures)
    {
        _signatures = signatures ?? throw new ArgumentNullException(nameof(signatures));
    }
    public IKey CreateMatchingKeyFor(ILock @lock)
    {
        var key = new FakeKey();
        foreach (var signature in _signatures)
        {
            key.Signature = signature;
            if (@ lock.DoesMatch(key))
            {
                return key;
            }
        }
        throw new ImpossibleToPickTheLockException(@lock);
    }
    public void Pick(ILock @lock)
    {
        var key = new FakeKey();
        foreach (var signature in _signatures)
        {
            key.Signature = signature;
            if (@ lock.DoesMatch(key))
            {
                @ lock.Unlock(key);
                if (!@ lock.IsLocked)
                {
                    return;
                }
            }
        }
        throw new ImpossibleToPickTheLockException(@lock);
    }
    private class FakeKey : IKey
    {
        public string Signature { get; set; }
    }
}

从该示例中,我们可以看到名为FakeKeyIKey的私有实现。我们在PredefinedPicklock类中使用该实现来模拟一个键,并向我们试图选取的ILock实例发送一个IKey.Signature。不幸的是,PredefinedPicklock的功能非常有限。

从这个示例开始显示接口的强度。如果我们看一看名为Should_unlock_the_specified_ILockPick测试方法,我们可以看到如何利用ILock接口的使用,在不知道它在测试用例中的情况下,针对不同类型的锁进行测试:

using Xunit;
namespace DoorLock
{
    public class PredefinedPicklockTest
    {
        //...
        public class Pick : PredefinedPicklockTest
        {
            public static TheoryData<ILock> PickableLocks = new TheoryData<ILock>
            {
                new BasicLock("key1", isLocked: true),
                new MultiLock(
                    new BasicLock("key2", isLocked: true), 
                    new BasicLock("key3", isLocked: true)
                ),
                new MultiLock(
                    new BasicLock("key2", isLocked: true),
                    new MultiLock(
                        new BasicLock("key1", isLocked: true),
                        new BasicLock("key3", isLocked: true)
                    )
                )
            };
            [Theory]
            [MemberData(nameof(PickableLocks))]
            public void Should_unlock_the_specified_ILock(ILock @lock)
            {
                // Arrange
                Assert.True(@ lock.IsLocked, "The lock should be locked.");
                var sut = new PredefinedPicklock(new[] { "key1", "key2", "key3" });
                // Act
                sut.Pick(@lock);
                // Assert
                Assert.False(@ lock.IsLocked, "The lock should be unlocked.");
            }
            //...
        }
    }
}

这只是开始。通过使用接口,我们可以不费吹灰之力地增加灵活性。我们可以将这个示例扩展一段时间,比如创建一个尝试自动生成IKey签名的BruteForcePickLock实现。最后一个想法对你来说可能是一个有用的练习。

合同测试

在继续之前,我想先看看ContractsTests课程。该课程包含我们对钥匙和门的初步评估:

using System.Collections.Generic;
using Xunit;
namespace DoorLock
{
    public class ContractsTests
    {
        [Fact]
        public void A_single_key_should_fit_multiple_locks_expecting_the_same_signature()
        {
            IKey key = new BasicKey("key1");
            LockAndAssertResult(new BasicLock("key1"));
            LockAndAssertResult(new BasicLock("key1"));
            LockAndAssertResult(new MultiLock(new List<ILock> {
                new BasicLock("key1"),
                new BasicLock("key1")
            }));
            void LockAndAssertResult(ILock @lock)
            {
                var result = @ lock.DoesMatch(key);
                Assert.True(result, $"The key '{key.Signature}' should fit the lock");
            }
        }
        [Fact]
        public void Multiple_keys_with_the_same_signature_should_fit_the_same_lock()
        {
            ILock @lock = new BasicLock("key1");
            var picklock = new PredefinedPicklock(new[] { "key1" });
            var fakeKey = picklock.CreateMatchingKeyFor(@lock);
            LockAndAssertResult(new BasicKey("key1"));
            LockAndAssertResult(new BasicKey("key1"));
            LockAndAssertResult(fakeKey);
            void LockAndAssertResult(IKey key)
            {
                var result = @ lock.DoesMatch(key);
                Assert.True(result, $"The key '{key.Signature}' should fit the lock");
            }
        }
    }
}

在这两种测试方法中,我们可以看到接口的可重用性和多功能性。无论锁是什么,我们都可以从它的接口推断出它的用法,减少重复代码。

在一个程序中,正如我们在本书中所探讨的,我们可以出于多种原因利用这些接口,包括依赖注入。

笔记

如果您想知道我如何在方法中编写方法,我们将在第 4 章中讨论表达式体函数成员(C#6),使用 Razor的 MVC 模式。

本例的结论

既然我们已经讨论了所有这些,为什么更小的接口更好?让我们首先将所有接口合并为一个,如下所示:

public interface IMixedInterface
{
    IKey CreateMatchingKeyFor(ILock @lock);
    void Pick(ILock @lock);
    string Signature { get; }
    bool IsLocked { get; }
    void Lock(IKey key);
    void Unlock(IKey key);
    bool DoesMatch(IKey key);
}

当你看到它时,界面告诉你什么?就我个人而言,它告诉我,这里有太多的责任,我很难围绕它建立一个系统。

该接口的主要问题是,每个门都是钥匙和撬锁,因此没有意义。通过拆分接口,可以更轻松地实现系统中不同的、更小的部分,而不必妥协。如果我们想要一个钥匙同时也是一个撬锁,我们可以实现IKeyIPicklock接口,但不要求所有钥匙都是撬锁。

让我们跳到另一个例子来添加更多透视图。

项目-库

上下文:我们正在构建一个用户具有不同角色的 web 应用;有些是管理员,有些只是在使用应用。管理员可以读取和写入系统中的所有数据,而普通用户只能读取。在 UI 方面,有两个不同的部分:公共 UI 和管理面板。

由于不允许用户编写数据,我们不想在那里公开这些方法,以防某些开发人员在某个时候决定使用它们。我们不希望未使用的代码停留在不应该使用该代码的地方。另一方面,我们也不想创建两个类,一个读类和一个写类;我们宁愿只保留一个数据访问类,这样应该更易于维护。

要做到这一点,我们首先通过提取接口来重构早期的BookStore类。为了提高可读性,我们将Load()方法重命名为Find(),然后添加Remove()方法;以前不见了。新界面如下所示:

public interface IBookStore
{
    IEnumerable<Book> Books { get; }
    Book Find(int bookId);
    void Create(Book book);
    void Replace(Book book);
    void Remove(Book book);
}

然后,为了确保使用者不能从外部修改我们的IBookStore实例(封装),我们还需要更新BookStore类的Books属性,以直接返回类型ReadOnlyCollection<Book>,而不是_books字段。这并不影响接口,只影响我们的实现,但它也允许我介绍这个概念。

笔记

System.Collections.ObjectModel命名空间包含几个只读类:

a) ReadOnlyCollection<T>

b) ReadOnlyDictionary<TKey,TValue>

c) ReadOnlyObservableCollection<T>

对于向客户机公开数据而不允许客户机修改数据,这些工具非常有用。在我们的示例中,IEnumerable<Book>实例属于ReadOnlyCollection<Book>类型。我们本可以继续返回我们的内部List<Book>,但一些聪明的开发人员可能会发现这一点,将IEnumerable<Book>转换为List<Book>,并向其添加一些书籍,从而打破封装!

现在我们来看更新的BookStore类:

public class BookStore : IBookStore
{
    private static int _lastId = 0;
    private static List<Book> _books;
    private static int NextId => ++_lastId;
    static BookStore()
    {
        _books = new List<Book>
        {
            new Book
            {
                Id = NextId,
                Title = "Some cool computer book"
            }
        };
    }
    public IEnumerable<Book> Books => new ReadOnlyCollection<Book>(_books);
    public Book Find(int bookId)
    {
        return _books.FirstOrDefault(x => x.Id == bookId);
    }
    public void Create(Book book)
    {
        if (book.Id != default(int))
        {
            throw new Exception("A new book cannot be created with an id.");
        }
        book.Id = NextId;
        _books.Add(book);
    }
    public void Replace(Book book)
    {
        if (_books.Any(x => x.Id == book.Id))
        {
            throw new Exception($"Book {book.Id} does not exist!");
        }
        var index = _books.FindIndex(x => x.Id == book.Id);
        _books[index] = book;
    }
    public void Remove(Book book)
    {
        if (_books.Any(x => x.Id == book.Id))
        {
            throw new Exception($"Book {book.Id} does not exist!");
        }
        var index = _books.FindIndex(x => x.Id == book.Id);
        _books.RemoveAt(index);
    }
}

在查看代码时,如果我们在公共 UI 中公开接口,那么我们也会公开写接口,这是我们想要避免的。

为了解决我们的设计问题,我们可以使用 ISP,通过将IBookStore接口分为两部分启动:IBookReaderIBookWriter

public interface IBookReader
{
    IEnumerable<Book> Books { get; }
    Book Find(int bookId);
}
public interface IBookWriter
{
    void Create(Book book);
    void Replace(Book book);
    void Remove(Book book);
}

通过遵循 ISP,我们甚至可以将IBookWriter分为三个接口:IBookCreatorIBookReplacerIBookRemover。警告一句,我们必须小心,因为这样做的超粒度接口分离可能会在系统中造成相当大的混乱,但也可能非常有益,这取决于上下文和您的目标。

提示

所以,我给你一点建议。注意不要盲目地过度使用这一原则,要考虑到内聚性和您试图构建的内容,而不是盲目地考虑接口的粒度。界面越精细,系统就越灵活,但请记住,灵活性是有成本的,而且成本很快就会变得非常高。

现在,我们需要更新我们的BookStore类。首先,我们必须实现两个新接口:

public class BookStore : IBookReader, IBookWriter
{
    // ...
}

那很容易!有了新的BookStore类,我们可以像这样独立使用IBookReaderIBookWriter

IBookReader reader = new BookStore();
IBookWriter writer = new BookStore();
// ...
var book3 = reader.Find(3);
// ...
writer.Create(new Book { Title = "Some nice new title!" });
// ...

让我们关注readerwriter变量。在公共方面,我们现在只能使用IBookReader接口,将BookStore实现隐藏在接口后面。在管理员端,我们可以使用这两个接口来管理书籍。

结论

为了恢复 ISP 背后的想法,如果您有多个较小的接口,则更容易重用它们,并且只公开您需要的功能,而不是公开不需要的 API。这就是目标:只依赖于您使用的接口。此外,有了多个专门的接口,如果需要的话,通过实现多个接口更容易组成更大的部分。如果我们将其与相反的情况进行比较,那么如果在一个大接口的实现中不需要方法,我们就无法从中删除方法。

如果你还没有看到所有的好处,不要担心。一旦我们讨论了下一个原理、DIP 和依赖注入,所有的部分都应该结合在一起。通过所有这些,我们可以以一种优雅且易于管理的方式实现充分的接口隔离。

相关性反转原理(DIP)

还有一段引用自罗伯特·C·马丁(Robert C.Martin)(包括维基百科的隐含上下文):

人们应该“依赖抽象,而不是具体。”

在上一节中,我向您介绍了与 SRP 和 ISP 的接口。界面是我们坚实武器库的关键元素之一!此外,使用界面是接近倾角的最佳方式。当然,抽象类也是抽象的,但根据经验,您应该尽可能依赖接口。

你可能会想,为什么不使用抽象类呢?abstract class是一个抽象,但不是 100%抽象,如果是,你应该用一个接口替换它。抽象类用于封装默认行为,然后可以在子类中继承这些行为。它们很有用,但接口更灵活、更强大,更适合设计合同。

此外,在编写单元测试时,使用接口可以为您节省数不清的艰难和复杂的工作时间。如果您正在构建一个其他人使用的框架或库,这一点就更加正确了。在这种情况下,请友好地为您的消费者提供接口。

所有这些关于接口的讨论都很好,但是依赖关系如何才能逆转呢?让我们从比较直接依赖和反向依赖开始。

直接相关

如果我们有一个使用Weapon实例的Ninja类,依赖关系图应该是这样的,因为Ninja直接依赖于Weapon类:

Figure 3.2 – Direct dependency schema

图 3.2–直接依赖模式

反向依赖

如果我们通过引入抽象来反转依赖关系,Ninja类将只依赖于新的IWeapon接口。这样做使我们能够灵活地改变武器类型,而不会影响系统的稳定性,也不会改变Ninja等级,特别是如果我们也遵循 OCP。间接地,Ninja仍然使用Weapon类实例,从而打破了直接依赖关系。

以下是更新后的模式:

Figure 3.3 – Indirect dependency schema

图 3.3——间接依赖模式

利用倾角反演子系统

更进一步说,您还可以通过创建两个或多个组件来隔离和解耦完整的子系统:

  1. 仅包含接口的抽象程序集。
  2. 包含来自第一个程序集的协定实现的一个或多个其他程序集。

在.NET 中有多个这样的示例,例如Microsoft.Extensions.DependencyInjection.AbstractionsMicrosoft.Extensions.DependencyInjection程序集。我们还在第 12 章理解分层中探讨这一概念。

在跳进更多代码之前,让我们看看另一个表示这个思想的模式。这一次,它与从数据库本身提取数据访问有关(我们稍后还会进一步讨论):

图 3.4–表示如何通过反转依赖关系打破紧密耦合的图表

在图中,App包直接依赖于Abstractions包,有两种实现:LocalSql。从那里,我们应该能够在不破坏我们的App的情况下,将一个实现替换为另一个实现。原因是我们依赖于抽象,并使用这些抽象对应用进行编码。无论使用什么实现,程序都应该运行良好。

我最近在基于微服务的应用中设计的另一个示例是发布-订阅(pub-sub)通信库。微服务使用一些抽象,并且有一个或多个可交换的实现,因此一个微服务可以使用提供者,而另一个微服务可以使用另一个提供者而不直接依赖它。我们在第 16 章【微服务架构简介】中讨论了发布子模式和微服务架构。在此之前,请将微服务视为一个应用。

包装

这里描述的包可以是名称空间,也可以是程序集。通过围绕程序集划分职责,它可以只加载需要加载的实现。例如,一个应用可以加载“本地”程序集,另一个应用可以加载“SQL”程序集,而第三个应用可以同时加载这两个程序集。

项目-依赖倒置

上下文:我们刚刚了解了 DIP,希望将其应用到我们的书店应用中。由于我们还没有任何真正的用户界面,我们相信创建多个可重用的程序集是有意义的,我们的 ASP.NET Core 应用可以在以后使用这些程序集,从而允许我们交换一个 GUI 和另一个 GUI。同时,我们将使用一个小型控制台应用测试我们的代码。

有三个项目:

  • GUI:控制台应用
  • 核心:应用逻辑
  • Data: the data access

    分层

    这个概念被称为分层。稍后我们将更深入地访问分层。现在,您可以将其视为将责任划分为不同的程序集。

使用经典的依赖关系层次结构,我们将得到以下依赖关系图:

Figure 3.5 – Diagram representing assemblies that directly depend on the next assembly

图 3.5-表示直接依赖于下一个组件的组件的图表

这不是很灵活,因为所有组件都直接链接到下一条生产线,从而在它们之间建立了牢固、牢不可破的纽带。现在让我们使用 DIP 重新讨论这个问题。

笔记

为了保持简单并只关注代码的一部分,我只抽象了程序的数据部分。在本书中,我们将进一步深入探讨依赖项反转,以及依赖项注入。

现在,请关注DIP.DataDIP.Data.InMemory以及本章的代码示例。

解决方案中有四个项目;三个库和一个控制台。他们的目标如下:

  • DIP.Console是入口点,程序。它的角色是编写和运行应用。它使用DIP.Core并定义应该使用什么实现来覆盖DIP.Data接口,在本例中为DIP.Data.InMemory
  • DIP.Core是程序核心,共享逻辑。它唯一的依赖项是DIP.Data,抽象掉了实现。
  • DIP.Data包含持久化接口:IBookReaderIBookWriter。它还包含数据模型(Book类)。
  • DIP.Data.InMemoryDIP.Data的具体实现。

为了可视化程序集的关系,让我们来看看下面的图表:

Figure 3.6 – Diagram representing assemblies that invert the dependency flow,  breaking coupling between DIP.Core and DIP.Data.InMemory

图 3.6–表示反转依赖流、断开 DIP.Core 和 DIP.Data.InMemory 之间耦合的组件的图表

如果我们从开始看Core项目的PublicService类,我们可以看到它只依赖于Data项目的IBookReader接口:

public class PublicService
{
    public IBookReader _bookReader;
    public Task<IEnumerable<Book>> FindAllAsync()
    {
        return Task.FromResult(_bookReader.Books);
    }
    public Task<Book> FindAsync(int bookId)
    {
        var book = _bookReader.Find(bookId);
        return Task.FromResult(book);
    }
}

PublicService类定义了一些使用IBookReader抽象来查询书籍的方法。PublicService扮演消费者角色,不知道任何具体类别。即使我们愿意,也无法从该项目访问实现。我们成功了;我们扭转了依赖关系。是的,就这么简单。

笔记

拥有_bookReader这样的公共字段会破坏封装,所以不要在项目中这样做。我只是想把例子的重点放在下降上。我们将在后面看到如何使用良好的实践来利用 DIP,包括利用依赖注入。

没有任何具体的实现,接口什么也做不了,因此 DIP 的另一部分是通过定义支持这些抽象的实现来配置消费者。为了帮助我们,让我们在Program内部创建一个名为Composer的私有类来集中 DIP 的这一步。

也就是说,在实际项目中,你通常不想做什么,但是在我们覆盖依赖注入之前,我们必须依靠一种更为手动的方法,所以让我们来看看这个轻版本,关注于 To.T0TA:

private static class Composer
{
    private readonly static BookStore BookStore = new 
    BookStore();
    // ...
    public static PublicService CreatePublicService()
    {
        return new PublicService
        {
            _bookReader = BookStore
        };
    }
}

CreatePublicService()方法负责构建PublicService实例。在其中,我们将具体类的一个实例BookStore分配给public IBookReader _bookReader;字段,使PublicService不知道其_bookReader 实现。

您是否注意到公共服务类中存在任何违反 DIP 的情况?

是的,PublicService是具体的,Program直接使用它。这违反了 DIP。如果您想尝试依赖项反转,您可以在项目中修复此冲突;编码永远是最好的学习方式!

这个小示例演示了如何反转依赖关系,确保:

  • 代码总是依赖于抽象(接口)。
  • 项目也依赖于抽象(依赖于DIP.Data而不是DIP.Data.InMemory

结论

这个原则的结论与下一步发生的事情密切相关(见下一节)。然而,这个想法是依赖于抽象(接口或抽象类)。尽量坚持使用接口。它们比抽象类更灵活。

根据具体情况,类之间会产生紧密耦合,从而导致系统更难维护。从长远来看,依赖关系之间的内聚对于耦合是否会帮助或伤害您起着至关重要的作用。稍后再谈。

下一步是什么?

单词依赖注入出现了几次,你可能对此感到好奇,所以我们来看看它是什么。依赖注入,或控制反转IoC),是一种机制(概念),是 ASP.NET Core 的一级公民。它允许您将抽象映射到实现,当您需要一个新类型时,整个对象树将根据您的配置自动创建。一旦你习惯了,你就不能后退;但是要小心挑战,因为你可能需要“忘却”一部分你所知道的来接受这项新技术。

说够了。在对依赖项注入过于兴奋之前,让我们先看一下最后几节。我们将从第 7 章开始这段旅程,深入研究依赖注入

其他重要原则

在进一步讨论之前,我还发现了另外两个原则:

  • 关注点分离
  • 不要重复你自己(干)

当然,在阅读了坚实的原则之后,你可能会发现这些更基本,但它们仍然是我们刚刚学到的东西的补充。

笔记

还有很多其他的原则,有些你可能已经知道,有些你以后很可能会了解,但在某个时候,我必须选择主题,否则就要写一本百科全书大小的书。

关注点分离

其思想是将软件分成逻辑块,每个逻辑块都是一个关注点;这可以从将程序分解为模块到将 SRP 应用于某些子系统。这可以应用于任何编程范例。如何封装特定关注点取决于范式和关注点的级别。级别越高,解决方案的范围越广;级别越低,颗粒越大。

例如,以下内容适用:

  • 通过使用面向方面编程AOP,我们可以将安全性或日志记录视为横切关注点,将代码封装在方面中。
  • 通过使用面向对象编程OOP,我们还可以将安全性或日志记录视为一个交叉关注点,将共享逻辑封装在 ASP.NET 过滤器中。
  • 通过再次使用 OOP,我们可以将 web 页面的呈现和 HTTP 请求的处理视为两个关注点,从而形成 MVC 模式;视图“呈现”页面,而控制器处理 HTTP 请求。
  • 通过使用任何范式,我们都可以将添加扩展点视为一个关注点,从而实现基于插件的设计。
  • 再次使用任何范例,我们都可以将负责将一个对象复制到另一个对象的组件视为关注点。相反,另一个组件的职责可能是通过遵循一些规则来高效地编排这些拷贝,例如限制并行发生的拷贝量、将溢出操作排队等等。

正如你在这些例子中所注意到的,一个问题可以是一个重要的问题,也可以是一个微小的细节;但是,当你把软件划分成碎片来创建有凝聚力的单元时,必须考虑关注点。良好的关注点分离应有助于您创建模块化设计,并帮助您更有效地面对设计困境。

不要重复你自己(干)

好的,这个原则的名称是不言自明的,而且,正如我们已经在 SRP 和 OCP 中看到的,我们可以而且应该将逻辑扩展并封装到更小的单元中,以实现可重用性和更低的维护成本。

干式原理通过以下说明,或多或少以另一种方式进行解释:

当系统中存在重复的逻辑时,将其封装并在多个位置重新使用新的封装

目标是避免在规范更改时进行多次更改。为什么?避免忘记制作,或避免在程序中产生不一致和错误。

通过关注点重新组合重复逻辑非常重要,而不仅仅是通过多个代码块的外观。让我们看看前一个示例的Program类中的这两个方法:

private static async Task PublicAppAsync()
{
    var publicService = Composer.CreatePublicService();
    var books = await publicService.FindAllAsync();
    foreach (var book in books)
    {
        presenter.Display(book);
    }
}
private static async Task AdminAppAsync()
{
    var adminService = Composer.CreateAdminService();
    var books = await adminService.FindAllAsync();
    foreach (var book in books)
    {
        presenter.Display(book);
    }
}

代码非常相似,但是试图从中提取一个方法是错误的。为什么?因为公共程序和管理程序可能有不同的更改原因(例如,在管理面板中添加过滤器,但在公共部分中不添加过滤器)。

但是,我们可以在 presenter 类中创建一个 display 方法来处理书籍集合,用一个presenter.Display(books)调用替换foreach循环。然后,我们可以将这两种方法移出Program,而不会产生太大影响。将来,这将允许我们支持多个实现,一个用于管理员,一个用于公共用户,以增加灵活性。

提示

我已经告诉过你了,但我们又来了。当您不知道如何命名类或方法时,您可能隔离了一个无效或不完整的关注点。这是一个很好的指标,你应该回到绘图板。

总结

在本章中,我们介绍了许多体系结构原则。我们从探索五个坚实的原则及其在现代软件工程中的重要性开始,然后跳到干涸和分离关注点原则,这为混合添加了更多的指导。通过遵循这些原则,您应该能够构建更好、更易于维护的软件。正如我们也谈到的,原则只是原则,而不是法律。你必须始终小心不要滥用它们,这样它们才是有益的,而不是有害的。环境总是很重要的;内部工具和关键业务应用需要不同程度的修补。尽量不要过度设计每件事。

有了我们工具箱中的所有这些原则,我们现在准备跳入设计模式,进一步提高我们的设计水平!在接下来的几章中,我们将探讨如何实现一些最常用的 GoF 模式,以及如何在依赖注入的另一个层次上应用这些模式。依赖注入将帮助我们在日常设计中遵循坚实的原则,但在此之前,在接下来的两章中,我们将探讨 ASP.NET Core MVC。

问题

让我们来看看几个练习题:

  1. 固体首字母缩略词代表了多少原则?
  2. 当遵循坚实的原则时,想法是创建更大的组件,每个组件都可以通过创建上帝大小的类来管理程序的更多元素,这是真的吗?
  3. 通过遵循 DRY 原则,您希望从任何地方消除所有代码重复,而不管源代码如何,并将代码封装到可重用组件中。这个肯定正确吗?
  4. ISP 告诉我们创建多个较小的接口比创建一个较大的接口好,这是真的吗?
  5. 什么原则告诉我们,创建多个处理单个职责的较小类比创建一个处理多个职责的类更好?