Как стать автором
Обновить

Вероятно вам не нужен MediatR

Время на прочтение 13 мин
Количество просмотров 21K
Автор оригинала: Arialdo Martini

MediatR - это очень популярная библиотека, которая используется для уменьшения зависимостей между объектами. Она популяризует архитектуру, основанную на крайне значимых принципах проектирования:

  • препятствование прямому общению между объектами для уменьшения связности;

  • нацеливание на Message-Oriented архитектуру;

  • поддержка асинхронного внутреннего обмена сообщениями;

  • ориентирование на переиспользование объектов.

Эти практики не пустое слово для меня. Кроме того, кажется, что такой способ создания программного обеспечения должен быть совершенно очевидным. Без сомнения, библиотека MediatR очень успешна и широко применяется разработчиками, которые разделяют эти ценности.

Существует множество статей о MediatR, но лишь их малая часть фокусируется на недостатках и помогает определить случаи, когда использование инструмента не имеет смысла. Этот пост один из таких.

В этой статье хочу поговорить о том когда и почему применение MediatR может не оправдать ожиданий. В частности, думаю, что, несмотря на добрые намерения, некоторые способы внедрения MediatR могут причинить вред в погоне за лучшими архитектурными практиками и даже привести к использованию анти-паттернов.

Также стоит отметить, что эта статья не покрывает положительные аспекты библиотеки, а фокусируется только на недостатках.


TL;DR для нетерпеливых

  • Несмотря на название, MediatR не является реализацией шаблона Посредник вовсе. Это диспетчер команд.

  • В большинстве случаев его использование это прикрытый вызов методов по сути схожий с Service Locator'ом, являющимся анти-паттерном.

  • Классы вынужденно зависят от методов, которые они не используют, и тем самым нарушают ISP, что влечёт неявную глобальную связанность.

  • Также есть тенденция к нарушению EDP: вместо явного запроса требуемых объектов, объявляется глобальная точка доступа ко всему домену.

  • У интерфейсов в домене нет возможности именоваться в стиле единого языка.

  • Код домена загрязнён методами Send и Handle.

  • Классы в домене вынуждены реализовывать интерфейсы из MediatR, что приводит к большой зависимости от сторонней библиотеки.

  • Стала тяжелее навигация по коду.

  • IntelliSense тебе не помощник.

  • Компилятор из-за непонимания происходящего помечает классы неиспользуемыми. Эту проблему приходится решать обходными приёмами.

  • Вызов обработчика напрямую где-то в 50 раз быстрее и требует на порядок меньше памяти, чем через MediatR.

  • Хорошая новость: MediatR можно легко заменить тривиальными практиками ООП.

Шаблон Посредник

MediatR представляется, как

simple mediator implementation in .NET

Однако можно сильно удивиться, если вспомнить, что такое шаблон Посредник (Mediator). Шаблон Посредник - это один из классических поведенческих паттернов, представленных в культовой книге Design Patterns: Elements of Reusable Object-Oriented Software, в 1994.

Не вводите себя в заблуждение тем, что приводится академическая библиографическая ссылка. Речь сейчас не об упражнениях. Наоборот, большинство недостатков MediatR вызваны отклонением от стандартных практик реализации паттерна. Поэтому, пожалуйста, наберитесь терпения. Надеюсь, предисловие прояснит когда и почему использование MediatR может привести к нежелательным исходам.

Первоначальный пример GOF

Мотивировка паттерна в книге GOF завязана на специфичном примере. Приведу этот пример и здесь, сохранив оригинальное именование. Так будет проще отсылаться к оригиналу. Хорошую выжимку по книге можно найти в Mediator Pattern Booknotes за авторством Mihaylov Preslav.

Рассмотрим реализацию диалогового окна в пользовательском графическом интерфейсе, предположив, что окно использует коллекцию виджетов (кнопки, меню, поля ввода). Также представим, что у виджетов есть набор динамических и нетривиальных зависимостей между ними:

Например, когда кнопка становится недоступной при отсутствии информации в некотором поле ввода. Выбор записи в выпадающем списке (listbox) может изменить содержимое поля ввода. Обратно, ввод текста в поле ввода может автоматически подобрать какую-то запись из listbox. Кнопки могут стать доступными пользователю для различного взаимодействия, как только некий текст появился в поле ввода.

