Loading

敏捷开发-SOLID-单一职责原则

单一职责原则(Single Responsibility Principle,SPR):要求开发人员所编写的代码有且只有一个变更理由

如果一个类有多个变更理由,那么它就具有多个职责。多职责类应该被分解为多个单职责类。

问题描述

一个简单的交易处理器类,它能从文件读取记录并更新数据库。尽管它现在看起来还很小,但为了满足一些需求,你需要在此基础上持续添加新特性。

一个拥有太多职责类的示例

public class TradeProcessor
{
    public void ProcessTrades(System.IO.Stream stream)
    {
        
        //(1)
        // read rows
        var lines = new List<string>();
        using(var reader = new System.IO.StreamReader(stream))
        {
            string line;
            while((line = reader.ReadLine()) != null)
            {
            	lines.Add(line);
            }
        }
        
        //(2)
        var trades = new List<TradeRecord>();
        var lineCount = 1;
        foreach(var line in lines)
        {
            var fields = line.Split(new char[] { ',' });
            if(fields.Length != 3)
            {
                Console.WriteLine("WARN: Line {0} malformed. Only {1} field(s) found.",
                lineCount, fields.Length);
                continue;
            }
            if(fields[0].Length != 6)
            {
                Console.WriteLine("WARN: Trade currencies on line {0} malformed: '{1}'",
                                  lineCount, fields[0]);
                continue;
            }
            int tradeAmount;
            if(!int.TryParse(fields[1], out tradeAmount))
            {
                Console.WriteLine("WARN: Trade amount on line {0} not a valid integer:'{1}'"
                              , lineCount, fields[1]);
            }
            decimal tradePrice;
            if (!decimal.TryParse(fields[2], out tradePrice))
            {
                Console.WriteLine("WARN: Trade price on line {0} not a valid decimal: '{1}'",
                                  lineCount, fields[2]);
            }
            
            //(3)
            var sourceCurrencyCode = fields[0].Substring(0, 3);
            var destinationCurrencyCode = fields[0].Substring(3, 3);
            // calculate values
            var trade = new TradeRecord
            {
                SourceCurrency = sourceCurrencyCode,
                DestinationCurrency = destinationCurrencyCode,
                Lots = tradeAmount / LotSize,
                Price = tradePrice
             };
             trades.Add(trade);
             lineCount++;
        }
        
        //(4)
        using (var connection = new System.Data.SqlClient
       .SqlConnection("Data Source=(local);Initial Catalog=TradeDatabase;Integrated Security=True"))
        {
            connection.Open();
            using (var transaction = connection.BeginTransaction())
            {
                foreach(var trade in trades)
                {
                    var command = connection.CreateCommand();
                    command.Transaction = transaction;
                    command.CommandType = System.Data.CommandType.StoredProcedure;
                    command.CommandText = "dbo.insert_trade";
                    command.Parameters.AddWithValue("@sourceCurrency", trade.SourceCurrency);
                    command.Parameters.AddWithValue("@destinationCurrency", trade.DestinationCurrency);
                    command.Parameters.AddWithValue("@lots", trade.Lots);
                    command.Parameters.AddWithValue("@price", trade.Price);
                    command.ExecuteNonQuery();
                }
                transaction.Commit();
            }
            connection.Close();
        }
        Console.WriteLine("INFO: {0} trades processed", trades.Count);
    }
    private static float LotSize = 100000f;
}

剖析上面的代码:

(1) 从一个Stream参数中读出每行内容并存放到一个字符串列表中。
(2) 解析出每行内容中的一组数据并把它们存放在一个更结构化的TradeRecord实例的列表中。
(3) 整个分析过程中包括了一些校验数据的动作和将日志输出到控制台的动作。
(4) 枚举每个TradeRecord实例,并调用了一个存储子流程来将数据存放到一个数据库中。

TradeProcessor的职责包括:读取流数据,解析字符串,验证数据,记录日志,以及向数据库插入数据

单一职责原则要求这个类和其他类一样应该只有一个变更理由

然而,TradeProcessor的现状则是会在以下场合都会发生变更:

  • 当你决定用远程Web服务来代替Stream作为输入源时。
  • 当输入数据的格式变化时,也许会增加一个新的数据项来表示交易的代理人。
  • 当输入数据的验证规则发生变化时。
  • 当你输出警告、错误和信息日志的方式改变时。输出给控制台的方式对远程Web服务来说是不可行的
  • 当数据库也发生了某些变化时,也许是insert_trade存储过程也需要一个额外的代理人参数,或者你决定使用文档存储来代替关系型数据库时,又或者将数据库移到你所使用的Web服务后台时。

初步:重构清晰度

TradeProcessor重构为单职责类的第一步就是ProcessTrades方法拆分为多个更小的方法,每个方法专注完成一个职责

重构后的ProcessTrades方法,它现在只是委托其他几个方法做事

public void ProcessTrades(System.IO.Stream stream)
{
    //从流中读取交易数据
    var lines = ReadTradeData(stream);
    //将字符串数据转换为TradeRecord实例
    var trades = ParseTrades(lines);
    //将交易数据写入永久存储中
    StoreTrades(trades);
}

注意:

方法的输出会成为下一个方法的输入。

如果没有从ParseTrades方法中返回的交易记录数据,你就无法调用StoreTrades方法,同样,直到ReadTradeData方法返回字符串后,你才可以调用ParseTrades方法。

从流中读取交易数据

//从流中读取交易数据
private IEnumerable<string> ReadTradeData(System.IO.Stream stream)
{
    var tradeData = new List<string>();
    using (var reader = new System.IO.StreamReader(stream))
    {
        string line;
        while ((line = reader.ReadLine()) != null)
        {
            tradeData.Add(line);
        }
    }
    return tradeData;
}

