详细介绍C#语言中的装饰器模式

作者:微信公众号:【架构师老卢】
6-8 9:38
28

概述:Decorator 设计模式允许软件开发人员在不更改其代码的情况下扩展组件的功能。本文探讨了在现代 .NET 中实现装饰器模式的主要技术,同时遵循单一责任原则 (SRP) 并避免使用样板代码。何时在 C 中使用 Decorator 模式#当您想要向现有组件添加行为但_不能_或_不想_修改源代码时,Decorator 模式非常有用。这样做通常是为了遵守单一责任原则 (SRP),以保持我们的代码干净、可读和可维护。装饰器设计模式的一些实际用例包括:执行策略,例如异常处理、重试或缓存,有助于提高应用的性能和可靠性。可观测性,例如,通过向外部组件的所有调用添加日志记录。用户界面,例如向大型文本框添加滚

Decorator 设计模式允许软件开发人员在不更改其代码的情况下扩展组件的功能。本文探讨了在现代 .NET 中实现装饰器模式的主要技术,同时遵循单一责任原则 (SRP) 并避免使用样板代码。

何时在 C 中使用 Decorator 模式#

当您想要向现有组件添加行为但_不能_或_不想_修改源代码时,Decorator 模式非常有用。这样做通常是为了遵守单一责任原则 (SRP),以保持我们的代码干净、可读和可维护。

装饰器设计模式的一些实际用例包括:

  • 执行策略,例如异常处理、重试或缓存,有助于提高应用的性能和可靠性。
  • 可观测性,例如,通过向外部组件的所有调用添加日志记录。
  • 用户界面,例如向大型文本框添加滚动条。另一个示例是 WPF 中的 Adorner 概念。
  • ,具有缓冲、加密或压缩等功能。

什么是 Decorator 模式

Decorator 本质上是一个包装器,它实现与它所包装的实体相同的协定。我们故意使用含糊不清_的条款合同_。正如我们将在本文中看到的,它可能意味着两件事:如果我们实现_类型装饰器_,则为 C# 接口,如果我们实现_方法装饰器_,则为方法签名。在这两种情况下,调用方都不需要知道它正在与装饰器通信,而不是与最终实现通信。该模式是递归的:我们可以将装饰器添加到装饰器中,从而创建责任链。

例如,我们可能希望在暂时性故障时重试几次,而不是仅仅调用不可靠的服务,最后为每个异常分配一个唯一的 ID,记录它,并包装异常。我们可以将链表示如下:

在本文中,我们将探讨两种类型的装饰器:类型装饰器方法装饰器

经典的 Type Decorator 图案

经典_类型装饰器_模式是依赖于类型_接口_的装饰器模式的纯面向对象的变体。

为了说明这个想法,假设我们想构建一个简单的消息传递应用程序。我们需要一个处理发送和接收消息的组件。此组件实现接口,并在第三方库中实现。

public interface IMessenger  
{  
    void Send( Message message );  
    public Message Receive();  
}

我们正在使用来自客户端类的服务:IMessenger

public class Client( IMessenger messenger )  
{  
    public void Greet()  
    {  
        messenger.Send( new Message( "Hello, world" ) );  
        Console.WriteLine( "--> " + messenger.Receive().Text );  
    }  
}

我们正在实例化以下类:ClientProgram.cs

var messenger = new Messenger();  
var client = new Client( messenger );  
client.Greet();

这一切都在我们的开发环境中运行良好。但是,一旦我们将事情转移到生产环境中,我们就会意识到信使服务不可靠,偶尔会导致我们的应用程序崩溃。由于我们不拥有实现的源代码,因此我们不能简单地将所需的逻辑添加到每个方法中。IMessenger

Decorator 模式如何帮助我们解决这个问题?

在下面的类图中,请看一下我们使用类型装饰器模式进行的设计。除了用于错误处理和重试的修饰器外,我们还引入了用于保存包装对象的抽象类,从而更轻松地实现单个修饰器。MessengerDecoratorIMessenger

下面是该类的实现:ExceptionReportingMessenger

