Hành vi Cơ bản 6 phút đọc

Observer Pattern trong C#: Event và IObservable

Observer pattern trong C# / .NET 10: thông báo subscriber khi state đổi qua event, IObservable, INotifyPropertyChanged, hay Channel — chọn đúng cái cho case.

Mục lục
  1. Observer pattern giải quyết bài toán gì trong C#?
  2. Từ khoá event C# cài Observer thế nào?
  3. Khi nào IObservable thay event trong C# hiện đại?
  4. Khi nào với INotifyPropertyChanged?
  5. So sánh Observer với Mediator và Command thế nào?
  6. Khi nào Observer pattern bắn trật?
  7. Một ví dụ thật trong .NET 10 trông thế nào?
  8. Đọc tiếp gì trong series?

UI cart hiện badge "(N items)" ở header. Mỗi lần cart đổi — add, remove, đổi quantity — badge phải refresh. Cách ngây thơ wire class cart biết về header view: coupling chặt phá unit test và chặn hai view cùng hiện một cart.

Observer pattern là câu trả lời. Cart raise event "changed". Ai quan tâm — badge, dòng total, module analytics — subscribe. Cart không bao giờ biết class của họ, chỉ biết ai đó đang nghe. Trong C# hiện đại đây là một trong những pattern mà ngôn ngữ cho bạn bốn tool khác nhau cho case hơi khác; bài này nói về chọn cái đúng.

Observer pattern giải quyết bài toán gì trong C#?

Pattern xứng chỗ khi state đổi ở một chỗ phải phản ánh ở zero hoặc nhiều chỗ khác, và chỗ đổi không nên biết ai khác quan tâm. Ba hình dạng cụ thể:

  1. UI binding. Property view-model đổi; một hoặc nhiều view bound update.
  2. Domain event. "Order shipped" phải đánh thức widget tracking, log audit row, có thể trigger webhook.
  3. Data streamed. Stock tick, sensor reading, log line. Producer emit; nhiều consumer subscribe với filter và rate riêng.

Cái không phải bài toán Observer: "Tôi muốn broadcast qua hub" — đó là Mediator. "Tôi muốn queue hành động cho replay" — đó là Command. Observer đặc biệt nói về thông báo trực tiếp, kiểu push.

Từ khoá event C# cài Observer thế nào?

Publisher giáo khoa với event typed:

public sealed class Cart
{
    public event EventHandler<CartChangedEventArgs>? Changed;

    private readonly List<LineItem> _items = new();

    public void Add(LineItem item)
    {
        _items.Add(item);
        Changed?.Invoke(this, new CartChangedEventArgs(ChangeKind.Added, item));
    }

    public void Remove(string sku)
    {
        var item = _items.FirstOrDefault(i => i.Sku == sku);
        if (item is null) return;
        _items.Remove(item);
        Changed?.Invoke(this, new CartChangedEventArgs(ChangeKind.Removed, item));
    }
}

public sealed record CartChangedEventArgs(ChangeKind Kind, LineItem Item);
public enum ChangeKind { Added, Removed, QuantityChanged }

Subscriber attach với += và detach với -=:

EventHandler<CartChangedEventArgs> handler = (sender, e) =>
    badge.Text = $"({((Cart)sender!).Items.Count} items)";
cart.Changed += handler;

// sau
cart.Changed -= handler;

Publisher không biết gì về badge. Nhiều subscriber làm cùng cách. Bức tranh cấu trúc:

flowchart LR
    Cart -->|raise Changed| H1[Badge handler]
    Cart -->|raise Changed| H2[Totals handler]
    Cart -->|raise Changed| H3[Analytics handler]

Quên -= giữ handler (và cái nó capture) sống cho cả lifetime cart. App chạy lâu với nhiều subscription đúng là chỗ leak này đau nhất.

Khi nào IObservable thay event trong C# hiện đại?

Reactive Extensions cho .NET (Rx.NET) cho bạn IObservable<T> — stream event compose bằng operator kiểu LINQ:

public sealed class Cart
{
    private readonly Subject<CartChangedEventArgs> _changes = new();
    public IObservable<CartChangedEventArgs> Changes => _changes;

    public void Add(LineItem item)
    {
        // ...
        _changes.OnNext(new CartChangedEventArgs(ChangeKind.Added, item));
    }
}

// Subscriber: throttle, filter, group
using IDisposable sub = cart.Changes
    .Throttle(TimeSpan.FromMilliseconds(100))     // gập edit nhanh
    .Where(e => e.Kind != ChangeKind.QuantityChanged)
    .Subscribe(e => Console.WriteLine($"{e.Kind}: {e.Item.Sku}"));

Subscribe trả IDisposable; Dispose() là call unsubscribe. Bề mặt leak co lại vì cleanup là pattern mức ngôn ngữ (using) thay vì kỷ luật.

Ba lý do ưu tiên IObservable<T> hơn event:

Khi nào với INotifyPropertyChanged?

INotifyPropertyChanged là vị data-binding của Observer. Publisher thường là view-model; subscriber là engine binding của WPF, WinUI, hay MAUI:

public sealed class CartViewModel : INotifyPropertyChanged
{
    private int _itemCount;
    public int ItemCount
    {
        get => _itemCount;
        set
        {
            if (_itemCount == value) return;
            _itemCount = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ItemCount)));
        }
    }
    public event PropertyChangedEventHandler? PropertyChanged;
}

