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

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

Создание ядра системы передачи показаний водосчётчиков

В предыдущих статьях я реализовал Saures API и TomRC API, благодаря чему появилась возможность получать и отправлять текущие показания водосчётчиков. Оба этих компонента обеспечивают связь с внешними системами и никак ни взаимодействуют друг с другом или другими компонентами. Можно сказать, что это компоненты транспортного или инфраструктурного уровня. И поначалу мне казалось, что этого вполне достаточно, осталось лишь добавить точку входа в приложение и там уже реализовать использование обоих компонентов. Однако дядюшка Боб против (Хабр). Современный подход в разработке программного обеспечения подразумевает наличие т.н. ядра приложения, в котором и должна быть реализована основная логика. Связь с внешним миром, равно как и UI, и БД — это детали реализации, от которых ядро зависеть не должно. Оно вообще ни от чего не должно зависеть — этакий сферический конь в вакууме, поведение которого целиком и полностью проверяется модульными тестами.
В теории то всё хорошо, но что именно должно быть в ядре приложения в моём конкретном случае — не понятно. Конечно там должны использоваться API Saures’а и TomRC, а что ещё? Была идея «прикрутить» БД и сохранять полученные и отправленные значения с датами — этакий вариант журналирования. Но в этом случае придётся ещё и об UI позаботиться, чего не очень-то и хочется… и в какой-то момент меня осенило: а нафига? YAGNI! — интерфейс просмотра текущих показаний есть и у источника и у приёмника данных. Всё что мне нужно — получить текущие показания от источника и приёмника, проверить что идентификаторы совпадают, а изменения в пределах нормы и отправить новые показания в приёмник. «Наши цели ясны, задачи определены! За работу, товарищи!» (с)

Создам проект библиотеки Core.
Я точно знаю, что в каталог Abstractions можно поместить 3 сервиса:

public interface IWaterMetersDataSourceService
{
    public Task<IEnumerable<WaterMeter>> GetWaterMetersAsync();
}

public interface IWaterMetersDataConsumerService
{
    public Task<ICollection<WaterMeter>> GetCurrentWaterMetersValuesAsync();
    public Task SendWaterMetersNewValuesAsync(IEnumerable<WaterMeter> newValues);
}

public interface INotificationService
{
    public void Notify(string message);
}

Первый для Saures API, второй для TomRC, а третий для уведомлений.
Далее в каталог Entities добавлю доменную сущность WaterMeter:

public class WaterMeter
{
    public WaterMeter(string id, decimal currentValue)
    {
        Id = id;
        CurrentValue = currentValue;
    }

    public string Id { get; set; }
    public decimal CurrentValue { get; set; }
}

И наконец главное «действующее лицо» класс MainInteractionService с асинхронным методом SendNewWaterMetersValuesFromSourceToConsumer:

public class MainInteractionService
{
    private readonly IWaterMetersDataSourceService _dataSourceService;
    private readonly IWaterMetersDataConsumerService _dataConsumerService;
    private readonly INotificationService _notificationService;
    private readonly decimal _maxChangeForSingleWaterMeter;
    private readonly decimal _maxChangeForAllWaterMeters;

    public MainInteractionService(IWaterMetersDataSourceService dataSourceService,
        IWaterMetersDataConsumerService dataConsumerService,
        INotificationService notificationService,
        decimal maxChangeForSingleWaterMeter,
        decimal maxChangeForAllWaterMeters)
    {
        _dataSourceService = dataSourceService;
        _dataConsumerService = dataConsumerService;
        _notificationService = notificationService;
        _maxChangeForSingleWaterMeter = maxChangeForSingleWaterMeter;
        _maxChangeForAllWaterMeters = maxChangeForAllWaterMeters;
    }

    public async Task SendNewWaterMetersValuesFromSourceToConsumer()
    {
        var newWaterMetersValuesTask = _dataSourceService.GetWaterMetersAsync();
        var existingConsumerValuesTask = _dataConsumerService.GetCurrentWaterMetersValuesAsync();

        var newWaterMetersValuesDictionary = (await newWaterMetersValuesTask).ToDictionary(k => k.Id, v => v.Value);

        if (newWaterMetersValuesDictionary.Count == 0)
            throw new ApplicationException(ExceptionsFormatted.GetSourceIsEmptyMessage());

        var existingConsumerValues = await existingConsumerValuesTask;
        
        if (newWaterMetersValuesDictionary.Count != existingConsumerValues.Count)
            throw new ApplicationException(ExceptionsFormatted.GetCollectionsCountDifferMessage(newWaterMetersValuesDictionary.Count, existingConsumerValues.Count));

        var totalChange = 0m;
        var notificationMessageBuilder = new StringBuilder();

        var newConsumerValues = existingConsumerValues.Select(o =>
            CreateNewConsumerValues(o, newWaterMetersValuesDictionary, ref totalChange, notificationMessageBuilder)).ToArray();

        var increaseValueTotalMessage = ExceptionsFormatted.GetIncreaseValueTotalMessage(totalChange);
        if (totalChange > _maxChangeForAllWaterMeters)
            throw new ApplicationException(increaseValueTotalMessage);

        notificationMessageBuilder.AppendLine(increaseValueTotalMessage);

        await _dataConsumerService.SendWaterMetersNewValuesAsync(newConsumerValues);

        _notificationService.Notify(notificationMessageBuilder.ToString());
    }

