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
- Observer pattern giải quyết bài toán gì trong C#?
- Từ khoá event C# cài Observer thế nào?
- Khi nào IObservable thay event trong C# hiện đại?
- Khi nào với INotifyPropertyChanged?
- So sánh Observer với Mediator và Command thế nào?
- Khi nào Observer pattern bắn trật?
- Một ví dụ thật trong .NET 10 trông thế nào?
- Đọ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ể:
- UI binding. Property view-model đổi; một hoặc nhiều view bound update.
- Domain event. "Order shipped" phải đánh thức widget tracking, log audit row, có thể trigger webhook.
- 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:
- Compose.
Throttle,Buffer,CombineLatest,DistinctUntilChanged— operator biến thông báo thô thành hành vi hữu ích mà không cần phát minh class. - Vệ sinh lifetime.
IDisposableđược type ép;usingvà DI quản lý được. - Backpressure (với
Channel<T>). Trong khi Rx chỉ-push,System.Threading.Channels.Channel<T>thêm queue có giới hạn giữa producer và consumer.
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:
- Memory leak từ quên
-=. Bug production phổ biến nhất cho service .NET chạy lâu. DùngIDisposableRx trả, hoặc helper weak-event, hoặc tài liệu hoá bước unsubscribe tường minh trong lifecycle. - Phụ thuộc thứ tự handler. Hai handler cùng edit state share sẽ va chạm. Thứ tự là thứ tự đăng ký trong event nhưng không bảo đảm qua mọi vị. Làm handler độc lập.
- Reentrancy. Handler raise change khác trên publisher bên
trong handler. Hoặc cấm reentrancy (
if (_inEvent) return;) hoặc tài liệu hoá hợp đồng đệ quy.
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?
- Bài trước: Memento — snapshot state cho undo và rollback.
- Bài kế: State — khi hành vi object phụ thuộc state nào nó đang ở.
- Tham chiếu chéo: Mediator — khi thông báo đi qua hub thay vì trực tiếp tới subscriber.
- Tham chiếu chéo: Iterator — khi consumer pull thay vì được push.
- Cây quyết định: Cách chọn design pattern phù hợp.
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#?
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?
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?
Cách đúng để unsubscribe khỏi event C# là gì?
+= 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.