Binding XAML <TextBlock Text="{Binding ItemCount}" /> listen PropertyChanged với propertyName == "ItemCount" và refresh visual. Bạn gần như không bao giờ viết view XAML gọi Subscribe — framework làm; bạn chỉ implement interface. Alternative hiện đại như CommunityToolkit.Mvvm sinh boilerplate từ attribute [ObservableProperty].

So sánh Observer với Mediator và Command thế nào?

Pattern Hub? Hướng .NET hiện đại
Observer Không Một publisher → nhiều subscriber, trực tiếp event, IObservable<T>, Channel<T>
Mediator Bắt buộc Nhiều ↔ nhiều qua hub IMediator.Publish của MediatR
Command Dispatcher optional Một sender → một handler IRequest<T> + IRequestHandler<T,U>

Câu rõ nhất: Observer là push, trực tiếp; Mediator là push qua hub; Command là send-and-receive đúng một lần. Ranh giới giữa Observer và Mediator là khả năng phát hiện — subscriber Mediator đăng ký với bên thứ ba; subscriber Observer đăng ký với publisher.

Khi nào Observer pattern bắn trật?

Ba bẫy:

Một ví dụ thật trong .NET 10 trông thế nào?

Kết hợp event cho case đơn giản và IObservable<T> (qua Subject<T>) cho case streamed trên cùng cart:

public enum ChangeKind { Added, Removed, QuantityChanged }
public sealed record CartChange(ChangeKind Kind, LineItem Item, int NewItemCount);

public sealed class Cart
{
    private readonly List<LineItem> _items = new();
    private readonly Subject<CartChange> _stream = new();

    public IReadOnlyList<LineItem> Items => _items;
    public IObservable<CartChange> Changes => _stream;

    public void Add(LineItem item)
    {
        _items.Add(item);
        _stream.OnNext(new CartChange(ChangeKind.Added, item, _items.Count));
    }

    public void Remove(string sku)
    {
        var item = _items.FirstOrDefault(i => i.Sku == sku);
        if (item is null) return;
        _items.Remove(item);
        _stream.OnNext(new CartChange(ChangeKind.Removed, item, _items.Count));
    }
}

// Subscriber A - badge UI update mỗi thay đổi
IDisposable badgeSub = cart.Changes.Subscribe(c =>
    Badge.Text = $"({c.NewItemCount} items)");

// Subscriber B - analytics, throttle và filter
IDisposable analyticsSub = cart.Changes
    .Where(c => c.Kind == ChangeKind.Added)
    .Throttle(TimeSpan.FromSeconds(1))
    .Subscribe(c => analytics.Track("add_to_cart", c.Item.Sku));

// Cleanup
badgeSub.Dispose();
analyticsSub.Dispose();

// Test
[Fact]
public void Adding_item_pushes_change()
{
    var cart = new Cart();
    var seen = new List<CartChange>();
    using var sub = cart.Changes.Subscribe(seen.Add);
    cart.Add(new LineItem("BOOK", 9.99m, 2));
    Assert.Single(seen);
    Assert.Equal(ChangeKind.Added, seen[0].Kind);
}

Cart không biết về badge hay module analytics. Mỗi subscriber sở hữu lifetime của mình qua IDisposable trả về. Thêm subscriber thứ ba là một call Subscribe mới.

Đọc tiếp gì trong series?

Một ghi chú thực dụng: Observer pattern trong .NET hiện đại chia bốn tool, và chọn đúng cái là phần lớn việc. event cho in-process đơn giản, IObservable<T> cho transformation streamed, INotifyPropertyChanged cho binding, Channel<T> cho producer-consumer có backpressure. Chọn đúng thì code còn lại rơi vào chỗ; sai thì bạn đánh nhau với framework.

Câu hỏi thường gặp

Khi nào dùng event so với IObservable trong C#?
Dùng event C# cho thông báo in-process, đồng bộ, tần suất thấp khi subscriber sống ngắn và biết theo lifetime publisher. Dùng IObservable<T> (Rx.NET) khi thông báo tạo stream bạn muốn filter, throttle, buffer, hoặc combine — Rx cho LINQ trên event. Dùng Channel<T> khi cần backpressure giữa producer và một hoặc nhiều consumer.
INotifyPropertyChanged có phải Observer pattern không?
Đúng — INotifyPropertyChanged là Observer chuyên cho thông báo thay đổi property-level, đó là cái framework data-binding (WPF, WinUI, MAUI) listen. Publisher là view-model; subscriber là binding system. Cùng pattern, với signature event cố định (PropertyChanged) và string property-name argument.
Observer khác Mediator thế nào?
Mediator route message qua hub trung tâm mà subscriber đăng ký. Observer trực tiếp: publisher tự sở hữu list subscriber (field event C#). Mediator nhiều-nhiều qua bên thứ ba; Observer một publisher tới nhiều subscriber. Nếu bên thứ ba không nên biết về cả hai phía, bạn cần Mediator.
Cách đúng để unsubscribe khỏi event C# là gì?
Match mỗi += với một -= trên cùng instance delegate. Nguồn memory leak lớn nhất trong app .NET chạy lâu là quên subscription event: publisher giữ subscriber sống mãi. Cho Rx.NET, Subscribe() trả IDisposable — dispose để unsubscribe. Cho subscription một-lần, có pattern WeakReference hoặc helper weak-event, nhưng khó làm đúng hơn -= kỷ luật.