开闭原则

出处:http://msdn.microsoft.com/en-us/magazine/cc546578.aspx

引言

本文是新开设的MSDN软件设计基础专栏的第一篇文章。我的目的是以不局限于某种特定工具或者某个(软件工程)周期方法(lifecycle methodology)的方式来讨论设计的模式和原则。换言之,我计划讨论一些可以引导你使用任何技术,或者在任何项目中更好地进行设计的基础知识。

我喜欢以讨论开闭原则和其他由 Robert C.Martin 在其著作《敏捷软件开发,原则,模式和实践》中所倡导的相关主题作为开始。不要因为在标题中出现“敏捷”一词就把书合上了,因为这本书实际上完全是关于如何竭力进行优良软件设计的。

问下你自己:有多少次你是从零开始去写一个全新的应用程序?又有多少次你是通过将新功能添加到现有代码库(codebase)中来作为开始?恐怕大多数的情况下,你是花费了更多的时间将新功能添加到现有代码库中吧。

然后再问自己另一个问题:写全新的代码容易还是对现有代码进行修改容易?通常对我来说写全新的方法和类要比深入旧代码中,找出我想要修改的部分容易得多。修改旧有代码增添了破坏已有功能的风险。对于新代码来说,你通常只需要测试下新实现的功能就可以了。而当你修改旧有代码时,你不得不既要测试你更改的部分,还要进行一系列的兼容测试,以保证你没有破坏任何的旧有代码。

所以,你通常基于现有的代码库进行工作,可是写全新的代码又比修改旧的代码容易得多。你难道不想像写全新代码一样多产、轻松地去对现有的代码库进行扩展么?这就是开闭原则一展身手的地方了。我来解释一下开闭原则,它的意思是:软件实体应该对于扩展是开放的,而对于修改是关闭的。

从字面上看这好像是矛盾的,实际并非如此。它的全部含义就是你应该这样去构建一个应用程序:可以在对现有代码做最小修改的同时添加新的功能。我曾经认为开闭原则仅仅是意味着使用插件(plugins),但并不是这么简单。

你应该避免一个小小的改动就波及了你应用程序中的多个类。这样会使程序更加脆弱,更倾向于产生向下兼容的问题,并使扩展付出更高的代价。为了隔离变化,你会想要以一种一旦写好了就再也不需要修改的方式去写类和方法。

然而你如何构建代码以实现隔离变化呢?我想说的第一步就是遵循单一责任原则。

单一责任原则

在遵循开闭原则的过程中,我期望能够写出一个类或者方法,在以后我回过头读它的时候,会很舒服地看到它能完成它的工作并且我也不需要再修改它。你永远也达不到真正的开闭天堂,但是通过严格地遵循与之相关的单一责任原则:一个类应该有并且只有一个更改的理由,你可以非常靠近地接近它。

写那些永远也不需要进行修改的类的最简单方法就是写一些只能做一件事情的类。通过这种方式,一个类只有在它所确切负责的那件事更改时它才需要更改。代码1演示了没有遵循单一责任原则的一个例子。我真的怀疑你正在像这样设计一个系统,但是最好记得为什么我们不应该这样去构建代码。

代码1. 这个类负责了太多的事

