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

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

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

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

О событиях в 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.

Начиная с .NET Framework 4.5 наследованиe аргументов от System.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<MailSentEventArgs>? 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<MailSentEventArgs>? 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<IObserver> _observers;
    private readonly string _emailFrom;

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

    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>, где TResponse – результат обработки запроса, а обработчик должен поддерживать интерфейс IRequestHandler<TRequest, TResponse>, где TRequest – созданный нами тип с интерфейсом IRequest. Но нужно помнить, что обработчик здесь может быть только один.

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

Рассмотрим пример (необходимо установить 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<IMediator>();
        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<MailSentRequest>
{
    public Task Handle(
        MailSentRequest request,
        CancellationToken cancellationToken)
	{
		Console.WriteLine(
			$"[Консоль] Письмо с темой '{request.Subject}' отправлено с адреса '{request.EmailFrom}' на адрес '{request.EmailTo}': {request.Body}");
		return Task.CompletedTask;
	}
}

class FileHandler : INotificationHandler<MailSentRequest>
{
    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<Request>
  {
    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<Request>
  {
    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<TRequest, TResponse>.

services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MailSentBehavior<,>));
 
 
class MailSentBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse> 
{
      	public async Task<TResponse> Handle(
            TRequest request,
            RequestHandlerDelegate<TResponse> 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<IMediator>();
        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<NotificationHandlerExecutor> 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 и написали самостоятельно, реализовав паттерн Наблюдатель. В разных ситуациях может быть удобно использовать разные варианты, но полезно знать и об альтернативных возможностях.

Комментарии (8)


  1. brn
    23.04.2024 08:56
    +1

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

    Нет, это уже обязательно.

    https://learn.microsoft.com/en-us/dotnet/csharp/modern-events


    1. IrinaMakovetskaya Автор
      23.04.2024 08:56
      +2

      Видимо Вы имели ввиду: теперь не обязательно. Спасибо за замечание, действительно обязательность наследования от System.EventArgs убрали начиная с .NET Framework 4.5


  1. Prikalel
    23.04.2024 08:56

    nice

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

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

    или "а есть ли лимит на кол-во обработчиков " ну и в придачу можно ли сделать event в static class-е чтобы ему не надо было при invoke передавать this.

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


    1. IrinaMakovetskaya Автор
      23.04.2024 08:56
      +2

      Отвечу на вопросы по пунктам:

      1. да, GC удалит объект в котором есть евент с обработчиком, так как это объект ссылается на обработчик, а не наоборот.

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

      3. Так как ссылка на следующий обработчик добавляется в конец предыдущего - явного ограничения количества обработчиков нет.

      4. Объявить событие в статическом классе возможно, тогда в Invoke надо передавать null

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


  1. andrey_stepanov1
    23.04.2024 08:56

    Например, у нас в руках была сахарница с синтаксическим сахаром, и мы сахар из нее рассыпали прямо в на наш язык программирования...

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


    1. Prikalel
      23.04.2024 08:56

      она будет одинакова для всех этих счастьей.

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


  1. Okunev_PY
    23.04.2024 08:56

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

    Паттерн наблюдатель никакого не имеет отношения к событиям. Это разные вещи, и зауши притягивать MediatR и его реализацию к событиям net вообще не стоит.

    Статья вообще не расскрывает сути как устроены события, что такое просто deligate и чем он отличаеться от multicast delegate.

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

    Если не отписываться от событий то GC такой объект не удалит, для него это зависшие ссылки.

    В общем можно долго продолжать, но посыл я думаю понятен.


    1. ryanl
      23.04.2024 08:56

      но посыл я думаю понятен.

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