Как известно, декоратор является одним из структурных паттернов проектирования. Его удобно комбинировать с другими паттернами для достижения гибкой и расширяемой системы, без изменения существующего кода.

Разбираемся на примере логирования

Допустим, перед нами появилась задача написать логирование для нашего приложения. Что же, давайте реализуем:

public enum LogLevel
{
    Debug,
    Info,
    Warn,
    Error,
    Fatal
}

public interface ILogger : IDisposable
{
    void Log(LogLevel level, string message, params object[] args);
}

public class ConsoleLogger : ILogger
{
    public void Log(LogLevel level, string message, params object[] args)
    {
        Console.Write("[{0}]: ", level);
        Console.WriteLine(message, args);
    }

    public void Dispose(){}
}

Что делать, если нам необходимо выключить логирование на определенных уровнях?

Первое, что может прийти в голову:

  • Добавить поле в ConsoleLogger и перед записью проверять, нужно ли логировать сообщение.
  • Сделать базовый класс, который будет делать эту проверку (лучше, но не так гибко, теперь нам обязательно нужно наследоваться от базового класса).

Эти решения будут работать, но вряд ли будут удобны в дальнейшей перспективе.

Используем декоратор

public class FilterLogLevelLogger : ILogger
{
    private readonly ILogger _logger;
    private readonly LogLevel _minimumLogLevel;

    public FilterLogLevelLogger(ILogger logger, LogLevel minimumLogLevel)
    {
        _logger = logger;
        _minimumLogLevel = minimumLogLevel;
    }

    public void Log(LogLevel level, string message, params object[] args)
    {
        if (level < _minimumLogLevel)
            return;
        _logger.Log(level, message, args);
    }
    
    public void Dispose() => _logger.Dispose();
}

Комбинируем:

using var consoleLogger = new FilterLogLevelLogger(new ConsoleLogger(), LogLevel.Info);
consoleLogger.Log(LogLevel.Debug, "Test");
consoleLogger.Log(LogLevel.Warn, "Test"); // выводит только это сообщение

Composite + Decorator

Наше приложение усложняется и теперь нам мало простой записи в консоль, у нас появилась необходимость писать логи в файл.

Это сделать просто:

public class FileLogger : ILogger
{
    private readonly StreamWriter _writer;

    public FileLogger(string path) => _writer = File.AppendText(path);

    public void Log(LogLevel level, string message, params object[] args)
    {
        _writer.Write("[{0}]: ", level);
        _writer.WriteLine(message, args);
    }

    public void Dispose() => _writer.Dispose();
}

А что если мы хотим логировать и в файл, и в консоль?

Используем композит

public class CompositeLogger : ILogger
{
    private readonly ICollection<ILogger> _loggers;

    public CompositeLogger(ICollection<ILogger> loggers) => _loggers = loggers;

    public void Log(LogLevel level, string message, params object[] args)
    {
        foreach (var logger in _loggers)
            logger.Log(level, message, args);
    }
    
    public void Dispose()
    {
        foreach (var logger in _loggers)
            logger.Dispose();
    }
}

Теперь мы можем использовать 2 логгера одновременно:

var loggers = new ILogger[]
{
    new FileLogger("test.txt"),
    new ConsoleLogger()
};
using var consoleLogger = new CompositeLogger(loggers);
consoleLogger.Log(LogLevel.Debug, "Test");
consoleLogger.Log(LogLevel.Warn, "Test");

Благодаря использованию Composite мы также можем включить определенный уровень логирования для конкретного логгера или же для всех одновременно.

Определенный уровень для всех логгеров одновременно

var loggers = new ILogger[]
{
    new FileLogger("test.txt"),
    new ConsoleLogger()
};
//                            Магия тут ↓
using var consoleLogger = new FilterLogLevelLogger(new CompositeLogger(loggers), LogLevel.Info);
consoleLogger.Log(LogLevel.Info, "Test");
consoleLogger.Log(LogLevel.Warn, "Test");

Как это работает:

Вызываем Log, с определенным уровнем логирования у FilterLogLevelLogger, который уже начинает проверку на уровень логирования и если он должен быть залогирован, то он попадает ниже, в нашем случае, это ComposeLogger, который вызовет Log, на каждом из внутренних логгеров ConsoleLogger и FileLogger.

В текущем примере на консоль и в файл попадёт 2 сообщения. Так как они все подходят по уровню логирования.

Определенный уровень логирования для каждого логгера отдельно

var loggers = new ILogger[]
{
    // Магия тут ↓
    new FilterLogLevelLogger(new FileLogger("test.txt"), LogLevel.Warn),
    // Магия тут ↓
    new FilterLogLevelLogger(new ConsoleLogger(), LogLevel.Info)
};
using var consoleLogger = new CompositeLogger(loggers);
consoleLogger.Log(LogLevel.Info, "Test");
consoleLogger.Log(LogLevel.Warn, "Test");

Как это работает:

Вызываем Log, с определенным уровнем логирования, на ComposeLogger, он в свою очередь берет каждый из внутренних логгеров и перенаправляет запрос к каждому из внутренних:

  • new FilterLogLevelLogger(new FileLogger(“test.txt”), LogLevel.Warn)
  • new FilterLogLevelLogger(new ConsoleLogger(), LogLevel.Info)

Который уже начинает проверку на уровень логирования и если он должен быть залогирован, то он попадает ниже, т.е уже к конкретной реализации ConsoleLogger или FileLogger.

В текущем примере на консоль попадёт 2 сообщения, так как они подходят по уровню, а в файл будет записано только одно сообщение, с уровнем Warn.

Proxy + Decorator

Представим, появилось требование, что мы не должны открывать файл логирования на старте приложения. Допустим, что это занимает много ресурсов и нам нужно отложить это действие, настолько, насколько это возможно.

Сделаем новый декоратор

public class LazyLogger : ILogger
{
    private readonly Lazy<ILogger> _lazyLogger;

    public LazyLogger(Func<ILogger> loggerFactory) => _lazyLogger = new Lazy<ILogger>(loggerFactory);

    public void Log(LogLevel level, string message, params object[] args)
    {
        _lazyLogger.Value.Log(level, message, args);
    }
    
    public void Dispose()
    {
        if(_lazyLogger.IsValueCreated)
            _lazyLogger.Value.Dispose();
    }
}

Использование:

using var lazyLogger = new LazyLogger(() => new FileLogger("test.txt")); 
consoleLogger.Log(LogLevel.Warn, "Test");

Теперь, если логгер не будет нужен, то файл не будет открыт/создан во время работы приложения и это произойдёт только по необходимости

Proxy + Composite + Decorator

Используем всё вместе:

var loggers = new ILogger[]
{
    new FilterLogLevelLogger(
        new LazyLogger(() => new FileLogger("test.txt")), 
        LogLevel.Warn
    ),
    new FilterLogLevelLogger(new ConsoleLogger(), LogLevel.Info)
};
using var consoleLogger = new CompositeLogger(loggers);
consoleLogger.Log(LogLevel.Info, "Test");

В данном примере сообщение попадёт только в консоль и файл даже не будет создан, так как в него могут попасть сообщения с уровнем Warn и выше.

Итог

Не стоит боятся комбинировать разные паттерны если правильно их применять, можно сделать гибкую и расширяемую систему.

Конечно, так можно всё испортить, так что не стоит переусердствовать и всегда стоит думать, а точно ли оно мне тут нужно?

Ссылки