[搬运]在C#使用.NET设计模式的新观点

原文地址:http://www.dotnetcurry.com/dotnet/1092/dotnet-design-patterns

软件开发有许多设计模式。其中一些模式非常受欢迎。说几乎所有的模式都可以被接受,而不管我们选择的编程语言如何。我们将看到如何在C#中使用一些设计模式。

在这篇文章中,我们不会只关注一组设计模式。我们很好的重新观察一些已有的问题,看看我们如何能够将它们用于现实世界的困境和疑虑。

.NET设计模式一个小背景

有些开发人员讨厌设计模式是事实。这主要是因为分析、决定和实施一个特定的模式可能是一个头痛的问题。我敢肯定,我们都遇到过开发者花费无数个小时,甚至几天的时间来讨论使用的模式类型的情况。更不用说,最好的方法和实施它的方式。这是一个非常糟糕的方式来开发软件。

这种困境往往是由于他们的代码可以适用于一组设计模式而引起的。这是一件难以置信的事情。但是,如果我们认为这些模式是一套工具,可以让我们做出正确的决定,并且可以用作有效的沟通工具; 那么我们正在以正确的方式接近它。

本文主要关注使用C#作为编程语言的.NET设计模式。一些模式也可以应用于非基于.NET的编程语言。重复我刚才所说的,大多数模式可以被接受,而不管我们选择的编程语言。

抽象工厂模式

维基百科定义:“ 抽象工厂模式提供了一种方法来封装一组具有共同主题的单个工厂,而不指定具体的类。”

虽然这个定义是正确的,但是这种模式的真实用法可能是不同的。它可以基于现实生活中的问题和人们可能遇到的问题。以最简单的形式,我们将创建具有相关对象的实例,而不必指定具体的实现。请参考下面的例子:

public class Book
{
    public string Title { get; set; }
    public int Pages { get; set; }
 
    public override string ToString()
    {
        return string.Format("Book {0} - {1}", Title, Pages);
    }
}
public class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine(CreateInstance("ConsoleApplication1.Book", new Dictionary));
    }
}

根据上面的例子,实例的创建被委托给一个名为CreateInstances()的方法。
这个方法接受一个类名和属性值作为参数。
乍一看,这似乎是很多代码只是为了创建一个实例,并为其属性添加一些值。但是当我们想要根据参数动态创建实例时,这种方法变得非常强大。例如,基于用户输入在运行时创建实例。这对于 依赖注入(DI) 也是非常重要的。上面的例子只是演示了这种模式的基础。但是展示抽象工厂模式的最好方法就是看看现实世界的例子。介绍一些已经存在的东西是多余的。因此,如果你有兴趣,请看这个堆栈溢出问题,它有一些很好的信息。
附加说明: Activator.CreateInstance不以抽象工厂模式为中心。它只是允许我们基于类型参数以便捷的方式创建实例。在某些情况下,我们只是通过新建(即新的Book())来创建实例,并仍然使用抽象工厂模式。这一切都取决于用例及其各种应用程序。

级联模式

public class MailManager
{
    public void To(string address) {  Console.WriteLine("To");}
    public void From(string address) { Console.WriteLine("From"); }
    public void Subject(string subject) { Console.WriteLine("Subject"); }
    public void Body(string body) { Console.WriteLine("Body"); }
    public void Send() { Console.WriteLine("Sent!"); }
}
 
public class Program
{
    public static void Main(string[] args)
    {
        var mailManager = new MailManager();
        mailManager.From("alan@developer.com");
        mailManager.To("jonsmith@developer.com");
        mailManager.Subject("Code sample");
        mailManager.Body("This is an the email body!");
        mailManager.Send();
    }
}

这是一个非常简单的代码示例。但是让我们把注意力集中在类Program的MailManager类的客户端上。如果我们看这个类,它创建一个MailManager实例并调用例如.To(),.From(),.Body() .Send()等方法。
如果我们仔细看一下代码,就像我们刚刚看到的那样编写代码有几个问题。 一个是注意变量“mailManager”已经重复了好几次了。所以我们觉得编写多余的写代码有些尴尬。其次如果我们想发送另外一封邮件?我们应该创建一个新的MailManager实例,还是应该重用现有的“ mailManager ”实例?我们首先遇到这些问题的原因是API(应用程序编程接口)对于使用者来说是不清楚的。
让我们看看更好的方式来表示这个代码。
首先,我们对MailManager类进行一个小改动,如下所示。我们修改代码,以便返回MailManager的当前实例,而不是返回类型void。
注意Send()方法不返回MailManager。我将解释为什么我们这样做是在下一节。
修改后的代码显示在这里。

