Приложение передачи показаний водосчётчиков готово, но я не спешу публиковать его на сервер, потому что не доволен реализацией. Отдельные части приложения, да и архитектура в целом оставляют желать лучшего. Самое время это исправить. В первую очередь я хочу изменить зависимости между компонентами так, чтобы это в бОльшей степени соответствовало принципам чистой архитектуры. А для большей наглядности решено было нарисовать диаграмму зависимостей:

- Saures API и TomRC API зависят от предметной области и ядра приложения, что не есть хорошо, поскольку с концептуальной точки зрения обе библиотеки предоставляют доступ к соответсnвующему API и на этом их ответственность заканчивается. Этим библиотекам не важно кто и как будет их использовать, важно что логика вызывающих приложений не должна просачиваться внутрь.
- Код инъекций зависимостей «размазан» ровным слоем по всем библиотекам. Да, зависимость от интерфейса, а не от конкретной реализации, но это всё равно не хорошо.
Произведу рефакторинг компонентов таким образом, чтобы в конечном итоге получилось вот так:

Начну с библиотек Saures API и TomRC API — уберу ссылку на проект Core и NuGet пакет Microsoft.Extensions.DependencyInjection.Abstractions, а также выделю классы предметной области (Domain). В результате проекты выглядят вот так:

Минимум зависимостей и единственная ответственность — то что надо! Однако нужно как-то обеспечить связь Saures API и TomRC API с интерфейсами объявленными в ядре приложения. Для этого создам проект Infrastructure. Задача этого слоя — быть мостом между ядром приложения и внешними зависимостями. Тут не должно быть сложных алгоритмов или реализаций некоторых сервисов, а лишь посреднические услуги:

Наконец последний проект WaterMeterAutomation переименованный в EntryPoint. Задачи у точки входа простые:
- загрузить конфигурацию приложения.
- обеспечить логирование необработанных исключений
- настроить необходимые сервисы
- запустить выполнение полезной работы.
В процессе реализации решено было отказаться от использования универсального узла .NET, поскольку он больше подходит для длительно работающих приложений, типа win-сервисов, а это не тот случай. Да и интеграция с расширениями от MS тут тоже не требуется. Так что я взял в качестве основы реализацию метода Main со страницы NLog — Getting started with .NET Core 2 Console application. В результате получилось вот так:
internal class Program
{
private static async Task Main()
{
var logger = LogManager.GetCurrentClassLogger();
try
{
var configuration = CreateConfiguration();
ConfigureMailTarget(configuration);
var container = new StashboxContainer()
.RegisterInstance(configuration)
.ComposeAssembly(Assembly.GetExecutingAssembly());
#if DEBUG
container.Validate();
#endif
var actor = container.Resolve<MainInteractionService>();
await actor.SendNewWaterMetersValuesFromSourceToConsumer();
}
catch (Exception ex)
{
logger.Error(ex, "Stopped program because of exception");
throw;
}
finally
{
// Ensure to flush and stop internal timers/threads before application-exit (Avoid segmentation fault on Linux)
LogManager.Shutdown();
}
}
private static IConfiguration CreateConfiguration()
{
return new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables("wma_")
.Build();
}
private static void ConfigureMailTarget(IConfiguration configuration)
{
var loggerCredentials = configuration.GetSection("Smtp").Get<LoggerCredentials>();
var config = LogManager.Configuration;
var mailTarget = config.FindTargetByName<MailTarget>("mail");
mailTarget.SmtpAuthentication = SmtpAuthenticationMode.Basic;
mailTarget.SmtpUserName = loggerCredentials.Login;
mailTarget.SmtpPassword = loggerCredentials.Password;
}
private record LoggerCredentials
{
public string Login { get; init; }
public string Password { get; init; }
}
}Вторым нововведением стало использование контейнера внедрения зависимостей StashBox, По сравнению с решением от MS этот контейнер обладает куда большим набором возможностей, например я воспользовался регистрацией зависимостей в классах с реализацией интерфейса ICompositionRoot:
Это позволяет регистрировать все зависимости приложения вызвав единственный метод ComposeAssembly с указанием сборки.
internal sealed class CompositionRootConfiguration : ICompositionRoot
{
private readonly IConfiguration _configuration;
public CompositionRootConfiguration(IConfiguration configuration)
{
_configuration = configuration;
}
public void Compose(IStashboxContainer container)
{
container.Register<SauresConfiguration>(o =>
o.WithFactory(() => _configuration.GetSection("SauresApi").Get<SauresConfiguration>())
.WithLifetime(Lifetimes.Singleton));
container.Register<TomRcConfiguration>(o =>
o.WithFactory(() => _configuration.GetSection("TomRcApi").Get<TomRcConfiguration>())
.WithLifetime(Lifetimes.Singleton));
container.Register<MainInteractionServiceConfiguration>(o =>
o.WithFactory(() => _configuration.GetSection("MainInteractionService").Get<MainInteractionServiceConfiguration>())
.WithLifetime(Lifetimes.Singleton));
}
}
internal sealed class CompositionRootCore : ICompositionRoot
{
public void Compose(IStashboxContainer container)
{
container.Register<MainInteractionService>();
}
}
internal class CompositionRootLoggerAndNotification : ICompositionRoot
{
public void Compose(IStashboxContainer container)
{
container.Register<ILogger>(o => o.WithFactory(() => LogManager.GetLogger("Global")));
container.RegisterSingleton<INotificationService, NotificationService>();
}
}
internal sealed class CompositionRootSauresApi : ICompositionRoot
{
private readonly SauresConfiguration _sauresConfiguration;
public CompositionRootSauresApi(SauresConfiguration sauresConfiguration)
{
_sauresConfiguration = sauresConfiguration;
}
public void Compose(IStashboxContainer container)
{
container.Register<Api>(o =>
o.WithFactory(() => new Api(_sauresConfiguration.Host))
.WithLifetime(Lifetimes.Singleton));
container.Register<IWaterMetersDataSourceService, WaterMetersDataSourceService>(o =>
o.WithFactory<Api>(api => new WaterMetersDataSourceService(api, _sauresConfiguration.Login, _sauresConfiguration.Password))
.WithLifetime(Lifetimes.Singleton));
}
}
internal class CompositionRootTomRcApi : ICompositionRoot
{
private readonly TomRcConfiguration _tomRcConfiguration;
public CompositionRootTomRcApi(TomRcConfiguration tomRcConfiguration)
{
_tomRcConfiguration = tomRcConfiguration;
}
public void Compose(IStashboxContainer container)
{
container.Register<Api>(o =>
o.WithFactory(() => new Api(_tomRcConfiguration.Host))
.WithLifetime(Lifetimes.Singleton));
container.RegisterSingleton<DocumentParser>();
container.Register<IWaterMetersDataConsumerService, WaterMetersDataConsumerService>(o =>
o.WithFactory<Api, DocumentParser>((api, parser) => new WaterMetersDataConsumerService(api, _tomRcConfiguration.Login, _tomRcConfiguration.Password, parser))
.WithLifetime(Lifetimes.Singleton));
}
}И немного проще стали классы конфигурации для SauresAPI и TomRcAPI:
internal abstract record ApiConfiguration
{
public string Host { get; init; }
public string Login { get; init; }
public string Password { get; init; }
}
internal record SauresConfiguration : ApiConfiguration;
internal record TomRcConfiguration : ApiConfiguration;
На этом рефакторинг решения завершён. Однако напоследок хочу поделиться особенностями отправки эл. писем из приложения. В настоящее время большинство почтовых служб вводят дополнительные проверки при авторизации и зачастую закрывают авторизацию только по имени пользователя и паролю. Yandex предлагает элегантное решение: Пароли приложений. Суть проста: для некоторых служб создаётся отдельная связка имя пользователя \ пароль для простой авторизации. В это же время для основного аккаунта дополнительные средства безопасности и в том числе двухфакторная авторизация продолжают работать. Удобно? — безусловно.