Вопрос или проблема
У меня есть консольное приложение на 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
, что позволит избежать циклической зависимости.
Итог
В зависимости от вашего сценария могут подойти разные решения. Главное, чтобы каждая часть системы отвечала только за свои обязанности и чтобы зависимостями управляли через интерфейсы или события, что эффективно предотвратит создание круговых зависимостей.
Попробуйте имплементировать один из этих подходов и проверьте, что это решает эвристику круговой зависимости в вашем проекте.