public class Mailmanager
{
  public MailManager To(string address) { Console.WriteLine("To"); return this; }
  public MailManager From(string address) { Console.WriteLine("From"); return this; }
  public MailManager Subject(string subject) { Console.WriteLine("Subject"); return this;} 
  public MailManager Body(string body) { Console.WriteLine("Body"); return this; }
  public void Send() { Console.WriteLine("Sent!"); }
}

为了使用新的MailManager实现,我们将修改程序如下。

public static void Main(string[] args)
{
    new MailManager()
        .From("alan@developer.com")
        .To("jonsmith@developer.com")
        .Subject("Code sample")
        .Body("This is an the email body!")
        .Send();
}

代码的重复和冗长已被删除。我们还介绍了一个很好的流畅风格的API。我们称之为级联模式。您可能已经在许多流行的框架(如FluentValidation)中看到过这种模式。我最喜欢的是NBuilder

Builder<product>.CreateNew().With(x => x.Title = "some title").Build(); </product>

级联Lambda模式

这就是我们开始为Cascade Pattern添加一些风味的地方。我们再来扩展一下这个例子。基于前面的例子,这是我们最终编写的代码。

new MailManager()
    .From("alan@developer.com")
    .To("jonsmith@developer.com")
    .Subject("Code sample")
    .Body("This is an the email body!")
    .Send();

请注意,Send()方法是从MailManager的一个实例中调用的。这是方法链的最后一个例程。因此它不需要返回一个实例。这也意味着API隐含地指出,如果我们想发送另一个邮件,我们将不得不创建一个新的MailManager实例。然而,在调用.Send()之后,用户还不清楚应该怎么做。
这是我们可以利用lambda表达式的优势,并明确向API的使用者展示意图。首先,我们将Send()方法转换为一个Static方法,并将其签名更改为接受一个Action委托。此委托将MailManager作为参数。我们在Send()方法中调用这个动作,如下所示:

public class MailManager
{
  public MailManager To(string address) { Console.WriteLine("To"); return this; }
  public MailManager From(string address) { Console.WriteLine("From"); return this; }
  public MailManager Subject(string subject) { Console.WriteLine("Subject"); return this;  
}
 
public MailManager Body(string body) { Console.WriteLine("Body"); return this; }
 
public static void Send(Action<mailmanager> action) 
{   
  action(new MailManager());   
  Console.WriteLine("Sent!"); 
}  //</mailmanager>

为了使用MailManager类,我们可以改变程序,如下所示:

Mailmanager.Send((mail) => mail
            .From("alan@developer.com")
            .To("jonsmith@developer.com")
            .Subject("Code sample")
            .Body("This is an the email body!"));

正如我们在代码示例中看到的那样,由委托指定的动作作为Send()方法的参数,清楚地表明动作与构造邮件有关。因此它可以通过调用Send()方法发送出去。这种方法更加优雅,因为它消除了前面描述的Send()方法的困惑。

插件模式

描述可插拔行为的最好方法是使用示例。以下代码示例计算给定数字数组的总数。

public class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine(GetTotal(new [] {1, 2, 3, 4, 5, 6}));
        Console.Read();
    }
 
    public static int GetTotal(int[] numbers)
    {
        int total = 0;
 
        foreach (int n in numbers)
        {
            total += n;
        }
 
        return total;
    }
}

假设我们有一个新的要求。尽管我们不想改变GetTotal()方法,但是我们也想计算偶数。我们大多数人会添加另一个方法,比如GetEvenTotalNumbers,如下所示。

public class Program
{
    public static int GetTotal(int[] numbers)
    {
        int total = 0;
 
        foreach (int n in numbers)
        {
            total += n;
        }
 
        return total;
    }
 
    public static int GetTotalEvenNumbers(int[] numbers)
    {
        int total = 0;
 
        foreach (int n in numbers)
        {
            if (n%2 == 0)
            {
                total += n;    
            }
        }
 
        return total;
    }
 