public class ExceptionReportingMessenger : MessengerDecorator  
{  
    private readonly IExceptionReportingService _reportingService;  
  
    public ExceptionReportingMessenger( IMessenger underlying,   
 IExceptionReportingService reportingService ) :  
        base( underlying )  
    {  
        this._reportingService = reportingService;  
    }  
  
    public override void Send( Message message )  
    {  
        try  
        {  
            this.Underlying.Send( message );  
        }  
        catch ( Exception e )  
        {  
            this._reportingService.ReportException(   
               "Failed to send message", e );              
            throw;  
        }  
    }  
  
    public override Message Receive()  
    {  
        try  
        {  
            return this.Underlying.Receive();  
        }  
        catch ( Exception e )  
        {  
            this._reportingService.ReportException(   
                   "Failed to receive message", e );  
            throw;  
        }  
    }  
}

信使非常相似。RetryingMessenger

现在,我们不是将原始组件传递给类,而是将 包装成 ,然后包装成 .这最后是我们传递给MessengerClientMessengerRetryingMessengerExceptionReportingMessengerExceptionReportingMessengerClient

var originalMessenger = new Messenger();  
var retryingMessenger = new ExceptionReportingMessenger(  
    new RetryingMessenger( originalMessenger ),  
    new ExceptionReportingService() );  
  
var clientUsingDecorator = new Client( retryingMessenger );  
clientUsingDecorator.Greet();

当程序调用时,控制流程如下:Client.Greet

使用具有依赖关系注入的类型装饰器

显然,在任何现代 C# 应用程序中,您都不会像上面的示例那样手动实例化组件,而是让依赖注入来完成这项工作。

如果你使用的是 .NET Core 的 IServiceCollection,则有一个名为 Scrutor 的不错的库,它可以轻松地使用装饰器包装服务。

例如,这是使用 Scrutor 应用装饰器的方法。请注意对方法的调用:它们由 Scrutor 定义。

var services = new ServiceCollection()  
    .AddSingleton<IExceptionReportingService, ExceptionReportingService>()  
    .AddSingleton<IMessenger, Messenger>()  
    .AddSingleton<Client>()  
    .Decorate<IMessenger, RetryingMessenger>()  
    .Decorate<IMessenger, ExceptionReportingMessenger>()  
    .BuildServiceProvider();  
  
var client = services.GetRequiredService<Client>();  
client.Greet();

许多依赖注入框架都内置了对装饰器的支持。例如,了解 Autofac 如何处理此问题

抽象类型装饰器模式

乍一看,我们的解决方案设计似乎很完美。但是,当我们深入研究装饰器的实现时,我们注意到异常处理将在其他方法中重复。在这里,我们违反了“不要重复自己”原则。代码现在比以前更难维护,因为必须在另一个类型的装饰器的每个方法和任何装饰器中对错误处理进行任何更改。ExceptionReportingMessenger

现在,我们将了解如何改进 Type Decorator 模式,使 Decorator 逻辑更易于重用。

让我们使用_单词 policy_ 来指定包装方法调用的逻辑。策略可以抽象出来,并以可重用的方式封装。在下图中,我们已将策略表示为接口。

以下是异常报告策略:

public class ReportExceptionPolicy(   
 IExceptionReportingService reportingService ) : IPolicy  
{  
    public T Invoke<T>( Func<T> func )  
    {  
        try  
        {  
            return func();  
        }  
        catch ( Exception e )  
        {  
            reportingService.ReportException( "Failed to send message", e );  
            throw;  
        }  
    }  
}

然后我们定义一个抽象类,可以用作任何装饰器的基础:

public abstract class AbstractDecorator( IPolicy policy )  
{  
    protected T Invoke<T>( Func<T> func ) => policy.Invoke( func );  
      
    protected void Invoke( Action action )  
        => policy.Invoke<object?>(  
            () =>  
            {  
                action();  
                return null!;  
            } );  
}

在实践中,您还需要在 和 中实现方法的版本。

使用此设置,所要做的就是使用对以下方法的调用来包装方法实现:

