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