public class OrderProcessingModule {
  public void Process(OrderStatusMessage orderStatusMessage) {
    // 从配置文件中读取连接字符串
    string connectionString =
      ConfigurationManager.ConnectionStrings["Main"].ConnectionString;

    Order order = null;

    using (SqlConnection connection =
      new SqlConnection(connectionString)) {
      // 从数据库中获取一些数据
      order = fetchData(orderStatusMessage, connection);
    }

    // 向来自于OrderStatusMessage的订单提交变更
    updateTheOrder(order);

    // 国际订单有一些特定的规则
    if (order.IsInternational) {
      processInternationalOrder(order);
    }

    // 对于大批量订单我们需要特别处理
    else if (order.LineItems.Count > 10) {
      processLargeDomesticOrder(order);
    }
    // 小的国内订单也需要区别处理
    else {
      processRegularDomesticOrder(order);
    }

    // 如果订单准备好了就发货
    if (order.IsReadyToShip()) {
      ShippingGateway gateway = new ShippingGateway();

      // 将订单对象提交运送
      ShipmentMessage message = createShipmentMessageForOrder(order);
      gateway.SendShipment(message);
  }
}

OrderProcessingModule真是太忙了。它要进行数据访问、获取配置文件信息、为订单处理执行业务规则(可能本身就非常复杂),并且将完成的订单转移出货。通常的情况是,如果你通过这种方式创建了OrderProcessingModule,你将会经常深入到这段代码中进行修改。而许多系统需求的变化也会造成OrderProcessingModule的代码产生非常多的变更,让系统变得岌岌可危并使变更花费很大代价。

除了这种一大块代码的方式,你应该遵循单一责任原则,将整个OrderProcessingModule分成一系列相关类的子系统,每一个类完成它自己特定的职责。举个例子,你可以将所有数据访问的功能放到一个新类中,管它叫OrderDataService,而把Order的业务逻辑放到另一个类中(我会在下一节进行更详细的讲述)。

根据开闭原则,通过将业务逻辑和数据访问的职责划分到不同的类中,你将可以独立地改变它们中的一个而不会影响到另一个。数据库物理部署的变化可能将使你把数据访问部分完全更换掉(对扩展开放),然而订单逻辑类依然没有任何改动(对变更关闭)。

单一责任原则的要点不仅仅是写一些更小的类和方法。它的要点是每一个类应该实现一系列紧密相关的功能。遵循单一责任原则的最简单办法就是不断地问自己是不是这个类的每一个方法和操作都与这个类的名称直接相关。如果你找到了一些方法与这个类的名称不相称,你可以考虑将这些方法移到另一个类中。

责任链模式

业务规则在代码库(Codebase)的生命周期中相对于系统的任何其他部分可能面临更多的变化。在OrderProcessingModule类中,基于接收的订单的类型,对于订单的处理有不少的分支逻辑:

if (order.IsInternational) {
  processInternationalOrder(order);
}else if (order.LineItems.Count > 10) {
  processLargeDomesticOrder(order);
}else {
  processRegularDomesticOrder(order);
}

一个真正的订单处理系统很有可能在业务增长的时候包含更多类型的订单 -- 并且要考虑很多的特殊情况,比如对于政府或者受到优待的客户,以及每周一次的特别供应。对你而言,如果能够书写并且测试一些新的订单处理逻辑而不用冒着破坏现有业务规则的风险将会是一件非常有利的事情。

最后,通过代码2所示的责任链模式,对于这个订单处理的例子你可以更进一步地运用开闭原则。我所做的第一件事就是把所有的分支判断由OrderProcessingModule中转移到一个独立的类中,这个类实现IOrderHandler接口:

public interface IOrderHandler {
  void ProcessOrder(Order order);
  bool CanProcess(Order order);
}

代码2. 引入责任链

public class OrderProcessingModule {
  private IOrderHandler[] _handlers;

  public OrderProcessingModule() {
    _handlers = new IOrderHandler[] {
                new InternationalOrderHandler(),
                new SmallDomesticOrderHandler(),
                new LargeDomesticOrderHandler(),
    };
  }

  public void Process (OrderStatusMessage orderStatusMessage,
    Order order) {
    // 对来自OrderStatusMessage的订单提交变更
    updateTheOrder(order);

    // 找出知道如何处理这个订单的第一个IOrderHandler
    IOrderHandler handler =
      Array.Find(_handlers, h => h.CanProcess(order));

    handler.ProcessOrder(order);
  }