    private WaterMeter CreateNewConsumerValues(WaterMeter wm, IDictionary<string, decimal> newWaterMetersValuesDictionary, ref decimal totalChange, StringBuilder notificationMessageBuilder)
    {
        if (!newWaterMetersValuesDictionary.TryGetValue(wm.Id, out var newValue))
            throw new ApplicationException(ExceptionsFormatted.GetWaterMeterNotFoundMessage(wm.Id));

        var change = newValue - wm.Value;
        if (change < 0M)
            throw new ApplicationException(ExceptionsFormatted.GetNewValueIsLessMessage(newValue, wm.Value, wm.Id));

        var increaseValueMessage = ExceptionsFormatted.GetIncreaseValueMessage(wm.Id, change, wm.Value, newValue);
        if (change > _maxChangeForSingleWaterMeter)
            throw new ApplicationException(increaseValueMessage);

        totalChange += change;

        notificationMessageBuilder.AppendLine(increaseValueMessage);

        return new WaterMeter(wm.Id, newValue);
    }
}

Предполагалось, что логика будет не сложной:

  • получить текущие данные от источника и приёмника
  • сформировать коллекцию новых значений для приёмника в том же порядке, что и текущие значения.
  • отправить новые значения приёмнику.

При этом необходимо чтобы выполнялись следующие условия:

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

При всём при этом представленный вариант у меня получилось реализовать с третьей или четвёртой попытки. Предыдущие варианты задачу выполняли, но привносили излишнюю сложность, что не лучшим образом отражалось на удобочитаемости кода. Текущий вариант в плане удобочитаемости тоже не идеален по сравнение с обоими API или DocumentParser, но ничего не поделаешь — ядрёный код он такой. Поэтому хотелось бы убедиться, что всё работает как задумывалось с помощью модульных тестов:

public class TestMainInteractionService
{
    private static readonly Fixture Fixture = new();

    private const decimal MaxChangeForSingleWaterMeter = 10M;
    private const decimal MaxChangeForAllWaterMeters = 20M;
    private readonly INotificationService _notificationService;
    private readonly MainInteractionService _sut;
    private readonly IWaterMetersDataConsumerService _waterMetersDataConsumerService;
    private readonly IWaterMetersDataSourceService _waterMetersDataSourceService;

    public TestMainInteractionService()
    {
        _waterMetersDataSourceService = Substitute.For<IWaterMetersDataSourceService>();
        _waterMetersDataConsumerService = Substitute.For<IWaterMetersDataConsumerService>();
        _notificationService = Substitute.For<INotificationService>();
        _sut = new MainInteractionService(_waterMetersDataSourceService, _waterMetersDataConsumerService, _notificationService, MaxChangeForSingleWaterMeter, MaxChangeForAllWaterMeters);
    }

    [Fact]
    public async void TestNormalFlow()
    {
        var consumerData = Fixture.CreateMany<WaterMeter>(4).ToArray();
        var sourceData = consumerData.Select(o => new WaterMeter(o.Id, o.Value + 4.9M)).ToArray();

        IEnumerable<WaterMeter> parametersForSendWaterMetersNewValuesAsync = null;
        _waterMetersDataSourceService.GetWaterMetersAsync().Returns(Task.FromResult((IEnumerable<WaterMeter>) sourceData));
        _waterMetersDataConsumerService.GetCurrentWaterMetersValuesAsync().Returns(Task.FromResult((ICollection<WaterMeter>) consumerData));
        _waterMetersDataConsumerService.SendWaterMetersNewValuesAsync(default).ReturnsForAnyArgs(_ => Task.FromResult(true))
            .AndDoes(o => { parametersForSendWaterMetersNewValuesAsync = (IEnumerable<WaterMeter>) o[0]; });

        // ACT
        await _sut.SendNewWaterMetersValuesFromSourceToConsumer();

        // Assert
        await _waterMetersDataSourceService.Received(1).GetWaterMetersAsync();
        await _waterMetersDataConsumerService.Received(1).GetCurrentWaterMetersValuesAsync();
        _notificationService.ReceivedWithAnyArgs(1).Notify(default);
        parametersForSendWaterMetersNewValuesAsync.Should().BeEquivalentTo(sourceData);
    }

