Hành vi Trung bình 7 phút đọc

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
  1. Memento pattern giải quyết bài toán gì trong C#?
  2. Memento giáo khoa trông thế nào?
  3. record với cho bạn Memento miễn phí thế nào?
  4. Khi nào nên persist snapshot Memento ngoài process?
  5. Khi nào Memento pattern bắn trật?
  6. So sánh Memento với Command và Prototype thế nào?
  7. Một ví dụ thật trong .NET 10 trông thế nào?
  8. Đọ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ể:

  1. UI optimistic. Hiện kết quả hành động; cho "cancel"; khôi phục khi cancel.
  2. Rollback transactional. Operation nhiều bước fail giữa chừng; khôi phục về state lúc đầu.
  3. 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:

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

Ba bẫy:

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?

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ì?
Prototype clone object để tạo instance mới dùng tiếp. Memento clone object để tạo snapshot mà bạn có thể vứt hoặc dùng khôi phục bản gốc sau. Cùng cơ chế (deep hay shallow copy); ý đồ khác. Memento đi cặp với method Restore(memento) trên originator; Prototype thường không.
Nên dùng record hay JSON cho snapshot Memento?
Dùng snapshot 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?
Command xử làm gì; Memento xử nhớ state nào. Một 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?
Thiết kế đúng thì không. Memento là Memento, không phải nội bộ originator. Originator là class duy nhất đọc được nội dung Memento; caller ngoài chỉ mang token mờ đục. Trong C# hiện đại đạt được bằng internal constructor, property private trên record snapshot, hoặc class wrapper nhỏ chỉ expose type identity.