我该如何有效地设计我的应用程序,使大多数类都依赖于ILogger?

71

我正在将Logger组件注入到所有我的类中。我的大多数类都定义了Logger属性,除了在继承链中的情况(在这种情况下,只有基类具有此属性,并且所有派生类使用它)。当这些通过Windsor容器实例化时,它们会得到我的ILogger实现。我目前正在使用属性注入,因为将所有内容注入到构造函数中感觉不好。

我能否按照需要从容器中提取它们,因为它们是短暂的?我是否必须将它们注册到容器中并将其注入到需要类的构造函数中?另外,对于一个类,我不想创建一个TypedFactory并将工厂注入到该类中。

还有一个想法就是根据需要进行new。因此,如果我进行新实例化,我将必须在这些类中手动实例化Logger。我如何继续为我的所有类使用容器?

Windsor注册示例:

//Install QueueMonitor as Singleton
Container.Register(Component.For<QueueMonitor>().LifestyleSingleton());
//Install DataProcessor as Trnsient
Container.Register(Component.For<DataProcessor>().LifestyleTransient());

Container.Register(Component.For<Data>().LifestyleScoped());

示例类:

public class QueueMonitor
{
    private dataProcessor;

    public ILogger Logger { get; set; }

    public void OnDataReceived(Data data)
    {
        // pull the dataProcessor from factory    
        dataProcessor.ProcessData(data);
    }
}

public class DataProcessor
{
    public ILogger Logger { get; set; }

    public Record[] ProcessData(Data data)
    {
        // Data can have multiple Records
        // Loop through the data and create new set of Records
        // Is this the correct way to create new records?
        // How do I use container here and avoid "new" 
        Record record = new Record(/*using the data */);
        ...
        // return a list of Records    
    }
}

public class Record
{
    public ILogger Logger { get; set; }

    private _recordNumber;
    private _recordOwner;

    public string GetDescription()
    {
        Logger.LogDebug("log something");
        // return the custom description
    }
}

问题:

  1. 如何在不使用 "new" 的情况下创建新的 Record 对象?

  2. QueueMonitorSingleton,而 Data 是 "Scoped"。如何将 Data 注入到 OnDataReceived() 方法中?


@Steven,我添加了一个代码示例,展示了Logger的使用。你认为这是一个糟糕的设计吗? - user1178376
1
你能用具体的代码来说明你尝试实现什么吗?最好附上一个测试。 - Mauricio Scheffer
抱歉我的问题表述不够清晰。我添加了更多的代码来解释我的情况。 - user1178376
1个回答

309

根据您提供的样本,很难做出非常具体的说明。但是通常情况下,在将ILogger实例注入到大多数服务中时,您应该考虑以下两个问题:

  1. 我是否记录了过多日志?
  2. 我是否违反了SOLID原则?

1. 我是否记录了过多日志

当您有很多这样的代码时,您就会记录太多日志:

try
{
   // some operations here.
}
catch (Exception ex)
{
    this.logger.Log(ex);
    throw;
}

写出这样的代码是因为担心丢失错误信息。但是在各个地方重复这些try-catch块并没有帮助。更糟糕的是,我经常看到开发人员通过删除最后一个throw语句来记录和继续执行:
try
{
   // some operations here.
}
catch (Exception ex)
{
    this.logger.Log(ex); // <!-- No more throw. Execution will continue.
}

这往往是个坏主意(并且闻起来像旧的VB ON ERROR RESUME NEXT行为),因为在大多数情况下,你没有足够的信息来确定是否安全继续。通常代码中存在错误或外部资源(如数据库)出现故障导致操作失败。继续执行意味着用户经常会认为操作成功了,而实际上并没有。问问自己:哪个更糟糕,向用户显示一个通用错误消息,告诉他们再试一次,还是默默地跳过错误,让用户认为他们的请求已被成功处理?
想象一下,如果两周后用户发现他们的订单从未发货,他们会感觉如何。你可能会失去一个客户。或者更糟糕的是,患者的MRSA注册默默失败,导致护理人员无法对其进行隔离,导致其他患者受到污染,引起高昂的费用或甚至死亡。
大多数这种try-catch-log行应该被删除,你应该让异常沿着调用堆栈向上抛出。