От таких хаотичных зависимостей и хотелось бы защититься:

Инкапсуляция сложности

Код клиента требует использования диалогового окна, но хочется уберечь его от сложных внутренних взаимосвязей между виджетами.

Суть шаблона Посредник в следующем:

Можно избежать этих проблем, инкапсулируя коллективное поведение в специальном объекте посреднике. Посредник ответственен за контроль и координацию взаимодействий групп объектов. Посредник служит прослойкой, огораживающей объекты от явных ссылок друг на друга. Код клиента знает только про посредника и, таким образом, уменьшает число взаимосвязей.

В представленной диаграмме aFontDialogDirector является нашим посредником.

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

Посредник принято называть Дирижёром (Director), так как цель - координировать Коллег.

Реализация шаблона Посредник

Кратко: вместо получения ссылок на все виджеты и работы со всей сложностью их поведения, клиент просто получит удобную посредническую прослойку. Реализация прямолинейна:

internal interface IFontDialogMediator
{
    void ShowDialog();
}

class FontDialogDirector : IFontDialogMediator
{
    private readonly ListBox _lisBox;
    private readonly Button _button;
    private readonly EntryField _entryField;

    FontDialogDirector(ListBox lisBox, Button button, EntryField entryField)
    {
        _lisBox = lisBox;
        _button = button;
        _entryField = entryField;
    }

    void IFontDialogMediator.ShowDialog()
    {
        // interacts with the 3 widgets,
        // taking care of the complexity of their interdependencies
    }
}

class MyClient
{
    private readonly IFontDialogMediator _fontDialogDirector;

    MyClient(IFontDialogMediator fontDialogDirector)
    {
        _fontDialogDirector = fontDialogDirector;
    }

    void DoStuff()
    {
        _fontDialogDirector.ShowDialog(); // encapsulates the complexity
    }
}

Вот и всё. Старый добрый ООП наряду с внедрением зависимостей.

Наблюдения

Заметьте следующие факты:

  • Наш Посредник относится к бизнес-сценарию использования, который мы хотели инкапсулировать, при этом являясь специфичным для предметной области.

  • Ожидаемо, что его интерфейс назван в соответствии с единым языком.

  • Тот же Посредник может легко реализовать любой дополнительный метод или бизнес-сценарий использования согласно необходимости. Каждый метод будет удобным образом назван в терминах предметной области.

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

  • MyClient может взаимодействовать только с диалоговым окном. При наличии другого класса посредника, MyClient не будет с ним связан никаким образом. Полученный посредник IFontDialogMediator не может самопроизвольно выставить наружу MyClient для тех сценариев использования в предметной области, где он не требуется. Другими словами, внедрение IFontDialogMediator в MyClient никоим образом не нарушает ISP.

MediatR

Каким образом MediatR вписывается в эту концепцию? На самом деле, никаким.

В терминах MediatR класс FontDialogDirector был бы Обработчиком Сообщений (Request Handler). MediatR требует реализовать интерфейс из библиотеки IRequestHandler<U, V>. IRequestHandler<U, V> содержит один метод:

Task<V> Handle(U request, CancellationToken cancellationToken)

Можно заметить, что сигнатура не отражает суть бизнес-сценария. При использовании MediatR каждый обработчик должен реализовывать один и тот же метод, меняя только формальные типовые параметры U и V.

При этом достаточно интересно, что согласно документации MediatR в клиентский код внедряется не FontDialogDirector, а некоторый сторонний класс, реализующий интерфейс IMediator, также являющийся контрактом из библиотеки. С помощью IMediator можно отправлять объекты сообщений, которые в свою очередь должны реализовывать другой интерфейс (также из библиотеки) IRequest<V>.

IRequest<V> странный интерфейс. Он пустой: в нём нет методов. MediatR использует его с помощью рефлексии, что расценивается, как плохая практика и code smell. Об этом Microsoft пишет в правиле качества кода CA1040: Avoid empty interfaces.

После отправки в IMediator объект сообщения будет направлен нужному обработчику, которым в нашем случае является FontDialogDirector.

Звучит слегка запутывающе, хотя впрочем так и есть. Вроде библиотека должна по идее реализовать шаблон Посредник, но MediatR добавляет некоторые дополнительные слои переадресации, которые не являются частью оригинальной архитектуры.

