设计模式基础篇第28讲:备忘录模式——用TaoToken统一Key保存对象状态,实现撤销与回滚
工试云启 考证服务中心整理

1. 文本编辑器撤销栈为什么总在“状态恢复”上翻车写文本编辑器的时候撤销功能看起来是最简单的需求之一用户按 CtrlZ把内容退回到上一步就行。但真正动手实现过的人都知道这里藏着一个经典矛盾——你要保存对象的内部状态又不能把对象的内部结构暴露出去。很多初学者第一版代码会写成这样在编辑器类里直接开一个ListString存历史文本撤销时把字符串塞回去。跑起来没问题可一旦编辑器状态从“一段文本”扩展成“文本 光标位置 选区 滚动偏移 语法高亮缓存”这个列表就开始失控每加一个状态字段保存和恢复逻辑都要跟着改编辑器类越来越臃肿封装性也被自己破坏得一干二净。备忘录模式Memento Pattern就是为这个场景准备的。它的定义是在不破坏封装性的前提下捕获一个对象的内部状态并在该对象之外保存这个状态以便以后可以将该对象恢复到原先保存的状态。翻译成编辑器语言就是编辑器发起人自己负责“拍快照”和“按快照还原”快照对象备忘录只负责装数据撤销栈管理者只负责存取快照三方各管一摊谁也不越界。这篇是设计模式基础篇第 28 讲我聚焦备忘录模式在编辑器撤销栈里的落地。你会看到三个角色的完整 Java 实现、可复制的状态快照类与撤销栈配置、保存/恢复/回滚三步验证动作以及如何用 TaoToken 统一 Key 管理多模型调用凭据让示例里的 AI 辅助补全、代码解释等调用都能跑通。适合正在写编辑器、表单、画板、配置中心这类需要撤销回滚功能的开发者也适合想彻底搞懂备忘录模式三角色协作的同学。核心检索词先摆出来备忘录模式是一种行为型设计模式用于保存和恢复对象状态实现撤销与回滚它适合需要状态快照、又不想暴露对象内部结构的场景。下面从问题场景开始一步步把可运行的代码搭出来。2. TaoToken 统一 Key 管理多模型调用凭据的前置准备在进入代码之前先解决一个实际工程问题现代编辑器里撤销栈往往不只是本地状态还可能涉及 AI 补全、代码解释、多模型切换等调用。如果你在示例里同时接入了多个模型服务每个服务一套 Key、一套 Base URL配置散落在环境变量、配置文件、代码常量里调试撤销逻辑时很容易被“401 未授权”打断。TaoToken 的价值就在这里——它提供统一的 API 通道和 Key 管理把多模型调用凭据收敛到一处。TaoToken 是什么它是一个面向开发者的模型调用统一入口提供兼容 OpenAI 风格的 API 通道你可以用同一个 Key 访问不同模型适合需要在项目里切换模型、做多模型对比、或者给编辑器加 AI 能力的场景。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 这个不加 UTM。适合谁如果你正在写一个带 AI 辅助的编辑器或者你的撤销栈示例需要调用模型做“代码解释”“补全建议”又不想为每个模型单独维护凭据那 TaoToken 的统一 Key 方式能省掉大量配置工作。它不替代你的编辑器也不替代你的业务逻辑只是把“调用哪个模型、用哪个 Key”这件事标准化。前置准备分三步。第一步注册并登录 TaoToken 控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在控制台里创建你的 API Key。第二步如果你用的是 Claude Code 这类编码工具可以走 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它面向长期编码和 Agent 场景。第三步把 Key 写进你的项目配置不要硬编码在源码里。这里要强调一个原则撤销栈的示例代码本身不依赖网络但如果你要加 AI 辅助功能凭据管理必须和业务代码分离。我试过把 Key 直接写在TextEditor类里结果每次换模型都要改业务类非常别扭。正确做法是单独放一个配置文件用环境变量注入。下面给出一个可复制的settings.json片段路径放在项目根目录的.taotoken/settings.json{ base_url: https://taotoken.net/api, api_key: ${TAOTOKEN_API_KEY}, default_model: claude-sonnet-4-20250514, models: { fast: gpt-4o-mini, balanced: claude-sonnet-4-20250514, reasoning: deepseek-reasoner }, timeout_ms: 30000, max_retries: 2 }注意api_key用的是环境变量占位符${TAOTOKEN_API_KEY}实际运行时从系统环境读取。这样你的撤销栈代码和模型凭据彻底解耦换 Key 不用动一行 Java。如果你需要查看可用模型列表和接入文档文档入口是 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite API Keys 管理页是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。配置好之后你的编辑器示例就可以在“保存快照”时顺带调用模型生成变更摘要或者在“恢复快照”时让模型解释这次回滚改了什么。凭据统一了调试撤销逻辑时就不会被认证问题干扰。3. 可复制的备忘录模式三角色配置与代码实现这一节是全文的技术核心给出完整可运行的 Java 实现。三角色分别是发起人Originator、备忘录Memento、管理者Caretaker。我以文本编辑器快照为例状态包含文本内容和光标位置撤销栈用Deque实现支持多步撤销和容量上限。先看备忘录类。它的职责是封装发起人的内部状态只提供读取方法不提供修改方法构造方法用包访问权限确保只有同包的发起人能创建它package com.example.memento; // 备忘录封装文本编辑器的内部状态不对外暴露修改入口 final class TextMemento { private final String textContent; private final int cursorPosition; private final long timestamp; // 包访问权限仅发起人可创建 TextMemento(String textContent, int cursorPosition) { this.textContent textContent; this.cursorPosition cursorPosition; this.timestamp System.currentTimeMillis(); } String getTextContent() { return textContent; } int getCursorPosition() { return cursorPosition; } long getTimestamp() { return timestamp; } }注意类声明为final字段全部private final没有任何 setter。这就是备忘录模式保护封装性的关键管理者拿到TextMemento对象后只能读不能改。接着是发起人类。它持有真正的业务状态提供createMemento()拍快照和restoreFromMemento()还原两个方法package com.example.memento; public class TextEditor { private StringBuilder textContent new StringBuilder(); private int cursorPosition 0; public void insertText(String content) { textContent.insert(cursorPosition, content); cursorPosition content.length(); System.out.println([编辑] 内容 textContent 光标 cursorPosition); } public void moveCursor(int position) { if (position 0 || position textContent.length()) { throw new IllegalArgumentException(光标位置越界: position); } this.cursorPosition position; System.out.println([移动] 光标 cursorPosition); } // 创建备忘录把当前内部状态封装进快照 public TextMemento createMemento() { System.out.println([快照] 保存 内容 textContent 光标 cursorPosition); return new TextMemento(textContent.toString(), cursorPosition); } // 从备忘录恢复还原内部状态 public void restoreFromMemento(TextMemento memento) { if (memento null) { System.out.println([恢复] 无可用快照忽略); return; } this.textContent new StringBuilder(memento.getTextContent()); this.cursorPosition memento.getCursorPosition(); System.out.println([恢复] 还原 内容 textContent 光标 cursorPosition); } public String getCurrentState() { return 内容 textContent 光标 cursorPosition; } }发起人只和备忘录交互完全不知道管理者存在。这是解耦的第一层。然后是管理者也就是撤销栈。它用ArrayDeque存快照设置容量上限超出时丢弃最早的快照package com.example.memento; import java.util.ArrayDeque; import java.util.Deque; public class UndoManager { private final DequeTextMemento undoStack new ArrayDeque(); private final DequeTextMemento redoStack new ArrayDeque(); private final int maxCapacity; public UndoManager(int maxCapacity) { if (maxCapacity 0) { throw new IllegalArgumentException(容量必须大于0); } this.maxCapacity maxCapacity; } public void save(TextMemento memento) { if (memento null) return; if (undoStack.size() maxCapacity) { undoStack.removeLast(); // 丢弃最早快照 System.out.println([栈] 容量达上限丢弃最早快照); } undoStack.push(memento); redoStack.clear(); // 新操作后重做栈失效 System.out.println([栈] 已保存当前快照数 undoStack.size()); } public TextMemento undo() { if (undoStack.isEmpty()) { System.out.println([栈] 无可撤销快照); return null; } TextMemento memento undoStack.pop(); redoStack.push(memento); System.out.println([栈] 撤销一步剩余快照数 undoStack.size()); return memento; } public TextMemento redo() { if (redoStack.isEmpty()) { System.out.println([栈] 无可重做快照); return null; } TextMemento memento redoStack.pop(); undoStack.push(memento); System.out.println([栈] 重做一步快照数 undoStack.size()); return memento; } public int size() { return undoStack.size(); } }管理者只负责存取快照不关心快照里装的是什么。这是解耦的第二层。三个角色合起来就构成了备忘录模式的完整结构。你可以把这三个类直接复制到 IDE 里包名按自己的项目改一下即可运行。4. 保存、恢复、回滚三步验证请求与成功结果代码写完了接下来做三步验证确认撤销栈真的能工作。我给出一个完整的测试类覆盖保存、恢复、回滚三个动作并打印每一步的状态。package com.example.memento; public class MementoDemo { public static void main(String[] args) { TextEditor editor new TextEditor(); UndoManager undoManager new UndoManager(5); System.out.println( 第一步编辑并保存快照 ); editor.insertText(Hello, ); undoManager.save(editor.createMemento()); editor.insertText(Design Pattern!); undoManager.save(editor.createMemento()); editor.moveCursor(7); undoManager.save(editor.createMemento()); System.out.println(当前状态: editor.getCurrentState()); System.out.println(\n 第二步撤销恢复 ); editor.restoreFromMemento(undoManager.undo()); editor.restoreFromMemento(undoManager.undo()); System.out.println(撤销两次后: editor.getCurrentState()); System.out.println(\n 第三步重做回滚 ); editor.restoreFromMemento(undoManager.redo()); System.out.println(重做一次后: editor.getCurrentState()); System.out.println(\n 边界验证容量上限 ); for (int i 1; i 6; i) { editor.insertText(X i); undoManager.save(editor.createMemento()); } System.out.println(最终快照数: undoManager.size()); } }运行这段代码你会看到类似下面的输出。第一步保存了三个快照第二步连续撤销两次内容从“Hello, Design Pattern!”退回到“Hello, ”第三步重做一次又回到“Hello, Design Pattern!”。边界验证里容量设为 5插入 6 次后快照数仍然是 5最早的被丢弃了。 第一步编辑并保存快照 [编辑] 内容Hello, 光标7 [快照] 保存 内容Hello, 光标7 [栈] 已保存当前快照数1 [编辑] 内容Hello, Design Pattern! 光标21 [快照] 保存 内容Hello, Design Pattern! 光标21 [栈] 已保存当前快照数2 [移动] 光标7 [快照] 保存 内容Hello, Design Pattern! 光标7 [栈] 已保存当前快照数3 当前状态: 内容Hello, Design Pattern! 光标7 第二步撤销恢复 [栈] 撤销一步剩余快照数2 [恢复] 还原 内容Hello, Design Pattern! 光标21 [栈] 撤销一步剩余快照数1 [恢复] 还原 内容Hello, 光标7 撤销两次后: 内容Hello, 光标7 第三步重做回滚 [栈] 重做一步快照数2 [恢复] 还原 内容Hello, Design Pattern! 光标21 重做一次后: 内容Hello, Design Pattern! 光标21 边界验证容量上限 [编辑] 内容Hello, Design Pattern!X1 光标22 ... [栈] 容量达上限丢弃最早快照 最终快照数: 5三步验证全部通过说明三角色协作正常发起人拍快照、管理者存快照、发起人按快照还原封装性没有被破坏。如果你在编辑器里加了 AI 辅助比如每次保存快照时调用模型生成一句变更摘要可以用 TaoToken 的模型对话入口测试地址是 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 先在对话页确认模型可用再写进代码。这里补一个实际技巧撤销栈的容量不要设太大。我见过有人设成 1000结果编辑器开久了内存涨到几百 MB。一般 50 到 100 足够覆盖用户的操作习惯超过就丢弃最早的快照。如果你的状态对象很大考虑只保存关键字段或者对快照做压缩。5. 备忘录模式常见报错排查401、local proxy failed、reading choices即使代码逻辑正确实际接入模型调用时还是会遇到各种报错。这一节对照真实错误信息给出排查路径。注意这些报错和备忘录模式本身无关但你的编辑器示例一旦加了 AI 功能就会碰到。第一个高频报错是401 Unauthorized。典型信息是{error:{message:Invalid API key,type:invalid_request_error}}。原因通常是 Key 没配置、Key 过期、或者环境变量没生效。排查步骤先确认.taotoken/settings.json里的api_key占位符${TAOTOKEN_API_KEY}在运行环境里真的有值用echo $TAOTOKEN_API_KEY检查再确认 Base URL 写的是https://taotoken.net/api不要多加斜杠或路径最后去 API Keys 页面确认 Key 状态正常。如果你用的是 Claude Code检查~/.claude/settings.json里的配置是否和项目配置冲突。第二个报错是local proxy failed或connection refused。这通常出现在你本地起了代理但没启动或者代理端口写错。排查确认没有残留的代理环境变量比如HTTP_PROXY、HTTPS_PROXY指向了一个不存在的端口确认你的网络能正常访问https://taotoken.net/api如果用了本地转发工具检查它的监听端口和配置文件是否一致。这个报错和备忘录模式无关但会阻断你的 AI 辅助调用。第三个报错是reading choices相关典型信息是Cannot read properties of undefined (reading choices)。这通常意味着返回体结构和你预期的不一致常见原因是请求发到了错误的端点或者模型名写错导致返回了错误对象。排查确认请求路径是/v1/chat/completions这类标准路径确认default_model字段里的模型 ID 是文档里列出的可用模型打印完整响应体看choices字段是否存在。如果你在代码里直接取response.choices[0]先加一层判空。第四个是 OAuth 相关报错比如OAuth token expired或invalid_grant。如果你用的是 Claude Code 或类似工具的 OAuth 登录方式token 过期后需要重新授权。排查检查工具的凭据文件是否过期如果同时配置了 API Key 和 OAuth确认优先级避免两者冲突。对于 Claude Code 接入可以参考 Anthropic 兼容配置地址是 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。这里要提醒一个配置三件套原则无论你用 CC Switch、Cline MCP 还是 Codex 的auth.json只要涉及模型接入必须同时确认三样东西——Base URL、API Key、Model ID。缺一个都会报错。Base URL 统一用https://taotoken.net/apiKey 从控制台获取Model ID 从文档里选。三件套对齐了401 和 reading choices 这类报错基本能消掉。另外备忘录模式本身的坑也要注意。最常见的是备忘录暴露了 setter导致管理者能改快照内容封装性被破坏。排查方法检查备忘录类的字段是不是private final有没有 public setter。第二个坑是快照数量无上限内存溢出。排查方法确认管理者构造函数传了容量参数且save()里有丢弃逻辑。第三个坑是恢复时没有清空重做栈导致撤销后重做出现状态错乱。排查方法确认save()里调用了redoStack.clear()。6. 把撤销栈接入真实项目的下一步代码跑通之后下一步是把它接进真实项目。如果你用的是 Spring 项目可以把UndoManager注册成 Bean作用域设为 prototype每个编辑会话一个实例。如果你做的是前端编辑器备忘录模式的思路一样适用只是把 Java 类换成 TypeScript 接口撤销栈用数组模拟。对于需要长期编码和 Agent 协作的场景建议走 Coding Plan地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它面向持续性的编码任务配合统一 Key 管理能把多模型调用凭据收敛到一处。如果你只是想先验证模型是否可用用模型对话页快速测一下就行。最后留一个实用技巧撤销栈的快照对象尽量设计成不可变immutable所有字段final这样多线程环境下也不需要加锁。如果你的编辑器支持协同编辑每个用户的撤销栈独立维护通过操作变换OT或 CRDT 合并备忘录模式依然是底层状态管理的基础。把TextMemento做成可序列化的还能顺带实现“保存草稿到磁盘”和“重启后恢复编辑状态”一举两得。