События в .NET: стандартная реализация, альтернативы, и причем тут сахар

События — это объекты, которые получают уведомления о некотором действии в разрабатываемом ПО и могут запускать реакции на это действие. Разработчик может определить эти действия, добавив к событию обработчик. Разберем в этом материале само понятие событий в .NET и разные способы работы с ними.

Объясним на сахаре

Если говорить простым языком, то можно провести аналогию с просыпанным сахаром. Например, у нас в руках была сахарница, и мы сахар из нее рассыпали — это событие. Что делать, если рассыпался сахар? Идти за веником, чтобы убрать — это и есть обработчик события. Этот обработчик с предназначенным для него действием «сидит у нас в голове». Даже если сахар мы никогда не просыпали, мы все равно знаем, что веником его можно будет убрать. 

621ab29465b5e91075ed321b9fb239eb.png

Обработчик может меняться. Например, мы покупаем пылесос — теперь у нас в голове меняется обработчик для того же события: мы пойдем не за веником, а за пылесосом (один обработчик мы удалили, другой добавили). Изменение обработчика не влияет на событие. Обработчика может и не быть вовсе — тогда человек будет просто ходить по просыпанному сахару.

О событиях в C#

В C# события существуют с самого начала. Например, при создании элементарного приложения Web Forms, для обработки нажатия на кнопку нужно добавить конструкцию вида

MyButton.Click += new EventHandler(this.MyBtn_Click);

Сlick — это и есть событие, которое уже было добавлено разработчиками в класс Button, а MyBtn_Click — это обработчик, написанный программистом. 

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

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

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

В C# события — это отдельный тип членов класса, обозначаемый ключевым словом event. Наряду со свойствами и параметрами.

Класс, который реализует событие, должен содержать следующую конструкцию:

event тип_делегата имя_события;
  • тип_делегата указывает на прототип вызываемого метода (или методов);

  • имя_события — конкретный объект объявляемого события.

Добавление обработчика события производится с помощью операции += :

имя_события += обработчик_события;

Разберем добавление обработки событий на примере логирования. Здесь, и в дальнейшем, мы будем использовать консольное приложение .Net 7.0, C# 11.

class Program
{
    static void Main()
    {
        EmailService emailService = new EmailService(emailFrom: "hr@disney.com");

        // Добавляем обработчик события
        emailService.MailSent += LogToConsole;

        emailService.SendMail(emailTo: "Milo.Murphy@disney.com", subject: "Welcome to Disney", body: "Today is your first day...");
    }

    // Обработчик, который логирует в консоль отправленное сообщение
    static void LogToConsole(MailSentEventArgs eventArgs)
        => Console.WriteLine(
            $"[Консоль] Письмо с темой '{eventArgs.Subject}' отправлено с адреса '{eventArgs.EmailFrom}' на адрес '{eventArgs.EmailTo}': {eventArgs.Body}");
}

// Класс в котором реализовано событие
class EmailService
{
    private readonly string _emailFrom;

    public EmailService(string emailFrom)
    {
        _emailFrom = emailFrom;
    }

    // Объявляем событие
    public event MailSentEventHandler? MailSent;

    public void SendMail(string emailTo, string subject, string body)
    {
        // Отправляем письмо пользователю...
        // Send(_emailFrom, emailTo, subject, body);

        // Вызываем метод запуска события
        var eventArgs = new MailSentEventArgs
        {
            EmailFrom = _emailFrom,
            EmailTo = emailTo,
            Subject = subject,
            Body = body
        };
        OnMailSent(eventArgs);
    }

    // Используем метод для запуска события
    protected virtual void OnMailSent(MailSentEventArgs eventArgs)
    {
        MailSentEventHandler? mailSentHandler = MailSent;
        if (mailSentHandler != null)
        {
            mailSentHandler(eventArgs);
        }
    }
}

// Объявляем тип события
public delegate void MailSentEventHandler(MailSentEventArgs eventArgs);

public record MailSentEventArgs
{
    public string? EmailFrom { get; init; }
    public string? EmailTo { get; init; }
    public string? Subject { get; init; }
    public string? Body { get; init; }
}

Мы добавили событие MailSent в класс EmailService и добавили обработчик этого события LogToConsole.

Результат:

[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...

Добавление и удаление обработчиков

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

Цепочка обработчиков реализуется через Delegate.Combine. То есть физически каждый делегат содержит ссылку на реализацию, внутри которой ссылка на следующую реализацию. Обработчик можно удалить в процессе.

static void Main()
    {
        EmailService emailService = new EmailService(emailFrom: "hr@disney.com");

        // Добавляем обработчик события
        emailService.MailSent += LogToConsole;
        emailService.MailSent += LogToFile;

        emailService.SendMail(emailTo: "Milo.Murphy@disney.com", subject: "Welcome to Disney", body: "Today is your first day...");

        emailService.MailSent -= LogToFile;
        emailService.SendMail(emailTo: "Milo.Murphy@disney.com", subject: "Welcome to Disney", body: "Today is your first day...");
    }

    // Обработчик, который логирует в консоль отправленное сообщение
    static void LogToConsole(MailSentEventArgs eventArgs)
        => Console.WriteLine(
            $"[Консоль] Письмо с темой '{eventArgs.Subject}' отправлено с адреса '{eventArgs.EmailFrom}' на адрес '{eventArgs.EmailTo}': {eventArgs.Body}");

    // Обработчик, который логирует в файл отправленное сообщение
    static void LogToFile(MailSentEventArgs eventArgs)
        => Console.WriteLine(
            $"[Файл] Письмо с темой '{eventArgs.Subject}' отправлено с адреса '{eventArgs.EmailFrom}' на адрес '{eventArgs.EmailTo}': {eventArgs.Body}");

Результат:

[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
[Файл] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...

Если нужно выполнить какие-то действия при добавлении или удалении обработчика, можно переписать функции add/remove у делегата. Мы добавим логирование к действиям добавления/удаления обработчика.

private MailSentEventHandler? mailSent;
	public event MailSentEventHandler MailSent
{
		add
		{
			mailSent += value;
			Console.WriteLine($"Обработчик {value.Method.Name} добавлен");
		}
		remove
		{
			Console.WriteLine($"Обработчик {value.Method.Name} удален");
			mailSent -= value;
		}
	}

Результат:

Обработчик LogToConsole добавлен
Обработчик LogToFile добавлен
[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
[Файл] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
Обработчик LogToFile удален
[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day..

Если в обработчике произойдет ошибка — ошибка будет на уровне приложения. Если добавить try catch при вызове обработчика, то в случае, если произойдет ошибка в первом обработчике, последующие обработчики выполняться не будут. Поэтому следует предусмотреть это в каждом обработчике.

static void LogToConsole(MailSentEventArgs eventArgs)
	{
		try
		{
			throw new Exception("Ошибка записи");
		}
		catch (Exception e)
		{
			Console.WriteLine(e);
		}
	}

EventHandler

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

Классы, которые мы собираемся использовать для хранения информации, передаваемой обработчику события, должны наследоваться от типа System.EventArgs. При этом имя типа желательно заканчивать словом EventArgs.

Создадим тип параметров, используемых обработчиками:

public class MailSentEventArgs : EventArgs
{
	public string? EmailFrom { get; init; }
	public string? EmailTo { get; init; }
	public string? Subject { get; init; }
	public string? Body { get; init; }
}

И тогда событие может быть объявлено как

public event EventHandler? MailSent;

При этом обработчик должен принимать параметры object sender и MailSentEventArgs args. Где sender — текущий элемент класса, в котором определен event, a args — передаваемые обработчику данные. Обработчик может быть использован для событий в разных классах, поэтому разумнее принимать экземпляр типа object, а не конкретного типа. Так как в этом случае в метод обработчика могут приходить данные от разных событий, но с одинаковыми параметрами.

То есть обработчики будут выглядеть так:

public static void LogToConsole(object? sender, MailSentEventArgs args)
        => Console.WriteLine(
            $"[Консоль] Письмо с темой '{args.Subject}' отправлено с адреса '{args.EmailFrom}' на адрес '{args.EmailTo}': {args.Body}");

 
    	public static void LogToFile(object? sender, MailSentEventArgs args)
        => Console.WriteLine(
            $"[Файл] Письмо с темой '{args.Subject}' отправлено с адреса '{args.EmailFrom}' на адрес '{args.EmailTo}': {args.Body}")

А вызов, поскольку событие представляет собой делегат, например, так:

MailSent?.Invoke(this, eventArgs);

Порой необходимо передать внутрь функции какие-то данные, а порой действие будет выполняться независимо от внешних данных — тогда передавать внутрь функции ничего не нужно. В случаях, когда не нужно передавать в обработчик никаких данных, мы можем воспользоваться EventArgs.Empty.  То есть объявление события не будет указывать тип аргумента:

public event EventHandler? MailSent;

Обработчик при этом должен принимать object sender и EventArgs:

static void LogToConsole(object? sender, EventArgs eventArgs)
	=> Console.WriteLine("[Консоль] Отправлено письмо");

а вызов будет выглядеть так:

MailSent?.Invoke(this, EventArgs.Empty);

AsyncEventHandler

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

Подключив к проекту Microsoft.VisualStudio.Threading, получаем возможность сделать обработку делегатов асинхронной.

Тогда объявлять событие мы будем так:

public event AsyncEventHandler? MailSent;

Обработчик будет выглядеть так:

static Task LogToConsoleAsync(object? sender, MailSentEventArgs args)
	{
		Console.WriteLine(
			$"[Консоль] Письмо с темой '{args.Subject}' отправлено с адреса '{args.EmailFrom}' на адрес '{args.EmailTo}': {args.Body}");
		return Task.CompletedTask;
	}

а вызываться так:

await MailSent?.InvokeAsync(this, new MailSentEventArgs());

Реализация событий компилятором

Несколько слов о представлении событий в IL-кодe. При компиляции кроме объявления события также создаются два метода add и remove: они реализуют конструкции += и –=.  

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

public event MailSentEventHandler MailSent
{
    [NullableContext(2)]
    [CompilerGenerated]
    add
    {
        MailSentEventHandler mailSentEventHandler = this.MailSent;
        while (true)
        {
      // берем текущий делегат
            MailSentEventHandler mailSentEventHandler2 = mailSentEventHandler;

            // добавляем к текущему делегату новый
            MailSentEventHandler value2 = (MailSentEventHandler)Delegate
                                         .Combine(mailSentEventHandler2, value);

            // сравниваем MailSent и mailSentEventHandler2
            // и, если они равны, заменяем MailSent на value2
            // а в mailSentEventHandler записываем исходное значение MailSent
            mailSentEventHandler = Interlocked
             .CompareExchange(ref this.MailSent, value2, mailSentEventHandler2);

            // если предыдущая операция выполнилась успешно - заканчиваем
            // это нужно для безопасной многопоточной работы - если в 
           // параллельном потоке у события изменится список делегатов, 
           // то while запустится повторно и добавит новый делегат к уже
           // обновленной цепочке делегатов
            if ((object)mailSentEventHandler == mailSentEventHandler2)
            {
                break;
            }
        }
    }
    [NullableContext(2)]
    [CompilerGenerated]
    remove
    {
        MailSentEventHandler mailSentEventHandler = this.MailSent;
        while (true)
        {
            // берем текущий делегат
            MailSentEventHandler mailSentEventHandler2 = mailSentEventHandler;

            // удаляем из него value
            MailSentEventHandler value2 = (MailSentEventHandler)Delegate
                                          .Remove(mailSentEventHandler2, value);

            // сравниваем MailSent и mailSentEventHandler2
            // и, если они равны, заменяем MailSent на value2
            // а в mailSentEventHandler записываем исходное значение MailSent            
            mailSentEventHandler = Interlocked
             .CompareExchange(ref this.MailSent, value2, mailSentEventHandler2);

            // если предыдущая операция выполнилась успешно - заканчиваем
            if ((object)mailSentEventHandler == mailSentEventHandler2)
            {
                break;
            }
        }
    }
}

Паттерн «Наблюдатель»

Реализация событий укладывается в паттерн «Наблюдатель», суть которого в наличии одного наблюдаемого объекта и нескольких наблюдателей. Если возвращаться к аналогии с сахаром, в рамках паттерна сахарница будет наблюдаемым объектом, а человек, который рассыпал сахар или просто находился рядом — наблюдателем. Наблюдателей может быть больше одного (кто-то с веником, а кто-то с пылесосом). Далее, когда рассыпается сахар, сначала один наблюдатель делает свои действия, а другой следом — свои.

Паттерн «Наблюдатель» можно реализовать через добавления наблюдателей как реализаций делегата, а можно — через добавление наблюдателей в список, хранящийся в наблюдаемом объекте.

Ниже приведен пример реализации этого паттерна без использования событий.

class Program
{
    static void Main()
    {
        EmailService emailService = new EmailService("hr@disney.com");

        // Добавляем обработчик события
        var consoleObserver = new ConsoleObserver(emailService);
        var fileObserver = new FileObserver(emailService);

        emailService.SendMail(
            emailTo: "Milo.Murphy@disney.com",
            subject: "Welcome to Disney",
            body: "Today is your first day...");

        fileObserver.StopObserve();

        emailService.SendMail(
            emailTo: "Milo.Murphy@disney.com",
            subject: "Welcome to Disney",
            body: "Today is your first day...");
    }
}

interface IObserver
{
    void Update(string emailFrom, string emailTo, string subject, string body);
}

interface IObservable
{
    void RegisterObserver(IObserver o);
    void RemoveObserver(IObserver o);
}

class EmailService : IObservable
{
    private readonly List _observers;
    private readonly string _emailFrom;

    public EmailService(string emailFrom)
    {
        _emailFrom = emailFrom;
        _observers = new List();
    }

    public void RegisterObserver(IObserver o)
    {
        _observers.Add(o);
    }

    public void RemoveObserver(IObserver o)
    {
        _observers.Remove(o);
    }

    protected void MailSent(string emailTo, string subject, string body)
    {
        foreach (IObserver o in _observers)
        {
            o.Update(_emailFrom, emailTo, subject, body);
        }
    }

    public void SendMail(string emailTo, string subject, string body)
    {
        // Отправить письмо пользователю
        //Send(_emailFrom, emailTo, subject, body);

        // Запускаем методы наблюдателей
        MailSent(emailTo, subject, body);
    }
}

class ConsoleObserver : IObserver
{
    IObservable? _stock;
    public ConsoleObserver(IObservable obs)
    {
        _stock = obs;
        _stock.RegisterObserver(this);
    }
    public void Update(
        string emailFrom,
        string emailTo,
        string subject,
        string body)
    {
        Console.WriteLine($"[Консоль] Письмо с темой '{subject}' отправлено с адреса '{emailFrom}' на адрес '{emailTo}': {body}");
    }

    public void StopObserve()
    {
        if (_stock is null)
        {
            return;
        }
        _stock.RemoveObserver(this);
        _stock = null;
    }
}

class FileObserver : IObserver
{
    IObservable? _stock;
    public FileObserver(IObservable obs)
    {
        _stock = obs;
        _stock.RegisterObserver(this);
    }

    public void Update(
        string emailFrom,
        string emailTo,
        string subject,
        string body)
    {
        Console.WriteLine($"[Файл] Письмо с темой '{subject}' отправлено с адреса '{emailFrom}' на адрес '{emailTo}': {body}");
    }

    public void StopObserve()
    {
        if (_stock is null)
        {
            return;
        }
        _stock.RemoveObserver(this);
        _stock = null;
    }
}

Результат:

[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
[Файл] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...

Минусы и плюсы такой реализации паттерна «Наблюдатель»

Минусы

  • в этой реализации нам пришлось самим писать интерфейсы, а для событий есть прописанные интерфейсы и классы, которыми остается только воспользоваться;

  • более высокий порог вхождения — нужно разобраться с работой паттерна;

  • под каждое событие придется писать свой набор интерфейсов из-за различий данных события, передаваемых в метод IObserver.Update (либо аналогично событиям использовать тип object, но тогда теряется наглядность);

  • при этом код стал более отлаживаемым — весь код доступен для редактирования, и более прозрачным — можно пройтись по реализации и в точности посмотреть работу (в событиях, например, не очевидно, что будет, если случится эксепшен в одном из цепочки делегатов).

Плюсы

  • подобную реализацию можно использовать также в языках, где нет делегатов — например, С++;

  • можно обеспечить распараллеливание реакции наблюдателей;

  • можно отловить случившийся эксепшн в цепочке и обработать в зависимости от логики задачи;

  • точные контракты: есть конкретные методы с конкретным набором параметров под нужное событие, а не набор параметров вида »object sender, EventArgs e».

MediatR

В библиотеке MediatR из коробки есть своя реализация событий. Это не существующие в C# события, но есть некоторое сходство.

Как следует из названия, MediatR — это реализация паттерна «Посредник». Суть паттерна в создании прослойки между частями кода. Это нужно в случае наличия большого количества связей между объектами — есть вероятность запутать логику реализации. 

«Посредник» ограничивает объекты от явных ссылок друг на друга, уменьшая количество взаимосвязей. Основной принцип реализации в том, что мы создаем объект и можем добавить обработчики для него. При этом достаточно использовать у типа отправляемого объекта интерфейс IRequest  или INotification и указать у обработчика интерфейс, связанный с типом объекта. Тогда MediatR вызовет нужный обработчик при выполнении команды Send для интерфейса IRequest и Publish для интерфейса INotification, в который будет передан объект.

Обычно при работе с библиотекой MediatR используются интерфейсы IRequest и IRequestHandler. Тип, используемый медиатором, должен быть унаследован от IRequest, где TResponse — результат обработки запроса, а обработчик должен поддерживать интерфейс IRequestHandler, TResponse>, где TRequest — созданный нами тип с интерфейсом IRequest. Но нужно помнить, что обработчик здесь может быть только один.

Для случая множества обработчиков в MediatR был создан интерфейс INotification. И, соответственно, обработчики должны поддерживать интерфейс INotificationHandler.

Рассмотрим пример (необходимо установить nuget-пакет MediatR и nuget-пакет Microsoft.Extensions.DependencyInjection):

using System.Reflection;
using MediatR;
using Microsoft.Extensions.DependencyInjection;

class Program
{
    static async Task Main()
    {
        var serviceCollection = new ServiceCollection()
            .AddMediatR(cfg =>
         cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()))
            .BuildServiceProvider();

        var mediator = serviceCollection.GetRequiredService();
        EmailService emailService = new EmailService(mediator, "hr@disney.com");

        await emailService.SendMailToUserAsync(
            emailTo: "Milo.Murphy@disney.com",
            subject: "Welcome to Disney",
            body: "Today is your first day...");
    }
}

class MailSentRequest : INotification
{
    public string? EmailFrom { get; init; }
    public string? EmailTo { get; init; }
    public string? Subject { get; init; }
    public string? Body { get; init; }
}

class ConsoleHandler : INotificationHandler
{
    public Task Handle(
        MailSentRequest request,
        CancellationToken cancellationToken)
	{
		Console.WriteLine(
			$"[Консоль] Письмо с темой '{request.Subject}' отправлено с адреса '{request.EmailFrom}' на адрес '{request.EmailTo}': {request.Body}");
		return Task.CompletedTask;
	}
}

class FileHandler : INotificationHandler
{
    public Task Handle(
        MailSentRequest request,
        CancellationToken cancellationToken)
	{
		Console.WriteLine(
			$"[Файл] Письмо с темой '{request.Subject}' отправлено с адреса '{request.EmailFrom}' на адрес '{request.EmailTo}': {request.Body}");
		return Task.CompletedTask;
	}
}

class EmailService
{
    private readonly string _emailFrom;
    private readonly IMediator _mediator;

    public EmailService(IMediator mediator, string emailFrom)
    {
        _mediator = mediator;
        _emailFrom = emailFrom;
    }

    public async Task SendMailToUserAsync(
        string emailTo,
        string subject,
        string body)
    {
        // Отправить письмо пользователю
        //Send(_emailFrom, emailTo, subject, body);
        // Вызываем метод запуска события
        await MailSentAsync(_emailFrom, emailTo, subject, body);
    }

    protected async Task MailSentAsync(string emailFrom, string emailTo, string subject, string body)
    {
        var request = new MailSentRequest
        {
            EmailFrom = emailFrom,
            EmailTo = emailTo,
            Subject = subject,
            Body = body
        };
        await _mediator.Publish(request);
    }
}

Результат:

[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
[Файл] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...

Плюсы и минусы использования MediatR:

Плюс

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

Минусы

  • классы хэндлеров помечаются не используемыми (это можно исправить, навесив атрибут [UsedImplicitly]);

  • нельзя «по щелчку» перейти к реализации;

  • не всегда очевидно, какие хэндлеры будут вызваны при вызове медиатора;

  • не получится во время выполнения программы добавить/удалить обработчик;

  • невозможно (или сложно) установить четкий порядок вызова обработчика. Он больше подходит для вызова независимых друг от друга обработчиков.

Есть способ обойти второй минус из перечисленных. Нужно реализацию запроса и обработчик поместить в один partial-класс:

public partial class MailSent
{
  public class Request : INotification
  {
	public string? EmailFrom { get; init; }
	public string? EmailTo { get; init; }
	public string? Subject { get; init; }
	public string? Body { get; init; }
  }
}

public partial class MailSent
{
  public class ConsoleHandler : INotificationHandler
  {
    public Task Handle(
		Request request,
		CancellationToken cancellationToken)
	{
		Console.WriteLine(
			$"[Консоль] Письмо с темой '{request.Subject}' отправлено с адреса '{request.EmailFrom}' на адрес '{request.EmailTo}': {request.Body}");
		return Task.CompletedTask;
	}
  }
}

public partial class MailSent
{
  public class FileHandler : INotificationHandler
  {
    public Task Handle(
		Request request,
		CancellationToken cancellationToken)
	{
		Console.WriteLine(
			$"[Файл] Письмо с темой '{request.Subject}' отправлено с адреса '{request.EmailFrom}' на адрес '{request.EmailTo}': {request.Body}");
		return Task.CompletedTask;
	}
  }
}

тогда при создании запроса нужно указывать оба класса:

protected async Task MailSentAsync(
		string emailFrom,
		string emailTo,
		string subject,
		string body)
	{
		var request = new MailSent.Request
		{
			EmailFrom = emailFrom,
			EmailTo = emailTo,
			Subject = subject,
			Body = body
		};
		await _mediator.Publish(request);
	}

При попытке перейти «по щелчку» к классу MailSent нам будет предложен выбор перейти к запросу или к реализации.

 Дополнительные возможности

В MediatR есть удобная реализация поведения конвейера (pipeline behavior). Для этого используется интерфейс IPipelineBehavior.

services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MailSentBehavior<,>));
 
 
class MailSentBehavior 
    : IPipelineBehavior 
{
      	public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
      	{
            	try
            	{
                  	Console.WriteLine($"Перед запуском {typeof(TRequest).Name}");
                  	return await next();
            	}
            	finally
            	{
                  	Console.WriteLine($"После запуска {typeof(TRequest).Name}");
            	}
    	}
  }

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

К сожалению, мы можем использовать PipelineBehavior только для IRequest и не можем для INotification.

Чтобы реализовать подобную функциональность для событий нужно переопределить NotificationPublisher.

class Program
{
    static async Task Main()
    {
        var serviceCollection = new ServiceCollection()
            .AddMediatR(config =>
        {
           config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
           config.NotificationPublisher = new MailSentPublisher();
           config.NotificationPublisherType = typeof(MailSentPublisher);
        })
            .BuildServiceProvider();

        var mediator = serviceCollection.GetRequiredService();
        EmailService emailService = 
               new EmailService(mediator: mediator, emailFrom: "hr@disney.com");

        await emailService.SendMailToUserAsync(
			emailTo: "Milo.Murphy@disney.com",
			subject: "Welcome to Disney",
			body: "Today is your first day...");
    }
}

class MailSentPublisher : INotificationPublisher
{
    public async Task Publish(
		IEnumerable handlerExecutors,
		INotification notification,
		CancellationToken cancellationToken)
    {
        foreach (var handler in handlerExecutors)
        {
            try
            {
                Console.WriteLine(
			$"Перед запуском {handler.HandlerInstance.GetType().Name}");
                await handler.HandlerCallback(notification, cancellationToken)
					.ConfigureAwait(false);
            }
            catch (Exception e)
            {
                Console.WriteLine($"Произошла ошибка {e.Message}");
            }
            finally
            {
                Console.WriteLine(
			$"После запуска {handler.HandlerInstance.GetType().Name}");
            }
        }
    }
}

Результат:

Перед запуском ConsoleHandler
[Консоль] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
После запуска ConsoleHandler
Перед запуском FileHandler
[Файл] Письмо с темой 'Welcome to Disney' отправлено с адреса 'hr@disney.com' на адрес 'Milo.Murphy@disney.com': Today is your first day...
После запуска FileHandler

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

Вместо заключения

Мы рассмотрели несколько вариантов реализации обработчиков Событий: с помощью стандартных средств C#, с помощью средств библиотеки MediatR и написали самостоятельно, реализовав паттерн Наблюдатель. В разных ситуациях может быть удобно использовать разные варианты, но полезно знать и об альтернативных возможностях.

© Habrahabr.ru