Observer w C# i .NET - implementacja, events i Rx.NET

Lista języków i bibliotek, gdzie można nauczyć się, jak zrobić observer: Python, Javascript, PHP, Underscore.js, Lodash.js, LINQ, Guava.

Spis treści

Gdy jedna zmiana w aplikacji ma odświeżyć interfejs, zapisać log, wyczyścić cache i uruchomić dodatkową akcję, szybko pojawia się problem z nadmiarem zależności. W tym artykule pokazuję, jak zrobić observera w C# i .NET, kiedy użyć własnych interfejsów, kiedy wystarczą zdarzenia oraz jak uniknąć wycieków pamięci i trudnych do śledzenia błędów. Znajdziesz tu gotowe przykłady kodu, porównanie podejść i praktyczne wskazówki dotyczące użycia wzorca w aplikacjach .NET.

Observer upraszcza powiadamianie wielu obiektów o jednej zmianie

  • Publisher przechowuje obserwatorów i informuje ich o zmianie stanu.
  • Najprostsza implementacja korzysta z metod Subscribe, Unsubscribe i Update.
  • W C# często wystarczą events, czyli wbudowana forma komunikacji obserwatorów.
  • Każda subskrypcja powinna mieć bezpieczne wyrejestrowanie, szczególnie w długowiecznych usługach.
  • Do strumieni asynchronicznych, filtrowania i łączenia zdarzeń lepiej pasuje Rx.NET niż ręcznie pisana lista obserwatorów.

Diagram pokazuje, jak zrobić observer: Subject, Observer i ChangeManager. Subject powiadamia Observerów o zmianach.

Czym jest Observer i jaki problem rozwiązuje

Observer to behawioralny wzorzec projektowy, w którym jeden obiekt publikuje informacje o zmianach, a wiele innych obiektów może je odbierać. Najważniejsze jest to, że publisher nie musi znać szczegółów działania subskrybentów. Wie tylko, że powinien przekazać im komunikat.

Dobrym przykładem jest stacja pogodowa. Gdy zmienia się temperatura, informację może odebrać wyświetlacz, moduł zapisujący dane do bazy i alarm informujący o przekroczeniu limitu. Bez Observera klasa stacji musiałaby znać wszystkie te komponenty i wywoływać ich metody bezpośrednio. To szybko prowadzi do sztywnego kodu, który trudno rozwijać.

Wzorzec składa się zwykle z dwóch ról:

  • Subject albo publisher przechowuje listę obserwatorów i wysyła powiadomienia.
  • Observer albo subscriber reaguje na otrzymaną informację.

Observer ma sens wtedy, gdy liczba odbiorców może się zmieniać albo gdy chcesz dodawać nowe reakcje bez edytowania głównego obiektu. Nie stosowałbym go jednak do każdej komunikacji. Jeśli obiekt ma zawsze dokładnie jednego odbiorcę, bezpośrednie wywołanie metody będzie czytelniejsze i tańsze w utrzymaniu.

Jak zaimplementować Observera ręcznie w C#

Najlepiej zacząć od małego przykładu, bo sedno wzorca jest proste. Zdefiniujmy dane pogodowe, interfejs obserwatora oraz interfejs obiektu, który pozwala na subskrypcję i jej zakończenie.

public sealed record WeatherReading(decimal Temperature, int Humidity);

public interface IWeatherObserver
{
    void Update(WeatherReading reading);
}

public interface IWeatherSubject
{
    void Subscribe(IWeatherObserver observer);
    void Unsubscribe(IWeatherObserver observer);
}

Klasa publikująca przechowuje obserwatorów w kolekcji. Przy zmianie danych wykonuje ich metody Update. Kopia listy w metodzie SetReading jest celowa. Dzięki niej obserwator może wypisać się w trakcie powiadamiania bez modyfikowania kolekcji, po której właśnie iterujemy.

public sealed class WeatherStation : IWeatherSubject
{
    private readonly List _observers = new();
    private WeatherReading? _currentReading;

    public void Subscribe(IWeatherObserver observer)
    {
        ArgumentNullException.ThrowIfNull(observer);

        if (!_observers.Contains(observer))
        {
            _observers.Add(observer);
        }
    }

    public void Unsubscribe(IWeatherObserver observer)
    {
        _observers.Remove(observer);
    }

    public void SetReading(WeatherReading reading)
    {
        _currentReading = reading;

        foreach (var observer in _observers.ToArray())
        {
            observer.Update(reading);
        }
    }
}

