Typowe wzorce dla delegatów

Poprzednie

Delegaty zapewniają mechanizm, który umożliwia projektowanie oprogramowania obejmujące minimalne sprzężenie między składnikami.

Jednym z doskonałych przykładów tego rodzaju projektu jest LINQ. Wzorzec wyrażenia zapytania LINQ opiera się na delegatach dla wszystkich jego funkcji. Rozważmy następujący prosty przykład:

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

Spowoduje to filtrowanie sekwencji liczb tylko do tych mniejszych niż wartość 10. Metoda Where używa delegata, który określa, które elementy sekwencji przechodzą filtr. Podczas tworzenia zapytania LINQ należy podać implementację delegata w tym konkretnym celu.

Prototyp metody Where to:

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

Ten przykład jest powtarzany ze wszystkimi metodami, które są częścią LINQ. Wszystkie one polegają na delegatach dla kodu, który zarządza określonym zapytaniem. Ten wzorzec projektowania interfejsu API to zaawansowany wzorzec do nauki i zrozumienia.

W tym prostym przykładzie pokazano, jak delegaty wymagają bardzo małego sprzężenia między składnikami. Nie musisz tworzyć klasy pochodzącej z określonej klasy bazowej. Nie trzeba implementować określonego interfejsu. Jedynym wymaganiem jest zapewnienie implementacji jednej metody, która jest podstawowa dla zadania.

Tworzenie własnych składników przy użyciu delegatów

Skompilujmy ten przykład, tworząc składnik przy użyciu projektu, który opiera się na delegatach.

Zdefiniujmy składnik, który może być używany do rejestrowania komunikatów w dużym systemie. Składniki biblioteki mogą być używane w wielu różnych środowiskach na wielu różnych platformach. Istnieje wiele typowych funkcji w składniku, który zarządza dziennikami. Konieczne będzie akceptowanie komunikatów z dowolnego składnika w systemie. Te komunikaty będą miały różne priorytety, którymi może zarządzać podstawowy składnik. Wiadomości powinny mieć znaczniki czasu w ich ostatecznej zarchiwizowanej formie. W przypadku bardziej zaawansowanych scenariuszy można filtrować komunikaty według składnika źródłowego.

Istnieje jeden aspekt funkcji, który często się zmienia: gdzie komunikaty są zapisywane. W niektórych środowiskach mogą być zapisywane w konsoli błędów. W innych plikach. Inne możliwości obejmują magazyn bazy danych, dzienniki zdarzeń systemu operacyjnego lub inny magazyn dokumentów.

Istnieją również kombinacje danych wyjściowych, które mogą być używane w różnych scenariuszach. Możesz chcieć zapisywać komunikaty w konsoli programu i w pliku.

Projekt oparty na delegatach zapewni dużą elastyczność i ułatwi obsługę mechanizmów magazynowania, które mogą zostać dodane w przyszłości.

W ramach tego projektu podstawowy składnik dziennika może być nawet niewirtualną, nawet zapieczętowaną klasą. Możesz podłączyć dowolny zestaw delegatów, aby zapisywać komunikaty na różnych nośnikach magazynu. Wbudowana obsługa delegatów multiemisji ułatwia obsługę scenariuszy, w których komunikaty muszą być zapisywane w wielu lokalizacjach (plik i konsola).

Pierwsza implementacja

Zacznijmy od małego: początkowa implementacja będzie akceptować nowe komunikaty i zapisywać je przy użyciu dowolnego dołączonego delegata. Możesz rozpocząć od jednego delegata, który zapisuje komunikaty w konsoli programu .

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

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

Klasa statyczna powyżej jest najprostszą rzeczą, która może działać. Musimy napisać pojedynczą implementację metody, która zapisuje komunikaty w konsoli:

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

Na koniec należy podłączyć delegata, dołączając go do delegata WriteMessage zadeklarowanego w rejestratorze:

Logger.WriteMessage += LoggingMethods.LogToConsole;

Praktyki

Nasza próbka do tej pory jest dość prosta, ale nadal pokazuje niektóre z ważnych wytycznych dotyczących projektów obejmujących delegatów.

Użycie typów delegatów zdefiniowanych w podstawowej strukturze ułatwia użytkownikom pracę z delegatami. Nie musisz definiować nowych typów, a deweloperzy korzystający z biblioteki nie muszą uczyć się nowych, wyspecjalizowanych typów delegatów.

Używane interfejsy są tak minimalne i jak najbardziej elastyczne: aby utworzyć nowy rejestrator danych wyjściowych, należy utworzyć jedną metodę. Ta metoda może być metodą statyczną lub metodą wystąpienia. Może mieć dostęp.

Formatuj dane wyjściowe

Utwórzmy tę pierwszą wersję nieco bardziej niezawodną, a następnie zacznijmy tworzyć inne mechanizmy rejestrowania.

Następnie dodajmy kilka argumentów do LogMessage() metody, aby klasa dziennika tworzyła bardziej ustrukturyzowane komunikaty:

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);
    }
}

Następnie użyjemy tego Severity argumentu do filtrowania komunikatów wysyłanych do danych wyjściowych dziennika.

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);
    }
}

Praktyki

Dodano nowe funkcje do infrastruktury rejestrowania. Ponieważ składnik rejestratora jest bardzo luźno powiązany z dowolnym mechanizmem danych wyjściowych, te nowe funkcje można dodać bez wpływu na żaden kod implementujący delegat rejestratora.

