Сетевой дневник одного программиста

Персональный блог Константина Огородова

Консольное приложение передачи показаний водосчётчиков

В предыдущих статьях я создал Saures API, TomRC API и ядро системы для передачи показаний водосчётчиков. Весь код был протестирован с помощью интеграционных и модульных тестов. Компоненты работают именно так, как и задумывалось, однако это всё ещё никак не связанные друг с другом компоненты, а не единое целое. Самое время это исправить. Начну с того, что добавлю ссылку на проект Core в проектах SauresApi и TomRcApi. Такая организация связей между компонентами соответствует принципу инверсии зависимостей (Хабр) из пятёрки принципов SOLID. Основная идея в том, что сущности предметной области и бизнес логика — это и есть ядро системы, которое не должно иметь зависимостей и имеет наибольшую ценность во всём приложении. Благодаря отсутствию зависимостей никакие другие модули не могут повлиять на функционирование ядра, т.е. изменение уровня доступа к данным, пользовательского интерфейса, интеграции со сторонними сервисами никак не повлияет на ядро приложения и, соответственно, не потребует каких-либо изменений. С другой стороны, поскольку все модули так или иначе зависят от ядра приложения, то изменение ядра (логики или сущностей) неминуемо приведёт к изменению части компонентов приложения. Такой подход организации зависимостей в приложении получил название «Чистая архитектура» (MS).

Итак, для взаимодействия с внешними сервисами в ядре приложения для SauresAPI объявлен интерфейс IWaterMetersDataSourceService, а для TomRcAPI — IWaterMetersDataConsumerService. Оба интерфейса объявляют только то, что нужно ядру и ничего больше. В то же время в обоих интеграционных проектах присутствует класс Api, который реализует непосредственно интеграцию со сторонними сервисами. При этом присутствуют детали реализации свойственные для каждого модуля, которые никак не учитываются в интерфейсах ядра. Другими словами: ядро не заботится о деталях реализации, а интеграционные компоненты не ориентируются на то, как будут использоваться ядром. Чтобы их подружить следует создать новый класс, который реализует интерфейс через использование объекта Api. При этом детали конкретного Api останутся внутри этого класса:

public class WaterMetersDataSourceService : IWaterMetersDataSourceService
{
    private readonly Api _api;
    private readonly string _sauresEmail;
    private readonly string _sauresPassword;

    public WaterMetersDataSourceService(Api api, string sauresEmail, string sauresPassword)
    {
        _api = api;
        _sauresEmail = sauresEmail;
        _sauresPassword = sauresPassword;
    }

    public async Task<IEnumerable<WaterMeter>> GetWaterMetersAsync()
    {
        var sid = await _api.LoginAsync(_sauresEmail, _sauresPassword);
        var userObjects = await _api.GetUserObjects(sid);

        if (userObjects.Length != 1)
            throw new ApplicationException($"Receive {userObjects.Length} user objects");

        var sensor = await _api.GetObjectMeters(sid, userObjects[0].id);

        return sensor.meters.Select(o => new WaterMeterInternal(o));
    }

    private class WaterMeterInternal : WaterMeter
    {
        public WaterMeterInternal(Meter meter) : base(meter.sn, Convert.ToDecimal(meter.vals.Single()))
        {
        }
    }
}
public class WaterMetersDataConsumerService : IWaterMetersDataConsumerService
{
    private readonly Api _api;
    private readonly DocumentParser _documentParser;
    private readonly string _login;
    private readonly string _password;

    private string _idAccount;
    private Invoice _invoice;

    public WaterMetersDataConsumerService(Api api, string login, string password, DocumentParser documentParser)
    {
        _api = api;
        _login = login;
        _password = password;
        _documentParser = documentParser;
    }

    public async Task<ICollection<WaterMeter>> GetCurrentWaterMetersValuesAsync()
    {
        await _api.LoginAsync(_login, _password);

        var accountHtml = await _api.GetAccountHtmlAsync();
        _idAccount = await _documentParser.GetIdAccountFromHtmlAsync(accountHtml);

        var invoiceHtml = await _api.GetInvoiceHtmlAsync(_idAccount);
        _invoice = await _documentParser.GetInvoiceFromHtmlAsync(invoiceHtml);

        return _invoice.WaterMeters;
    }