Teraz możemy dodać dwa niezależne zachowania. Jedno wyświetli temperaturę, a drugie zareaguje na zbyt wysoką wartość. Stacja pogodowa nie musi wiedzieć, czym różnią się te klasy. Dla niej każda implementacja ma po prostu metodę Update.

public sealed class ConsoleDisplay : IWeatherObserver
{
    public void Update(WeatherReading reading)
    {
        Console.WriteLine(
            $"Temperatura: {reading.Temperature}°C, wilgotność: {reading.Humidity}%");
    }
}

public sealed class TemperatureAlert : IWeatherObserver
{
    private readonly decimal _limit;

    public TemperatureAlert(decimal limit)
    {
        _limit = limit;
    }

    public void Update(WeatherReading reading)
    {
        if (reading.Temperature > _limit)
        {
            Console.WriteLine($"Alarm. Przekroczono {_limit}°C.");
        }
    }
}

var station = new WeatherStation();
var display = new ConsoleDisplay();
var alert = new TemperatureAlert(30);

station.Subscribe(display);
station.Subscribe(alert);

station.SetReading(new WeatherReading(32.5m, 45));

station.Unsubscribe(alert);
station.SetReading(new WeatherReading(21.2m, 60));

Ten wariant dobrze pokazuje klasyczną strukturę wzorca. Obserwator sam nie pyta regularnie o stan stacji, tylko dostaje powiadomienie wtedy, gdy coś się zmieni. To model typu push. Alternatywą jest model pull, w którym publisher informuje jedynie o zmianie, a obserwator pobiera szczegóły przez osobną metodę. Push jest prostszy, ale pull może ograniczyć ilość danych przekazywanych w komunikacie.

Events są naturalną odmianą Observera w .NET

W codziennym kodzie C# rzadko trzeba tworzyć własne interfejsy obserwatorów. Język ma mechanizm zdarzeń, który realizuje ten sam pomysł w bardziej idiomatyczny sposób. Publisher wystawia zdarzenie, a odbiorca dołącza handler za pomocą operatora +=.

public sealed class TemperatureChangedEventArgs : EventArgs
{
    public TemperatureChangedEventArgs(decimal temperature, int humidity)
    {
        Temperature = temperature;
        Humidity = humidity;
    }

    public decimal Temperature { get; }
    public int Humidity { get; }
}

public sealed class WeatherStation
{
    public event EventHandler? ReadingChanged;

    public void SetReading(decimal temperature, int humidity)
    {
        var args = new TemperatureChangedEventArgs(temperature, humidity);
        ReadingChanged?.Invoke(this, args);
    }
}

Subskrypcja wygląda krótko i czytelnie:

var station = new WeatherStation();

void DisplayReading(
    object? sender,
    TemperatureChangedEventArgs args)
{
    Console.WriteLine($"{args.Temperature}°C, {args.Humidity}%");
}

station.ReadingChanged += DisplayReading;
station.SetReading(24.5m, 52);
station.ReadingChanged -= DisplayReading;

W praktyce events są świetnym wyborem dla komunikacji wewnątrz jednej aplikacji, szczególnie w interfejsach użytkownika, modelach domenowych i komponentach infrastruktury. Nie rozwiążą jednak komunikacji między procesami. Jeśli zdarzenie ma przejść przez granicę aplikacji, potrzebujesz brokera wiadomości lub usługi pub/sub, a nie zwykłego eventu w pamięci.

Rozwiązanie Kiedy użyć Największa zaleta Ograniczenie
Własny Observer Gdy potrzebujesz wyraźnego kontraktu i pełnej kontroli Prosta, testowalna struktura Więcej kodu do utrzymania
Event w C# Gdy komunikacja odbywa się lokalnie w aplikacji Naturalna składnia i mały narzut Łatwo zapomnieć o wypisaniu handlera
IObservable Gdy chcesz korzystać ze standardowych interfejsów .NET Ujednolicony model subskrypcji Wymaga zrozumienia cyklu życia obserwacji
Rx.NET Gdy łączysz, filtrujesz i opóźniasz strumienie danych Bogaty model operatorów Większa złożoność dla prostych przypadków

IObservable i Rx.NET dla bardziej złożonych strumieni