Теперь сравните с диаграммой классов, реализованной ранее:

Мотивация MediatR

Почему вообще библиотека была спроектирована таким образом? Можно было бы объяснить это тем, что MediatR просто не реализует шаблон Посредник и имеет другую мотивацию и цели.

В самом деле, когда оригинальная мотивация GOF звучит так:

Определить объект, инкапсулирующий механизм взаимодействия множества других объектов, чтобы их общение происходило независимо друг от друга.

Прочитав MediatR Wiki, увидим, что цель библиотеки едва близка к тому, что мы прочитали:

Декаплинг внутреннего процесса отправки и обработки сообщений.

Вроде понятно, почему примеры из документации MediatR никак не прячут любые сложные взаимодействия объектов.

Основы MediatR

Рассмотрим детально пример кода из MediatR Wiki:

class Ping : IRequest<string> { }

class PingHandler : IRequestHandler<Ping, string>
{
    public Task<string> Handle(Ping request, CancellationToken cancellationToken)
    {
        return Task.FromResult("Pong");
    }
}

class MyClass
{
    private readonly IMediator _mediator;
    
    MyClass(IMediator mediator)
    {
        _mediator = mediator;
    }
    
    void DoSomething()
    {
        var response = await _mediator.Send(new Ping());
        Debug.WriteLine(response); // "Pong";
    }
}

Тоже самое, отражённое в диаграмме классов:

Наблюдения

Интересно заметить следующее:

  • Вся конструкция служит одной цели: неявно вызвать метод, что перекликается с назначением Service Locator:

var response = await _mediator.Send<MyCommand>(new MyCommand());

Что в сущности то же самое, что и:

var response = await _serviceProvider.GetRequiredService<IHandler<MyCommand>>().Send<MyCommand>(new MyCommand());
  • В терминах шаблона Посредник PingHandler был бы Дирижёром, упаковавшим сложность взаимодействий между Коллегами. Любопытно, что тема инкапсуляции сложности вообще не упоминается в библиотеке MediatR.

  • Объект обработчика не реализует интерфейс, конкретный для нашей предметной области. И наоборот, наш домен вынужден занимать реализацией сторонних контрактов, которые решают скорее инфраструктурные задачи, нежели нашей бизнес-логики. А это явное нарушение одной из догм DDD:

Когда некоторый процесс или преобразование в предметной области не естественен для её СУЩНОСТЕЙ и ОБЪЕКТОВ-ЗНАЧЕНИЙ, добавьте операцию к модели в виде отдельного интерфейса, объявленного как СЕРВИС. Определите интерфейс в терминах языка модели и убедитесь, что вводимая операция названа, как часть ЕДИНОГО ЯЗЫКА. Сам СЕРВИС не должен иметь состояния. (Эрик Эванс - Предметно-Ориентированное Проектирование, с. 104)

Кроме того, Джимми Богард показал в Domain-Driven Refactoring: Extracting Domain Services, как применять этот принцип к доменным сервисам. Однако, обработчики в MediatR неотвратимо вынуждены реализовать не относящийся к предметной области контракт.

  • Наш объект посредник не может реализовать никаких дополнительных методов. Единственный метод, который доступен это Handle(). Нет никаких способов назвать операцию в терминах языка предметной области. В частности, типичное последствие применения MediatR - это распространение вызовов Send() и Handle(), к ней не относящихся. Сравните с этим пример класса со страницы шаблона Посредник в Википедии:

class Mediator<T> {
  public void setValue(String storageName, T value) [...]
  public Optional<T> getValue(String storageName)  [...]
  public void addObserver(String storageName, Runnable observer) [...]
  void notifyObservers(String eventName) [...]
}

Это очень специфичный контракт, основанный на некотором сценарии из предметной области. Такая реализация вместе с MediatR просто невозможна.

  • Неожиданно, MyClass получает не PingHandler, а экземпляр IMediator. Последствия этого с блеском проанализированы в интересной публикации Скотта Ханнена, No, MediatR Didn’t Run Over My Dog.

  • При вызове Send() у IMediator, запросы MyClass могут быть распределены к обработчику с любым возможным расположением в проекте. ISP нарушен, поскольку IMediator ведёт себя как Service Locator.

  • Нет никакого способа выяснить, какое отношение MyClass имеет с обработчиками, существующими в проекте, кроме явного чтения всех реализаций. Конструктор MyClass потерял способность излагать эту информацию. Это нарушение EDP, описанного Microsoft в Architectural Principles, что типично следует из использования анти-паттерна Service Locator. Об этом можно прочитать больше в классике Марка Симанна, Service Locator is an Anti-Pattern.