public class MessengerDecorator( IMessenger underlying, IPolicy policy ) :  
    AbstractDecorator( policy ), IMessenger  
{  
    public void Send( Message message ) => this.Invoke(   
                                        () => underlying.Send( message ) );  
    public Message Receive() => this.Invoke( underlying.Receive );  
}

请注意,此装饰器现在已从任何策略中抽象出来。现在唯一重复的代码是在调用该方法时。

最后,我们使用 Scrutor 的方法通过向类提供 1 来连接服务集合:

var services = new ServiceCollection()  
    .AddSingleton<IExceptionReportingService, ExceptionReportingService>()  
    .AddSingleton<IMessenger, Messenger>()  
    .Decorate<IMessenger>(  
        ( inner, _ ) => new MessengerDecorator(  
            inner,  
            new RetryPolicy() ) )  
    .Decorate<IMessenger>(  
        ( inner, serviceProvider ) => new MessengerDecorator(  
            inner,  
            new ReportExceptionPolicy( serviceProvider  
                    .GetRequiredService<IExceptionReportingService>() ) ) )  
    .BuildServiceProvider();  
  
var client = services.GetRequiredService<Client>();  
client.Greet();

现在,我们已将错误处理逻辑整合到一个地方。

控制流现在变为:

自动生成类型装饰器

类中仍然有重复的代码。可以说,它_纯粹_是样板,理想情况下应该从代码库中删除。有两种方法可以生成此类:

  • 在_运行时_,使用称为_动态代理_的方法,或者
  • 在_编译时_,使用源生成器。

在本文中,我们将仅探讨第一种解决方案。

动态代理背后的原理是在运行时生成装饰器类,即应用程序初始化时。在实现此功能的少数库中,最受欢迎的是 Castle DynamicProxy。政策的概念在_早期发展到_Castle的界面。这是作为 Castle 拦截器的重试策略的实现。请注意与上面示例中的类的相似性。IInterceptorRetryPolicy

internal class RetryInterceptor( int retryAttempts = 3,   
 double retryDelay = 1000 ) : IInterceptor  
{  
    public void Intercept( IInvocation invocation )  
    {  
        for ( var i = 0;; i++ )  
        {  
            try  
            {  
                invocation.Proceed();  
            }  
            catch ( Exception ) when ( i < retryAttempts )  
            {  
                var delay = retryDelay * Math.Pow( 2, i );  
  
                Console.WriteLine(  
          "Failed to receive message. " +  
          $"Retrying in {delay / 1000} seconds... ({i + 1}/{retryAttempts})" );                Thread.Sleep( (int) delay );  
            }  
        }  
    }  
}

正如所承诺的那样,自从 Castle 实现它以来,不再需要任何装饰器代码。

现在,我们可以继续执行应用程序的启动顺序。我们需要一个:

var proxyGenerator = new ProxyGenerator();

现在,我们可以使用该方法来创建代理类,并提供两个实现策略的拦截器:

var services = new ServiceCollection()  
    .AddSingleton<IExceptionReportingService, ExceptionReportingService>()  
    .AddSingleton<IMessenger, Messenger>()  
    .Decorate<IMessenger>(  
        ( inner, _ ) => new MessengerDecorator(  
            inner,  
            new RetryPolicy() ) )  
    .Decorate<IMessenger>(  
        ( inner, serviceProvider ) => new MessengerDecorator(  
            inner,  
            new ReportExceptionPolicy(  
    serviceProvider.GetRequiredService<IExceptionReportingService>() ) ) )  
    .BuildServiceProvider();  
  
var client = services.GetRequiredService<Client>();  
client.Greet();

方法装饰器模式

到目前为止,我们已经讨论了一些技术,这些技术有助于将一个类型替换为实现相同接口的另一种类型,但提供额外的服务。这种方法的主要优点是:

  • 它是纯粹面向对象的,
  • 它适用于您不拥有的代码,
  • 它在运行时是可组合的。