    [Theory]
    [MemberData(nameof(ExceptionalSituationsData))]
    public async void TestExceptionalSituations(IEnumerable<WaterMeter> sourceData, ICollection<WaterMeter> consumerData, string exceptionMessage)
    {
        _waterMetersDataSourceService.GetWaterMetersAsync().Returns(Task.FromResult(sourceData));
        _waterMetersDataConsumerService.GetCurrentWaterMetersValuesAsync().Returns(Task.FromResult(consumerData));

        (await _sut.Invoking(o => o.SendNewWaterMetersValuesFromSourceToConsumer()).Should().ThrowAsync<ApplicationException>())
            .WithMessage(exceptionMessage);

        await _waterMetersDataSourceService.Received(1).GetWaterMetersAsync();
        await _waterMetersDataConsumerService.Received(1).GetCurrentWaterMetersValuesAsync();
        await _waterMetersDataConsumerService.DidNotReceiveWithAnyArgs().SendWaterMetersNewValuesAsync(default);
        _notificationService.DidNotReceiveWithAnyArgs().Notify(default);
    }

    public static IEnumerable<object[]> ExceptionalSituationsData()
    {
        yield return EmptyCollectionsData();
        yield return DifferentCollectionsSize();
        yield return DifferentWaterMetersIdentifiers();
        yield return NewValuesIsLess();
        yield return WaterMeterChangeExceeded();
        yield return AllWaterMetersChangeExceeded();

        static object[] EmptyCollectionsData()
        {
            var sourceData = Array.Empty<WaterMeter>();
            var consumerData = Array.Empty<WaterMeter>();
            var exceptionMessage = ExceptionsFormatted.GetSourceIsEmptyMessage();
            return new object[] {sourceData, consumerData, exceptionMessage};
        }

        static object[] DifferentCollectionsSize()
        {
            var sourceData = Fixture.CreateMany<WaterMeter>(3).ToArray();
            var consumerData = Fixture.CreateMany<WaterMeter>(5).ToArray();
            var exceptionMessage = ExceptionsFormatted.GetCollectionsCountDifferMessage(sourceData.Length, consumerData.Length);
            return new object[] {sourceData, consumerData, exceptionMessage};
        }

        static object[] DifferentWaterMetersIdentifiers()
        {
            var sourceData = Fixture.CreateMany<WaterMeter>(3).ToArray();
            var consumerData = Fixture.CreateMany<WaterMeter>(3).ToArray();
            var exceptionMessage = ExceptionsFormatted.GetWaterMeterNotFoundMessage(consumerData[0].Id);
            return new object[] {sourceData, consumerData, exceptionMessage};
        }

        static object[] NewValuesIsLess()
        {
            var consumerData = Fixture.CreateMany<WaterMeter>(4).ToArray();
            var sourceData = consumerData.Select(o => new WaterMeter(o.Id, o.Value - 1M)).ToArray();
            var exceptionMessage = ExceptionsFormatted.GetNewValueIsLessMessage(sourceData[0].Value, consumerData[0].Value, sourceData[0].Id);
            return new object[] {sourceData, consumerData, exceptionMessage};
        }

        static object[] WaterMeterChangeExceeded()
        {
            var consumerData = Fixture.CreateMany<WaterMeter>(4).ToArray();
            var sourceData = consumerData.Select(o => new WaterMeter(o.Id, o.Value + 20M)).ToArray();
            var exceptionMessage = ExceptionsFormatted.GetIncreaseValueMessage(sourceData[0].Id, sourceData[0].Value - consumerData[0].Value, consumerData[0].Value, sourceData[0].Value);
            return new object[] {sourceData, consumerData, exceptionMessage};
        }

        static object[] AllWaterMetersChangeExceeded()
        {
            var consumerData = Fixture.CreateMany<WaterMeter>(4).ToArray();
            var sourceData = consumerData.Select(o => new WaterMeter(o.Id, o.Value + 5.1M)).ToArray();
            var exceptionMessage = ExceptionsFormatted.GetIncreaseValueTotalMessage(5.1M * 4);
            return new object[] {sourceData, consumerData, exceptionMessage};
        }
    }
}

Обратите внимание, что метод ExceptionalSituationsData возвращает число элементов равное количеству бизнес требований, т.е. я проверяю нарушение каждого бизнес правила в тесте TestExceptionalSituations, и безошибочное поведение в тесте TestNormalFlow. Т.е. класс MainInteractionService имеет 100% покрытие кода тестами.
Отмечу также, что в этих тестах я отказался от использования библиотеки FakeItEasy потому что она не работает должным образом с асинхронными методами. Выбор пал на NSubstitute ввиду очень симпатичного синтаксиса в добавок к полноценной поддержке асинхронного подхода.

Создание ядра системы передачи показаний водосчётчиков

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

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

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