.NET udostępnia interfejsy IObservable i IObserver. Standardowy obserwator ma metody OnNext, OnError oraz OnCompleted. Dzięki temu komunikacja może przekazywać nie tylko kolejne wartości, ale także informację o błędzie i zakończeniu strumienia.

public sealed class TemperatureObserver : IObserver
{
    public void OnNext(decimal value)
    {
        Console.WriteLine($"Nowa temperatura: {value}°C");
    }

    public void OnError(Exception error)
    {
        Console.WriteLine($"Błąd odczytu: {error.Message}");
    }

    public void OnCompleted()
    {
        Console.WriteLine("Odczyt temperatury zakończony.");
    }
}

Metoda Subscribe zwraca obiekt IDisposable. Jego wywołanie kończy subskrypcję, co jest bezpieczniejszym modelem niż ręczne pilnowanie dwóch niezależnych metod. W aplikacjach działających długo, na przykład w usługach ASP.NET Core, takie zarządzanie cyklem życia ma duże znaczenie.

Jeżeli musisz odfiltrować wartości, połączyć kilka źródeł, reagować z opóźnieniem albo kontrolować pracę asynchronicznych strumieni, rozważ Reactive Extensions. Rx.NET oferuje operatory takie jak filtrowanie, mapowanie, grupowanie i debouncing. Do pojedynczego powiadomienia byłby jednak przesadą. W prostym przypadku event pozostaje bardziej zrozumiały dla zespołu.

Najczęstsze pułapki przy użyciu Observera

Brak wyrejestrowania obserwatora

Publisher przechowuje referencję do subskrybenta. Jeśli subskrybent powinien już zniknąć, ale nadal jest zapisany na liście albo do eventu, garbage collector może nie zwolnić jego pamięci. Najczęściej widać to w widokach, timerach, singletonach i komponentach działających przez cały czas życia aplikacji.

Moja praktyczna zasada jest prosta. Jeśli obiekt sam kontroluje swój cykl życia, powinien również kontrolować moment wypisania z obserwacji. W interfejsie użytkownika może to być metoda zamykająca widok, a w usłudze ASP.NET Core mechanizm CancellationToken lub implementacja IAsyncDisposable.

Wyjątek jednego obserwatora zatrzymuje wszystkie powiadomienia

Jeżeli pierwszy obserwator rzuci wyjątek, zwykła pętla może przerwać wysyłanie komunikatu do pozostałych. To bywa właściwe dla operacji krytycznej, ale często jest niepożądane w logowaniu, telemetrii czy odświeżaniu interfejsu. Zdecyduj, czy błąd ma przerwać całą publikację, czy powinien zostać odizolowany i zapisany.

Nie ukrywałbym jednak wyjątków bez śladu. Jeśli publisher przechwytuje błędy, powinien przekazać je do logera, metryki albo osobnego kanału diagnostycznego. Cicha utrata powiadomienia jest zwykle trudniejsza do znalezienia niż sam wyjątek.

Nieprzewidywalna kolejność i reentrancy

Observer nie gwarantuje automatycznie kolejności wykonania ani tego, że obserwatorzy są bezstanowi. Jeśli jeden handler zmienia dane publishera i wywołuje kolejne powiadomienie, możesz stworzyć rekurencję albo trudny do odtworzenia cykl. Pomaga tu blokada reentrantnych aktualizacji, kolejka zdarzeń lub jasna zasada, że handler nie modyfikuje źródła podczas obsługi komunikatu.

Przeczytaj również: Architektura monolityczna w .NET - kiedy ma sens?

Wysyłanie zbyt dużych obiektów

Powiadomienie nie powinno przekazywać całego agregatu domenowego tylko dlatego, że jest to wygodne. Lepiej wysłać mały, niemutowalny rekord opisujący zmianę. Dzięki temu obserwatorzy są mniej zależni od modelu źródłowego, a komunikaty łatwiej testować.

Gdzie Observer pasuje do aplikacji .NET

Najbardziej naturalne miejsce dla tego wzorca to sytuacja, w której jedna zmiana ma kilka niezależnych konsekwencji. Przykładowo zmiana statusu zamówienia może uruchomić aktualizację widoku, wpis do dziennika audytowego, przeliczenie uprawnień i wysłanie powiadomienia. Każda reakcja może być osobnym obserwatorem, więc główny model nie musi zawierać całej logiki pobocznej.

  • Interfejs użytkownika może odświeżać widok po zmianie modelu.
  • Cache może usuwać nieaktualne dane po modyfikacji encji.
  • Telemetria może obserwować zdarzenia biznesowe i wysyłać metryki.
  • Moduł AI może reagować na nowe dokumenty lub zmianę statusu zadania.
  • Warstwa domenowa może publikować zdarzenia bez znajomości infrastruktury.

