Рано или поздно каждый программист должен задуматься о вопросах архитектуры приложения. Архитектура это про выделение ответственностей, определение компонентов их реализующих и связей между ними. Начинается все с ответственностей, а грани тонки. Очень часто будет сложно определить где начинается одна роль и заканчивается другая, сформулировать признаки по которым мы разделяем. Человеку требуются ориентиры, точки опоры и чем проще тем лучше. В тоже время, очень популярной темой будут шаблоны проектирования. Кульминацией популярности станет SOLID, а SRP самый привлекательный из принципов, наверно по причине своей простоты и понятности. Наверно ни один принцип не упоминается так часто как SRP. Кажется, апофеозом обсуждений стали идеи о том, что интерфейс должен иметь только один метод только с одним параметром. Тут на ум приходят разные пословицы, но все они слишком токсичны, чтоб приводить. Ограничимся словами, что SRP часто интерпретируется как призыв к тотальной декомпозиции. Декомпозиция, же лишь инструмент в руках проектировщика. Такой же как и композиция, только более популярный. На практике я столкнулся с любопытным примером, побудившим написать эту статью. Типовая задача обеспечить отправку данных пакетами в некоторый API. Сейчас последуют примеры кода, очень похожие на то, что было в реальности или на то, что могло бы быть. При этом код приводится исключительно в целях иллюстрации рассуждений и относиться к нему нужно соответствующе. Так вот, на первом этапе код выглядит так:
internal class MyApi : IMyApi
{
private readonly TimeSpan _autoFlushInterval = TimeSpan.FromSeconds(1);
private readonly int _batchSize = 100;
private readonly HttpClient _httpClient;
private readonly List<string> _messages = new();
public MyApi(HttpClient httpClient)
{
_httpClient = httpClient;
_ = Autoflush();
}
public async Task Send(IReadOnlyCollection<string> messages, CancellationToken cancellationToken)
{
IList<string>? messagesToSend = null;
lock (_messages)
{
_messages.AddRange(messages);
if (_messages.Count > _batchSize)
{
messagesToSend = _messages.Take(_batchSize).ToList();
_messages.RemoveRange(0, _batchSize);
}
}
if (messagesToSend != null)
{
await _httpClient.PostAsJsonAsync("v1/messages", messagesToSend, cancellationToken);
}
}
public async Task Flush(CancellationToken cancellationToken)
{
IList<string>? messagesToSend = null;
lock (_messages)
{
if (_messages.Count > 0)
{
messagesToSend = new List<string>(_messages);
_messages.Clear();
}
}
if (messagesToSend != null)
{
await _httpClient.PostAsJsonAsync("v1/messages", messagesToSend, cancellationToken);
}
}
private async Task Autoflush()
{
await Task.Delay(_autoFlushInterval);
await Flush(default);
}
}
В одном классе мы имеем метод отправки сообщений, который не отправляет но добавляет сообщение в очередь. После достижения очередью некоторой пороговой длинны сообщения будут отправлены. Есть метод для принудительной отправки и некоторая отправка по расписанию, чтоб при низкой активности сообщения не задерживались слишком долго. Здесь очевидно совместились реализация контракта некоторого API и сбор пакета для отправки. Конфигурация здесь не выделена, но, очевидно, она тоже совместилась. Серьезно масштабировать и повторно использовать такой код вряд-ли получится. Напрашивается решение разделить контракт API и роль подготовки пакета (batching). Ну и на выходе у нас могло бы получиться нечто такое:
internal class BatchCollector<T> : IBatchCollector<T>
{
private readonly TimeSpan _autoFlushInterval = TimeSpan.FromSeconds(1);
private readonly int _batchSize = 100;
private readonly List<T> _messages = new();
public event IBatchCollector<T>.OnFlushDelegate? OnFlush;
private DateTimeOffset _lastFlushTime;
public BatchCollector()
{
_ = Autoflush();
}
public Task Add(IEnumerable<T> items, CancellationToken cancellationToken)
{
IList<T>? messagesToFlush = null;
lock (_messages)
{
if (_messages.Count > _batchSize)
{
messagesToFlush = _messages.Take(_batchSize).ToList();
_messages.RemoveRange(0, _batchSize);
}
}
return RiseOnFlush(messagesToFlush, cancellationToken);
}
public Task Flush(CancellationToken cancellationToken)
{
IList<T>? messagesToFlush = null;
lock (_messages)
{
if (_messages.Count > 0)
{
messagesToFlush = new List<T>(_messages);
_messages.Clear();
}
}
return RiseOnFlush(messagesToFlush, cancellationToken);
}
private Task RiseOnFlush(IList<T>? messagesToFlush, CancellationToken cancellationToken)
{
if (messagesToFlush != null)
{
var taskToWait = OnFlush?.Invoke(messagesToFlush, cancellationToken);
if (taskToWait != null)
{
return taskToWait;
}
}
_lastFlushTime = DateTimeOffset.Now;
return Task.CompletedTask;
}
private async Task Autoflush()
{
var delay = _autoFlushInterval;
while (true)
{
await Task.Delay(delay);
var timeFromLastFlush = DateTimeOffset.Now - _lastFlushTime;
if (timeFromLastFlush >= _autoFlushInterval)
{
await Flush(default);
delay = _autoFlushInterval;
}
else
{
delay = _autoFlushInterval - timeFromLastFlush;
}
}
}
}
internal class MyApi : IMyApi
{
private readonly HttpClient _httpClient;
private readonly IBatchCollector<string> _messagesCollector;
public MyApi2(HttpClient httpClient)
{
_httpClient = httpClient;
_messagesCollector = new BatchCollector<string>();
_messagesCollector.OnFlush += OnMessagesBatchReady;
}
public async Task Send(IReadOnlyCollection<string> messages, CancellationToken cancellationToken)
{
await _messagesCollector.Add(messages, cancellationToken);
}
private async Task OnMessagesBatchReady(IEnumerable<string> items, CancellationToken cancellationToken)
{
await _httpClient.PostAsJsonAsync("v1/messages", items, cancellationToken);
}
}
Здесь мы выделили сбор пакетов и расписание в отдельный класс, для простоты взаимодействие с потребителем организовали событиями. Получился довольно тонкий и понятный клиент к API, развивать такой тип одно удовольствие, а вот к BatchCollector все еще есть претензии. Он не только собирает пакеты, но и управляет расписанием их отправки. На самом деле довольно распространенный прием, но только не для нас, мы выступаем за четкое выделение ответственностей и не отступаем на пол пути! Продолжив работу мы получили:
internal class AutoFlushTimer<T> : IDisposable
{
private readonly IBatchCollector<T> _batchCollector;
private readonly TimeSpan _autoFlushInterval = TimeSpan.FromSeconds(1);
private readonly CancellationTokenSource _cancellationTokenSource = new();
private readonly Task _workerTask;
private int _disposed = 0;
private DateTimeOffset _lastFlushTime = DateTimeOffset.MinValue;
public AutoFlushTimer(IBatchCollector<T> batchCollector)
{
_batchCollector = batchCollector;
_batchCollector.OnFlush += OnBatchFlush;
_workerTask = Autoflush();
}
private Task OnBatchFlush(IEnumerable<T> items, CancellationToken cancellationToken)
{
_lastFlushTime = DateTimeOffset.Now;
return Task.CompletedTask;
}
public void Dispose()
{
if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0)
{
_cancellationTokenSource.Cancel();
_batchCollector.OnFlush -= OnBatchFlush;
_workerTask.Wait();
_cancellationTokenSource.Dispose();
}
}
private async Task Autoflush()
{
var delay = _autoFlushInterval;
while (!_cancellationTokenSource.IsCancellationRequested)
{
try
{
await Task.Delay(delay, _cancellationTokenSource.Token);
}
catch (TaskCanceledException)
{
// It's fine, disposing
}
var timeFromLastFlush = DateTimeOffset.Now - _lastFlushTime;
if (timeFromLastFlush >= _autoFlushInterval)
{
await _batchCollector.Flush(_cancellationTokenSource.Token);
delay = _autoFlushInterval;
}
else
{
delay = _autoFlushInterval - timeFromLastFlush;
}
}
}
}
internal class BatchCollector<T> : IBatchCollector<T>
{
private readonly int _batchSize = 100;
private readonly List<T> _messages = new();
public event IBatchCollector<T>.OnFlushDelegate? OnFlush;
public Task Add(IEnumerable<T> items, CancellationToken cancellationToken)
{
IList<T>? messagesToFlush = null;
lock (_messages)
{
if (_messages.Count > _batchSize)
{
messagesToFlush = _messages.Take(_batchSize).ToList();
_messages.RemoveRange(0, _batchSize);
}
}
return RiseOnFlush(messagesToFlush, cancellationToken);
}
public Task Flush(CancellationToken cancellationToken)
{
IList<T>? messagesToFlush = null;
lock (_messages)
{
if (_messages.Count > 0)
{
messagesToFlush = new List<T>(_messages);
_messages.Clear();
}
}
return RiseOnFlush(messagesToFlush, cancellationToken);
}
private Task RiseOnFlush(IList<T>? messagesToFlush, CancellationToken cancellationToken)
{
if (messagesToFlush != null)
{
var taskToWait = OnFlush?.Invoke(messagesToFlush, cancellationToken);
if (taskToWait != null)
{
return taskToWait;
}
}
return Task.CompletedTask;
}
}
internal class MyApi : IMyApi, IDisposable
{
private readonly HttpClient _httpClient;
private readonly IBatchCollector<string> _messagesCollector;
private readonly AutoFlushTimer<string> _flushTimer;
public MyApi3(HttpClient httpClient)
{
_httpClient = httpClient;
_messagesCollector = new BatchCollector<string>();
_messagesCollector.OnFlush += OnMessagesBatchReady;
_flushTimer = new AutoFlushTimer<string>(_messagesCollector);
}
public async Task Send(IReadOnlyCollection<string> messages, CancellationToken cancellationToken)
{
await _messagesCollector.Add(messages, cancellationToken);
}
private async Task OnMessagesBatchReady(IEnumerable<string> items, CancellationToken cancellationToken)
{
await _httpClient.PostAsJsonAsync("v1/messages", items, cancellationToken);
}
public void Dispose()
{
_flushTimer.Dispose();
}
}
Теперь, расписание выделено в отдельный класс и управляет им уже не сам BatchCollector, а клиент API. Это решение, кажется, уже избавлено от недостатков. Был у меня в черновиках и четвертый вариант, но уже и так много кода. А кто любит много кода? Подведем итоги: в несколько итераций нам удалось разделить ответственности. У нас вырос объем кода, а еще тестов, ну и пришлось знатно попотеть, чтоб сделать все так идеально. Количество работы возросло, поддержка получившегося решения, явно, так же будет стоить дороже. Нам ведь потребовалось сильно больше времени от решения к решению, чтоб только прочитать код, а еще его предстояло понять. Понимать систему состоящую из большего числа компонентов всегда сложнее. По этой, чисто технической причине, так же сокращается применение наследования. Восстанавливать общую картину поведения компоненты по нескольким документам человеку сложнее. Что важно все три решения решают одну и ту же задачу и все успешно. При этом стоимость решений возрастает, а надежность падает. Падает потому, что чем больше кода приходится сопровождать, тем больше шансов на ошибку. Не в моменте, а в перспективе. При всем при том, для потребителя API интерфейс IMyApi остается неизменным. Это очень важно в том смысле, что сочетание принципов инверсии зависимости и подстановки Лисков позволяют провести декомпозицию в любой момент, как только потребуется практически с теми же трудозатратами. А куда менее популярный, но более эффективный на ранних порах шаблон YAGNI и вовсе предписывает отложить работы до востребования. Ловушка SRP заключается в том, что мы подменяем цель практическую целью умозрительной. В качестве мерила качества кода используем категорию не отражающую сути. Мелко-декомпозированный код не является качественным, он является мелко-декомпозированным и только. Решаем не задачу не отправить в API пакет, а организовать код по некому шаблону. Ситуацию усугубляет то, что люди пристрастны и нуждаются в творческой реализации. Нам нравится создавать такие масштабные решения и развернуть бурную деятельность доступно. Не все задумываются о практической стороне решения, а среди тех кто задумывается не все успели научиться созидать практичное. Все слышали термин оверинженеринг. Неверная интерпретация SRP и приводит к особой форме оверинженеринга - злоупотреблению декомпозицией. Это не значит, что нужно слепо стремиться к меньшему количеству кода и срочно бросаться объединять классы. Все очень не просто и сильно зависти от решаемой задачи. Иначе бы не было так много написано о том, как разрабатывать архитектуру и так сложно найти сильно архитектора. Я хочу лишь подсветить, что программисты на пути своего развития часто попадают в ловушку SRP. Столь соблазнительного за счет своей простоты, для умов блуждающих в лабиринтах несовершенных архитектур.