这个方法里面的代码直接从原有的ProcessTrades方法中提取。经过简单的封装后,它用一个字符串枚举返回了读取到的所有字符串数据。

注意:此处与原始代码是有所不同的,返回的字符串枚举是只读的,而原有实现中却无法阻止后续代码添加更多的字符串。

将字符串数据转换为TradeRecord实例

//将字符串数据转换为TradeRecord实例
private IEnumerable<TradeRecord> ParseTrades(IEnumerable<string> tradeData)
{
    var trades = new List<TradeRecord>();
    var lineCount = 1;
    foreach (var line in tradeData)
    {
        var fields = line.Split(new char[] { ',' });
        //检验数据
        if(!ValidateTradeData(fields, lineCount))
        {
            continue;
        }
        //一组从流中读取到的字符串映射为一组TradeRecord类的实例
        var trade = MapTradeDataToTradeRecord(fields);
        trades.Add(trade);
        lineCount++;
    }
    return trades;
}

该方法把数据校验和映射这两个职责委托给了其他两个方法。如果不做这种委托,这部分的处理逻辑依然会因带有太多职责从而太过复杂。

ValidateTradeData方法:它返回一个布尔值来表明交易记录数据是否有效。

所有的校验代码都在这一个方法内

private bool ValidateTradeData(string[] fields, int currentLine)
{
    if (fields.Length != 3)
    {
        LogMessage("WARN: Line {0} malformed. Only {1} field(s) found.",
                   currentLine, fields.Length);
        return false;
    }
    if (fields[0].Length != 6)
    {
        LogMessage("WARN: Trade currencies on line {0} malformed: '{1}'", 
                   currentLine, fields[0]);
        return false;
    }
    int tradeAmount;
    if (!int.TryParse(fields[1], out tradeAmount))
    {
        LogMessage("WARN: Trade amount on line {0} not a valid integer: '{1}'",
                   currentLine, fields[1]);
        return false;
    }
    decimal tradePrice;
    if (!decimal.TryParse(fields[2], out tradePrice))
    {
        LogMessage("WARN: Trade price on line {0} not a valid decimal: '{1}'",
                   currentLine, fields[2]);
        return false;
    }
    return true;
}

相对于原始代码,唯一的改动就是委托另外一个方法来记录日志。

LogMessage方法只相当于给Console.WriteLine起了一个别名

private void LogMessage(string message, params object[] args)
{
    Console.WriteLine(message, args);
}

ParseTrades方法委托的另外一个方法。它将一组从流中读取到的字符串映射为一组TradeRecord类的实例。

从一个类型映射到另外一个类型是一个独立的职责

private TradeRecord MapTradeDataToTradeRecord(string[] fields)
{
    var sourceCurrencyCode = fields[0].Substring(0, 3);
    var destinationCurrencyCode = fields[0].Substring(3, 3);
    var tradeAmount = int.Parse(fields[1]);
    var tradePrice = decimal.Parse(fields[2]);
    var tradeRecord = new TradeRecord
    {
        SourceCurrency = sourceCurrencyCode,
        DestinationCurrency = destinationCurrencyCode,
        Lots = tradeAmount / LotSize,
        Price = tradePrice
    };
    return tradeRecord;
}

重构生成的第六个也是最后一个方法是StoreTrades.这个方法封装了与数据库交互的代码。它也委托了前面讲到的LogMessage方法来记录信息日志。

private void StoreTrades(IEnumerable<TradeRecord> trades)
{
    using (var connection = new System.Data.SqlClient
           .SqlConnection("DataSource=(local);Initial Catalog=TradeDatabase;Integrated Security=True"))
    {
        connection.Open();
        using (var transaction = connection.BeginTransaction())
        {
            foreach (var trade in trades)
            {
                var command = connection.CreateCommand();
                command.Transaction = transaction;
                command.CommandType = System.Data.CommandType.StoredProcedure;
                command.CommandText = "dbo.insert_trade";
                command.Parameters.AddWithValue("@sourceCurrency", trade.SourceCurrency);
                command.Parameters.AddWithValue("@destinationCurrency",
                trade.DestinationCurrency);
                command.Parameters.AddWithValue("@lots", trade.Lots);
                command.Parameters.AddWithValue("@price", trade.Price);
                command.ExecuteNonQuery();
            }
            transaction.Commit();
        }
        connection.Close();
    }
    LogMessage("INFO: {0} trades processed", trades.Count());
}

深入:重构抽象

术语原型(prototype)概念验证(proof of concept)会用于描述这种小的应用程序,而从原型到产品应用程序的转变几乎是无缝的.

如果不做抽象重构,大量的只带有模糊职责和抽象定义的需求就会发展成为一个“大泥球”:一个包含一个或一组类的程序集。最终得到的应用程序没有相应的单元测试,又很难维护和增强,而它还可能是业务链上一个很重要的节点。

重构TradeProcessor抽象的第一步就是设计一个或一组接口来执行三个最高级别的任务:读取数据,处理数据和存储数据

TradeProcessor将会依赖三个新的接口
image

基于上一节重构分割ProcessTrades方法代码形成的三个方法,你应该清楚如何开始第一组抽象。

根据单一职责原则的定义,这三个主要的职责应该由不同的类来负责。

TradeProcessor只是简单地封装了一个流程

public class TradeProcessor
{
    //读取数据
    private readonly ITradeDataProvider tradeDataProvider;
    //处理数据
    private readonly ITradeParser tradeParser;
    //存储数据
    private readonly ITradeStorage tradeStorage;
    
