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