保存快照和撤銷功能的實現方案——備忘錄模式總結


1、前言

本模式用的不是特別多,知道即可,本文主要是平時的讀書筆記的整理

2、出現的動機和概念

備忘錄模式——也叫 Memo 模式,或者快照模式等,顧名思義就是實現歷史記錄的作用,比如可以實現游戲關卡的角色復活,任務進度保存,命令的撤銷,以及系統的快照留存記錄等功能。

備忘錄模式的用意是在不破壞封裝的條件下,將一個對象的狀態捕捉(Capture),並外部化存儲,從而可以在將來合適的時候把這個對象還原到存儲時的狀態(undo/rollback)。

很簡單的概念,可以聯系Git,還有數據庫事務處理等,它們都有版本記錄,操作回滾的邏輯,這些都可以基於備忘錄模式,搭配其他模式來優雅的實現。

一句話:當業務需求是讓對象返回之前的某個歷史性的狀態的時候,就應該使用備忘錄模式加以封裝。

2.1、什么叫破壞了封裝性

假如保存某個對象 A 的當前狀態 A1,那么 RD 自然不是撐得沒事干,肯定是為了未來能回滾或者查看對象 A 的這個當前狀態 A1。自然的,外部的類(對象)就一定要能夠自由的訪問 A 的內部狀態(即有一段代碼 B 需要依賴 A 的內部結構),否則連保存什么都不知道,那還保存個什么勁兒呢。那么問題來了,如果稍不注意,就會把 B 分散在系統的各個角落,導致系統對 A 恢復操作的管理日益雜亂,增大開發和維護成本。這就叫破壞了封裝性。

2.2、如何防止關鍵對象的封裝性遭到破壞

答案很明顯,就是使用備忘錄模式加以設計。

3、由投色子游戲引出

游戲會有玩家復活功能,或者關卡進度恢復的功能,如果你不提供這樣的功能,肯定沒人玩。但是游戲的狀態是非常關鍵的數據,必須要封裝得當,不能讓別人隨意訪問。

下面看一個投色子的游戲機例子,玩家先充錢(200起步)才能玩,且按下投擲按鈕,讓機器來搖色子:

1、點數為1,玩家贏100塊錢

2、為2,輸200塊錢

3、為6,玩家不贏錢,但是可以得到一個禮物,禮物里分為兩類:

  • 紀念意義的禮物,不值錢

  • 可以積累換積分,兌換錢的vip禮物

4、玩家沒錢了,游戲結束

5、如果玩家不想結束當前游戲,則可以充錢恢復到最初狀態。

我們用 User 代表玩家,Memo 代表備忘錄,Game 代表游戲機。

3.1、備忘錄類和單一職責原則

Memo 代表備忘錄類,是備忘錄模式的核心類。顧名思義,它只有一個功能——負責保存和恢復目標對象的狀態,比如創建快照,恢復快照。而到底什么時候創建快照,什么時候恢復快照,Memo 類並不關心。

在例子中,Memo 表示玩家的狀態,注意該類和代表玩家類的類(User)都必須在一個包下面。

import java.util.ArrayList;
import java.util.List;

/**
 * 特別要注意,各個屬性和方法的 包 權限,它和用戶類需在一個包下
 */
public class Memo {
    /**
     * 代表用戶的錢,為包訪問權限
     */
    int money;

    /**
     * 代表用戶的禮物,為包訪問權限
     */
    ArrayList gifts;

    /**
     * 包訪問權限的構造器——這是一個寬接口
     */
    Memo(int money) {
        this.money = money;
        this.gifts = new ArrayList<>();
    }

    // 窄接口,獲取用戶的當前狀態下的錢
    public int getMoney() {
        return money;
    }

    /**
     * 寬接口,保存禮物
     */
    void addGift(String gift) {
        this.gifts.add(gift);
    }

    /**
     * 寬接口,獲取當前用戶持有的所有禮物
     */
    List getGifts() {
        return (List) this.gifts.clone();
    }
}

一定注意 Memo 類的成員權限,這非常重要:

1、構造器在包外無法被訪問,只有本包內的類可以訪問,生成 Memo 實例

2、addGift 方法也是只有同一個包下的類能訪問——給用戶保存所得的禮物,外部包的類無法改變 Memo(備忘錄)的數據

3、只有 getMoney 是 public,雖然只有它能被外界隨意訪問,但叫窄接口

3.1.1、Java 類成員的訪問權限

權限

訪問限制的說明

public

任何類

protected

同一個包的類,或者該類的子類

無,也叫默認權限,或者包權限

同一個包的類

private

該類自己

3.1.2、寬接口和窄接口

所謂的寬,窄,要明白針對誰說的——它們都是面向的備忘錄 Memo,即寬接口是說其他類調用了該方法,那么就能獲得 or 修改 Memo 的所有快照中的數據,這就是所謂的寬的意思。而窄接口,是說其他類調用了該方法,那么只能獲得當前快照中的數據,這就是所謂的窄。

在 Memo 類中,只有 public int getMoney() 方法是窄接口,只有它可以被外部的類訪問,而修改狀態的寬接口們,不可以被外部的類訪問。