    public TradeProcessor(ITradeDataProvider tradeDataProvider, 
                          ITradeParser tradeParser,
                          ITradeStorage tradeStorage)
    {
        this.tradeDataProvider = tradeDataProvider;
        this.tradeParser = tradeParser;
        this.tradeStorage = tradeStorage;
    }
    //
    public void ProcessTrades()
    {
        var lines = tradeDataProvider.GetTradeData();
        var trades = tradeParser.Parse(lines);
        tradeStorage.Persist(trades);
    }
}

TradeProcessor类现在不包括任何交易处理流程的细节实现,取而代之的是整个流程的蓝图。

这个类现在只对交易数据格式转换的流程建模,这是它的唯一职责,也是引起该类后续变更的唯一原因。

如果流程本身发生改变,该类也需要改变以反映更新后的流程。

你如果只是打算不接受流数据,不再将日志输出到控制台,或者不再将交易数据存储到数据库中,这些都不会影响TradeProcessor类。

TradeProcessor类依赖的所有接口都应该在各自独立的程序集内,这样就保证了TradeProcessor类的客户端或接口的实现程序集之间没有相
互依赖。

三个接口的实现类StreamTradeDataProviderSimpleTradeParserAdoNetTradeStorage可以分布在三个不同的程序集中。

这三个类型有个共同的命名约定:用实现所需的具体上下文信息代替了接口名称的前缀I

  • StreamTradeProvider顾名思义就是从Stream获取数据的ITradeProvider接口的一个实现;
  • AdoNetTradeStorage类就是指使用ADO.NET将交易数据存储到ITradeStorage接口的一个实现;
  • SimpleTradeParser类名中的Simple则表示该类没有其他上下文的依赖关系。

这三个实现类可以放置在同一个程序集中,因为它们都依赖Microsoft.NET Framework的一组核心程序集。如果要引入的实现依赖的是第三方程序集、第一方程序集或者非核心的.NET Framework程序集,你就应该把它们布置在各自独立的程序集中。

ITradeDataProvider接口并不依赖Stream类。而上一节中用来获取交易数据的方法需要一个Stream实例作为传入参数,但是这样做明显会让该方法依赖Stream所在的程序集。当你在创建接口并做抽象重构时,不要保留那些会对代码自适应能力有副作用的依赖关系。

StreamTradeProvider类需要一个Stream实例作为其构造函数的传入参数,而不是成员方法的传入参数。通过使用构造函数,你可以建立需要的任何依赖关系,而且不会影响接口。

通过构造函数的传入参数将上下文传入方法,保持接口不受影响

public class StreamTradeDataProvider : ITradeDataProvider
{
    private Stream stream;
    
    public StreamTradeDataProvider(Stream stream)
    {
        this.stream = stream;
    }
    
    public IEnumerable<string> GetTradeData()
    {
        var tradeData = new List<string>();
        using (var reader = new StreamReader(stream))
        {
            string line;
            while ((line = reader.ReadLine()) != null)
            {
                tradeData.Add(line);
            }
        }
        return tradeData;
    }
}

记住,作为客户端的TradeProcessor类现在不清楚,当然也不应该清楚StreamTradeDataProvider类的任何实现细节,现在它只能通过ITradeDataProvider接口的GetTradeData方法来获取数据。

TradeProcessor类还可以提取更多的抽象。比如原有的ParseTrades方法被委托承担数据校验和类型映射的职责,你可以通过重复重构来实现只具备单一职责的SimpleTradeParser

重构SimpleTradeParser类后产生的新类也只具备单一职责
image

将职责抽象成为接口(以及相应的实现)的过程是递归的。在检视每个类时,你需要判断它是否具备多重职责,如果是,提取职责的抽象直到该类只具备单个职责。

SimpleTradeParser类,它同样在需要的时候委托其他接口来辅助完成自己的任务。对于它而言,唯一的变更缘由就是交易数据整体结构的改变,比如,可能会用tab替代逗号来分隔字符串数据,或者用XML结构来代替简单结构的字符串。

解析交易数据的算法封装在ITradeParser接口的实现当中

public class SimpleTradeParser : ITradeParser
{
    
    private readonly ITradeValidator tradeValidator;
    private readonly ITradeMapper tradeMapper;
    
    public SimpleTradeParser(ITradeValidator tradeValidator, ITradeMapper tradeMapper)
    {
        this.tradeValidator = tradeValidator;
        this.tradeMapper = tradeMapper;
    }
    
    public IEnumerable<TradeRecord> Parse(IEnumerable<string> tradeData)
    {
        var trades = new List<TradeRecord>();
        var lineCount = 1;
        foreach (var line in tradeData)
        {
            var fields = line.Split(new char[] { ',' });
            if (!tradeValidator.Validate(fields))
            {
                continue;
            }
            var trade = tradeMapper.Map(fields);
            trades.Add(trade);
            lineCount++;
        }
        return trades;
    }
}

最后一个重构的目标是将日志功能的抽象从两个使用它的类中提取出来

现在ITradeValidatorITradeStorage两个接口的实现过程中都会直接将日志输出到控制台中。

这次重构,你不需要再实现自己的日志类型,而是创建一个适配器类来调用流行的日志库Log4Net.

通过为Log4Net实现一个适配器,就无需在每个程序集中都引用它了
image

Log4NetLoggerAdapter等适配器类的好处是,你可以通过它们把第三方引用转换为第一方引用

注意看,AdoNetTradeStorageSimpleTradeValidator两个类都依赖第一方的ILogger接口,但在运行时实际调用的依然是Log4Net的程序集.

重构后的SimpleTradeValidator

public class SimpleTradeValidator : ITradeValidator
{
    private readonly ILogger logger;
    
