Memento Pattern trong C#: Snapshot Cho Undo và Rollback
Memento pattern trong C# / .NET 10: capture state thành snapshot immutable cho undo, rollback transactional, và time-travel debug bằng record và with.
Mục lục
- Memento pattern giải quyết bài toán gì trong C#?
- Memento giáo khoa trông thế nào?
- record với cho bạn Memento miễn phí thế nào?
- Khi nào nên persist snapshot Memento ngoài process?
- Khi nào Memento pattern bắn trật?
- So sánh Memento với Command và Prototype thế nào?
- Một ví dụ thật trong .NET 10 trông thế nào?
- Đọc tiếp gì trong series?
Tính năng cart preview: user bấm "apply discount", engine discount mutate cart in-place để hiện total sau giảm, UI hiện nút "Confirm" hoặc "Cancel". Cancel phải undo mọi mutation engine discount đã làm. Cách ngây thơ — mỗi module discount tự ghi diff — không sống nổi với discount lồng và coupon stacked. Cách sạch là chụp snapshot trước operation và khôi phục khi cancel.
Memento pattern là câu trả lời. Bắt state originator vào
snapshot mờ đục; để originator khôi phục từ snapshot sau. Trong
C# hiện đại snapshot gần như luôn là record và restore là một
dòng. Pattern là nền tảng của câu chuyện rộng hơn về undo/redo,
rollback transactional, và time-travel debug.
Memento pattern giải quyết bài toán gì trong C#?
Pattern xứng chỗ khi state phải sống sót qua một operation theo cách cho phép operation đảo hoặc bỏ. Ba hình dạng cụ thể:
- UI optimistic. Hiện kết quả hành động; cho "cancel"; khôi phục khi cancel.
- Rollback transactional. Operation nhiều bước fail giữa chừng; khôi phục về state lúc đầu.
- Undo / redo. Mỗi hành động user chụp snapshot; stack undo lưu chúng; redo replay hoặc reapply từ memento sau.
Cái không phải bài toán Memento: "Tôi muốn clone để dùng tiếp" — đó là Prototype. "Tôi muốn ghi chính bản thân hành động" — đó là Command. Memento đặc biệt nói về lưu state.
Memento giáo khoa trông thế nào?
Ba bên: originator (object có state), memento (type snapshot), và caretaker (object giữ memento nhưng không đọc).
public sealed class Cart
{
private readonly List<LineItem> _items = new();
private decimal _discountFactor = 1.0m;
public IReadOnlyList<LineItem> Items => _items;
public decimal DiscountFactor => _discountFactor;
public CartMemento Save()
=> new(_items.ToArray(), _discountFactor);
public void Restore(CartMemento m)
{
_items.Clear();
_items.AddRange(m.Items);
_discountFactor = m.DiscountFactor;
}
public void ApplyDiscount(decimal factor) => _discountFactor *= factor;
public void Add(LineItem item) => _items.Add(item);
}
public sealed record CartMemento(
IReadOnlyList<LineItem> Items,
decimal DiscountFactor);
Caretaker giữ memento; engine discount khôi phục khi cancel:
var snapshot = cart.Save();
try
{
cart.ApplyDiscount(0.9m);
cart.Add(new LineItem("FREE_GIFT", 0m, 1));
if (!await ConfirmAsync()) cart.Restore(snapshot);
}
catch
{
cart.Restore(snapshot);
throw;
}
Bức tranh cấu trúc:
sequenceDiagram
participant U as User
participant C as Caretaker (UI preview)
participant Cart as Originator (Cart)
participant M as Memento
C->>Cart: Save()
Cart-->>M: new CartMemento(...)
Cart-->>C: token memento
C->>Cart: ApplyDiscount(0.9)
C->>Cart: Add(FreeGift)
U->>C: Cancel
C->>Cart: Restore(memento)
Cart-->>Cart: state khôi phục
Caretaker không bao giờ đọc memento; chỉ giữ token và đưa lại khi cần.
record với cho bạn Memento miễn phí thế nào?
Cho hình dạng cart immutable, originator chính nó là record,
và memento chỉ là instance gốc:
public sealed record CartState(
IReadOnlyList<LineItem> Items,
decimal DiscountFactor);
public sealed class Cart
{
public CartState State { get; private set; }
= new CartState(Array.Empty<LineItem>(), 1.0m);
public CartState Save() => State; // memento *là* state
public void Restore(CartState s) => State = s;
public void ApplyDiscount(decimal factor)
=> State = State with { DiscountFactor = State.DiscountFactor * factor };
public void Add(LineItem item)
=> State = State with { Items = State.Items.Append(item).ToArray() };
}
Snapshot trở thành miễn phí trong thiết kế này — mọi mutation đã sinh record state mới. Save chỉ trả cái hiện tại; Restore ghi đè cái hiện tại. Cả pattern co lại còn hai method một dòng.
Khi nào nên persist snapshot Memento ngoài process?
Snapshot in-process là record; snapshot cross-process là data serialize. JSON là default an toàn:
public sealed class PersistentCart
{
private readonly IDistributedCache _cache;
private CartState _state = new(Array.Empty<LineItem>(), 1.0m);
public PersistentCart(IDistributedCache cache) => _cache = cache;
public async Task SaveAsync(string key, CancellationToken ct)
{
var json = JsonSerializer.Serialize(_state);
await _cache.SetStringAsync(key, json, ct);
}
public async Task RestoreAsync(string key, CancellationToken ct)
{
var json = await _cache.GetStringAsync(key, ct);
if (json is null) return;
_state = JsonSerializer.Deserialize<CartState>(json)!;
}
}
Ba thứ cần nghĩ khi serialize:
- Schema evolution. Snapshot hôm nay phải deserialize được sau
khi đổi type. Ưu tiên
[JsonInclude]trên property, tài liệu hoá[JsonPropertyName], và versioning envelope snapshot. - Size. Snapshot cart 10K dòng là to; cân nhắc lưu diff so với baseline known-good, hoặc dùng format binary-stable như Protobuf cho hot path.
- PII. Snapshot persist user data. Áp cùng rule retention với store chính; không để memento sống lâu hơn privacy policy.
Khi nào Memento pattern bắn trật?
Ba bẫy:
- Snapshot đắt mỗi action. Lưu deep clone state 10K-row mỗi keystroke giết CPU và memory. Lưu transactional (quanh operation cần rollback), không phải mỗi thay đổi.
- Reference cũ trong snapshot. Shallow snapshot lưu reference
List<>mà originator tiếp tục mutate. Restore từ snapshot như vậy không xác định. Ưu tiên state immutable (record,IReadOnlyList<>array) hoặc deep clone trước khi lưu. - Lạm dụng cho giao tiếp inter-service. Memento là khái niệm local. Đừng truyền chúng qua biên service như format truyền data; thiết kế DTO đúng cho việc đó.
So sánh Memento với Command và Prototype thế nào?
| Pattern | Bắt cái gì | Dùng cho | .NET hiện đại |
|---|---|---|---|
| Memento | Một snapshot state của object | Undo, rollback | record + with |
| Command | Một hành động với input | Queue, replay, audit | record + handler |
| Prototype | Một clone dùng tiếp | Tạo biến thể | record + with |
Tách rõ: Memento là state đã là gì; Command là chúng ta đã làm gì; Prototype là bản copy ta sẽ dùng. Undo/redo kết hợp Memento (state) với Command (timeline) — không cái nào đủ một mình.
Một ví dụ thật trong .NET 10 trông thế nào?
Cart undo/redo đầy đủ dùng record và stack memento cộng Command pattern cho timeline:
public sealed record CartState(IReadOnlyList<LineItem> Items, decimal DiscountFactor);
public sealed class Cart
{
public CartState State { get; private set; } =
new(Array.Empty<LineItem>(), 1.0m);
public CartState Save() => State;
public void Restore(CartState s) => State = s;
public void Add(LineItem item)
=> State = State with { Items = State.Items.Append(item).ToArray() };
public void ApplyDiscount(decimal factor)
=> State = State with { DiscountFactor = State.DiscountFactor * factor };
public decimal Total() => State.Items.Sum(i => i.UnitPrice * i.Quantity) * State.DiscountFactor;
}
public sealed class CartHistory
{
private readonly Stack<CartState> _undo = new();
private readonly Stack<CartState> _redo = new();
private readonly Cart _cart;
public CartHistory(Cart cart) => _cart = cart;
public void Execute(Action<Cart> action)
{
_undo.Push(_cart.Save());
action(_cart);
_redo.Clear();
}
public void Undo()
{
if (!_undo.TryPop(out var prev)) return;
_redo.Push(_cart.Save());
_cart.Restore(prev);
}
public void Redo()
{
if (!_redo.TryPop(out var next)) return;
_undo.Push(_cart.Save());
_cart.Restore(next);
}
}
// Caller
var cart = new Cart();
var history = new CartHistory(cart);
history.Execute(c => c.Add(new LineItem("BOOK", 9.99m, 2)));
history.Execute(c => c.Add(new LineItem("MUG", 12m, 1)));
history.Execute(c => c.ApplyDiscount(0.9m)); // giảm 10%
Console.WriteLine(cart.Total()); // 28.78
history.Undo(); // bỏ discount
Console.WriteLine(cart.Total()); // 31.98
history.Undo(); // bỏ mug
history.Redo(); // mang lại
Cái thiếu trong code: bất kỳ class "memento" tường minh. Record
CartState làm cả hai việc — value type và snapshot — và history
làm việc của caretaker. Pattern được giữ; boilerplate thì không.
Đọc tiếp gì trong series?
- Bài trước: Mediator — khi peer publish vào hub thay vì gọi nhau.
- Bài kế: Observer — khi publisher thông báo subscriber trực tiếp, không qua hub.
- Tham chiếu chéo: Command — đi cặp với Memento cho undo: Command là timeline, Memento là state mỗi điểm.
- Tham chiếu chéo: Prototype — cùng cơ chế clone, ý đồ khác.
- Cây quyết định: Cách chọn design pattern phù hợp.
Một ghi chú thực dụng: Memento là một trong ví dụ sạch nhất về
C# hiện đại làm pattern GoF gần như vô hình. Một record cộng
biểu thức with co originator + memento + caretaker thành "lưu
state, khôi phục state". Ý đồ pattern — lưu state mờ đục để khôi
phục — sống sót; hierarchy ba class giáo khoa thì không.
Câu hỏi thường gặp
Memento khác Prototype ở điểm gì?
Restore(memento) trên originator; Prototype thường không.Nên dùng record hay JSON cho snapshot Memento?
record khi state ở trong-process và nhỏ tới trung bình. Biểu thức with làm shallow copy miễn phí; deep copy cần with đệ quy. Dùng JSON serialize khi snapshot được persist (Redis, DB, file), đi giữa service, hoặc chứa graph mutable lồng. JSON round-trip chậm hơn nhưng chắc chắn.Memento kết hợp Command pattern cho undo thế nào?
ReversibleCommand chụp Memento trước khi execute, rồi Undo() khôi phục từ Memento. Cùng nhau cài undo/redo sạch: Command cho timeline, Memento cho state mỗi tick.Memento có phá encapsulation bằng cách expose state không?
internal constructor, property private trên record snapshot, hoặc class wrapper nhỏ chỉ expose type identity.