Руководство по проектированию для избежания круговой зависимости при настройке LoggerConfiguration

Вопрос или проблема

У меня есть консольное приложение на C# .NET, использующее Autofac, Serilog и Spectre.Console. Моя конфигурация Serilog стала достаточно сложной, что оценивается по количеству зависимостей, необходимых для общего процесса настройки логирования. Это достигло такой степени, что внедрение зависимостей стало очень хрупким, и мне нужно быть очень осторожным с введением циклических зависимостей из-за внедрения ILogger в неправильные классы.

У меня есть один случай, который мне трудно избежать. Он включает в себя некоторые ключевые компоненты:

  • LoggerFactory, который я использую для создания ILogger с помощью LoggerConfiguration
  • SettingsProvider, который по сути является фабрикой, загружающей настройки из YAML-файла и проверяющей их с помощью FluentValidation.

Недавно я добавил новый sink Serilog, который отвечает за перевод определенных сообщений логирования в уведомления о событиях. Конкретно, это можно рассматривать как способ сбора логов предупреждений и ошибок, чтобы я мог отправить их в сообщении на веб-хук Discord.

Проблема возникла, когда я реализовал этот sink логирования, который добавляет зависимость от LoggerFactory к SettingsProvider. SettingsProvider требует ILogger, чтобы, когда проверка не удается, можно было записать конкретные сообщения об ошибках в логи. Однако, поскольку LoggerFactory также зависит от настроек, это приводит к циклической зависимости.

Я хотел бы получить некоторые рекомендации по дизайну о наиболее подходящем способе разрыва этой циклической зависимости. Я могу придумать несколько способов, каждый из которых имеет свои недостатки. Ничто из того, о чем я думал до сих пор, не “ощущалось правильно”.

  • Я мог бы сделать так, чтобы SettingsProvider не использовал ILogger и вместо этого вызывал исключение, которое обрабатывается в другом месте. Проблема заключается в том, что настройки используются везде в моем приложении, и трудно централизованно управлять этими ошибками валидации вне класса провайдера.
  • Из-за того, как Spectre.Console требует, чтобы структура моей точки входа программы была организована, не очень легко или интуитивно выполнять глобальную настройку приложения; именно поэтому я принял подход “ленивых настроек” (провайдер), чтобы настройки всегда загружались по мере необходимости и настраивались там, где они используются.
  • Возможно, использование sink Serilog для этого является неверным инструментом для этой задачи; но я не могу представить себе лучшего (т.е. централизованного) подхода для перевода ошибок и предупреждений в сообщения уведомлений.

Я сделал все возможное, чтобы создать минимальный, воспроизводимый пример проблемы ниже; он все еще не очень короткий, но его легко скопировать и вставить в проект, запустить и увидеть исключение от Autofac.

using System;
using System.Threading.Tasks;
using Autofac;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Spectre.Console;
using Spectre.Console.Cli;

namespace ConsoleApp1;

public class NotificationLogSink : ILogEventSink
{
    public void Emit(LogEvent logEvent)
    {
        switch (logEvent.Level)
        {
            case LogEventLevel.Warning:
                // отправить уведомление о предупреждении
                break;

            case LogEventLevel.Error:
            case LogEventLevel.Fatal:
                // отправить уведомление об ошибке
                break;
        }
    }
}

public record SettingsValues
{
    public string? ThisIsMySetting { get; init; }
}

// ILogger технически не должен внедряться в этот класс из-за проблемы 22 (циклическая
// зависимость).
//
// В общем: LoggerFactory создает ILogger. LoggerFactory зависит от настроек. Settings пытается
// получить ILogger до того, как он будет доступен.
public class SettingsProvider
{
    private readonly ILogger _log;
    private readonly Lazy<SettingsValues> _settings;

    public SettingsValues Settings => _settings.Value;

    public SettingsProvider(ILogger log)
    {
        _log = log;
        _settings = new Lazy<SettingsValues>(LoadOrCreateSettingsFile);
    }

    private SettingsValues LoadOrCreateSettingsFile()
    {
        // Это заглушка для демонстрационных целей, но в этом методе мы загружаем YAML
        // файл и читаем настройки из него.
        var values = new SettingsValues { ThisIsMySetting = "fubar" };
        ValidateSettings(values);
        return values;
    }

    private void ValidateSettings(SettingsValues settings)
    {
        if (settings is { ThisIsMySetting: "invalid" })
        {
            _log.Error("Проверка конфигурации не удалась, потому что настройка недействительна");
        }
    }
}

public class LoggerFactory(SettingsProvider settingsProvider)
{
    public ILogger Create()
    {
        var config = new LoggerConfiguration()
            .MinimumLevel.Is(LogEventLevel.Verbose)
            .WriteTo.Console();

        // Если пользователь отключил уведомления, не имеет смысла добавлять sink уведомлений.
        if (settingsProvider.Settings.ThisIsMySetting is not null)
        {
            var sink = new NotificationLogSink();
            config.WriteTo.Sink(sink, LogEventLevel.Information);
        }

        return config.CreateLogger();
    }
}

public class TestCommand(ILogger log) : Command<TestCommand.Settings>
{
    public class Settings : CommandSettings
    {
    }

    public override int Execute(CommandContext context, Settings settings)
    {
        log.Information("Команда тестирования выполнена");
        return 0;
    }
}