你应该记录日志吗?当然应该!但如果可以的话,最好在应用程序的顶部定义一个try-catch块。对于ASP.NET,您可以实现Application_Error事件、注册HttpModule或定义自定义错误页面来记录日志。对于Win Forms,解决方案是不同的,但概念是相同的:定义一个单一的顶级catch-all。

然而,有时您仍然希望捕获和记录特定类型的异常。我曾经使用过的一个系统允许业务层抛出ValidationExceptions,这些异常将被表示层捕获。这些异常包含用于向用户显示的验证信息。由于这些异常会在表示层中被捕获和处理,因此它们不会冒泡到应用程序的最顶部,并且不会出现在应用程序的catch-all代码中。尽管如此,我仍然想记录这些信息,只是为了找出用户输入无效信息的频率,并找出验证是否出于正确的原因触发。因此,这不是错误日志记录;只是日志记录。我编写了以下代码来实现:

try
{
   // some operations here.
}
catch (ValidationException ex)
{
    this.logger.Log(ex);
    throw;
}

看起来很熟悉?是的,与先前的代码片段完全相同,不同之处在于我仅捕获了ValidationException异常。但是,有另一个区别,仅从这个片段中无法看到。整个应用程序中只有一个地方包含了那段代码!它是一个装饰器,这让我想到了下一个你应该问自己的问题:

2. 我违反了SOLID原则吗?

像日志记录、审计和安全等内容被称为cross-cutting concerns(或方面)。它们被称为横切关注点,因为它们可以穿过应用程序的许多部分,并且通常必须应用于系统中的许多类。但是,当您发现为其在系统中的许多类中编写代码时,您很可能正在违反SOLID原则。例如,考虑以下示例:

public void MoveCustomer(int customerId, Address newAddress)
{
    var watch = Stopwatch.StartNew();

    // Real operation
    
    this.logger.Log("MoveCustomer executed in " +
        watch.ElapsedMiliseconds + " ms.");
}

在这里,您可以测量执行MoveCustomer操作所需的时间,并记录该信息。很可能系统中的其他操作需要相同的横切关注点。您开始为ShipOrderCancelOrderCancelShipping和其他用例添加类似于此的代码,这导致了大量的代码重复,最终成为一个维护噩梦(我也曾经历过)。
这段代码的问题可以追溯到违反SOLID原则。SOLID原则是一组面向对象设计原则,可帮助您定义灵活且易于维护的(面向对象)软件。 MoveCustomer示例违反了至少两个规则:
  1. 单一职责原则 (SRP) - 类应该只有一个职责。然而,持有MoveCustomer方法的类不仅包含核心业务逻辑,还测量执行操作所需的时间。换句话说,它有多个职责
  2. 开闭原则 (OCP) - 它规定了一种应用程序设计,可以防止您不得不在整个代码库中进行大规模更改;或者换句话说,在OCP的词汇表中,一个类应该是可扩展的,但应该对修改关闭。如果您需要将异常处理(第三个职责)添加到MoveCustomer用例中,则必须(再次)修改MoveCustomer方法。但是,您不仅需要修改MoveCustomer方法,还需要修改许多其他方法,因为它们通常需要相同的异常处理,从而使其成为大规模更改。

解决此问题的方法是将日志记录提取到自己的类中,并允许该类包装原始类:

// The real thing
public class MoveCustomerService : IMoveCustomerService
{
    public virtual void MoveCustomer(int customerId, Address newAddress)
    {
        // Real operation
    }
}

// The decorator
public class MeasuringMoveCustomerDecorator : IMoveCustomerService
{
    private readonly IMoveCustomerService decorated;
    private readonly ILogger logger;