但是,有一个明显的缺点:它只有在您可以将自己注入调用方和服务之间的通信中时才有效 - 通常通过接口,尽管可以使用 OR 方法实现相同的目的。如果你欣赏能够用任何行为来装饰方法的好处,那么不得不如此限制自己真是太可惜了。更糟糕的是:您可能很想将应用程序拆分为更小的组件,以便从装饰器中受益。这是一个_框架独裁_的案例,应该避免。abstractvirtual

类型装饰器模式的替代方法是_方法装饰器_。顾名思义,方法修饰器面向单个方法,而不是整个类型。方法装饰器通常用于动态语言(如 Python)中。C#不直接支持它们,但像Metalama这样的一些工具包使它成为可能。

C# 方法修饰器的思想是将策略的逻辑移动到一种称为 aspect 的特殊自定义属性,该属性可以与代码模板进行比较。与其他自定义属性不同,方面在编译过程中应用于代码。由于这种方法是编译时,因此我们并不局限于 .NET 运行时的限制,即我们不限于虚拟或接口方法,但我们可以拦截任何内容(包括静态私有字段,如果您询问的话)。

以下是重试策略的 Metalama 版本:

internal class RetryAttribute : OverrideMethodAspect  
{  
    public int Attempts { get; set; } = 3;  
  
    public double Delay { get; set; } = 1000;  
  
    public override dynamic? OverrideMethod()  
    {  
        for ( var i = 0;; i++ )  
        {  
            try  
            {  
                return meta.Proceed();  
            }  
            catch ( Exception e ) when ( i < this.Attempts )  
            {  
                var delay = this.Delay * Math.Pow( 2, i + 1 );  
  
                Console.WriteLine(  
$"Method {meta.Target.Method.DeclaringType.Name}.{meta.Target.Method} has failed " +  
" on {e.GetType().Name}. Retrying in {delay / 1000} seconds... ({i + 1}/{this.Attempts})" );  
  
                Thread.Sleep( (int) delay );  
            }  
        }  
    }  
  
    // **TODO:** Implement OverrideMethodAsync and call Task.Delay instead of Thread.Sleep.  
}

若要将策略添加到方法,请将其作为自定义属性应用:

public partial class Messenger
{
    private int _receiveCount;
    private int _sendCount;

    [Retry]
    [ReportExceptions]
    public void Send( Message message )
    {
        Console.WriteLine( "Sending message..." );

        // Simulate unreliable message sending
        if ( ++this._sendCount % 3 == 0 )
        {
            Console.WriteLine( "Message sent successfully." );
        }
        else
        {
            throw new IOException( "Failed to send message." );
        }
    }

    [Retry]
    [ReportExceptions]
    public Message Receive()
    {
        Console.WriteLine( "Receiving message..." );

        // Simulate unreliable message receiving
        if ( ++this._receiveCount % 3 == 0 )
        {
            Console.WriteLine( "Message received successfully." );

            return new Message( "Hi!" );
        }

        throw new IOException( "Failed to receive message." );
    }
}

为了进一步提高可维护性,像 Metalama 这样的工具包促进了方面的批量应用,使开发人员无需手动指定每个方面的使用位置。例如,我们可以规定特定命名空间中的所有公共方法都应该有异常报告。因此,当向此命名空间添加新方法时,将自动应用异常报告方面。这种方法不仅增强了代码库的可读性和可维护性,还简化了可伸缩性。在 Metalama 中,这是使用织物实现的。下面的示例演示如何向项目中的所有公共方法添加异常报告:

internal class AddExceptionReportingToPublicMethodsFabric : ProjectFabric  
{  
    public override void AmendProject( IProjectAmender amender )  
    {  
        amender.Outbound.SelectMany( t => t.AllTypes )  
            .SelectMany( t => t.Methods )  
            .Where( m => m.Accessibility == Accessibility.Public )  
            .AddAspectIfEligible<ReportExceptionsAttribute>();  
    }  
}

装饰器是维护干净代码和坚持单一责任原则的有效方法。如果您不拥有希望使用新行为增强的代码,或者需要在运行时动态添加行为,请选择类型装饰器。当您拥有源代码并旨在遵守单一责任原则时,请使用方法装饰器。

阅读排行