Trzeba odróżnić lokalnego Observera od architektury publish-subscribe działającej między usługami. Event w pamięci znika razem z procesem i nie daje sam z siebie gwarancji dostarczenia. Gdy komunikacja ma działać między aplikacjami, potrzebujesz dodatkowo trwałości, ponawiania, idempotencji i obsługi niedostępności odbiorcy.

Przy projektowaniu sprawdzam przede wszystkim trzy rzeczy. Po pierwsze, czy publisher rzeczywiście nie powinien znać odbiorców. Po drugie, czy liczba reakcji będzie rosła. Po trzecie, czy komunikat musi być dostarczony niezależnie od tego, czy konkretny obserwator chwilowo działa. Odpowiedzi na te pytania zwykle pokazują, czy wystarczy event, czy potrzebny jest broker wiadomości.

Jak podejść do Observera, żeby nie stworzyć ukrytej magistrali

Observer jest małym wzorcem, ale potrafi mocno wpłynąć na architekturę. Zacznij od prostego eventu albo niewielkiego interfejsu, nazwij komunikat konkretnie i zadbaj o jawny cykl życia subskrypcji. Największą wartość daje rozdzielenie odpowiedzialności, a nie sama liczba klas w projekcie.

Jeśli powiadomienie jest lokalne, synchroniczne i ma kilku odbiorców, events lub własny Observer będą dobrym rozwiązaniem. Gdy pojawiają się filtry, opóźnienia i strumienie asynchroniczne, wybierz IObservable albo Rx.NET. A jeśli wiadomość ma przekraczać granicę procesu, potraktuj Observera jako inspirację, lecz użyj rozwiązania zapewniającego trwałe dostarczenie i kontrolę błędów.

FAQ - Najczęstsze pytania

Własny Observer sprawdzi się, gdy potrzebujesz wyraźnego kontraktu, metod Subscribe i Unsubscribe oraz pełnej kontroli nad strukturą. Events są naturalniejszym wyborem dla lokalnej komunikacji wewnątrz jednej aplikacji, na przykład w interfejsie użytkownika, modelu domenowym lub komponencie infrastruktury.

Subskrybent powinien wypisać się z listy obserwatorów lub eventu, gdy kończy się jego cykl życia. W długowiecznych usługach pomocne są IDisposable zwracane przez Subscribe, CancellationToken oraz IAsyncDisposable, ponieważ publisher przechowuje referencję do subskrybenta.

IObservable zapewnia obsługę wartości przez OnNext, błędów przez OnError i zakończenia strumienia przez OnCompleted, a Subscribe zwraca IDisposable. Rx.NET warto rozważyć przy filtrowaniu, łączeniu, opóźnianiu i obsłudze asynchronicznych strumieni; dla pojedynczego prostego powiadomienia będzie zwykle zbyt złożony.

Nie, zwykły event działa tylko w pamięci jednego procesu i znika wraz z aplikacją. Komunikacja między usługami wymaga brokera wiadomości lub rozwiązania pub/sub z trwałością, ponawianiem, idempotencją i obsługą niedostępności odbiorcy.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

observer
zdarzenia
subskrypcje
rx.net
iobservable
Autor Przemysław Kwiatkowski
Przemysław Kwiatkowski
Jestem Przemysław i od 15 lat zajmuję się programowaniem .NET, chmurą Azure oraz sztuczną inteligencją. Moja przygoda z tymi technologiami zaczęła się od fascynacji możliwościami, jakie dają, a z czasem przerodziła się w pasję do tworzenia rozwiązań, które realnie wpływają na pracę i życie ludzi. Na kursdotnet.pl staram się dzielić się swoją wiedzą w sposób przystępny, tłumacząc złożone zagadnienia i pomagając zrozumieć, jak te dynamicznie rozwijające się obszary IT mogą być wykorzystane w praktyce. Dokładam wszelkich starań, aby prezentowane przeze mnie materiały były rzetelne, aktualne i oparte na sprawdzonych źródłach, a także aby uporządkować wiedzę w sposób ułatwiający jej przyswojenie.

Udostępnij artykuł

Napisz komentarz