编程

设计模式之备忘录(Memento)模式

590 2024-02-14 01:29:00

又名: 快照模式(Snapshot)

意图

备忘录(Memento)模式是一种行为设计模式,允许你在不暴露实现细节的情况下,保存及恢复对象的前一种状态。

问题描述

想象一下,你正在创建一个文本编辑器应用。除了简单的文本编辑外,编辑器还可以格式化文本、插入内联图像等。

在某个时刻,你决定让用户撤消对文本执行的任何操作。多年来,这一功能变得如此普遍,以至于现在人们希望每个应用都有这一功能。为了实现,你选择了直接的方法。在执行任何操作之前,应用程序都会记录所有对象的状态,并将其保存下来。稍后,当用户决定恢复操作时,应用会从历史记录中获取最新的快照,并使用它来恢复所有对象的状态。

让我们想想那些状态快照。你会如何生成一个快照呢?可能需要遍历对象中的所有字段,并将它们的值复制到存储中。然而,只有当对象对其内容的访问限制相当宽松时,这才有效。不幸的是,大多数真实对象不会让其他人那么容易地窥探它们的内部,它们将所有重要数据隐藏在私有字段中。

暂时忽略这个问题,让我们假设我们的对象表现得像嬉皮士:更喜欢开放的关系,并保持他们的状态公开。虽然这种方法可以解决眼前的问题,并允许你随意生成对象状态的快照,但它仍然存在一些严重的问题。将来,你可能会决定重构一些编辑器类,或者添加或删除一些字段。听起来很简单,但这也需要修改负责复制受影响对象状态的类。

How to make a copy of the object's private state?
如何复制对象的私有状态?

但还有更多。让我们考虑一下编辑器状态的实际“快照”。它包含哪些数据?至少,它必须包含实际文本、光标坐标、当前滚动位置等。要制作快照,你需要收集这些值并将它们放入某种容器中。

最有可能的是,你将把许多这样的容器对象存储在表示历史的列表中。因此,容器可能最终成为一个类的对象。该类几乎没有方法,但有许多字段反映编辑器的状态。为了允许其他对象向快照写入数据和从快照读取数据,你可能需要公开该字段。这将暴露所有编辑的状态,无论是私有的还是非私有的。其他类将会对快照类的每一个微小修改有依赖,否则这些修改将发生在私有字段和方法中,而不会影响外部类。

看起来我们已经走到了死胡同:要么暴露类的所有内部细节,使其过于脆弱;要么限制对其状态的访问,使其无法生成快照。还有其他方法可以实现“撤消”吗?

方案

我们刚刚经历的所有问题都是由封装损坏引起的。有些对象试图做超出预期的事情。为了收集执行某些操作所需的数据,它们入侵了其他对象的私有空间,而不是让这些对象执行实际操作。

备忘录(Memento)模式将创建状态快照委托给该状态的实际所有者,即发起人(originator )对象。因此,编辑器类自己可以生成快照,而不是其他对象试图从“外部”复制编辑器的状态,因为有完全的权限访问自己的状态。

该模式建议将对象状态的副本存储在一个名为备忘录(memento)的特殊对象中。除生成备忘录的对象外,任何其他对象都无法访问备忘录的内容。其他对象必须使用有限的接口与备忘录通信,该接口可能允许获取快照的元数据(创建时间、执行的操作的名称等),但不允许获取快照中包含的原始对象的状态。

这样的限制性策略允许你将备忘录存储在其他对象(通常称为管理员caretaker)中。由于管理员只能通过有限的接口处理备忘录,因此无法篡改存储在备忘录内的状态。同时,发起人(originator)可以访问备忘录内的所有字段,使其可以随意恢复以前的状态。

在文本编辑器示例中,我们可以创建一个单独的历史类来充当管理员(caretaker)。每当编辑器要执行操作时,存储在管理员内部的备忘录堆栈就会增加。你甚至可以在应用程序的 UI 中渲染此堆栈,向用户显示以前执行的操作的历史记录。

当用户触发撤销时,历史记录会从堆栈中获取最新的备忘录,并将其传递回编辑器,请求回滚。由于编辑器有完全的权限访问备忘录,它会根据从备忘录中获取的值来改变自己的状态。

结构

基于嵌套类的实现