  private void updateTheOrder(Order order) {
  }
}

然后我可以对于每种类型的订单写一个独立的IOrderHandler实现,包含着像这样的基本逻辑,“我知道如何处理这个订单,让我来处理它”。

现在对于每种类型的订单处理逻辑都分隔到了独立的处理类中(Handler Class),对于某种类型的订单你可以更改业务规则而不用担心会破化其他类型订单的规则。更好的是,你可以添加全新类型的订单处理程序而只需要对现有代码做细小的改动。

举个例子,比如说,以后某个时候,我需要在系统中为政府的订单添加支持。通过责任链模式,我可以添加一个全新的类,叫做GovernmentOrderHandler,这个类实现IOrderHandler接口。一旦我对GovernmentOrderHanlder按期望的方式所进行的工作感到满意,通过修改OrderProcessingModule类构造函数的一行代码,我就可以添加这个新的政府订单处理规则:

public OrderProcessingModule() {
  _handlers = new IOrderHandler[] {
              new InternationalOrderHandler(),
              new SmallDomesticOrderHandler(),
              new LargeDomesticOrderHandler(),
              new GovernmentOrderHandler(),       // 新添加的处理规则
  };
}

通过在订单处理规则上遵循开闭原则,我使得在系统中添加新类型的订单处理逻辑容易得多。我能够用比在一个类中实现各种类型订单处理所要面临的小得多的影响其它类型订单的风险来完成政府订单规则的添加。

双重分发

如果以后上面的步骤变得更加复杂该怎么办呢?如果仅仅依靠多态无法满足未来可能出现的所有变化呢?我们可以使用称为双重分发的模式将变化推入子类中,通过这种方式,我们不需要破坏现有的接口定义。

举个例子,比如说我们正在构建一个复杂的桌面应用程序,它能一次显示某种主面板中的一屏(screen)。每次我在程序中打开一个新屏,我需要做很多的事情。我可能需要更改可用的菜单,检查那些已经打开的屏幕的状态,做一些定制整个屏幕显示的事,并且,yeah,以某种方式显示新屏。

典型地,我会使用某种Model View Presenter(MVP)模式的变体作为我的桌面应用程序的构架,并且我通常会使用程序控制器(Application Controller)模式去协调应用程序中各种不同MVP组(译注:因为MVP由三个部分组成,所以将每三个部件分为一组)。通过在MVP中使用一个程序控制器(了解MVP的更多信息,可以参考Jean-Paul Boodhoo在MSDN杂志设计模式专栏中关于MVP模式的文章,http://msdn.microsoft.com/en-us/magazine/cc188690.aspx ),激活屏幕可能会包含下面三个基本的部分:

  1. 每一屏(Screen)都有一个提供器(Presenter),每个提供器知道关于一个特定屏幕的所有事情。
  2. 应用程序的主窗体有一个ApplicationShell。ApplicationShell负责以其自己的某种方式显示位于面板(Panel)或者Tab控件(TabControl)中的独立视图(view)。ApplilcationShell也将包含所有的菜单。
  3. 应用程序控制器(ApplicationController)在程序中扮演交警的角色。它知道ApplicationShell以及在应用程序中传输的每一个提供器。应用程序控制器控制屏幕激活和反激活的生命周期。

如果我所需要做得只不过简单地在激活时显示ApplicationShell中的视图,代码可能如同代码3所示。对于简单的应用程序来说这完全是可行的,但是如果程序变得更加复杂会怎样呢?如果在下一个发布版本中,我有新的需求,在某些屏幕激活的时候向主Shell中添加菜单项?如果对于某些而非全部的视图,我想要在靠着主屏幕左边际的新面板中显示额外的控件?

代码3.一个简单的基于视图的应用程序

public interface IApplicationShell {
  void DisplayMainView(object view);
}

public interface IPresenter {
  // 仅仅提供对于内部Windows窗体用户控件或者窗体的访问
  object View { get; }
}

public class ApplicationController {
  private IApplicationShell _shell;

  public ApplicationController(IApplicationShell shell) {
    _shell = shell;
  }

  public void ActivateScreen(IPresenter presenter) {
    teardownCurrentScreen();

    // 设置新屏幕
    _shell.DisplayMainView(presenter.View);
  }

  private void teardownCurrentScreen() {
    // 移除现存屏幕
  }
}

我还想让构架支持嵌入(pluggable),以便于通过简单的嵌入新的提供器就可以在程序中添加新屏幕,所以现有提供器的抽象应该对于这些新菜单以及左边面板的构造函数有所了解。然后我还必须更改ApplicationShell或者程序控制器,以对新菜单项以及左边面板中额外的控件做出响应。

代码4 显示了一种可能的解决方案。我向IPrensenter接口中添加了新的属性用于对新的菜单项以及任何有可能添加到新的左侧面板中的控件进行建模。我同样为这些新的概念向IApplicationShell添加了一些新的成员。然后我在ApplicationController.ActivateScreen(IPresenter)方法中添加了些新代码

代码4. 试图扩展IPresenter

public class MenuCommand{
    // ...
}
public interface IApplicationShell{
    void DisplayMainView(object view);
   
    // 新行为
    void AddMenuCommands(MenuCommand[] commands);
    void DisplayInExplorerPane(object paneView);
}
public interface IPresenter
{
    object View { get; }

    // 新属性
    MenuCommand[] Commands{ get; }
    object[] ExplorerViews { get; }
}
public class ApplicationController {
    private IApplicationShell _shell;
      
    public ApplicationController(IApplicationShell shell){   
       _shell = shell;
    }

    public void ActivateScreen(IPresenter presenter)
    {
       teardownCurrentScreen();
      
       // 设置新屏幕
       _shell.DisplayMainView(presenter.View);

       // 新代码
       _shell.AddMenuCommands(presenter.Commands);
       foreach (var explorerView in presenter.ExplorerViews){
           _shell.DisplayInExplorerPane(explorerView);
       }
    }

    private void teardownCurrentScreen()
    {
       // 移除现有屏幕
    }
}

那么,这个解决方案遵守了开闭原则么?一点也没有。首先,我必须修改IPresenter接口。因为它是一个接口,我必须在代码库中修改IPresenter接口的每一个实现,并且为这些新的方法添加一些空的实现,仅仅为了我的代码可以再一次编译通过。这通常是一个无法忍受的改变,尤其是当你不能直接控制这些IPresenter实现中的任何一个的时候。关于这部分我们后面再说。

我同样需要修改ApplicationController类,以使得它知道主ApplicationShell中的屏幕所可能需要的所有新的定制化类型。最后,我需要修改ApplicationShell以使它支持这些新的Shell定制。变化很小,但是同样,我很有可能不久以后想要再次添加更多的屏幕定制。

在一个真正的应用程序中,ApplicationControll类可能会变得足够复杂,而不必承担额外配置ApplicationShell的责任。我们将这些职责置于每个提供器中可能会更好一些。

通过使用一个名为Presenter的抽象类,而不是使用一个接口将会减少修改每个IPresenter接口的实现的痛苦。像代码5这样,我可以仅仅向抽象类中添加一些默认的实现。并且在添加新的行为时我不需要修改任何现有的Presenter实现。

代码5.使用抽象的Presenter

public abstract class BasePresenter
{
    public abstract object View { get;}

    // Commands 的默认实现
    public virtual MenuCommand[] Commands {
         get{
             return new MenuCommand[0];
         }
    }

    // 默认的 ExplorerViews
    public virtual object[] ExplorerViews{
         get{
             return new object[0];
         }
    }
}

最后,还有一种更靠近开闭原则的方式需要说明。除了在IPresenter和BasePresenter中添加Get选择器,我可以使用双重分发模式。

几天前在实际生活中我意外地得到了双重分发模式的一个演示。我的团队刚刚转移到一个新的办公室中,我们一直在解决网络上的问题。我们的网络负责人上周给我打了个电话并且告诉我我的同事应该如何做以连接到VPN。他喋喋不休地向我讲述一大堆我不懂的网络术语,所以我最终把电话给了我的同事,让他们直接对话。

现在我们也为程序控制器做同样的事情。并非让程序控制器去询问每个提供器哪些需要被显示在ApplicationShell中,提供器可以简单地忽略中间人并且告诉ApplicationShell对于每一屏应该显示些什么(查看 代码6)。

public interface IPresenter {
  void SetupView(IApplicationShell shell);
}

public class ApplicationController {
  private IApplicationShell _shell;

  public ApplicationController(IApplicationShell shell) {
    _shell = shell;
  }

  public void ActivateScreen(IPresenter presenter) {
    teardownCurrentScreen();

// 使用双重分发设置新屏幕
    presenter.SetupView(_shell);
  }

  private void teardownCurrentScreen() {
    // 移除现有屏幕
  }
}

起初不管我如何做,我都将不得不为了新的定制菜单以及左栏面板中的控件而去修改ApplicationShell,但如果我使用双重分发策略,对于新的变更,程序控制器和提供器都只需要做非常少的修改。创建额外的屏幕概念(screen concepts)我不再需要修改程序控制器和提供器类。对于新的Shell概念(screen concepts),这个构架是开放的可扩展的,而程序控制器和单独的提供器类对于修改是关闭的。

Liskov 替换原则

如果我前面所说的,使用开闭原则最通常的做法就是使用多态去用一个全新的类替换程序中现存的一部分。就拿最早的例子来说,你有一个称为BusinessProcess的类,它的工作是,嗯,执行业务处理。在这个过程中,它需要从数据源中访问数据:

public class BusinessProcess {
  private IDataSource _source;

  public BusinessProcess(IDataSource source) {
    _source = source;
  }
}
public interface IDataSource {
  Entity FindEntity(long key);
}

如果你可以通过实现IDataSource对这个系统进行扩展并且不对BusinessProcess类做任何的修改,那么这个设计就遵循了开闭原则。你可能起初通过一个简单的基于XML文件的机制,然后转而使用数据库进行存储,随后添加某种类型的缓存-- 但是你还是不想修改BusinessProcess类。所有这些都是可能的,只要你能够遵循一个相关的原则:Liskov替代原则。

粗略地说,如果你可以在任何接受抽象的地方使用那个抽象的任何实现,就是在遵循Liskov替换原则。BusinessProcess应该可以使用IDataSource的任何实现而不需要进行修改。BusinessProcess不应该知道IDataSource中除了进行通信的的公共接口以外的任何内部事务。

为了深入这个观点,代码7演示了一个没有遵循Liskov替换原则的例子。这个版本的BusinessProcess类型对于获取FileSource有着特定的逻辑,同时依赖一些针对于DatabaseSource类的特定错误处理逻辑。你应该创建IDataSource的实现以便他们可以处理所有特定的底层需求。通过这样做可以使 BusinessProcess类像代码8这样书写:

代码7.没有对IDataSource进行抽象的BusinessProcess类

public class BusinessProcess {
  private IDataSource _source;

  public BusinessProcess(IDataSource source) {
    _source = source;
  }

  public void Process() {
    long theKey = 112;

    // 针对于 FileSource的特定代码
    if (_source is FileSource)  {
      ((FileSource)_source).LoadFile();
    }

    try {
      Entity entity = _source.FindEntity(theKey);
    }
    catch (System.Data.DataException) {
     // 对于DatabaseSource的特定处理程序
     // 这是 向下转换(downcast) 的一个例子
      ((DatabaseSource)_source).CleanUpTheConnection();
    }
  }
}

代码8更好的BusinessProcess

public class BusinessProcess {
  private readonly IDataSource _source;

  public BusinessProcess(IDataSource source) {
    _source = source;
  }

  public void Process(Message message) {
     // Process()方法的第一部分
   
     // 这里不再有针对于某个特定IDataSource实现的代码
    Entity entity = _source.FindEntity(message.Key);

     // Process()方法的最后部分
  }
}

寻找闭包

记得,如果一个类仅仅依赖于它所交互的另一个类的公共契约(Contract)(译注:其实就是公共接口),开闭原则只是通过多态来实现。如果在某一部分中,一个抽象了的类必须向下转换为特定的子类,那么你就没有遵循开闭原则。

如果一个使用另一个类的类嵌入了关于它所依赖的类的内部工作(比如假设一个方法的返回值总是由大到小排序),那么实际上对于这个依赖你不能替换为另一个实现。因为对于阅读你代码的人来说它们是不明显的,这种类型的对于特定实现的隐式耦合特别有害。不要让抽象的消费者依赖于除过那个抽象的公共契约的任何东西。

我建议你将开闭原则作为一个设计方向而非一个完全的目标。如果你试图将你能想到所有可能改变的东西都变成完全可嵌入式的,你很有可能创建一个非常难于工作的过度设计的系统。你可能并非总是试图写一些在各个方面都满足开闭原则的代码,但是即使只进行到中途也是非常有益的。