Поделиться через


Общие шаблоны делегатов

Предыдущий

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

Отличным примером такого проектирования является LINQ. В модели выражений запросов LINQ делегаты применяются для обеспечения всех возможностей. Рассмотрим простой пример.

var smallNumbers = numbers.Where(n => n < 10);

В нем из последовательности чисел отфильтровываются только числа со значением меньше 10. Метод Where использует делегат, который определяет, какие элементы последовательности проходят через фильтр. При создании запроса LINQ вы предоставляете реализацию делегата для этой цели.

Прототип метода Where имеет следующий вид:

public static IEnumerable<TSource> Where<TSource> (this IEnumerable<TSource> source, Func<TSource, bool> predicate);

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

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

Создание собственных компонентов с помощью делегатов

Давайте продолжим пример, создав компонент с помощью модели, основанной на делегатах.

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

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

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

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

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

Первая реализация

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

public static class Logger
{
    public static Action<string>? WriteMessage;

    public static void LogMessage(string msg)
    {
        if (WriteMessage is not null)
            WriteMessage(msg);
    }
}

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

public static class LoggingMethods
{
    public static void LogToConsole(string message)
    {
        Console.Error.WriteLine(message);
    }
}

Наконец, необходимо подключить делегат к делегату WriteMessage, объявленному в средстве ведения журнала.

Logger.WriteMessage += LoggingMethods.LogToConsole;

Правила

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

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

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

Форматирование вывода

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

Затем добавим в метод LogMessage() несколько аргументов, чтобы класс журнала создавал более структурированные сообщения.

public enum Severity
{
    Verbose,
    Trace,
    Information,
    Warning,
    Error,
    Critical
}
public static class Logger
{
    public static Action<string>? WriteMessage;

    public static void LogMessage(Severity s, string component, string msg)
    {
        var outputMsg = $"{DateTime.Now}\t{s}\t{component}\t{msg}";
        if (WriteMessage is not null)
            WriteMessage(outputMsg);
    }
}

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

public static class Logger
{
    public static Action<string>? WriteMessage;

    public static Severity LogLevel { get; set; } = Severity.Warning;

    public static void LogMessage(Severity s, string component, string msg)
    {
        if (s < LogLevel)
            return;

        var outputMsg = $"{DateTime.Now}\t{s}\t{component}\t{msg}";
        if (WriteMessage is not null)
            WriteMessage(outputMsg);
    }
}

Правила

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

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

Создание второго модуля вывода

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

Вот это средство ведения журнала на основе файла:

public class FileLogger
{
    private readonly string logPath;
    public FileLogger(string path)
    {
        logPath = path;
        Logger.WriteMessage += LogMessage;
    }

    public void DetachLog() => Logger.WriteMessage -= LogMessage;
    // make sure this can't throw.
    private void LogMessage(string msg)
    {
        try
        {
            using (var log = File.AppendText(logPath))
            {
                log.WriteLine(msg);
                log.Flush();
            }
        }
        catch (Exception)
        {
            // Hmm. We caught an exception while
            // logging. We can't really log the
            // problem (since it's the log that's failing).
            // So, while normally, catching an exception
            // and doing nothing isn't wise, it's really the
            // only reasonable option here.
        }
    }
}

После создания этого класса можно создать его экземпляр, и он подключит свой метод LogMessage к компоненту Logger:

var file = new FileLogger("log.txt");

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

var fileOutput = new FileLogger("log.txt");
Logger.WriteMessage += LoggingMethods.LogToConsole; // LoggingMethods is the static class we utilized earlier

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

Logger.WriteMessage -= LoggingMethods.LogToConsole;

Правила

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

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

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

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

Для поддержки каждого из сценариев в код класса Logger не нужно вносить никаких изменений.

Обработка NULL-делегатов

Наконец, давайте изменим метод LogMessage так, чтобы он был эффективен в случаях, когда механизм вывода не выбран. Текущая реализация вызывает исключение NullReferenceException, когда к делегату WriteMessage не подключен список вызова. Однако предпочтительнее может быть автоматическое продолжение выполнения в случае, если методы не подключены. Такое поведение легко реализовать с помощью условного оператора null в сочетании с методом Delegate.Invoke().

public static void LogMessage(string msg)
{
    WriteMessage?.Invoke(msg);
}

Условный оператор null (?.) замыкается, если левый операнд (в данном случае WriteMessage) имеет значение null, что означает, что попытки записать сообщение не предпринимаются.

Метод Invoke() не указан в документации по System.Delegate или System.MulticastDelegate. Компилятор создает типобезопасный метод Invoke для любого объявленного типа делегата. В этом примере это означает, что метод Invoke принимает один аргумент string и имеет тип возвращаемого значения void.

Сводка рекомендаций

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

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

Далее