internal class AutofacTypeRegistrar(ContainerBuilder builder)
    : ITypeRegistrar
{
    public void Register(Type service, Type implementation)
    {
        builder.RegisterType(implementation).As(service).SingleInstance();
    }

    public void RegisterInstance(Type service, object implementation)
    {
        builder.RegisterInstance(implementation).As(service);
    }

    public void RegisterLazy(Type service, Func<object> factory)
    {
        builder.Register(_ => factory()).As(service).SingleInstance();
    }

    public ITypeResolver Build()
    {
        return new AutofacTypeResolver(builder.Build());
    }
}

internal class AutofacTypeResolver(ILifetimeScope scope) : ITypeResolver
{
    public object? Resolve(Type? type)
    {
        return type is not null ? scope.Resolve(type) : null;
    }
}

public static class Program
{
    public static async Task<int> Main(string[] args)
    {
        var builder = new ContainerBuilder();

        builder.RegisterType<LoggerFactory>();
        builder.Register(c => c.Resolve<LoggerFactory>().Create()).As<ILogger>().SingleInstance();
        builder.RegisterType<SettingsProvider>().SingleInstance();

        var app = new CommandApp(new AutofacTypeRegistrar(builder));
        app.Configure(config =>
        {
#if DEBUG
            config.PropagateExceptions();
            config.ValidateExamples();
#endif

            config.Settings.StrictParsing = true;
            config.SetApplicationName("ConsoleApp");
            config.SetExceptionHandler(ExceptionHandler);

            config.AddCommand<TestCommand>("test");
        });

        return await app.RunAsync(args);
    }

    private static void ExceptionHandler(Exception ex, ITypeResolver? resolver)
    {
        var log = (ILogger?)resolver?.Resolve(typeof(ILogger));
        if (log is null)
        {
            AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
        }
        else
        {
            log.Error(ex, "Необратимое исключение");
        }
    }
}

Ответ или решение

Чтобы избежать проблем с круговыми зависимостями при конфигурации логирования с использованием LoggerConfiguration, ILogger, и SettingsProvider, можно рассмотреть несколько подходов. Ниже приведены рекомендации, которые могут помочь вам решить данную проблему, сохраняя при этом семантику и структуру вашего приложения.

Подход 1: Использование события для передачи логов

Одним из способов избежать прямой зависимости между SettingsProvider и LoggerFactory является использование событий для обработки логов. Вы можете определить интерфейс для обработки логов и подписать его реализацию на события, генерируемые внутри SettingsProvider. Это позволит избежать циклической зависимости:

public interface ILogHandler
{
    void HandleLog(LogEvent logEvent);
}

public class SettingsProvider
{
    private readonly ILogHandler _logHandler;
    private readonly Lazy<SettingsValues> _settings;

    public SettingsValues Settings => _settings.Value;

    public SettingsProvider(ILogHandler logHandler)
    {
        _logHandler = logHandler;
        _settings = new Lazy<SettingsValues>(LoadOrCreateSettingsFile);
    }

    private SettingsValues LoadOrCreateSettingsFile()
    {
        var values = new SettingsValues { ThisIsMySetting = "fubar" };
        ValidateSettings(values);
        return values;
    }

    private void ValidateSettings(SettingsValues settings)
    {
        if (settings is { ThisIsMySetting: "invalid" })
        {
            _logHandler.HandleLog(new LogEvent {...}); // Передайте соответствующее событие логирования.
        }
    }
}

Для LoggerFactory вы можете создать реализацию ILogHandler, которая будет обрабатывать сообщения логирования.

Подход 2: Lazy Initialization и отложенная загрузка логирования

Вы можете использовать ленивую инициализацию для ILogger внутри SettingsProvider. Это позволяет не передавать логгер при создании экземпляра SettingsProvider, а вместо этого создавать его, когда это действительно необходимо:

public class SettingsProvider
{
    private readonly Func<ILogger> _loggerFactory;
    private Lazy<SettingsValues> _settings;

    public SettingsValues Settings => _settings.Value;

    public SettingsProvider(Func<ILogger> loggerFactory)
    {
        _loggerFactory = loggerFactory;
        _settings = new Lazy<SettingsValues>(LoadOrCreateSettingsFile);
    }

    private SettingsValues LoadOrCreateSettingsFile()
    {
        var values = new SettingsValues { ThisIsMySetting = "fubar" };
        ValidateSettings(values);
        return values;
    }

    private void ValidateSettings(SettingsValues settings)
    {
        if (settings is { ThisIsMySetting: "invalid" })
        {
            var log = _loggerFactory();
            log.Error("Config validation failed because setting is invalid");
        }
    }
}

В этом случае, ILogger создается только тогда, когда он реально нужен, что позволяет избежать циклической зависимости.

Подход 3: Паттерн "Службы нотификаций"

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

public class NotificationService
{
    private readonly ILogger _logger;

    public NotificationService(ILogger logger)
    {
        _logger = logger;
    }

    public void HandleLog(LogEvent logEvent)
    {
        // Обработка и отправка уведомлений
    }
}

SettingsProvider может получать NotificationService, а не ILogger, что позволит избежать циклической зависимости.

Итог

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

Попробуйте имплементировать один из этих подходов и проверьте, что это решает эвристику круговой зависимости в вашем проекте.

Оцените материал
Добавить комментарий

Капча загружается...