Стандартные шаблоны событий .NET

Предыдущий

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

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

Подписи делегата событий

Стандартной сигнатурой делегата события .NET является:

void EventRaised(object sender, EventArgs args);

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

Список аргументов содержит два аргумента: отправителя и аргументы события. Тип времени компиляции sender , System.Objectдаже если вы, вероятно, знаете более производный тип, который всегда будет правильным. По соглашению используйте object.

Второй аргумент обычно являлся типом, производным от System.EventArgs. (В следующем разделе вы увидите, что это соглашение больше не является обязательным.) Даже если тип события не требует дополнительных аргументов, необходимо предоставить оба аргумента. Существует специальное значение EventArgs.Empty, которое следует использовать для обозначения того, что событие не содержит никаких дополнительных сведений.

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

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

Ниже показано объявление аргумента исходного события для поиска искомого файла:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

Несмотря на то, что этот тип выглядит как небольшой тип, содержащий только данные, вы должны выполнить соглашение и назначить его ссылочным типом (class). Это означает, что объект аргумента будет передаваться по ссылке, а любые обновления данных будут доступны всем подписчикам. Первая версия является неизменяемым объектом. Рекомендуется сделать свойства в типе аргумента события неизменяемыми. Таким образом, один подписчик не сможет изменить значения до того, как их увидит другой подписчик. (Существуют исключения, как можно будет увидеть ниже.)

Затем нужно создать объявление события в классе FileSearcher. Использование типа EventHandler<T> означает, что вам не требуется создавать еще одно определение типа. Вы просто используете универсальную специализацию.

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

public class FileSearcher
{
    public event EventHandler<FileFoundArgs>? FileFound;

    public void Search(string directory, string searchPattern)
    {
        foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
        {
            RaiseFileFound(file);
        }
    }
    
    private void RaiseFileFound(string file) =>
        FileFound?.Invoke(this, new FileFoundArgs(file));
}

Определение и вызов событий, таких как поле

Самый простой способ добавить событие в класс — объявить это событие как открытое поле, как показано в предыдущем примере.

public event EventHandler<FileFoundArgs>? FileFound;

Это похоже на объявление общедоступного поля, которое, как представляется, является плохой объектно-ориентированной практикой. поскольку необходимо обеспечить защиту доступа к данным с помощью свойств и методов. Хотя это выглядит нарушением рекомендаций, код, созданный компилятором, создает программы-оболочки, чтобы доступ к объектам событий мог осуществляться только безопасным образом. Единственные операции, доступные для событий, подобных полям, — обработчик add:

var fileLister = new FileSearcher();
int filesFound = 0;

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    filesFound++;
};

fileLister.FileFound += onFileFound;

и обработчик remove:

fileLister.FileFound -= onFileFound;

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

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

Возврат значений от подписчиков событий

Простая версия работает нормально. Давайте добавим еще одну возможность — отмену.

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

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

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

Один шаблон позволяет любому одному подписчику отменить операцию. Для этого шаблона новое поле инициализируется значением false. Любой подписчик можно изменить его на true. После того как все подписчики увидят событие, компонент FileSearcher проверяет логическое значение и выполняет действие.

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

Реализуем первую версию для этого примера. Добавьте логическое поле с именем CancelRequested в тип FileFoundArgs:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }
    public bool CancelRequested { get; set; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

Это новое поле автоматически инициализировано в , значение по умолчанию для Boolean поля, поэтому не отменять falseслучайно. Единственным другим изменением в компоненте является установка флага после вызова события для просмотра, если любой из подписчиков запросил отмену:

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        FileFoundArgs args = RaiseFileFound(file);
        if (args.CancelRequested)
        {
            break;
        }
    }
}

private FileFoundArgs RaiseFileFound(string file)
{
    var args = new FileFoundArgs(file);
    FileFound?.Invoke(this, args);
    return args;
}

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

Изменим подписчик, чтобы он запрашивал отмену, когда обнаруживает первый исполняемый файл:

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    eventArgs.CancelRequested = true;
};

Добавление другого объявления события

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

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

Вы начнете с создания нового производного класса EventArgs для передачи сведений о новом каталоге и ходе выполнения.

internal class SearchDirectoryArgs : EventArgs
{
    internal string CurrentSearchDirectory { get; }
    internal int TotalDirs { get; }
    internal int CompletedDirs { get; }

    internal SearchDirectoryArgs(string dir, int totalDirs, int completedDirs)
    {
        CurrentSearchDirectory = dir;
        TotalDirs = totalDirs;
        CompletedDirs = completedDirs;
    }
}

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

Теперь определим событие. На этот раз будет использоваться другой синтаксис. Помимо синтаксиса полей можно явно создать свойство c помощью обработчиков add и remove. В этом примере вы не будете добавлять код в эти обработчики, здесь просто демонстрируется их создание.

internal event EventHandler<SearchDirectoryArgs> DirectoryChanged
{
    add { _directoryChanged += value; }
    remove { _directoryChanged -= value; }
}
private EventHandler<SearchDirectoryArgs>? _directoryChanged;

Созданный здесь код очень похож на тот код, который компилятор создает для определения полей событий, как было показано ранее. Для создания события используется синтаксис, очень похожий на используемый для свойств. Обратите внимание, что обработчики имеют разные имена: add и remove. Они вызываются для подписки на событие или отмены подписки на событие. Учтите, что вы также должны объявить закрытое резервное поле для хранения переменной событий. Оно инициализируется значением NULL.

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

public void Search(string directory, string searchPattern, bool searchSubDirs = false)
{
    if (searchSubDirs)
    {
        var allDirectories = Directory.GetDirectories(directory, "*.*", SearchOption.AllDirectories);
        var completedDirs = 0;
        var totalDirs = allDirectories.Length + 1;
        foreach (var dir in allDirectories)
        {
            RaiseSearchDirectoryChanged(dir, totalDirs, completedDirs++);
            // Search 'dir' and its subdirectories for files that match the search pattern:
            SearchDirectory(dir, searchPattern);
        }
        // Include the Current Directory:
        RaiseSearchDirectoryChanged(directory, totalDirs, completedDirs++);
        
        SearchDirectory(directory, searchPattern);
    }
    else
    {
        SearchDirectory(directory, searchPattern);
    }
}

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        FileFoundArgs args = RaiseFileFound(file);
        if (args.CancelRequested)
        {
            break;
        }
    }
}

private void RaiseSearchDirectoryChanged(
    string directory, int totalDirs, int completedDirs) =>
    _directoryChanged?.Invoke(
        this,
            new SearchDirectoryArgs(directory, totalDirs, completedDirs));

private FileFoundArgs RaiseFileFound(string file)
{
    var args = new FileFoundArgs(file);
    FileFound?.Invoke(this, args);
    return args;
}

На этом этапе можно запустить приложение, вызывающее перегруженный метод для поиска всех вложенных каталогов. Для нового события DirectoryChanged нет подписчиков, однако благодаря использованию идиомы ?.Invoke() мы можем гарантировать правильную работу метода.

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

fileLister.DirectoryChanged += (sender, eventArgs) =>
{
    Console.Write($"Entering '{eventArgs.CurrentSearchDirectory}'.");
    Console.WriteLine($" {eventArgs.CompletedDirs} of {eventArgs.TotalDirs} completed...");
};

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

См. также

Далее мы рассмотрим некоторые изменения в этих шаблонах в самой последней версии .NET.