    public async Task SendWaterMetersNewValuesAsync(IEnumerable<WaterMeter> newValues)
    {
        var newValuesDictionary = newValues.ToDictionary(o => o.Id, o => o.Value);

        foreach (var waterMeter in _invoice.WaterMeters)
            waterMeter.Value = newValuesDictionary[waterMeter.Id];

        await _api.SendWaterMeterValuesAsync(_idAccount, _invoice);
        await _api.LogoutAsync();
    }
}


Теперь ядро и интеграционные компоненты могут совместно работать, но для запуска нужно организовать точку входа. Пусть это будет консольное приложение с именем WaterMeterAutomation. Реализация консольки могла быть выполнена «по-модному» через Универсальный узел .NET, но я решил сделать всё по старинке через собственный загрузчик, задача которого будет инициализировать инфраструктуру приложения. И поскольку одной из обязанностей консольки будет получение параметров из внешних источников, то первым делом позабочусь о конфигурации. Меня в данном контексте интересуют 2 поставщика: файл параметров appsettings.json и переменные среды:

private static IConfiguration CreateConfiguration()
{
    return new ConfigurationBuilder()
        .AddJsonFile("appsettings.json")
        .AddEnvironmentVariables("wma_")
        .Build();
}

В конфигурации для переменных среды я указал, что должен использоваться префикс wma_ — это позволяет сгруппировать переменные по приложениям, что повышает удобство работы с ними. Также имеется возможность сделать ключ иерархическим, разделяя каждый уровень двойным подчёркиванием __ , что даёт возможность делить настройки по секциям. Учитывая вышесказанное, переменная окружения с именем пользователя Saures примет вид wma_SauresApi__Login . Напомню, что все секретные данные типа логинов, паролей и проч. ключей для приложения я храню в переменных окружения. Это позволяет во первых не переживать, что эти данные попадут в исходный код и будут скомпрометированы, а во вторых позволяет передавать эти данные в приложение не только на платформе Windows, но и на Linux (пригодиться в будущем). Остальные конфигурационные параметры я помещу в appsettings.json:

{
  "SauresApi": {
    "Host": "https://api.saures.ru"
  },
  "TomRcApi": {
    "Host": "https://tomrc.ru/"
  },
  "MainInteractionService": {
    "MaxChangeForSingleWaterMeter": 5,
    "MaxChangeForAllWaterMeters": 20 
  } 
}

Созданная конфигурация представляет собой коллекцию элементов ключ-значение. Можно было бы с ней именно так и работать, но гораздо удобней с помощью метода IConfiguration.GetSection(String) сначала получить нужную секцию, а затем с помощью метода ConfigurationBinder.Get<T>(IConfiguration) создать объект типа T и привязать конфигурационные параметры к одноимённым свойствам этого объекта.

Теперь, когда приложение может получать параметры извне, нужно позаботиться о механизме создания объектов. Поскольку приложение небольшое, можно было обойтись созданием всего графа объектов через оператор new. Однако использование контейнеров инъекций зависимостей стало настолько обыденным делом, что даже для таких маленьких приложений не стоит отходить от шаблона. Тем более, что я давненько хотел «пощупать» реализацию внедрения зависимостей от Microsoft. Регистрацию сервисов предлагается делать через метод-расширение с именем «ДобавьГруппуСервисов», например:

public static class DiExtension
{
    public static IServiceCollection AddTomRcApi(this IServiceCollection services, TomRcConfiguration configuration) =>
        services.AddSingleton<IWaterMetersDataConsumerService>(_ =>
            new WaterMetersDataConsumerService(new Api(configuration.Host), configuration.Login, configuration.Password, new DocumentParser()));
}

Мне несколько непривычно использовать статический метод для регистрации сервисов, равно как и конструировать сервисы через делегаты, поскольку мой предыдущий опыт был в основном вокруг Ninject, А Ninject — это классный контейнер с ку-у-учей возможностей, часть из которых реализована через сторонние библиотеки. Однако есть 2 проблемы:

  1. Последняя версия 3.3.4 была выпущена 13 ноября 2017 года.
  2. Это один из самых медленных, если не самый медленный контейнер.

