Udostępnij za pośrednictwem


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 w tym konkretnym celu należy podać implementację delegata.

Prototyp metody Where jest następujący:

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

Rozwińmy ten przykład, tworząc składnik przy użyciu projektu, który wykorzystuje delegaty.

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, plik. 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ć zarówno niewirtualną, jak i zapieczętowaną klasą. Możesz podłączyć dowolny zestaw delegatów, aby zapisywać komunikaty na różnych nośnikach pamięci. 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 na konsoli.

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;

Praktyk

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ą instancji. Może mieć dowolny 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);
    }
}

Praktyk

Dodałeś nowe funkcje do infrastruktury rejestrowania. Ponieważ składnik rejestratora jest bardzo luźno powiązany z dowolnym mechanizmem wyjściowym, nowe funkcje można dodać bez wpływu na kod implementujący delegata rejestratora.

Kontynuując budowę tego, zobaczysz więcej przykładów, jak luźne sprzężenie zapewnia większą elastyczność w aktualizowaniu części serwisu bez zmiany innych części. W rzeczywistości w większej aplikacji klasy wyjściowe rejestratora mogą znajdować się w innym zestawie, a nawet nie trzeba ich ponownie skompilować.

Budowanie drugiego silnika wyjściowego

Składnik logu rozwija się dobrze. Dodajmy jeszcze jeden silnik wyjściowy, który rejestruje komunikaty w pliku. Będzie to nieco bardziej zaangażowany mechanizm 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 ją zainstancjować i dołączyć jej metodę LogMessage do składnika Rejestrator.

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

Te dwa nie wykluczają się wzajemnie. Możesz jednocześnie używać obu metod logowania, aby generować komunikaty wyświetlane w konsoli oraz zapisywane w 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;

Praktyk

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ą instancji. 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.

Na zakończenie, rejestrator plików musi zarządzać swoimi zasobami, otwierając i zamykając plik przy każdej wiadomości logu. 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 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ń. Możesz woleć projekt, który dyskretnie kontynuuje działanie, gdy nie zostały dołączone żadne metody. Jest to łatwe dzięki użyciu operatora warunkowego o wartości null, w połączeniu z metodą Delegate.Invoke().

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

Zerowy operator warunkowy (?.) przerywa działanie, gdy lewy operand (WriteMessage w tym przypadku) ma wartość null, co oznacza, że nie podejmowana jest próba zapisania komunikatu.

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

Podsumowanie praktyk

Widziałeś już początki składnika dziennika, który można rozbudować o innych autorów i inne funkcje. 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 pisarza polega na zaimplementowaniu jednej metody. Ta metoda może być metodą statyczną lub metodą instancji. Może to być publiczny, prywatny lub inny dostęp prawny.

Klasa Logger może wprowadzać dowolną liczbę ulepszeń lub zmian bez wprowadzania zmian powodujących problemy z kompatybilnością. Jak każda klasa, nie można modyfikować publicznego interfejsu API bez ryzyka zmian kompatybilności. Jednak ponieważ połączenie pomiędzy rejestratorem a dowolnymi silnikami 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