    public MeasuringMoveCustomerDecorator(
        IMoveCustomerService decorated, ILogger logger)
    {
        this.decorated = decorated;
        this.logger = logger;
    }

    public void MoveCustomer(int customerId, Address newAddress)
    {
        var watch = Stopwatch.StartNew();

        this.decorated.MoveCustomer(customerId, newAddress);
    
        this.logger.Log("MoveCustomer executed in " +
            watch.ElapsedMiliseconds + " ms.");
    }
}

通过将decorator包装在真实实例周围,您现在可以将此测量行为添加到类中,而无需更改系统的任何其他部分:
IMoveCustomerService service =
    new MeasuringMoveCustomerDecorator(
        new MoveCustomerService(),
        new DatabaseLogger());

上一个示例仅解决了问题的一部分(仅 SRP 部分)。当按照上述代码编写时,您将不得不为系统中的所有操作定义单独的装饰器,并且您最终将拥有像 MeasuringShipOrderDecoratorMeasuringCancelOrderDecoratorMeasuringCancelShippingDecorator 这样的装饰器。这又导致了大量重复代码(违反了 OCP 原则),并且仍然需要为系统中的每个操作编写代码。缺少的是系统用例上的公共抽象。

缺少的是一个 ICommandHandler<TCommand> 接口。

让我们来定义这个接口:

public interface ICommandHandler<TCommand>
{
    void Execute(TCommand command);
}

让我们将MoveCustomer方法的方法参数存储到其自己的(Parameter Object)类中,称为MoveCustomerCommand

public class MoveCustomerCommand
{
    public int CustomerId { get; set; }
    public Address NewAddress { get; set; }
}

提示:这个MoveCustomerCommand对象变成了一条消息。这就是为什么有些人在这种类型后缀中加上“Message”,称其为MoveCustomerMessage。其他人倾向于将其称为MoveCustomerRequest,而其他人则完全删除后缀,只需将此参数对象称为MoveCustomer。当我最初撰写本答案时,我使用的是“Command”后缀,但现在,我倾向于仅使用MoveCustomer。但无论您选择什么,这里的力量在于数据(命令/消息)和行为(处理程序)之间的分离,接下来我们将看到。
让我们将MoveCustomer方法的行为放入一个实现ICommandHandler<MoveCustomerCommand>的新类中:
public class MoveCustomerCommandHandler : ICommandHandler<MoveCustomerCommand>
{
    public void Execute(MoveCustomerCommand command)
    {
        int customerId = command.CustomerId;
        Address newAddress = command.NewAddress;
        // Real operation
    }
}

一开始可能看起来有些奇怪,但由于您现在拥有了用例的通用抽象,因此可以将装饰器重写为以下内容:

public class MeasuringCommandHandlerDecorator<TCommand>
    : ICommandHandler<TCommand>
{
    private ILogger logger;
    private ICommandHandler<TCommand> decorated;

    public MeasuringCommandHandlerDecorator(
        ILogger logger,
        ICommandHandler<TCommand> decorated)
    {
        this.decorated = decorated;
        this.logger = logger;
    }

    public void Execute(TCommand command)
    {
        var watch = Stopwatch.StartNew();

        this.decorated.Execute(command);
    
        this.logger.Log(typeof(TCommand).Name + " executed in " +
            watch.ElapsedMiliseconds + " ms.");
    }
}

这个新的MeasuringCommandHandlerDecorator<T>看起来很像MeasuringMoveCustomerDecorator,但是这个类可以在系统中重复使用于所有命令处理器。
ICommandHandler<MoveCustomerCommand> handler1 =
    new MeasuringCommandHandlerDecorator<MoveCustomerCommand>(
        new MoveCustomerCommandHandler(),
        new DatabaseLogger());

ICommandHandler<ShipOrderCommand> handler2 =
    new MeasuringCommandHandlerDecorator<ShipOrderCommand>(
        new ShipOrderCommandHandler(),
        new DatabaseLogger());