Последний факт я узнал, когда случайно наткнулся на сравнение быстродействия контейнеров внедрения зависимостей от Daniel Palme. Если верить страничке блога Daniel, то впервые материалы были опубликованы 30-го августа 2011, а последнее обновление (на момент написания статьи) было 7-го февраля 2021, т.е. информация в достаточной степени актуальная. Но не репрезентативная — много данных, с которыми ничего нельзя сделать. Чтобы получить больше возможностей для анализа, я перетащил всё в Excel, а затем отфильтровал так, чтобы показатели базовых возможностей + IEnumerable были не ниже чем показатели Microsoft Extensions DependencyInjection 5.0.1, а также была поддержка обобщённых типов (Generic). Почему так? — контейнер от MS набирает популярность и становится в некотором смысле стандартом, но при этом предоставляет лишь базовые возможности хоть и с высокой производительностью. Стало быть если выбирать стороннее решение, то либо с большей производительностью, либо с большим функционалом. Таким образом получаем пятёрку лидеров:

  • DryIoc.
  • Grace.
  • Pure.DI — это не контейнер, а средство генерации кода связывания «на лету». Очень любопытное решение. Лучшие скоростные показатели. Хабр: 1, 2.
  • Singularity.
  • Stashbox — эта реализация понравилась больше всего, благодаря очень широким функциональным возможностям и внятной документации.

И раз уж пришлось углубиться в тему контейнеров внедрения зависимостей — поговорим немного про использование. Существует несколько вариантов подключения контейнера к решению:

  • Напрямую только в корне композиции. Самый простой вариант, но подходит только для небольших приложений, например таких как WaterMeterAutomation. Возможно в будущем я опробую на нём Stashbox.
  • Через абстракцию, собственную или Microsoft.Extensions.DependencyInjection.Abstractions. Второй вариант предпочтительней, поскольку имеет широкое распространение и поддерживается некоторыми сторонними контейнерами.
  • Напрямую в специальных библиотеках с добавлением в окончание имени используемого контейнера DI. Такой подход позволяет использовать все возможности контейнера и при этом не слишком сильно зависеть от него. Более того: для одной и той же библиотеки может быть реализована поддержка нескольких контейнеров зависимости одновременно.

Отмечу также, что возможны комбинирование описанных выше вариантов в зависимости от требований внедрения зависимостей. Например на начальном этапе можно обойтись размещением инструкций внедрения зависимостей только в корне композиции. Далее по мере роста проекта рассмотреть вариант использования абстракций от MS. А если требуется использовать специфический контейнер зависимостей, например для поддержки аспектно-ориентированного программирования через перехватчики, то для таких библиотек создать отдельную библиотеку с инструкциями DI и всем необходимым.
Напоследок пример того, как НЕ нужно делать — использовать контейнер внедрения зависимости напрямую во всех библиотеках приложения. В этом случае код DI-контейнера «размазан» ровным слоем по всему приложению и, следовательно, получим слишком сильную зависимость от библиотеки контейнера.

Ладно, хватит уже о контейнерах — переключимся обратно на приложение. Мне бы хотелось получать информацию об успешном выполнении и сбоях не только через вывод в консоль, но и удалённо. Пожалуй самым простым в этом случае будет отправка письма на эл. почту. Поможет в этом библиотека логирования Nlog (Руководство). Nlog позволяет бОльшую часть конфигурации задать декларативно в конфигурационном файле nlog.config, т.е. появляется возможность менять поведение NLog без последующей сборки и публикации приложения, что весьма удобно. Можно настроить одновременно несколько приёмников сообщений, задав для каждого уровень сообщений. Например в моём случае будет console с уровнем trace и mail с уровнем Info. Для последнего необходимо задать логин и пароль к учётной записи, поэтому конфигурирование частично будет выполнено через файл конфигурации, а частично через код при создании:

private ILogger CreateLogger()
{
    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;

    return LogManager.GetLogger("Global");
}

Чтобы ILogger создавался в единственном экземпляре я использовал «ленивую» инициализацию _lazyLogger = new Lazy(CreateLogger); в конструкторе и уже через свойство добавил ILogger в контейнер внедрения зависимостей, а также в метод Main для обработки всех необработанных исключений:

AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) => { logger.Error(eventArgs.ExceptionObject); };

Описанный выше «велосипед» хоть и работоспособный, но пожалуй не такой удачный, как предлагаемый на странице NLog — Getting started with ASP.NET Core 5. Нужно будет поиграться с этим вариантом в будущем чтобы лучше понять насколько хорошо выполнена интеграция с ведением журнала в NET от MS. По идее всё должно работать хорошо, поскольку NLog заявлен в качестве одного из сторонних поставщиков ведения журнала.

Консольное приложение передачи показаний водосчётчиков

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Пролистать наверх