    public static void Main(string[] args)
    {
        Console.WriteLine(GetTotal(new [] {1, 2, 3, 4, 5, 6}));
        Console.WriteLine(GetTotalEvenNumbers(new[] { 1, 2, 3, 4, 5, 6 }));
        Console.Read();
    }
}

我们只是复制/粘贴现有​​的函数,并添加了需要计算偶数的唯一条件。那很容易!假设有另外一个要求计算奇数的总数,它就像复制/粘贴一个较早的方法一样简单,并稍微修改它来计算奇数。

public static int GetTotalOddNumbers(int[] numbers)
{
    int total = 0;
 
    foreach (int n in numbers)
    {
        if (n % 2 != 0)
        {
            total += n;
        }
    }
 
    return total;
}

在这个阶段,你可能会意识到,这不是我们编写软件的方法。这几乎是复制粘贴不可维护的代码。为什么不可维护?比方说,如果我们必须改变我们计算总数的方式。这意味着我们将不得不对3种不同的方法进行修改。如果我们仔细分析这三种方法,它们在实施上是非常相似的。if条件只有不同。为了删除代码重复,我们可以引入可插入行为。我们可以将差异外化并将其注入方法。通过这种方式,API的使用者可以控制传入该方法的内容。这被称为可插入行为。

public class Program
{
    public static int GetTotal(int[] numbers, Predicate<int> selector)    
    {         
         int total = 0;          
         foreach (int n in numbers)
         {             
              if (selector(n)) {total += n;} 
          }
          return total;     
    }     
    public static void Main(string[] args)
    {
        Console.WriteLine(GetTotal(new [] {1, 2, 3, 4, 5, 6}, i => true));
        Console.WriteLine(GetTotal(new[] { 1, 2, 3, 4, 5, 6 }, i => i % 2 == 0));
        Console.WriteLine(GetTotal(new[] { 1, 2, 3, 4, 5, 6 }, i => i % 2 != 0));
        Console.Read();
    } 
}  

正如我们在上面的例子中看到的一个委托已被注入该方法。这使我们能够将选择标准外化。代码重复已被删除,我们有更多的可维护代码。
除此之外,假设我们要扩展选择器的行为。例如,选择基于多个参数。为此,我们可以使用一个Func委托。您可以指定多个参数给选择器并返回您所需的结果。有关如何使用Func委托的更多信息,请参阅Func

用Lambda表达式执行模式

这个模式允许我们使用lambda表达式来执行一段代码。现在听起来很简单,这就是lambda表达式所做的。然而,这种模式是关于使用lambda表达式并实现一种编码风格,这将增强现有流行的设计模式之一。我们来看一个例子。
假设我们要清理对象中的资源。我们将编写类似于以下的代码:

public class Database
{
    public Database()
    {
        Debug.WriteLine("Database Created..");
    }
 
    public void Query1()
    {
        Debug.WriteLine("Query1..");
    }
 
    public void Query2()
    {
        Debug.WriteLine("Query2..");
    }
 
    ~Database()
    {
        Debug.WriteLine("Cleaned-Up");
    }
}
 
public class Program
{
    public static void Main(string[] args)
    {
        var db = new Database();
        db.Query1();
        db.Query2();
    }
}

这个程序的输出是:
Database Created..
Query1..
Query2..
Cleaned Up!
请注意,Finalizer/Destructor隐式调用,它将清理资源。与上面的代码的问题是,我们没有在控制时终结被调用。
让我们看看for循环中的相同代码并执行它几次:

public class Program
{
    public static void Main(string[] args)
    {
        for (int i = 0; i < 4; i++)
        {
            var db = new Database();
            db.Query1();
            db.Query2();
        }          
    }
}

它的执行结果将如下:
Database Created..
Query1..
Query2..
Database Created..
Query1..
Query2..
Database Created..
Query1..
Query2..
Database Created..
Query1..
Query2..
Cleaned Up!
Cleaned Up!
Cleaned Up!
Cleaned Up!

每个数据库创建的所有清理操作发生在循环的结尾!如果我们想要明确地释放资源,这样可能并不理想,因此在被垃圾收集之前他们不会在托管堆中生活太久。在现实世界的例子中,可能有太多的对象有一个大的对象图,试图创建数据库连接和超时。显而易见的解决办法是尽快明确清理资源。

我们来介绍一个Cleanup()方法,如下所示:

public class Database
{
    public Database()
    {
        Debug.WriteLine("Database Created..");
    }
 