3.1.3、Java 拷貝

Memo 類里,對 List getGifts 方法的返回值進行了 clone,其中,ArrayList 默認給重寫了 clone 方法,但是是淺拷貝的,需要注意。

/**
 * Returns a shallow copy of this <tt>ArrayList</tt> instance.  (The
 * elements themselves are not copied.)
 *
 * @return a clone of this <tt>ArrayList</tt> instance
 */
public Object clone() {
    try {
        ArrayList<?> v = (ArrayList<?>) super.clone();
        v.elementData = Arrays.copyOf(elementData, size);
        v.modCount = 0;
        return v;
    } catch (CloneNotSupportedException e) {
        // this shouldn't happen, since we are Cloneable
        throw new InternalError(e);
    }
}

3.2、和 Memo 類同包的用戶類 User(生成者類)

User類——需要被保存狀態以便恢復的那個對象。而如何恢復和保存快照的邏輯,它不 care,是前面的 Memo 類負責。

簡單的規則就是,只要玩家沒有輸光了錢,它就可以一直玩下去

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;

public class User {
    private static final String[] GIFT_NAME = {"手機", "掃地機器人", "圓珠筆"};
    private Random random = new Random();
    private int money; // 玩家的錢
    private List gifts = new ArrayList<>();

    public User(int money) {
        this.money = money;
    }

    public int getMoney() {
        return money;
    }

    public void playGame() {
        int dice = random.nextInt(6) + 1; // [1-6]
        switch (dice) {
            case 1:
                this.money += 100;
                System.out.println("money + 100");
                break;
            case 2:
                this.money -= 200;
                System.out.println("money -100");
                break;
            case 6:
                String gift = getGift();
                System.out.println("gitf is " + gift);
                break;
            default:
                System.out.println("平局");
        }
    }

    // 保存快照
    public Memo captureState() {
        // 保存當前余額
        Memo memo = new Memo(this.money);
        Iterator iterator = this.gifts.iterator(); // 保存當前全部禮物(只保存 VIP 禮物)
        while (iterator.hasNext()) {
            String g = (String) iterator.next();
            if (g.startsWith("VIP")) {
                memo.addGift(g);
            }
        }
        return memo;
    }

    // 恢復快照
    public void restoreState(Memo memo) {
        this.money = memo.getMoney();
        this.gifts = memo.getGifts();
    }

    // 模擬隨機生成一個禮物給用戶,該方法不應該放這里的,為了演示
    private String getGift() {
        String prefix = "";
        if (random.nextBoolean()) {
            prefix = "VIP: ";
        }
        return prefix + GIFT_NAME[random.nextInt(GIFT_NAME.length)];
    }

    @Override
    public String toString() {
        return "[ money = " + this.money + ", gifts = " + this.gifts + " ]";
    }
}

captureState 方法用來保存玩家的當前狀態(拍攝快照),並把快照返回給調用者,這個調用者就是接下來要實現的管理類。類比拍照,captureState 方法拍下當前玩家的快照,並保存到 Memo 類中。

restoreState 相反就是撤銷(回滾,恢復)的操作。

3.3、包外的管理者類 Main——何時保存/恢復快照

Main 類會初始化一個 User 實例,代表一個玩家,在玩家游戲的過程中,由 Main 類決定何時保存 User 快照,何時恢復 User 快照。具體的保存和恢復策略以及存儲的位置,是 Memo 這個備忘錄類實現的。

如果玩家運氣好,會贏錢(禮物),並保存當前快照以便於未來恢復到這個狀態。如果運氣不好,輸光了,玩家會馬上買籌碼,此時系統自動調用恢復快照的方法,讓玩家恢復到死亡之前的狀態。

import com.dashuai.D10Memo.memo.Memo;
import com.dashuai.D10Memo.memo.User;

public class Main {
    public static void main(String[] args) {
        // 玩家開始游戲,初始化一個用戶實例,代表該玩家
        User user = new User(100);
        System.out.println("玩家111,買了100籌碼,開始游戲");
        // 保存玩家初始化的狀態,這是最早的恢復點
        Memo memo = user.captureState();
for (int i = 1; i <= 10; i++) { System.out.println("用戶的當前狀態:" + user); System.out.println("------第 " + i + " 局"); user.playGame(); System.out.println("該局結束后,當前用戶的金錢 = " + user.getMoney()); if (user.getMoney() > memo.getMoney()) { System.out.println("贏了很多啊,值得保存一下游戲進度"); memo = user.captureState(); System.out.println("保存完畢!"); } else if (user.getMoney() <= 0) { System.out.println("輸光了,復活時間內,用戶馬上買籌碼,為其復活,恢復到游戲結束前的狀態"); user.restoreState(memo); } } } }玩家111,買了100籌碼,開始游戲 用戶的當前狀態:[ money = 100, gifts = [] ] ------第 1 局 平局 該局結束后,當前用戶的金錢 = 100 用戶的當前狀態:[ money = 100, gifts = [] ] ------第 2 局 平局 該局結束后,當前用戶的金錢 = 100 用戶的當前狀態:[ money = 100, gifts = [] ] ------第 3 局 平局 該局結束后,當前用戶的金錢 = 100 用戶的當前狀態:[ money = 100, gifts = [] ] ------第 4 局 平局 該局結束后,當前用戶的金錢 = 100 用戶的當前狀態:[ money = 100, gifts = [] ] ------第 5 局 money + 100 該局結束后,當前用戶的金錢 = 200 贏了很多啊,值得保存一下游戲進度 保存完畢! 用戶的當前狀態:[ money = 200, gifts = [] ] ------第 6 局 平局 該局結束后,當前用戶的金錢 = 200 用戶的當前狀態:[ money = 200, gifts = [] ] ------第 7 局 money -200 該局結束后,當前用戶的金錢 = 0 輸光了,復活時間內,用戶馬上買籌碼,為其復活,恢復到游戲結束前的狀態 用戶的當前狀態:[ money = 200, gifts = [] ] ------第 8 局 gitf is VIP: 圓珠筆 該局結束后,當前用戶的金錢 = 200 用戶的當前狀態:[ money = 200, gifts = [] ] ------第 9 局 平局 該局結束后,當前用戶的金錢 = 200 用戶的當前狀態:[ money = 200, gifts = [] ] ------第 10 局 gitf is VIP: 手機 該局結束后,當前用戶的金錢 = 200

1、Main 作為包外的類,就是所謂的管理者類,它管理 User(生成者類) 和 Memo (備忘錄類),前者用來表示要保存的對象,后者表示如何保存的邏輯和保存的地點。

2、由於管理者類Main在包外,故 Main 不能直接訪問 Memo 類的構造器,無法直接生成備忘錄,保證了備忘錄的封裝完整,Main 只能通過調用 User 類的 public 的 getMoney 方法獲取當前玩家的金錢,不能隨意改變玩家的余額,保證了安全性。

3、由管理者類——Main 決定,何時拍攝玩家的快照或者何時恢復這個快照。具體的拍照和恢復策略,是 Memo——備忘錄類本身實現。

4、備忘錄模式的標准類圖和角色

1、生成者——對應了示例的 User 類,是需要被保存狀態以便恢復的那個對象

2、備忘錄——Memo 類,該對象由生成者創建,主要用來保存生成者的內部狀態

3、管理者——Main 類,負責管理在適當的時間保存/恢復生成者對象的狀態。

備忘錄角色有如下責任:

1)將生成者對象的內戰狀態存儲。備忘錄可以根據生成者對象的狀態判斷來決定存儲多少生成者對象的內部狀態

2)備忘錄可以保護其內容不被生成者對象之外的任何對象所讀取

5、備忘錄模式的應用場景

如果一個對象需要保存狀態並可通過undo或rollback等操作恢復到以前的狀態時,可以使用Memento模式

具體說,如果一個類需要保存它的對象的狀態(相當於生成者角色),可以

1、設計一個類,該類只是用來保存上述對象的狀態(相當於 Memo 角色)

2、需要的時候,管理者角色要求生成者返回一個Memo並加以保存

3、undo或rollback時,通過管理者保存的Memo對象,恢復生成者的狀態

6、多個備忘錄的情景

之前的例子,Main 這個管理者類只保存了一個 memo,如果在Main集成數組或者list,則可以實現歷史訪問點的快照,便於恢復各個時間點的狀態。

7、Memo 備忘錄類的有效期問題

如果在內存中保存 memo,那么程序結束,就沒用了。此時可把 memo 保存到數據庫或者文件里序列化。但是到底保存多久又是個新問題,需要結合具體業務涉及。

8、划分管理者和生成者角色的意義

為什么要這么麻煩呢,直接全部實現在備忘錄類不得了么。

因為,管理者角色的職責是決定何時保存生成者的快照,何時撤銷。另一方面,生成者角色的職責是代表被保存和恢復的那個對象,他生成備忘錄角色對象和使用接受到的備忘錄角色對象恢復自己的狀態。

這樣就實現了職責分離。如下當需求變動:

1、撤銷一次的操作,變更為撤銷多次時

2、變更拍攝快照保存到內存為保存到數據庫,or 文件時

都不需要反復修改生成者角色的代碼了,這個生成者是實現關鍵業務邏輯的類,保證封裝的穩定性。

9、經常和備忘錄模式搭配的其他模式

備忘錄模式常常與命令模式和迭代模式一同使用。比如命令模式實現撤銷操作,可以搭配備忘錄模式

10、備忘錄模式的優缺點

1、優點

把被存儲的狀態放在外面——Memo角色,不和關鍵對象(生成者角色)混在一起,維護了各自的內聚和封裝性。

能提供快照和恢復功能

2、缺點

如果連接數據庫或者文件,可能拍攝快照和恢復的動作比較耗時

11、序列化和備忘錄模式

Java中,能使用序列化機制實現對象的狀態保存,因此可以搭配序列化機制實現備忘錄模式,參看:Java對象序列化全面總結

歡迎關注

dashuai的博客是終身學習踐行者,大廠程序員,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於互聯網行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM