Skip to content

备忘录模式 (Memento) —— 游戏存档,随时回档

前言

在深入探讨备忘录模式之前,想向大家推荐一个非常棒的开源项目:design-patterns-23。这个项目用最现代的技术栈重写了 23 种设计模式,非常适合实战学习,还配套了在线交互演示站,可以边读文章边动手玩。本文的实战案例灵感也来源于此。

1. 备忘录模式是什么?

备忘录模式(Memento Pattern),也称为快照模式(Snapshot Pattern),是一种行为型设计模式。它允许在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后当需要时能将该对象恢复到原先保存的状态。

简单来说,备忘录模式就是给你的对象吃一颗“后悔药”。

1.1 核心概念

备忘录模式包含三个核心角色:

  • Originator(发起人):记录当前时刻的内部状态,负责定义哪些属于备份范围的状态,负责创建和恢复备忘录数据。
  • Memento(备忘录):负责存储 Originator 对象的内部状态,在需要的时候提供这些内部状态给 Originator。
  • Caretaker(管理者):对备忘录进行管理,保存和提供备忘录,但不能对备忘录的内容进行访问或者修改。

1.2 为什么我们需要它?

场景: 想象你在写文档,写了一大段突然发现写偏了,想回到十分钟前的状态。或者你在玩 RPG 游戏,打 Boss 前存了个档,打输了读档重来。

痛点: 如果想恢复状态,最直接的方法是让对象提供 getState()setState()。但是:

  1. 破坏封装:为了备份,你必须把对象内部所有的私有属性(包括一些不该暴露的中间状态)都暴露给外部。
  2. 职责混乱:由外部类(比如 Client)来负责记录对象的状态,会导致 Client 代码极其臃肿,且 Client 必须知道对象内部的细节。

解决方案: 备忘录模式通过将状态的存储和恢复逻辑封装在 MementoOriginator 内部,外部 Caretaker 只负责保管“盒子”,而无法窥探盒子内部的细节(即“黑箱”操作),从而完美解决了上述问题。

2. 备忘录模式的两种实现:白箱 vs 黑箱

备忘录模式的核心在于如何保证封装性。根据备忘录对外部的开放程度,分为“白箱”和“黑箱”两种实现。

2.1 白箱备忘录(White-box Memento)

白箱模式下,备忘录类(Memento)对所有类都是可见的。虽然实现简单,但它破坏了封装性,因为 Caretaker 可以随意修改 Memento 中的状态。

代码示例

java
// 备忘录:简单的 POJO,状态对外暴露
public class Memento {
    private String state;

    public Memento(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }

    public void setState(String state) {
        this.state = state;
    }
}

// 发起人
public class Originator {
    private String state;

    public void setState(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }

    public Memento saveStateToMemento() {
        return new Memento(state);
    }

    public void getStateFromMemento(Memento memento) {
        this.state = memento.getState();
    }
}

// 管理者
public class Caretaker {
    private Memento memento;

    public void setMemento(Memento memento) {
        this.memento = memento;
    }

    public Memento getMemento() {
        return memento;
    }
}

缺点Caretaker 获取到 Memento 后,可以随意修改 state,这可能会导致 Originator 恢复到一个错误的状态。

2.2 黑箱备忘录(Black-box Memento)—— 推荐做法

黑箱模式是备忘录模式的标准实现。它通过将 Memento 定义为 Originator私有内部类,或者使用接口标记,使得只有 Originator 能访问 Memento 的内部状态,而 Caretaker 只能看到一个空的接口(标识接口)。

代码结构

  1. 定义一个窄接口 IMemento,对外提供(Caretaker 只能看到这个)。
  2. Originator 内部定义私有静态内部类 Memento 实现 IMemento,存储实际状态。

代码实现

java
// 1. 窄接口:对外只是一个标识,没有任何方法
public interface IMemento {
}

// 2. 发起人:包含内部类
public class Originator {
    private String state;

    public void setState(String state) {
        this.state = state;
        System.out.println("当前状态:" + state);
    }

    // 创建备忘录:返回的是接口,但实际上是内部类实例
    public IMemento save() {
        return new Memento(state);
    }

    // 恢复备忘录
    public void restore(IMemento memento) {
        if (!(memento instanceof Memento)) {
            throw new IllegalArgumentException("Unknown memento class");
        }
        this.state = ((Memento) memento).state;
    }

    // 私有内部类:真正的备忘录,只有 Originator 能访问
    private static class Memento implements IMemento {
        private String state;

        private Memento(String state) {
            this.state = state;
        }
    }
}

// 3. 管理者
public class Caretaker {
    // 只能持有 IMemento 接口,无法访问内部属性
    private IMemento memento;

    public void saveMemento(IMemento memento) {
        this.memento = memento;
    }

    public IMemento retrieveMemento() {
        return this.memento;
    }
}

// 4. 客户端
public class Client {
    public static void main(String[] args) {
        Originator originator = new Originator();
        Caretaker caretaker = new Caretaker();

        // 状态 1
        originator.setState("State #1");
        // 状态 2
        originator.setState("State #2");
        
        // 存档
        caretaker.saveMemento(originator.save());

        // 状态 3
        originator.setState("State #3");

        // 读档
        originator.restore(caretaker.retrieveMemento());
        System.out.println("恢复后的状态:" + originator.save()); // 这里只是为了验证,实际 save 返回的是对象
    }
}

优点

  • 安全性高Caretaker 拿到的是 IMemento 接口,无法访问 state,也无法修改。
  • 封装性好Memento 的具体实现细节完全被隐藏在 Originator 内部。

3. 实战案例:文本编辑器撤销功能

我们来模拟一个更复杂的场景:一个简单的文本编辑器,支持输入文本、修改光标位置,并支持无限步撤销(Undo)。

3.1 需求分析

  • 编辑器属性:content (文本内容), cursorPosition (光标位置)。
  • 操作:输入文字,移动光标。
  • 撤销:Ctrl+Z,回到上一步。

3.2 代码实现

java
import java.util.Stack;

// 1. 备忘录接口
interface EditorMemento {
}

// 2. 编辑器 (Originator)
class TextEditor {
    private StringBuilder content = new StringBuilder();
    private int cursorPosition = 0;

    public void append(String text) {
        content.insert(cursorPosition, text);
        cursorPosition += text.length();
        print();
    }

    public void delete() {
        if (cursorPosition > 0 && content.length() > 0) {
            content.deleteCharAt(cursorPosition - 1);
            cursorPosition--;
            print();
        }
    }

    // 创建快照
    public EditorMemento save() {
        return new Snapshot(content.toString(), cursorPosition);
    }

    // 恢复快照
    public void restore(EditorMemento memento) {
        Snapshot snapshot = (Snapshot) memento;
        this.content = new StringBuilder(snapshot.content);
        this.cursorPosition = snapshot.cursorPosition;
        print();
    }

    private void print() {
        System.out.println("当前内容: [" + content + "], 光标: " + cursorPosition);
    }

    // 私有内部类实现备忘录
    private static class Snapshot implements EditorMemento {
        private final String content;
        private final int cursorPosition;

        private Snapshot(String content, int cursorPosition) {
            this.content = content;
            this.cursorPosition = cursorPosition;
        }
    }
}

// 3. 历史记录管理者 (Caretaker)
class History {
    private Stack<EditorMemento> undoStack = new Stack<>();
    private TextEditor editor;

    public History(TextEditor editor) {
        this.editor = editor;
    }

    // 执行操作前先备份
    public void backup() {
        undoStack.push(editor.save());
    }

    // 撤销
    public void undo() {
        if (!undoStack.isEmpty()) {
            EditorMemento memento = undoStack.pop();
            editor.restore(memento);
        } else {
            System.out.println("无法撤销:已经是初始状态");
        }
    }
}

// 4. 测试客户端
public class EditorClient {
    public static void main(String[] args) {
        TextEditor editor = new TextEditor();
        History history = new History(editor);

        System.out.println("--- 开始编辑 ---");
        
        // 操作1
        history.backup();
        editor.append("Hello");

        // 操作2
        history.backup();
        editor.append(" World");

        // 操作3:删除一个字符
        history.backup();
        editor.delete();

        System.out.println("\n--- 开始撤销 ---");
        
        // 第一次撤销:恢复删除前
        System.out.print("Undo 1: ");
        history.undo();

        // 第二次撤销:恢复 World 输入前
        System.out.print("Undo 2: ");
        history.undo();

        // 第三次撤销:恢复 Hello 输入前
        System.out.print("Undo 3: ");
        history.undo();
    }
}

输出结果

text
--- 开始编辑 ---
当前内容: [Hello], 光标: 5
当前内容: [Hello World], 光标: 11
当前内容: [Hello Worl], 光标: 10

--- 开始撤销 ---
Undo 1: 当前内容: [Hello World], 光标: 11
Undo 2: 当前内容: [Hello], 光标: 5
Undo 3: 当前内容: [], 光标: 0

4. 源码中的备忘录模式

4.1 Java Servlet API

在 Java Web 开发中,HttpSession 某种程度上扮演了 Caretaker 的角色,而 session 中存储的对象状态(Attribute)就是 Memento。当我们在不同的请求之间恢复用户状态时,就是在应用备忘录模式的思想(虽然实现上更多是 Map 存储)。

4.2 Spring Web Flow

Spring Web Flow 是 Spring 的一个子项目,用于管理复杂的 Web 流程。其中的 StateManageableMessageContext 接口就涉及到了状态的管理和恢复。

4.3 数据库事务 (Transaction)

数据库的事务管理是备忘录模式最硬核的应用。

  • Savepoint:SQL 中的保存点,就是一个 Memento。
  • ROLLBACK TO Savepoint:回滚到保存点,就是 restore。
  • Undo Log:数据库底层的 Undo Log 记录了事务修改前的旧值,用于事务回滚,这也是备忘录模式的一种底层实现形式。

5. 备忘录模式优缺点与避坑指南

5.1 优点

  1. 提供了一种状态恢复机制:让用户可以方便地回到历史状态(后悔药)。
  2. 实现了信息的封装:使得用户不需要关心状态的保存细节,由 Originator 自己完成,不需要暴露内部成员变量。

5.2 缺点

  1. 资源消耗大:这是备忘录模式最大的痛点。如果对象的状态数据很大(例如一张高清图片),或者状态改变很频繁,那么保存大量的历史状态会占用巨量的内存。
  2. 管理成本高:需要维护历史列表,还要考虑何时清理旧的历史记录。

5.3 避坑与优化

  • 增量存储:不要每次都全量备份。可以只记录发生变化的属性(Delta),或者使用命令模式(Command Pattern)记录操作指令,通过逆向操作来恢复(Time Machine 机制)。
  • 限制历史记录数量:使用有界栈或循环队列,只保留最近的 N 次操作(例如 Photoshop 默认只保留 50 步历史)。
  • 软引用/弱引用:对于非关键的备忘录,可以使用 Java 的 SoftReference,在内存不足时自动回收,防止 OOM。

6. 总结

备忘录模式是“时间机器”的核心。在需要“撤销”、“回滚”、“恢复现场”的场景下,它是不二之选。

  • 如果你需要保存对象的全部状态,且不希望破坏封装,用黑箱备忘录
  • 如果状态数据很大,请慎用,或考虑增量备份

7. 实验实操

design-patterns-web 项目中,你可以体验一个支持历史快照的文本编辑器。 这个 Demo 完美复刻了备忘录模式的核心功能:

  • 创建快照:输入一段文字后,点击 "Save Snapshot" 按钮,系统会创建一个包含当前文本和时间戳的备忘录对象。
  • 时光倒流:在右侧的 "History" 列表中点击任意一个历史记录,编辑器内容会瞬间恢复到那个时刻的状态。
  • 封装性:你可以观察到,虽然我们能看到历史列表,但无法直接修改快照中的内容,保证了历史数据的安全性。 截图建议:先输入并保存几个不同版本的文本,然后点击其中一个旧版本的快照进行恢复。截取此时的编辑器界面,重点包含编辑器中的文字内容和右侧高亮选中的历史记录项。