    public void Query1()
    {
        Debug.WriteLine("Query1..");
    }
 
    public void Query2()
    {
        Debug.WriteLine("Query2..");
    }
 
    public void Cleanup()
    {
        Debug.WriteLine("Cleaned Up!");
    }
 
    ~Database()
    {
        Debug.WriteLine("Cleaned Up!");
    }
}
 
public class Program
{
    public static void Main(string[] args)
    {
        for (int i = 0; i < 4; i++)
        {
            var db = new Database();
            db.Query1();
            db.Query2();
            db.Cleanup();
        }          
    }
}

输出结果:
Database Created..
Query1..
Query2..
Cleaned Up!
Database Created..
Query1..
Query2..
Cleaned Up!
Database Created..
Query1..
Query2..
Cleaned Up!
Database Created..
Query1..
Query2..
Cleaned Up!
Cleaned Up!
Cleaned Up!
Cleaned Up!
Cleaned Up!
请注意,我们还没有删除Finalizer 。对于每个数据库创建Cleanup()将显式执行。正如我们在循环的第一个例子中看到的那样,在循环结束时,资源将被垃圾收集。
这种方法的一个问题是,如果在其中一个查询操作中出现异常,则清理操作将永远不会被调用。
正如我们许多人所做的那样,我们将查询操作封装在try {}块和finally {}块中,并执行清理操作。此外,我们捕捉异常并做一些事情,但为了简化代码,我忽略了这一点。

public class Program
{
    public static void Main(string[] args)
    {
        for (int i = 0; i < 4; i++)
        {
            var db = new Database();
 
            try
            {
                db.Query1();
                db.Query2();
            }                
            finally
            {
                db.Cleanup();    
            }                
        }          
    }
}

从技术上讲,这解决了这个问题。由于清理操作总是被调用,不管是否有例外。
但是这个方法还有其他一些问题。例如,每当我们实例化Db并调用查询操作时,作为开发人员,我们必须记住将它包含在try {}和finally块中。更糟的是,在更复杂的情况下,我们甚至可以在不知道要调用哪个操作的情况下引入错误。
那么我们该如何处理这种情况呢?
这是我们大多数人会使用有名的 [Dispose模式] (http://msdn.microsoft.com/en-us/library/b1yfkh5e(v=vs.110).aspx)的地方。使用Dispose模式{}和{}} 不再需要{} 。如IDisposable.Dispose()方法在操作结束清除资源。这包括查询操作期间的任何异常情况。

public class Database : IDisposable
{
//More code..
    public void Dispose()
    {
        Cleanup();
        GC.SuppressFinalize(this);
    }
}
 
public class Program
{
    public static void Main(string[] args)
    {
        for (int i = 0; i < 4; i++)
        {
            using (var db = new Database())
            {
                db.Query1();
                db.Query2();
            } 
        }          
    }
}

这绝对是编写代码更好的方法。在使用块抽象了对象的处置。它确保使用Dispose()方法进行清理。我们大多数人会用这种方法来解决。你会看到这个模式在很多应用程序中使用。
但是,如果我们仔细观察,使用模式本身仍然存在问题。从技术上说,明确地清理资源是正确的。但是,不能保证API的客户端将使用使用块来清理资源。例如,任何人都可以编写下面的代码:

var db = new Database();
db.Query1();
db.Query2();

对于资源密集型应用程序,如果此代码已被提交而未被注意到,则可能对应用程序产生不利影响。所以我们回到原点。正如我们已经注意到的那样,没有立即进行处理或清理操作。
丢失Dispose()方法是一个严重的问题。更何况,我们还面临着一个新的挑战,要确保我们正确实施Dispose方法/逻辑。不是每个人都知道如何正确地实现Dispose方法/逻辑。大多数会采取一些其他资源,如博客/文章。这是所有不必要的麻烦。

public class Database
{
    private Database()
    {
        Debug.WriteLine("Database Created..");
    }
 
    public void Query1()
    {
        Debug.WriteLine("Query1..");
    }
 
    public void Query2()
    {
        Debug.WriteLine("Query2..");
    }
 
    private void Cleanup()
    {
        Debug.WriteLine("Cleaned Up!");
    }
 
   public static void Create(Action<database> execution)     
   {
     var db = new Database();
      try
      {
          execution(db);
      }
      finally 
      {
          db.Cleanup();         
      }     
    }  
}  </database>

这里发生了一些有趣的事情。IDisposable的实现已被删除。这个类的构造函数变成私有的。所以这个设计被强制执行,用户不能直接实例化数据库实例。同样,Cleanup()方法也是私有的。有一个新的Create()方法,它将Action委托(它接受数据库的一个实例)作为参数。这个方法的实现将执行Action指定的动作参数。重要的是,这个动作的执行已经封装在一个try {}块中,允许清理操作,就像我们前面看到的那样。
这是客户端/用户如何使用这个API:

public class Program
{
    public static void Main(string[] args)
    {
        Database.Create(database =>
        {
            database.Query1();
            database.Query2();
        });
    }
}

与以前的方法的主要区别在于,现在我们正在从客户端/用户抽象清理操作,而是引导用户使用特定的API。由于所有样板代码都已经从客户端抽象出来,所以这种方法变得非常自然。这样很难想象开发者会犯错。

用Lambda表达式实现更多的方法模式的实际应用

显然这种模式不限于管理数据库的资源。它有很多其他的潜力。这里有一些应用程序。

  • 在创建事务的事务代码中,检查事务是否完成,然后在需要时进行提交或回滚。
  • 如果我们有很大的外部资源,我们要尽快处理,而不必等待.NET垃圾收集。
  • 为了解决一些框架限制 - 下面更多的内容。这很有趣。请看下面。

解决框架限制

我相信大部分人都熟悉单元测试。我是单元测试的忠实拥趸。在.NET平台中,如果你使用了MSTest框架,我相信你已经看到了ExpectedException属性。这个属性的使用有一个限制,我们不能在测试执行过程中指定引发异常的确切的调用。
例如,请参阅这里的测试。

[TestClass]
public class UnitTest1
{
    [TestMethod][ExpectedException(typeof(Exception))]
    public void SomeTestMethodThrowsException()
    {
        var sut = new SystemUnderTest("some param");
 
        sut.SomeMethod();
    }
}

此代码演示ExpectedException属性的典型实现。请注意,我们期望sut.SomeMethod()会抛出异常。
以下是SUT(被测系统)的外观。请注意,我已经删除了代码简洁的详细实现。

public class SystemUnderTest
{
    public SystemUnderTest(string param)
    {
    }
 
    public void SomeMethod()
    {
        //more code
        //throws exception
    }
}

在执行测试期间,如果有异常被抛出,它将被捕获并且测试会成功。然而,测试不会确切知道抛出异常的位置。例如,它可能在创建SytemUnderTest的过程中。
我们可以使用Lambda表达式执行周围方法模式来解决这个限制。这是通过创建一个辅助方法,它接受一个行动 参数作为委托。

public static class ExceptionAssert
{
    public static T Throws<t>(Action action) where T : Exception
    {
         try
         {
             action();
         }
         catch (T ex)
         {
             return ex;
         }
         Assert.Fail("Expected exception of type {0}.", typeof(T));
         return null;
    }
}

调用方法如下:

[TestMethod]
public void SomeTestMethodThrowsException()
{
    var sut = new SystemUnderTest("some param");
    ExceptionAssert.Throws<exception>(() => sut.SomeMethod()); 
}  </exception>

上面的ExceptionAssert.Throws()可用于显式调用抛出异常的方法。

题外话...
在其他一些单元测试框架(如NUnitxUnit)中我们不会有这个限制。这些框架已经有内置的帮助方法(使用这种模式实现)来定位导致异常的确切操作。
例如xUnit.NET有:
public static T Throws<t>(Assert.ThrowsDelegate testCode) where T : Exception </t>

总结

在这篇文章中,我们研究了C#中的各种.NET设计模式。设计模式是好的,但只有正确使用它们才能生效。我们希望将设计模式视为一套工具,使我们能够在代码基础上做出更好的决策。我们也希望把它们当作通讯工具,这样我们可以改善代码库的沟通。
我们已经看了 抽象工厂模式级联模式 。我们还研究了使用lambda表达式对现有设计模式采用稍微不同的方法。这包括 级联Lambda模式插件模式,最后是 Lambda表达式执行模式 。在本文中,我们看到Lambda表达式是增强某些着名软件模式的强大功能的好方法。

posted @ 2017-11-19 21:37  张蘅水  阅读(818)  评论(2编辑  收藏  举报