这样做将使您的系统更加容易添加横切关注点。在您的组合根中创建一个便捷的方法来包装系统中任何已创建的命令处理程序与适用的命令处理程序非常容易。例如:
private static ICommandHandler<T> Decorate<T>(ICommandHandler<T> decoratee)
{
    return
        new MeasuringCommandHandlerDecorator<T>(
            new DatabaseLogger(),
            new ValidationCommandHandlerDecorator<T>(
                new ValidationProvider(),
                new AuthorizationCommandHandlerDecorator<T>(
                    new AuthorizationChecker(
                        new AspNetUserProvider()),
                    new TransactionCommandHandlerDecorator<T>(
                        decoratee))));
}

这个方法可以按照以下方式使用:
ICommandHandler<MoveCustomerCommand> handler1 = 
    Decorate(new MoveCustomerCommandHandler());

ICommandHandler<ShipOrderCommand> handler2 =
    Decorate(new ShipOrderCommandHandler());

如果你的应用程序开始变得越来越复杂,那么使用 DI 容器进行引导可能会非常有用,因为 DI 容器可以支持自动注册。这样一来,你就不必为每个新的命令/处理程序对添加到系统中而更改组合根。

大多数现代成熟的 .NET DI 容器都具有相当不错的装饰器支持,特别是 Autofac(示例)和 Simple Injector(示例),它们使注册开放式通用装饰器变得容易。

另一方面,Unity 和 Castle 则拥有动态拦截功能(就像 Autofac 一样)。动态拦截与装饰器有很多相似之处,但它在底层使用了动态代理生成。这可能比使用通用装饰器更灵活,但在可维护性方面则需要付出代价,因为你经常会失去类型安全性,并且拦截器总是强制让你依赖于拦截库,而装饰器是类型安全的,并且可以编写而不必依赖外部库。

我已经使用这些类型的设计十多年了,无法想象在没有它的情况下设计我的应用程序。我已经广泛撰写关于这些设计的文章,并且最近,我合著了一本名为Dependency Injection Principles, Practices, and Patterns的书,其中详细介绍了这种SOLID编程风格和上面描述的设计(见第10章)。


8
“Steven,你的回答很好。我同意你在这里说的一切,我也不赞成仅为记录日志和重新抛出异常而捕获异常,因为我认为应该有一个应用程序域中的中央点来完成此操作。然而,我确实认为有时候你想要捕获特定的异常,比如SQLException,以重试操作。” - OutOFTouch
1
SimpleInjector看起来很不错,是我要使用的首选,继续保持好工作。这里有一个使用Windsor的装饰器示例http://mikehadlow.blogspot.com/2010/01/10-advanced-windsor-tricks-4-how-to.html,供有兴趣的人参考。 - OutOFTouch
3
对于重试操作,使用AOP非常合适,但您不希望在每个数据库操作周围包装这样的事情,而是要在事务界限处进行。实际上,我的这篇博客文章展示了这一点(请查看“DeadlockRetryCommandHandlerDecorator”)。@OutOFTouch - Steven
39
如果有办法更广泛地宣传这个,那就做吧。我用过数百万种编程语言编写代码(包括Haskell、Racket和Forth),它们都声称能改变你的思维方式,而我认为自己在这方面做得相当好。但这是过去20年以来第一件真正改变我的思维方式的事情(也是自《四骑士》书以来的第一件),让我后悔自己曾经写过的每一个应用程序。这应该成为必读之物。 - Dax Fohl
@Steven 如果我理解正确的话,你基于命令模式的方法可以通过将 Logging 等内容放在装饰器中来减少代码重复。由于这个装饰器是 ICommandHandler<TCommand> 的实现,这意味着它可以包装 任何 命令处理程序,对吗?此外,这个 UML 解释正确吗?https://i.imgur.com/3uLzcNE.png - Muroxxas
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接