该模式的经典实现依赖于嵌套类的支持,在许多流行的编程语言(比如 C++、C# 和 Java)中都可用。

Originator 类可以生成它自己状态的快照,并在需要时恢复该状态。

Memento 是一个值对象,扮演 Originator 状态的快照。通常的做法是使 memento 不可变,并通过构造函数只向其传递一次数据。

Caretaker 不仅知道”何时“以及”为什么“捕获 Originator 的状态,同时也知道何时该恢复该状态。

Caretaker 可以通过保存 Memento 堆栈跟踪 Originator 的历史。当 Originator 不得不回溯历史时,发起人(Originator )从堆栈中取出最上面的备忘录(Memento),并将其传递给发起人(Orginator)的恢复方法。

在这个实现中,备忘录(memento)类嵌套在发起人(Orginator)内部。这允许发起人(Originator)访问备忘录(memento)的字段和方法,即使它们被声明为私有的。另一方面,管理员对备忘录的字段和方法的访问非常有限,这使得它可以将备忘录存储在堆栈中,但不会篡改它们的状态。

基于中间接口的实现

还有一种替代实现,适用于不支持嵌套类的编程语言(是的,我说的是 PHP)。

在没有嵌套类的情况下,可以通过建立一个规范来限制对备忘录(memento)字段的访问,即管理员(caretaker)只能通过显式声明的中间接口来处理备忘录(memento),该接口只声明与备忘录(memento)元数据相关的方法。

另一方面,发起人(Originator)可以直接使用备忘录(memento)对象,访问备忘录(Memento)类中声明的字段和方法。这种方法的缺点是,你需要将备忘录(memento)的所有成员都公开。

使用更严格的封装来实现

当你不想让其他类通过备忘录(memento)访问发起人(Originator)的状态时,还有另一个实现是有用的。

这种实现允许多种发起人类型和备忘录类型。每个发起人与对应的备忘录类协作。发起人和备忘录都不会暴露状态给任何人。

管理者(Caretaker)现在被明确限制修改存储在备忘录中的状态。此外,管理者(Caretaker)变得独立于发起人(Originator),因为恢复方法现在在备忘录(memento)类中定义。

每个备忘录都与制作它的发起者相连。发起者将自己与状态值一起传递给备忘录的构造函数。由于这些类之间的密切关系,备忘录可以恢复其发起者的状态,前提是后者已经定义了合适的 setter。

伪代码

此示例使用备忘录(Memento)模式和命令(Command)模式来存储复杂文本编辑器状态的快照,并在需要时从这些快照恢复早期状态。

命令对象充当管理员(caretaker)。它们在执行与命令相关的操作之前获取编辑器的备忘录。当用户试图撤消最近的命令时,编辑器可以使用该命令中存储的备忘录(memento)将自己恢复到以前的状态。

备忘录(memento)类不声明任何公共字段、getter 或 setter。因此,任何对象都不能更改其内容。备忘录(Memento)链接到创建它们的编辑器对象。这允许备忘录(Memento)通过编辑器对象上的 setter 传递数据来恢复链接编辑器的状态。由于备忘录(Memento)链接到特定的编辑器对象,你可以使用中心化的撤消堆栈使应用程序支持多个独立的编辑器窗口。

// The originator holds some important data that may change over
// time. It also defines a method for saving its state inside a
// memento and another method for restoring the state from it.
class Editor is
    private field text, curX, curY, selectionWidth
    method setText(text) is
        this.text = text
    method setCursor(x, y) is
        this.curX = x
        this.curY = y
    method setSelectionWidth(width) is
        this.selectionWidth = width
    // Saves the current state inside a memento.
    method createSnapshot():Snapshot is
        // Memento is an immutable object; that's why the
        // originator passes its state to the memento's
        // constructor parameters.
        return new Snapshot(this, text, curX, curY, selectionWidth)
// The memento class stores the past state of the editor.
class Snapshot is
    private field editor: Editor
    private field text, curX, curY, selectionWidth
    constructor Snapshot(editor, text, curX, curY, selectionWidth) is
        this.editor = editor
        this.text = text
        this.curX = x
        this.curY = y
        this.selectionWidth = selectionWidth
    // At some point, a previous state of the editor can be
    // restored using a memento object.
    method restore() is
        editor.setText(text)
        editor.setCursor(curX, curY)
        editor.setSelectionWidth(selectionWidth)
// A command object can act as a caretaker. In that case, the
// command gets a memento just before it changes the
// originator's state. When undo is requested, it restores the
// originator's state from a memento.
class Command is
    private field backup: Snapshot
    method makeBackup() is
        backup = editor.createSnapshot()
    method undo() is
        if (backup != null)
            backup.restore()
    // ...

适用

当你希望生成对象状态的快照以恢复对象以前的状态时,请使用备忘(Memento)模式。

备忘录(Memento)模式允许你制作对象状态的完整副本,包括私有字段,并将它们与对象分开存储。虽然大多数人都记得这种模式,是因为“撤消”用例,但在处理事务时(即,如果需要在错误时回滚操作),它也是必不可少的。

当直接访问对象的字段/getters/ssetter 违反了其封装时,请使用该模式。

备忘录(Memento)使对象本身负责创建其状态的快照。其他对象不能读取快照,从而使原始对象的状态数据安全可靠。

如何实现

1. 确定哪一类将扮演发起人(Originator)的角色。重要的是要知道程序是使用一个这种类型的中心对象还是使用多个较小的对象。

2. 创建备忘录(Memento)类。逐个声明一组字段,这些字段与原始类内部声明的字段相对应。

3. 让备忘录(Memento)类不可修改。备忘录(Memento)应该只接收一次数据(通过构造函数)。该类应该没有 Setter。

4. 如果编程语言支持嵌套类,请将备忘录(Memento)类嵌套在发起人(Originator)中。如果不支持,请从备忘录(Memento)类中提取一个空白接口,并使所有其他对象都使用它来引用备忘录(Memento)。可以向接口添加一些元数据操作,但不添加任何暴露原始用户状态的操作。

5. 向发起人(Originator)类添加一个用于生成备忘录(Memento)的方法。发起人(Originator)应通过备忘录(Memento)构造函数的一个或多个参数将其状态传递给备忘录(Memento)。

方法的返回类型应该是上一步中提取的接口(假设你提取了它)。在底层,备忘录(Memento)制作方法应该直接与备忘录(Memento)类一起工作。

6. 添加一个用于将发起人(Originator)的状态恢复到其类的方法。它应该接受一个备忘录(Memento)to对象作为自变量。如果在上一步中提取了一个接口,请将其作为参数的类型。在这种情况下,需要将传入的对象类型转换为备忘录(Memento)类,因为发起者需要对该对象的完全访问权限。

7. 管理员(Caretaker),无论是代表命令(Command)对象、历史记录还是完全不同的东西,都应该知道何时向发起人(Originator)请求新的备忘录(Memento),如何存储它们以及何时用特定的备忘录(Memento)恢复发起人(Originator)。

8. 管理员和发起人之间的链接可以移动到备忘录(Memento)类中。在这种情况下,每个备忘录(Memento)都必须连接到创建它的发起者。恢复方法也会转移到备忘录(Memento)类。然而,只有当备忘录(Memento)类嵌套到发起人(Originator)r中,或者发起人(Originator)类提供了足够的 setter 来重写其状态时,这一切才有意义。

优缺点

  • ✔️你可以在不违反对象封装的情况下生成对象状态的快照。
  • ✔️你可以通过让管理员(Caretaker)维护发起人(Originator)状态的历史记录来简化发起人的代码。
  • ❌如果客户端过于频繁地创建备忘录(Memento),该应用可能会消耗大量内存。
  • ❌管理员(Caretaker)应该追踪发起人(Originator)的生命周期,以便能够销毁过时的备忘录(Memento)。
  • ❌多数动态编程语言,如 PHP、Python 和 JavaScript,都不能保证备忘录(Memento)中的状态保持不变。

与其他模式的关系

  • 实现撤销功能时,你可以同时使用命令(Command)模式和备忘录(Memento)模式。在这种情况下,命令负责对目标对象执行各种操作,而备忘录(memento)则在命令执行之前保存该对象的状态。
  • 你可以将备忘录(Memento)迭代(Iterator)模式一起使用来捕获当前迭代状态,并在必要时将其回滚。
  • 有时原型(Prototype)可以是备忘录(Memento)的一个更简单的替代方案。如果对象(你希望将其状态存储在历史记录中)相当简单,并且没有到外部资源的链接,或者链接很容易重新建立,则此方法有效。

 

代码示例

index.php: 概念示例

<?php
namespace RefactoringGuru\Memento\Conceptual;
/**
 * The Originator holds some important state that may change over time. It also
 * defines a method for saving the state inside a memento and another method for
 * restoring the state from it.
 */
class Originator
{
    /**
     * @var string For the sake of simplicity, the originator's state is stored
     * inside a single variable.
     */
    private $state;
    public function __construct(string $state)
    {
        $this->state = $state;
        echo "Originator: My initial state is: {$this->state}\n";
    }
    /**
     * The Originator's business logic may affect its internal state. Therefore,
     * the client should backup the state before launching methods of the
     * business logic via the save() method.
     */
    public function doSomething(): void
    {
        echo "Originator: I'm doing something important.\n";
        $this->state = $this->generateRandomString(30);
        echo "Originator: and my state has changed to: {$this->state}\n";
    }
    private function generateRandomString(int $length = 10): string
    {
        return substr(
            str_shuffle(
                str_repeat(
                    $x = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
                    ceil($length / strlen($x))
                )
            ),
            1,
            $length,
        );
    }
    /**
     * Saves the current state inside a memento.
     */
    public function save(): Memento
    {
        return new ConcreteMemento($this->state);
    }
    /**
     * Restores the Originator's state from a memento object.
     */
    public function restore(Memento $memento): void
    {
        $this->state = $memento->getState();
        echo "Originator: My state has changed to: {$this->state}\n";
    }
}
/**
 * The Memento interface provides a way to retrieve the memento's metadata, such
 * as creation date or name. However, it doesn't expose the Originator's state.
 */
interface Memento
{
    public function getName(): string;
    public function getDate(): string;
}
/**
 * The Concrete Memento contains the infrastructure for storing the Originator's
 * state.
 */
class ConcreteMemento implements Memento
{
    private $state;
    private $date;
    public function __construct(string $state)
    {
        $this->state = $state;
        $this->date = date('Y-m-d H:i:s');
    }
    /**
     * The Originator uses this method when restoring its state.
     */
    public function getState(): string
    {
        return $this->state;
    }
    /**
     * The rest of the methods are used by the Caretaker to display metadata.
     */
    public function getName(): string
    {
        return $this->date . " / (" . substr($this->state, 0, 9) . "...)";
    }
    public function getDate(): string
    {
        return $this->date;
    }
}
/**
 * The Caretaker doesn't depend on the Concrete Memento class. Therefore, it
 * doesn't have access to the originator's state, stored inside the memento. It
 * works with all mementos via the base Memento interface.
 */
class Caretaker
{
    /**
     * @var Memento[]
     */
    private $mementos = [];
    /**
     * @var Originator
     */
    private $originator;
    public function __construct(Originator $originator)
    {
        $this->originator = $originator;
    }
    public function backup(): void
    {
        echo "\nCaretaker: Saving Originator's state...\n";
        $this->mementos[] = $this->originator->save();
    }
    public function undo(): void
    {
        if (!count($this->mementos)) {
            return;
        }
        $memento = array_pop($this->mementos);
        echo "Caretaker: Restoring state to: " . $memento->getName() . "\n";
        try {
            $this->originator->restore($memento);
        } catch (\Exception $e) {
            $this->undo();
        }
    }
    public function showHistory(): void
    {
        echo "Caretaker: Here's the list of mementos:\n";
        foreach ($this->mementos as $memento) {
            echo $memento->getName() . "\n";
        }
    }
}
/**
 * Client code.
 */
$originator = new Originator("Super-duper-super-puper-super.");
$caretaker = new Caretaker($originator);
$caretaker->backup();
$originator->doSomething();
$caretaker->backup();
$originator->doSomething();
$caretaker->backup();
$originator->doSomething();
echo "\n";
$caretaker->showHistory();
echo "\nClient: Now, let's rollback!\n\n";
$caretaker->undo();
echo "\nClient: Once more!\n\n";
$caretaker->undo();

Output.txt: 执行结果

Originator: My initial state is: Super-duper-super-puper-super. 
Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to: srGIngezAEboNPDjBkuvymJKUtMSFX
Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to: UwCZQaHJOiERLlchyVuMbXNtpqTgWF
Caretaker: Saving Originator's state...
Originator: I'm doing something important.
Originator: and my state has changed to: incqsdoJXkbDUuVOvRFYyKBgfzwZCQ
Caretaker: Here's the list of mementos:
2018-06-04 14:50:39 / (Super-dup...)
2018-06-04 14:50:39 / (srGIngezA...)
2018-06-04 14:50:39 / (UwCZQaHJO...)
Client: Now, let's rollback!
Caretaker: Restoring state to: 2018-06-04 14:50:39 / (UwCZQaHJO...)
Originator: My state has changed to: UwCZQaHJOiERLlchyVuMbXNtpqTgWF
Client: Once more!
Caretaker: Restoring state to: 2018-06-04 14:50:39 / (srGIngezA...)
Originator: My state has changed to: srGIngezAEboNPDjBkuvymJKUtMSFX