Podczas tworzenia tego celu zobaczysz więcej przykładów tego, jak luźne sprzęganie zapewnia większą elastyczność aktualizowania części lokacji bez żadnych zmian w innych lokalizacjach. W rzeczywistości w większej aplikacji klasy wyjściowe rejestratora mogą znajdować się w innym zestawie, a nawet nie trzeba ich ponownie skompilować.

Tworzenie drugiego aparatu wyjściowego

Składnik Dziennika jest dobrze. Dodajmy jeszcze jeden aparat danych wyjściowych, który rejestruje komunikaty w pliku. Będzie to nieco bardziej zaangażowany aparat wyjściowy. Będzie to klasa, która hermetyzuje operacje na plikach i zapewnia, że plik jest zawsze zamknięty po każdym zapisie. Dzięki temu wszystkie dane są opróżniane na dysk po wygenerowaniu każdego komunikatu.

Oto ten rejestrator oparty na plikach:

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.
        }
    }
}

Po utworzeniu tej klasy można utworzyć wystąpienie tej klasy i dołączyć jej metodę LogMessage do składnika Rejestrator:

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

Te dwa nie wykluczają się wzajemnie. Możesz dołączyć zarówno metody dziennika, jak i wygenerować komunikaty do konsoli programu i pliku:

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

Później, nawet w tej samej aplikacji, można usunąć jednego z delegatów bez żadnych innych problemów z systemem:

Logger.WriteMessage -= LoggingMethods.LogToConsole;

Praktyki

Teraz dodano drugą procedurę obsługi danych wyjściowych dla podsystemu rejestrowania. Ten element wymaga nieco więcej infrastruktury, aby poprawnie obsługiwać system plików. Delegat jest metodą wystąpienia. Jest to również metoda prywatna. Nie ma potrzeby zwiększenia ułatwień dostępu, ponieważ infrastruktura delegatów może łączyć delegatów.

Po drugie, projekt oparty na delegatach umożliwia korzystanie z wielu metod wyjściowych bez dodatkowego kodu. Nie musisz tworzyć żadnej dodatkowej infrastruktury, aby obsługiwać wiele metod wyjściowych. Po prostu stają się kolejną metodą na liście wywołań.

Zwróć szczególną uwagę na kod w metodzie wyjściowej rejestrowania plików. Kodowany jest w celu zapewnienia, że nie zgłasza żadnych wyjątków. Chociaż nie zawsze jest to absolutnie konieczne, często jest to dobra praktyka. Jeśli którakolwiek z metod delegatów zgłasza wyjątek, pozostałe delegaty, które znajdują się w wywołaniu, nie zostaną wywołane.

W ostatniej notatce rejestrator plików musi zarządzać swoimi zasobami, otwierając i zamykając plik w każdym komunikacie dziennika. Możesz pozostawić plik otwarty i zaimplementować IDisposable , aby zamknąć plik po zakończeniu. Każda z metod ma swoje zalety i wady. Obie metody tworzą nieco więcej sprzężeń między klasami.

Żaden kod w Logger klasie nie musi być aktualizowany w celu obsługi obu scenariuszy.

Obsługa delegatów o wartości null

Na koniec zaktualizujmy metodę LogMessage, aby była niezawodna w tych przypadkach, gdy nie wybrano żadnego mechanizmu wyjściowego. Bieżąca implementacja zgłosi błąd NullReferenceException , gdy WriteMessage delegat nie ma dołączonej listy wywołań. Wolisz projekt, który dyskretnie będzie kontynuowany, gdy nie dołączono żadnych metod. Jest to łatwe w użyciu operatora warunkowego o wartości null w połączeniu Delegate.Invoke() z metodą :

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

Zerowy operator warunkowy (?.) zwarć, gdy lewy operand (WriteMessage w tym przypadku) ma wartość null, co oznacza, że nie podjęto próby zarejestrowania komunikatu.

Nie znajdziesz Invoke() metody wymienionej w dokumentacji programu System.Delegate lub System.MulticastDelegate. Kompilator generuje bezpieczną metodę typu Invoke dla dowolnego zadeklarowanego typu delegata. W tym przykładzie oznacza to, że przyjmuje Invoke jeden string argument i ma zwracany typ void.

Podsumowanie praktyk

Znasz już początki składnika dziennika, który można rozszerzyć wraz z innymi składnikami zapisywania i innymi funkcjami. Dzięki użyciu delegatów w projekcie te różne składniki są luźno powiązane. Zapewnia to kilka zalet. Tworzenie nowych mechanizmów danych wyjściowych i dołączanie ich do systemu jest łatwe. Te inne mechanizmy wymagają tylko jednej metody: metody, która zapisuje komunikat dziennika. Jest to projekt odporny po dodaniu nowych funkcji. Kontrakt wymagany dla każdego składnika zapisywania polega na zaimplementowaniu jednej metody. Ta metoda może być metodą statyczną lub wystąpieniem. Może to być publiczny, prywatny lub inny dostęp prawny.

Klasa Rejestrator może wprowadzać dowolną liczbę ulepszeń lub zmian bez wprowadzania zmian powodujących niezgodność. Podobnie jak w przypadku każdej klasy, nie można modyfikować publicznego interfejsu API bez ryzyka wystąpienia zmian powodujących niezgodność. Jednak ponieważ sprzęganie między rejestratorem i wszystkimi aparatami wyjściowymi odbywa się tylko za pośrednictwem delegata, nie są uwzględniane żadne inne typy (takie jak interfejsy lub klasy bazowe). Sprzężenie jest tak małe, jak to możliwe.

Dalej