Обратно к старому доброму ООП

Оставив позади пример MediatR и его оторванность от шаблона Посредник, интересно посмотреть как добиться схожих результатов более простой реализацией.

Посмотрим, что произойдёт, если избавиться от дополнительных слоёв переадресации, обозначенных на диаграмме красным:

И реализовать это заместо предыдущей итерации:

class Ping : IRequest<string> { }

class PingHandler : IRequestHandler<Ping, string>
{
    public Task<string> Handle(Ping request, CancellationToken cancellationToken)
    {
        return Task.FromResult("Pong");
    }
}

class MyClass
{
    private readonly IRequestHandler<Ping, string> _pingHandler;
    
    MyClass(IRequestHandler<Ping, string> pingHandler)
    {
        _pingHandler = pingHandler;
    }

    async Task DoSomething()
    {
        var response = await _pingHandler.Handle(new Ping());
        Debug.WriteLine(response); // "Pong";
    }
}

Или, упрощая ещё сильнее, использовав наконец названия из предметной области:

record struct Ping; // The Query object

interface IPingHandler
{
    string Ping(Ping request);
}

class PingHandler : IPingHandler // Its handler
{
    string IPingHandler.Ping(Ping request) =>
        "Pong";
}

class MyClass
{
    private readonly IPingHandler _pingHandler;

    MyClass(IPingHandler pingHandler)
    {
        _pingHandler = pingHandler;
    }

    private void DoSomething()
    {
        var response = _pingHandler.Ping(new());
        Debug.WriteLine(response); // "Pong";
    }
}

Заметьте главное отличие:

  • MyClass не связан с PingHandler: он зависит от абстракции, интерфейса IPingHandler. Таким образом, реализация всё ещё отвязывает внутренний обмен сообщениями от обработки сообщений, что объявлено целью MediatR.

  • Конструктор MyClass получает экземпляр обработчика сообщения, нежели сторонний компонент, который знает, как обратиться к нужному обработчику. На один уровень переадресации меньше или принцип KISS в действии.

  • MyClass не зависит от сторонней библиотеки.

  • Упрощённая реализация делает очевидным, что класс сообщения PingRequest просто не нужен. Его существование обусловлено дополнительным уровнем переадресации, введённым MediatR для получения возможности выяснить, к какому обработчику распределится вызов.

Больше деталей

Прикрытый вызов методов

Уорд Каннингэм написал, описывая шаблон Посредник:

При использовании шаблона Посредник следите за тем, чтобы он не превратился в дорогую замену глобальных переменных и не принёс все плохие вещи, связанные с таким анти-паттерном. Ward Cunningham - Mediator Pattern

Сравните следующее:

// Implicit
IMediator mediator = GetMediatorSomehow();

var myClass = new MyClass(mediator);

с этим:

// Explicit
IPingHandler ping = GetPingHandlerSomehow();

var myClass = new MyClass(ping);

Последнее может отправлять только сообщения Ping для обработчика IPingHandler, в то время когда предыдущее также может отправить некое сообщение Foo для IFooHandler или любое другое. Из контракта нельзя выяснить какие зависимости используются.

Этот вопрос сродни использованию Service Locator. Диаграмма зависимостей системы меняется от:

К следующему виду:

Заметьте, что в первой диаграмме компоненты не связаны тесно. Существующие связи можно абстрагировать, используя интерфейсы, а значит и "развязать" их должным образом.

Эти две диаграммы взяты из публикации Нэма Дуонга Are you using MediatR?. Предлагаю остановить чтение этой публикации и уделить 3 минуты своего времени для ознакомления с материалом Нэма. Это лучшее, исчерпывающее и ясное описание вопроса, которое вообще можно найти.

Схожесть MediatR и Service Locator ни для кого не сюрприз. Несмотря на то что это анти-паттерн (смотри Mark Seemann - Service Locator is an Anti-Pattern), создатель библиотеки не видит в этом никакой проблемы.