    public SimpleTradeValidator(ILogger logger)
    {
        this.logger = logger;
    }
    
    public bool Validate(string[] tradeData)
    {
        if (tradeData.Length != 3)
        {
            logger.LogWarning("Line malformed. Only {1} field(s) found.",tradeData.Length);
            return false;
        }
        if (tradeData[0].Length != 6)
        {
            logger.LogWarning("Trade currencies malformed: '{1}'", tradeData[0]);
            return false;
        }
        int tradeAmount;
        if (!int.TryParse(tradeData[1], out tradeAmount))
        {
            logger.LogWarning("Trade amount not a valid integer: '{1}'", tradeData[1]);
            return false;
        }
        decimal tradePrice;
        if (!decimal.TryParse(tradeData[2], out tradePrice))
        {
            logger.LogWarning("WARN: Trade price not a valid decimal: '{1}'",tradeData[2]);
            return false;
        }
        return true;
    }
}

请记住,对于代码的功能你没有做任何改动,代码在功能上和原来是完全一样的。尽管如此,如果你就是想要增强功能,也可以很轻松地做到。为满足
新需求而给代码增加新功能不只是扩展和重构现有代码,还需要增加实现新功能的新代码。

重构后的新版本能在无需改变任何现有类的情况下实现以下需求的增强功能:

  • 需求:当你决定用远程Web服务来代替Stream做输入源时。

    ​ 解决方案:创建一个ITradeDataProvider接口的新实现类来支持从服务获取数据。

  • 需求:当输入数据的格式变化时,也许会增加了一个新的数据项来表示交易的代理人。

    ​ 解决方案:改变ITradeDataValidatorITradeDataMapper以及ITradeStorage三个接口的实现以支持处理新的代理人数据。

  • 需求:当输入数据的验证规则变化时。

    ​ 解决方案:修改ITradeDataValidator接口的实现以反映最新的规则。

  • 需求:当你输出警告、错误和信息日志的方式改变时。输出给控制台的方式对远程Web服务来说是不可行的。

    ​ 解决方案:通过适配器访问的Log4Net已经提供了非常丰富的日志记录方法了。

  • 需求:当数据库也发生了某些变化时,也许是insert_trade存储过程也需要一个额外的代理人参数,或者你决定使用文档存储来代替关系型数据库,又或者将数据库移到你所使用的Web服务后台。

    ​ 解决方案:如果存储过程改变了,你需要编辑AdoNetTradeStoragel类来包含代理人数据的处理。对于其他两个改变,你需要创建MongoTradeStorage类来使用MongoDB存储交易数据,你还需要创建一个ServiceTradeStorage类来隐藏Web服务后的实现。

单一职责原则和修饰器模式

修饰器模式(Decorator Pattern,DP):能够很好地确保每个类只有单个职责。一般情况下,完成很多事情的类并不能轻易地将职责划分到其他类型中,因为很多职责看起来是相互关联的。

修饰器模式的前置条件是:每个修饰器类实现一个接口且能同时接受一个或多个同一个接口实例作为构造函数的输入参数。

好处:可以给已经实现了某个特定接口的类添加功能,而且修饰器同时也是所需接口的一个实现,并且对用户不可见.

修饰器模式实现的UML图
image

修饰器模式的示例

public interface IComponent
{
    void Something();
}
// . . .
public class ConcreteComponent : IComponent
{
    public void Something()
    {
    }
}
// . . .
public class DecoratorComponent : IComponent
{
    public DecoratorComponent(IComponent decoratedComponent)
    {
        this.decoratedComponent = decoratedComponent;
    }
    public void Something()
    {
        SomethingElse();
        decoratedComponent.Something();
    }
    private void SomethingElse()
    {
    }
    private readonly IComponent decoratedComponent;
}
// . . .
class Program
{
    static IComponent component;
    static void Main(string[] args)
    {
        component = new DecoratorComponent(new ConcreteComponent());
        component.Something();
    }
}

客户端(Program类)从构造函数方法参数中接受了接口实例,你可以给用户提供原有的未修饰类的实例,也可以提供已修饰类的实例。

注意,无论你提供的是未修饰的原始类还是已修饰的类,客户端都无需做任何改变。

复合模式

复合模式(Composite Pattern)是修饰器模式的一个特例,也是应用最广泛的修饰器模式。

复合模式的目的就是让你能把某个接口的一组实例看作该接口的一个实例。因此,客户端只需要接受接口的一个实例,在无需任何改变的情况下就能隐式地使用该接口的一组实例。

复合模式的UML图
image

一个接口的组合实现

public interface IComponent
{
    void Something();
}
// . . .
public class Leaf : IComponent
{
    public void Something()
    {
    }
}
// . . .
public class CompositeComponent : IComponent
{
    private ICollection<IComponent> children;
    
    public CompositeComponent()
    {
        children = new List<IComponent>();
    }
    
    public void AddComponent(IComponent component)
    {
        children.Add(component);
    }
    
    public void RemoveComponent(IComponent component)
    {
        children.Remove(component);
    }
    
    public void Something()
    {
        foreach(var child in children)
        {
            child.Something();
        }
    }

}
// . . .
class Program
{
    static IComponent component;
    static void Main(string[] args)
    {
        var composite = new CompositeComponent();
        composite.AddComponent(new Leaf());
        composite.AddComponent(new Leaf());
        composite.AddComponent(new Leaf());
        component = composite;
        component.Something();
    }
}

CompositeComponent类中有增删IComponent接口实例的方法。这些方法并不是IComponent接口的组成部分,只被CompositeComponent类的客户端直接使用。无论哪个创建CompositeComponent类实例的工程方法或类型,也都需要能创建被修饰的实例并将它们传入到Add方法;否则,使用IComponent接口的客户端就必须为了配合组合而做改变。

无论客户端何时调用CompositeComponent类的Something方法,组合列表中的所有IComponent接口实例的Something方法都会被调用一次。这就是你将IComponent接口的单个实例(实现该接口的CompositeComponent类)的调用重新路由给该接口的很多实例(实现该接口的子类)的方式。

每个你提供给CompositeComponent类的实例都必须实现IComponent接口,但是这些实例不一定是同一种具体实现类。借助多态的强大能力,你能把所有该接口的实现类的实例只看作接口的实例。

提供给组合列表的实例可以是不同的子类

public class SecondTypeOfLeaf : IComponent
{
    public void Something()
    {
    }
}
// . . .
public class AThirdLeafType : IComponent
{
    public void Something()
    {
    }
}
// . . .
public void AlternativeComposite()
{
    var composite = new CompositeComponent();
    composite.AddComponent(new Leaf());
    composite.AddComponent(new SecondTypeOfLeaf());
    composite.AddComponent(new AThirdLeafType());
    component = composite;
    composite.Something();
}

根据复合模式的逻辑设计,你甚至可以通过Add方法添加一个或多个CompositeComponent类的实例,这样就可以形成树状层次结构的一组实例链。

何时使用组合?

实现不应该与其接口位于相同的程序集中。然而,该规则有个例外情况:当实现的依赖是接口依赖的子集时。

有些组合的具体实现并不会引入更多的依赖,在这种情况下,组合类接口所在的程序集也可以同时包含组合类的具体实现。

图中节点表示对象实例,有向边线则代表了方法调用;对象图可以形象地展示程序的运行时结构
image

谓词修饰器

谓词修饰器(Predicate Decorator)能够很好地消除客户端代码中的条件执行语句.

客户端代码只会在每月的双数日执行Something方法

public class DateTester
{
    public bool TodayIsAnEvenDayOfTheMonth
    {
        get
        {
            return DateTime.Now.Day % 2 == 0;
        }
    }
}
// . . .
class PredicatedDecoratorExample
{
    private readonly IComponent component;
    public PredicatedDecoratorExample(IComponent component)
    {
        this.component = component;
    }
    
    public void Run()
    {
       
        DateTester dateTester = new DateTester();
        if (dateTester.TodayIsAnEvenDayOfTheMonth)
        {
            component.Something();
        }
    }
}

上面示例中的DateTester类是谓词修饰器类的一个依赖项。第一次重构的目标代码,如下代码清单所示。但是,它还只是一个不完整的方案。

class PredicatedDecoratorExample
{
    private readonly IComponent component;
    public PredicatedDecoratorExample(IComponent component)
    {
        this.component = component;
    }
    public void Run(DateTester dateTester)
    {
        if (dateTester.TodayIsAnEvenDayOfTheMonth)
        {
            component.Something();
        }
    }
}

现在你要求给Run方法传入一个DateTester参数,但这样就破坏了客户端的公共接口设计,并要求它的客户端去实现DateTester类。

此时,如果应用修饰器模式,你依然能保持现有客户端公共接口的设计,同时也能保持根据条件执行动作的能力。

谓词修饰器包含了依赖,客户端代码接口保持不变而且实现更加简洁了,这样重构后的结果依然不够好。

public class PredicatedComponent : IComponent
{
    private readonly IComponent decoratedComponent;
    private readonly DateTester dateTester;
    
    public PredicatedComponent(IComponent decoratedComponent, DateTester dateTester)
    {
        this.decoratedComponent = decoratedComponent;
        this.dateTester = dateTester;
    }
    public void Something()
    {
        if(dateTester.TodayIsAnEvenDayOfTheMonth)
        {
            decoratedComponent.Something();
        }
    }
}
// . . .
class PredicatedDecoratorExample
{
    private readonly IComponent component;
    
    public PredicatedDecoratorExample(IComponent component)
    {
        this.component = component;
    }
    public void Run()
    {
        component.Something();
    }
}

上面示例中将条件分支添加到了谓词修饰器中,并没有改动客户端代码或原有的其他实现类。虽然也给谓词修饰器类引入了对DateTester类的依赖,但是你可以通过定义专门的谓词接口来更加通用地处理这种具有条件分支的场景。

定义一个被修饰的IPredicate接口,可以让解决方案更加通用

public interface IPredicate
{
    bool Test();
}
// . . .
public class PredicatedComponent : IComponent
{
    private readonly IComponent decoratedComponent;
    private readonly IPredicate predicate;
    
    public PredicatedComponent(IComponent decoratedComponent, IPredicate predicate)
    {
        this.decoratedComponent = decoratedComponent;
        this.predicate = predicate;
    }
    public void Something()
    {
        if (predicate.Test())
        {
            decoratedComponent.Something();
        }
    }
}
// . . .
public class TodayIsAnEvenDayOfTheMonthPredicate : IPredicate
{
    private readonly DateTester dateTester;
    
    public TodayIsAnEvenDayOfTheMonthPredicate(DateTester dateTester)
    {
        this.dateTester = dateTester;
    }
    public bool Test()
    {
        return dateTester.TodayIsAnEvenDayOfTheMonth;
    }
    
}

现在由实现了IPredicate接口的TodayIsAnEvenDayOfTheMonthPredicate类来依赖DateTester类。

分支修饰器

分支修饰器接受两个组件和一个谓词

public class BranchedComponent : IComponent
{
    private readonly IComponent trueComponent;
    private readonly IComponent falseComponent;
    private readonly IPredicate predicate;
    
    public BranchedComponent(IComponent trueComponent, 
                             IComponent falseComponent,
                             IPredicate predicate)
    {
        this.trueComponent = trueComponent;
        this.falseComponent = falseComponent;
        this.predicate = predicate;
    }
    
    public void Something()
    {
        if (predicate.Test())
        {
            trueComponent.Something();
        }
        else
        {
            falseComponent.Something();
        }
    }
}

无论何时调用谓词进行判断,如果返回值为真,就调用trueComponent实例的Something方法,如果返回值不为真,就调用falseComponnet实例的Something方法。

延迟修饰器

延迟修饰器允许客户端提供某个接口的引用,但是直到第一次使用它时才进行实例化。通常客户端直到看到传入的Lazy<T>参数时才会觉察到延迟实例的存在,但是不应该让它们知道有延迟实例存在的细节信息.

客户端接受了一个Lazy<T>参数

public class ComponentClient
{
    private readonly Lazy<IComponent> component;
    
    public ComponentClient(Lazy<IComponent> component)
    {
        this.component = component;
    }
    
    public void Run()
    {
        component.Value.Something();
    }
}

上面示例中的客户端代码只有一个构造函数,且只能接受延迟实例化的IComponent接口的实例。然而,基于该接口更标准的使用方式,你还可以选择创建一个延迟修饰器,这样就可以防止客户端知道正在处理的是Lazy<T>实例,而且也允许一些ComponentClient对象接受非延迟实例化的IComponent实例。

LasyComponent类是IComponent接口的一个延迟实例化的实现,但是ComponentClient并不知道这些细节

public class LazyComponent : IComponent
{
    private readonly Lazy<IComponent> lazyComponent;
    
    public LazyComponent(Lazy<IComponent> lazyComponent)
    {
        this.lazyComponent = lazyComponent;
    }
    
    public void Something()
    {
        lazyComponent.Value.Something();
    }
}
// . . .
public class ComponentClient
{
    private readonly IComponent component;
    
    public ComponentClient(IComponent component)
    {
        this.component = component;
    }
    
    public void Run()
    {
        component.Something();
    }
}

日志记录修饰器

日志代码影响了方法意图的连贯表达

public class ConcreteCalculator : ICalculator
{
    public int Add(int x, int y)
    {
        Console.WriteLine("Add(x={0}, y={1})", x, y);
        var addition = x + y;
        Console.WriteLine("result={0}", addition);
        return addition;
    }
}

日志记录修饰器能提取出日志语句,让方法功能的实现看起来更简洁

public class LoggingCalculator : ICalculator
{
    private readonly ICalculator calculator;
    
    public LoggingCalculator(ICalculator calculator)
    {
        this.calculator = calculator;
    }
    
    public int Add(int x, int y)
    {
        Console.WriteLine("Add(x={0}, y={1})", x, y);
        var result = calculator.Add(x, y);
        Console.WriteLine("result={0}", result);
        return result;
    }
}
// . . .
public class ConcreteCalculator : ICalculator
{
    public int Add(int x, int y)
    {
        return x + y;
    }
}

ICalculator接口的客户端会传入多个参数,有些接口方法本身也会有返回值。因为LoggingCalculator类处于客户端和接口之间,因此它可以将二者直接联系起来。

当然,日志记录修饰器的使用有一些局限性需要注意:

第一,被修饰类中的所有私有状态一样对日志记录修饰器不可见,因此也无法将它们写入日志。

第二,应用程序中的每个接口都要有对应的日志记录修饰器,这个任务工作量太过巨大。

为了实现同样的目的,应该用日志记录方面来代替日志记录修饰器.

性能修饰器

(故意设计出的)性能比较差的代码

public class SlowComponent : IComponent
{
    private readonly Random random;
    public SlowComponent()
    {
        random = new Random((int)DateTime.Now.Ticks);
    }
    public void Something()
    {
        for(var i = 0; i<100; ++i)
        {
            Thread.Sleep(random.Next(i) * 10);
        }
    };
}

上面示例中的Something方法性能很差。当然,性能的好坏都是相对的。现在这个示例里,一个性能差的方法定义是执行时间超过了一秒钟。那又如何判断一个方法是否符合这个性能差的定义呢?你可以通过对方法执行始末计时来判定.

System.Disgnostics.Stopwatch类可以对方法执行时间进行计时

public class SlowComponent : IComponent
{
    private readonly Random random;
    private readonly Stopwatch;
    public SlowComponent()
    {
        random = new Random((int)DateTime.Now.Ticks);
        stopwatch = new Stopwatch();
    }
    public void Something()
    {
        stopwatch.Start();
        for(var i = 0; i<100; ++i)
        {
            System.Threading.Thread.Sleep(random.Next(i) * 10);
        }
        stopwatch.Stop();
        Console.WriteLine("The method took {0} seconds to complete",
                          stopwatch.ElapsedMilliseconds / 1000);
    }
}

这里使用的Stopwatch类包含在System.Diagnosticas程序集中,它可以用来对每个方法计时。可以看到上面示例代码中的Something方法在入口启动了秒表,在出口停止了秒表。
当然,可以把这个功能提取到一个性能修饰器中。对整个要测试性能的接口进行修饰,而且在委托被修饰的实例前,启动秒表。在被修饰实例的方法返回后,停止秒表。

性能修饰器的代码

public class ProfilingComponent : IComponent
{
    private readonly IComponent decoratedComponent;
    private readonly Stopwatch stopwatch;
    public ProfilingComponent(IComponent decoratedComponent)
    {
        this.decoratedComponent = decoratedComponent;
        stopwatch = new Stopwatch();
    }
    public void Something()
    {
        stopwatch.Start();
        decoratedComponent.Something();
        stopwatch.Stop();
        Console.WriteLine("The method took {0} seconds to complete",
                          stopwatch.ElapsedMilliseconds / 1000);
    }
}

在此基础上,还可以对ProfillingComponent再做一次重构:消除性能日志语句。第一,需要把秒表启停和计算时间间隔的代码提取并隐藏在一个接口后,这样你就可以提供多种实现,包括修饰器。这通常就是在朝着更好的职责划分目标进行重构时的第一步.

中间状态;在实现修饰器前,你必须先用接口替换具体的实现

public class ProfilingComponent : IComponent
{
    private readonly IComponent decoratedComponent;
    private readonly IStopwatch stopwatch;
    
    public ProfilingComponent(IComponent decoratedComponent, IStopwatch stopwatch)
    {
        this.decoratedComponent = decoratedComponent;
        this.stopwatch = stopwatch;
    }
    public void Something()
    {
        stopwatch.Start();
        decoratedComponent.Something();
        var elapsedMilliseconds = stopwatch.Stop();
        Console.WriteLine("The method took {0} seconds to complete", elapsedMilliseconds / 1000);
    }

}

现在,ProfillingComponent类不在直接依赖系统的System.Diagnostics.StopWatch类,你可以更改IStopWatch接口的实现。基于IStopWatch接口实现的LoggingStopwatch修饰器,能够为后续IStopWatch的实现提供日志功能.

LoggingStopwatch修饰器类是IStopwatch的一个实现,它记录日志并委托其他IStopwatch实现完成真正的计时动作

public class LoggingStopwatch : IStopwatch
{
    private readonly IStopwatch decoratedStopwatch;
    
    public LoggingStopwatch(IStopwatch decoratedStopwatch)
    {
        this.decoratedStopwatch = decoratedStopwatch;
    }
    public void Start()
    {
        decoratedStopwatch.Start();
        Console.WriteLine("Stopwatch started...");
    }
    public long Stop()
    {
        var elapsedMilliseconds = decoratedStopwatch.Stop();
        Console.WriteLine("Stopwatch stopped after {0} seconds",
                          TimeSpan.FromMilliseconds(elapsedMilliseconds).TotalSeconds);
        return elapsedMilliseconds;
    }
}

当然,你需要一个IStopwatch接口的非修饰器实现,它会完成真正的秒表功能。

IStopwatch接口的实现主要使用了已有的Stopwatch

public class StopwatchAdapter : IStopwatch
{
    private readonly Stopwatch stopwatch;
    public StopwatchAdapter(Stopwatch stopwatch)
    {
        this.stopwatch = stopwatch;
    }
    public void Start()
    {
        stopwatch.Start();
    }
    public long Stop()
    {
        stopwatch.Stop();
        var elapsedMilliseconds = stopwatch.ElapsedMilliseconds;
        stopwatch.Reset();
        return elapsedMilliseconds;
    }
}

注意,你也可以将IStopwatch的实现作为System.Diagnostics.Stopwatch类的子类以利用它已有的StartStop方法。然而,Stopwatch类的Start方法在秒表停止后再次调用的意图是继续前一次的计时,因此你需要在调用Stopwatch类的Stop 方法以及取出ElapsedMilliseconds属性值后,立即调用它的Reset方法。

异步修饰器

异步方法是指与客户端代码运行在不同线程上的方法。异步方式在方法执行需要很长时间的情况下很有用,因为在同步执行期间,客户端代码会被完全阻塞直到从该方法返回。

用户界面线程上的命令会阻塞用户界面,从而导致用户界面不响应

public class MainWindowViewModel : INotifyPropertyChanged
{
    private string result;
    private IComponent component;
    private RelayCommand calculateCommand;

    public MainWindowViewModel(IComponent component)
    {
        this.component = component;
        calculateCommand = new RelayCommand(Calculate);
    }
    
    public string Result
    {
        get
        {
            return result;
        }
        private set
        {
            if (result != value)
            {
                result = value;
                PropertyChanged(this, new PropertyChangedEventArgs("Result"));
            }
        }
    }
    
    public ICommand CalculateCommand
    {
        get
        {
            return calculateCommand;
        }
    }
    
    public event PropertyChangedEventHandler PropertyChanged = delegate { };
    private void Calculate(object parameter)
    {
        Result = "Processing...";
        component.Process();
        Result = "Finished!";
    }
}

通过创建一个异步修饰器,你可以指示被调用的方法在一个单独的线程中执行。这可以通过将具体工作委托给一个Task类的实例来实现,它也会变成你的异步修饰器的依赖.

一个为WPF使用Dispatcher类的异步修饰器

public class AsyncComponent : IComponent
{
    private readonly IComponent decoratedComponent;
    
    public AsyncComponent(IComponent decoratedComponent)
    {
        this.decoratedComponent = decoratedComponent;
    }
    public void Process()
    {
        Task.Run((Action)decoratedComponent.Process);
    }
}

示例中的AsyncComponent类有一个问题:它隐式依赖Task类,这就意味着很难测试它。使用静态依赖的代码很难进行单元测试,因此你最好选个塔吊替代这个天钩。

异步修饰器的局限性

  • 并不是所有方法都可以使用修饰器模式提供给客户端并不可见的异步版本。实际上,只有那些即发即弃(fire-and-forget)的方法才可以应用异步修饰器。
  • 一个即发即弃的方法并没有返回值,客户端代码无需知道这个方法何时返回调用。如果一个方法实现为异步修饰器,客户端代码就无法知道该方法什么时候才真正完成,因为对它的调用会立即返回,实际上真正要执行的工作很可能依然在进行中。
  • 请求—响应(request-response)方法通常用来获取数据,也经常会被实现为异步方法,因为这些方法通常会花点时间且阻塞UI线程。客户端需要知道该方法是异步的,这样它们就可以显式编写回调以便在该异步方法完成时得到通知。因此,请求—响应方法并不适合使用异步修饰器来实现。

修饰属性和事件

手动创建的属性,它的存取器直接委托被修饰实例的存取器,而不是使用一个后备字段。

属性也可以像方法一样使用修饰器模式

public class ComponentDecorator : IComponent
{
    private readonly IComponent decoratedComponent;
    public ComponentDecorator(IComponent decoratedComponent)
    {
        this.decoratedComponent = decoratedComponent;
    }
    public string Property
    {
        get
        {
            // 检索到值后,我们可以在这里做一些突变
            return decoratedComponent.Property;
        }
        set
        {
            // And/or here, before we set the value
            decoratedComponent.Property = value;
        }
    }

}

一个手动创建的事件,它的添加器和移除器直接委托被修饰实例的事件的添加和移除方式,而不是使用一个后备字段。

事件也可以像方法一样使用修饰器模式

public class ComponentDecorator : IComponent
{
    private readonly IComponent decoratedComponent;
    public ComponentDecorator(IComponent decoratedComponent)
    {
        this.decoratedComponent = decoratedComponent;
    }
    public event EventHandler Event
    {
        add
        {
            // We can do something here, when the event handler is registered
            decoratedComponent.Event += value;
        }
        remove
        {
            // And/or here, when the event handler is deregistered
            decoratedComponent.Event -= value;
        }
    }

}

用策略模式替代switch语句

为了理解应用策略模式的最佳时机,你可以看看条件分支的场景。任何使用switch语句的场合,你都可以通过策略模式将复杂性委托给所依赖的接口以简化客户端代码。

这个方法使用了switch语句,但是策略模式能提供更好的适应变更的能力

public class OnlineCart
{
    public void CheckOut(PaymentType paymentType)
    {
        switch(paymentType)
        {
            case PaymentType.CreditCard:
                ProcessCreditCardPayment();
                break;
            case PaymentType.Paypal:
                ProcessPaypalPayment();
                break;
            case PaymentType.GoogleCheckout:
                ProcessGooglePayment();
                break;
            case PaymentType.AmazonPayments:
                ProcessAmazonPayment();
                break;
        }
    }
    private void ProcessCreditCardPayment()
    {
        Console.WriteLine("Credit card payment chosen");
    }
    private void ProcessPaypalPayment()
    {
        Console.WriteLine("Paypal payment chosen");
    }
    private void ProcessGooglePayment()
    {
        Console.WriteLine("Google payment chosen");
    }
    private void ProcessAmazonPayment()
    {
        Console.WriteLine("Amazon payment chosen");
    }
}

示例中,对应switch语句下的每个case分支,类的行为都有变化。这种方式会引入代码维护问题,因为增加任何新的case分支都需要更改这个类。相反,如果你用同一接口的一组实现来替代所有的case分支,那么后续还可以增加新的实现来封装新的功能,而且客户端代码也无需改变。

替换switch语句后,客户端代码看起来适应变更的能力高了很多

public class OnlineCart
{
    private IDictionary<PaymentType, IPaymentStrategy> paymentStrategies;
    
    public OnlineCart()
    {
        paymentStrategies = new Dictionary<PaymentType, IPaymentStrategy>();
        paymentStrategies.Add(PaymentType.CreditCard, new PaypalPaymentStrategy());
        paymentStrategies.Add(PaymentType.GoogleCheckout, new GoogleCheckoutPaymentStrategy());
        paymentStrategies.Add(PaymentType.AmazonPayments, new AmazonPaymentsPaymentStrategy());
        paymentStrategies.Add(PaymentType.Paypal, new PaypalPaymentStrategy());
    }
    public void CheckOut(PaymentType paymentType)
    {
        paymentStrategies[paymentType].ProcessPayment();
    }
}

按照面向对象编程的传统,示例代码将不同的支付类型具体化为不同的类,它们都实现了IPaymentStrategy接口。这个示例中,OnlineCart类带有一个私有的字典字段,它可以将PaymentType枚举的每个值映射到一个对应的IPaymentStrategy接口的实例上。这个字典大大地简化了Checkout方法的复杂度。不仅switch语句被移除了,各种类的支付处理过程也随之不复存在。OnlineCart类不需要知道如何处理支付,大量不同的处理方式会给该类引入太多不必要的依赖。现在的OnlineCart类只是选择恰当的支付策略并委托它来完成实际处理过程。

在添加新的支付策略实现时,该示例依然存在一个维护负担。比如,在添加实现以支持WePay时,你就需要更改构造函数以映射新的WePayPaymentStrategy类给对应的WePay枚举值。

总结

单一职责原则对代码自适应能力有着至关重要的正面影响。与不应用该模式的同样功能的代码相比,符合单一职责原则的代码会由更多的小规模但目标更明确的类组成。单个巨型类或相互依赖的一组类只会导致职责混淆,而单一职责原则能带来有序和清晰的良好效果。

单一职责原则主要是通过接口抽象以及在运行时将无关功能的责任委托给相应接口完成来达成目标的。一些设计模式(特别是适配器模式和修饰器模式)非常适合支持单一职责类的实现。适配器模式能让你的绝大多数代码都引用你能完全控制的第一方组件,尽管实际上是在利用第三方库。当一个类的某些功能需要被移除但这些功能又和该类意图紧密联系时,就可以应用修饰器模式。

posted @ 2022-04-26 20:54  F(x)_King  阅读(64)  评论(0编辑  收藏  举报