Джимми предоставил больше деталей по теме в комментарии и специальной публикации, на которую последовал ответ от Марка в рамках исчерпывающего комментария.

Нарушение ISP

Принцип разделения интерфейса гласит, что

Клиент не должен зависеть от интерфейсов, которые он не использует

Как подметил Скотт Ханнен:

У кода, зависящего от MediatR, одна общая проблема. Рассмотрев внимательно каждый вызов, каждую отправку сообщения можно понять одну вещь. В приложении могут быть зарегистрированы десятки сообщений и обработчиков. Что ограничивает класс от отправки сообщения, не относящегося к целевому назначению этого класса? Ничего. Все эти зависимости скрыты за туманом IMediator. "Душок" от кода с MediatR такой же, как от кода с Service Locator.

Навигация по коду

Есть два досадных недостатка использования MediatR.

Первый заключается в менее удобной навигации по коду.

При использовании самодельного Посредника навигация по вызову метода Ping укажет прямо на его определение в IPingHandler или его реализации.

private void DoSomething()
{
    var response = _pingHandler.Ping(new());
    Debug.WriteLine(response); // "Pong";
}

Однако, в MediatR она так не работает для метода Send()

Навигация укажет на определённый извне, сторонний метод:

Есть вариант пробраться к определению PingRequest, чтобы посмотреть, где он используется. Однако, это всё ещё не так удобно, как маршрут в один шаг.

Это происходит из-за того, что отношение между клиентским кодом и обработчиком построено на дополнительном слое переадресации, который мы уже видели:

IntelliSense

Сигнатура ISender.Send() такова:

Task<object?> Send(object request, CancellationToken cancellationToken = default)

Поскольку первый параметр это грубый object, то ничего кроме беспомощности от IntelliSense ожидать не стоит.

Ограничения для обработчиков действуют только во время выполнения программы

Дополнительный слой переадресации влечёт за собой ещё один, вероятно, трудно уловимый недостаток.

Проверить, что PingRequest обрабатывается 0, 1 или большим числом обработчиков, можно только в рантайме. Компилятор никак не может помочь удостовериться в том, что сообщение обработано должным образом. Эту проблему, возможно, получится смягчить дополнительным набором unit тестов, раз на ошибки компиляции положиться не получится.

Неиспользуемые классы

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

Стандартный трюк для её обхода - пометить все обработчики атрибутом [UsedImplicitly] из NuGet пакета JetBrains.Annotations. Есть и другие подходы, описанные в материале Фила Скотта, UsedImplicitly on External Libraries.

Проблема возникает при использовании MediatR, но не когда мы работаем с собственной реализацией шаблона Посредник.

Интересно, что проблема возникает и у другой известной библиотеки AutoMapper, за авторством того же человека.

Потребление памяти и скорость

Адам Ринод обнаружил, что вызов обработчика в MediatR потребляет слишком много ресурсов. Где-то в 50 раз больше, чем вызов напрямую.

В своих (микро) бенчмарках Адам измерил, что MediatR выделил более 1.67 ГБ памяти за 1 минуту работы, заставив GC работать больше 2 секунд.

Больше информации можно найти в публикации MediatR Performance Benchmarks.

Обратите внимание, что Адам сделал микро-бенчмарк: в реальных сценариях эти цифры вряд ли будут волновать вас так сильно. В своём видео How slow is MediatR really? Ник Чапсас провёл детальный анализ сути вопроса, поэтому предлагаю ознакомиться.

Заключение

MediatR не является реализацией шаблона Посредник, а является внутренней шиной, напоминающей по устройству и сценариям использования Service Locator. Библиотека создаёт связь между бизнес-логикой и внешне определёнными контрактами, а также добавляет едва позволительный дополнительный слой переадресации. Шансы успешной реализации слабосвязанной message-oriented архитектуры возрастут, если пользоваться старыми добрыми интерфейсами.

В качестве резюме могу предложить простое и надёжное правило grauenwolf:

Мой совет заключается в том, чтобы попросить людей построить решение без MediatR. А потом попросить показать, особо конкретизировав, что именно они не могут сделать без этой библиотеки.


Ещё я веду telegram канал StepOne, где оставляю много интересных заметок про коммерческую разработку и мир IT глазами эксперта.

Теги:
Хабы:
+16
Комментарии 16
Комментарии Комментарии 16

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн