Baserow 撤销/重做(Undo/Redo)技术指南:ActionType、Action 表与 ActionHandler 全解析
【免费下载链接】baserowBuild databases, automations, apps & agents with AI — no code. Open source platform available on cloud and self-hosted. GDPR, HIPAA, SOC 2 compliant. Best Airtable alternative.项目地址: https://gitcode.com/GitHub_Trending/ba/baserow
Baserow 的后端内置了一套完整、可扩展的撤销/重做机制,允许用户在表格、数据库、工作区等任意层级回退或重放自己刚刚执行过的操作。本文以 docs/technical/undo-redo-guide.md 为骨架,结合 backend/src/baserow/core/action 与 backend/src/baserow/contrib/database/table/actions.py 等源码,系统讲解ActionType抽象、Action数据表、ActionHandler的 undo/redo 流程、作用域(scope)过滤以及失败回退语义。读完本文,你将能理解 Baserow 撤销/重做的完整调用链,并掌握为任意业务动作新增可撤销 ActionType 的实战方法。
一、核心抽象:ActionType 定义 do / undo / redo 三态
Baserow 的撤销/重做建立在**动作(Action)**这一抽象之上。一个ActionType是一个类,它定义了某个特定操作如何被do(执行)、undo(撤销)、redo(重做)。它可以自由调用 Handler 来完成业务逻辑,但几乎不应调用其他 ActionType——除非将来出现某种 "meta" Action 类型。ActionType 会通过注册表按类型(type)取出,并由 API 方法触发调用,例如文档中的典型调用:
action_type_registry.get_by_type(DeleteWorkspaceAction).do(user, workspace_to_delete)在 backend/src/baserow/core/action/registries.py 中可以看到完整的类体系:
ActionType:抽象基类,必须实现do,并通过register_action落库;UndoableActionTypeMixin:为ActionType补充undo/redo抽象方法,并实现真正的register_action(registries.py#L300-L373);UndoableActionType:组合UndoableActionTypeMixin与ActionType的最终基类,绝大部分可撤销动作继承它;UndoableActionCustomCleanupMixin:可选混入,允许在动作被清理时执行额外的数据清理逻辑;action_type_registry:模块级注册表实例(ActionTypeRegistry,name = "action_type")。
必须实现的三个方法
一个ActionType必须实现以下三个方法(通过UndoableActionTypeMixin约定):
do:在用户请求执行动作时执行实际操作,必须调用cls.register_action保存一条Action记录。undo:撤销do所做的事,不得保存任何新的Action记录(否则撤销本身会污染历史栈)。签名固定为undo(cls, user, params, action_being_undone)。redo:在undo之后重做该动作,同样不得保存任何Action记录。签名固定为redo(cls, user, params, action_being_redone)。
从 registries.py#L300-L331 的注释可以确认设计约束:"undo 绝不应调用另一个 ActionType 的 do 方法,因为那会注册一条我们不希望出现的新动作"。
Params 数据类:撤销/重做的参数载体
每个ActionType还必须实现一个内部的Paramsdataclass,用于保存撤销/重做所需的一切参数。流程为:
do方法把本次操作的关键信息填入Params实例;cls.register_action(user, params, scope, workspace)将Params序列化为 JSON 存入Action表的params字段;- 当
undo/redo被调用时,后端从Action行的 JSON 重新构造出该 dataclass 实例(action_type.serialized_to_params(action.params)),并传入undo/redo函数。
ActionType还提供两个可覆写的钩子(registries.py#L189-L204):
params_to_serializable(params):在序列化前改写参数对象;serialized_to_params(serialized_params):默认实现为cls.Params(**deepcopy(serialized_params)),把 JSON 字典还原成Params实例。
一个完整的示例:UpdateTableActionType
以文档中反复提及的"表格重命名"为例,backend/src/baserow/contrib/database/table/actions.py#L224-L293 中的UpdateTableActionType展示了完整的实现模式:
class UpdateTableActionType(UndoableActionType): type = "update_table" description = ActionTypeDescription( _("Update table"), _('Table (%(table_id)s) name changed from "%(original_table_name)s" to "%(table_name)s"'), DATABASE_ACTION_CONTEXT, ) analytics_params = ["database_id", "table_id"] @dataclasses.dataclass class Params: database_id: int database_name: str table_id: int table_name: str original_table_name: str @classmethod def do(cls, user, table, name): original_table_name = table.name TableHandler().update_table(user, table, name=name) database = table.database params = cls.Params(database.id, database.name, table.id, name, original_table_name) cls.register_action(user, params, cls.scope(database.id), workspace=database.workspace) return table @classmethod def scope(cls, database_id): return ApplicationActionScopeType.value(database_id) @classmethod def undo(cls, user, params, action_being_undone): TableHandler().update_table_by_id(user, params.table_id, name=params.original_table_name) @classmethod def redo(cls, user, params, action_being_redone): TableHandler().update_table_by_id(user, params.table_id, name=params.table_name)关键点一目了然:
do先执行真实的改名操作,再把旧名(original_table_name)和新名一起放进Params;undo用original_table_name把名字改回去;redo用params.table_name再次改名;type = "update_table"作为注册表中的唯一标识,也直接写入Action表的type字段。
二、Action 表:撤销/重做的持久化存储
每次do成功,UndoableActionTypeMixin.register_action都会创建一条Action记录。模型定义在 backend/src/baserow/core/action/models.py,与文档中的示例表结构一一对应:
| 字段 | 类型 | 说明 |
|---|---|---|
id | serial | 自增主键 |
user_id | FK -> user 表,可空 | 执行动作的用户,撤销/重做时据此做权限校验 |
workspace | FK -> core.Workspace,可空 | 动作关联的工作区 |
session | text,可空,带索引 | 客户端会话 ID(ClientSessionId头) |
type | text,带索引 | 动作类型名,如update_table、workspace_created |
params | JSONB | 撤销/重做所需的参数,JSON 序列化存储 |
scope | text,带索引 | 动作所在作用域,如root、workspace1、application2 |
created_on | auto_now_add DateTimeField | 动作创建时间(来自CreatedAndUpdatedOnMixin) |
undone_at | nullable DateTimeField,带索引 | 撤销时间戳,null表示尚未撤销 |
error | text,可空 | 撤销/重做失败时写入的异常堆栈 |
action_group | UUID,可空,带索引 | 动作组 ID,用于把一组原子动作绑定在一起整体撤销/重做 |
文档给出的示例行:
| id | user_id | session | category(scope) | created_on | type | params | undone_at | error |
|---|---|---|---|---|---|---|---|---|
| 1 | 2 | 'some-uuid-from-client' | 'root' | datetime | 'workspace_created' | '{created_workspace_id:10}' | null | null |
模型上还提供了两个便捷方法:is_undone()(判断undone_at是否非空)和has_error()(判断error是否非空),并被Meta.ordering = ("-created_on",)及三个组合索引(-created_on/-id、-undone_at/-id、updated_on/id)支撑查询性能。
注意:文档中"category"列在现版本源码中已演化为scope字段,语义相同——描述动作发生在 Baserow 的哪个逻辑区域。后文统一使用scope。
三、ActionHandler:undo / redo 的执行引擎
ActionHandler定义在 backend/src/baserow/core/action/handler.py,它提供undo与redo两个类方法,对应两个 REST 端点(文档中为/api/user/undo与/api/user/redo,新版路由定义于 backend/src/baserow/api/urls.py 下的 user 视图)。
触发一次 undo/redo 需要三方面信息:
- 触发者用户(user):用于校验其是否仍有权撤销/重做该动作。例如用户正在重做一次工作区删除,但如果他期间已被移出该工作区,则应阻止这次重做。源码中所有查询都以
user=user为第一过滤条件。 - 客户端会话 ID(client session id):每次用户执行动作时,后端检查
ClientSessionId请求头(backend/src/baserow/api/sessions.py 中读取settings.CLIENT_SESSION_ID_HEADER),若存在则把动作关联到该会话。undo/redo 时前端也携带该头,后端只允许撤销/重做同一会话内的动作。这使得每个浏览器标签页拥有独立的撤销/重做历史——每个标签页生成唯一的ClientSessionId。 - 作用域(scope/category):每次动作执行时都会关联一个作用域,本质是
Action表上的文本列,取值形如root、table10、workspace20,由ActionType在调用register_action时自行决定。undo/redo 时,web 前端把用户当前正在浏览的区域对应的作用域集合发给后端。例如当前打开表格 20、侧边栏处于工作区 6 时,发送的请求体为:
{ "root": true, "table": 20, "workspace": 6 }后端据此把可撤销的动作限定在 root、table20、workspace6 这三个作用域内。
作用域(Scope)的实现
作用域并非自由文本,而是由ActionScopeType体系规范化生成。在 backend/src/baserow/core/action/scopes.py 中可以看到:
RootActionScopeType(type = "root"):value()直接返回"root",请求序列化字段为 BooleanField(设为 true 才纳入撤销范围);WorkspaceActionScopeType(type = "workspace"):value(workspace_id)返回"workspace" + str(workspace_id),如workspace6;ApplicationActionScopeType(type = "application"):value(application_id)返回"application" + str(application_id),如application2。
每种ActionScopeType都实现get_request_serializer_field()(DRF 请求字段)与valid_serializer_value_to_scope_str(value)(把合法请求值转成 scope 字符串),供 undo/redo 端点反序列化请求并生成查询条件。ActionScopeStr则是一个 NewType 别名(registries.py#L36),用于类型系统上区分"普通字符串"与"作用域字符串"。
作用域嵌套语义:为什么"看得到"才能"撤得掉"
作用域过滤遵循包含语义:发送给 undo/redo 端点的作用域集合,等价于"撤销发生在这些作用域内的动作"。文档给出了典型场景:
我把表格 20 重命名了,那么
update_table动作会被记录在 workspace6 作用域内(因为表格 20 位于工作区 6)。如果此时我正看着表格 20 并按撤销,UI 会把 workspace6 也作为活跃作用域发送,于是可以撤销这次重命名;如果我先切到工作区 5 再按撤销,UI 只发送 workspace5,我便无法撤销对表格 20 的重命名,直到回到 workspace6 活跃的界面区域。
这就是"每个界面区域各自维护撤销上下文"的设计——作用域把全局历史切分成了彼此隔离的若干条"栈"。
undo 的内部查询逻辑
ActionHandler.undo(handler.py#L92-L151)的核心步骤:
- 将
user.web_socket_id置空,确保执行撤销的用户能收到该动作触发的实时事件; - 查询"该用户、该会话、
undone_at IS NULL、作用域命中、按-created_on, -id排序"的最新一条动作,并加select_for_update行锁防止并发竞争; - 若该动作属于某个
action_group,则把整个动作组(上限settings.MAX_UNDOABLE_ACTIONS_PER_ACTION_GROUP条)一起撤销,保证一组原子动作要么整体撤销、要么整体回滚; - 在
transaction.atomic()内逐个调用_undo_action:反序列化params→ 调用action_type.undo(user, params, action)→ 将undone_at置为datetime.now(tz=timezone.utc)并保存; - 任一步抛异常(
LockConflict除外)则整体回滚,并把相同的错误写入该组所有动作的error字段、同时把undone_at置为当前时间(跳过失败动作); - 最后刷新动作列表并广播
action_done信号(ActionCommandType.UNDO)。
redo(handler.py#L174-L265)的查询逻辑与之镜像:查找该用户/会话/作用域下最新被撤销过(undone_at IS NOT NULL)的动作,按-undone_at, -created_on, -id排序。此外 redo 还多了一道保护:如果自撤销以来(created_on > undone_at)又发生了新的未撤销动作,则拒绝重做(返回空列表),保证历史栈不回退覆盖新操作。
四、完整工作流示例:重命名表格后撤销
文档以"用户 A 重命名表格"为例完整走了一遍撤销流程,结合源码可整理为如下时间线:
- 页面加载:用户 A 打开位于应用 2、工作区 1 内的表格 10。前端生成一个
ClientSessionId(通常为 UUID)存入authstore;undoRedostore 把当前页面作用域设为{root: true, table_id: 10, application_id: 2, workspace_id: 1}。 - 执行动作:用户 A 修改表格名称,请求发往表格更新端点:
- 请求头携带
ClientSessionId: example_client_session_id; - API 调用
action_type_registry.get(UpdateTableActionType).do(user, ...); do完成改名,register_action写入新Action:scope=application2(UpdateTableActionType.scope调用ApplicationActionScopeType.value(database.id),对应文档所描述的workspace1层级归属);session=example_client_session_id(取自请求头);user= 用户 A;params= 包含新旧表名的 JSON,供 undo/redo 使用。
- 请求头携带
- 按下撤销:用户 A 在界面上按 Undo:
- 请求发往 undo 端点,请求体
category(scope)取undoRedostore 中的当前作用域集合; - 请求头携带同一
ClientSessionId; ActionHandler.undo执行:- 在会话
example_client_session_id、作用域["root", "workspace1", "application2", "table10"]内查找用户 A 的最新未撤销动作; - 命中"表格重命名"动作:会话匹配、作用域命中、用户匹配、
undone_at为 null; - 从表中反序列化
params为UpdateTableActionType.Params; - 调用
action_type_registry.get(UpdateTableActionType).undo(user, params, action_to_undo); undo用original_table_name恢复旧名;Action.undone_at置为datetime.now(tz=timezone.utc),动作标记为已撤销。
- 在会话
- 请求发往 undo 端点,请求体
之后用户再按 Redo,redo会用params.table_name再次改名,并把undone_at置回 null。
五、undo/redo 失败时会发生什么
场景:并发编辑导致撤销失败
假设两位用户同时编辑同一张表,按时间顺序:
- 用户 A 修改了名为 'date' 的字段中的单元格;
- 用户 A 修改了名为 'Name' 的字段中的单元格;
- 用户 B 删除了 'name' 字段;
- 用户 A 按撤销——当前实现下会得到错误提示:"撤销失败,已跳过";
- 用户 A 再次按撤销——此时用户 A 的第一次修改('date' 单元格)被成功撤销。
原因:用户 A 的最新动作作用于已被删除的 'name' 字段,该字段已不存在,无法撤销。失败处理流程对应 handler.py#L127-L151:
- 尝试调用
ActionHandler.undo撤销该动作; - 底层
undo抛出异常; ActionHandler.undo捕获异常并:- 把异常堆栈写入动作的
error字段(Action.objects.filter(pk__in=...).update(error=tb, undone_at=undone_at)); - 把动作标记为已撤销(
undone_at置为当前 UTC 时间); - 向用户返回"撤销失败,已跳过"的特定错误。
- 把异常堆栈写入动作的
有趣的后续:连续两次 redo 会怎样
文档指出一个值得注意的语义:失败后用户如果连按两次 redo——
- 第一次 redo:成功重做用户 A 的第一个动作;
- 第二次 redo:尝试重做之前失败的那个动作。它带有
error字段,后端检测到后向用户返回can't redo due to error, skipping.(无法重做,因存在错误,跳过)——但同时会清除该动作的 error 并把undone_at置回 null,即标记为"已重做"; - 此时用户再按撤销,该动作会被第二次尝试撤销。如果期间用户 B 已恢复了被删字段,这次撤销就可能成功。
这正是 handler.py#L228-L237 的注释所描述的语义:"我们正在重做一个撤销时失败的动作组,实际上没什么可重做的。这种情况下我们把它标记为已重做,这样用户可以再次尝试撤销它,看看这次是否可行。"因此,error 标记不是终态,它提供的是"跳过 + 重试"的容错循环,而非永久卡死。
并发与一致性保障
- undo/redo 全程使用
select_for_update行锁 +transaction.atomic,一个动作组内任一条失败都会整体回滚(LockConflict除外,它会被原样上抛处理); - redo 遇到"撤销后又发生新动作"时直接返回空(无动作可重做),避免历史栈被破坏;
- 结果码与状态常量可对照前端 web-frontend/modules/core/utils/undoRedoConstants.js:
NOTHING_TO_DO/SUCCESS/SKIPPED_DUE_TO_ERROR以及NO_MORE_UNDO/NO_MORE_REDO/ERROR_WITH_UNDO/ERROR_WITH_REDO等。
六、相关机制与边界说明
- action_group(动作组):多个原子动作可以绑定同一个
action_groupUUID(由 backend/src/baserow/api/sessions.py 的set_client_undo_redo_action_group_id_from_request_or_raise_if_invalid从请求头读取),undo/redo 时整组一起处理,上限由settings.MAX_UNDOABLE_ACTIONS_PER_ACTION_GROUP控制。 without_undo_redo_registration上下文:backend/src/baserow/core/action/context.py 提供without_undo_redo_registration(user),在块内清空会话 ID 与动作组 ID,使动作"照常注册并发送action_done信号(行历史、审计日志、webhook、实时更新都正常),但不会进入用户的撤销栈"。- 过期清理:
ActionHandler.clean_up_old_undoable_actions会删除超过settings.MINUTES_UNTIL_ACTION_CLEANED_UP分钟未更新的动作;实现了UndoableActionCustomCleanupMixin的类型会先调用各自的clean_up_any_extra_action_data再做删除,清理失败的单个动作不会回滚其他成功清理(handler.py#L267-L356)。 - 文档与代码的命名演进:本文档撰写时使用 "category" 一词,而当前仓库源码中对应概念已命名为
scope(Action.scope字段与ActionScopeType体系),语义与用途完全一致;同理文档中的 "Application" 概念与源码中的Database/Application对应,表格的撤销作用域实际落在其所属 application/database 层级。
七、小结
Baserow 的撤销/重做体系可以概括为三句话:
- 写动作:继承
UndoableActionType,实现do(执行并register_action)、undo、redo,并定义Paramsdataclass 保存撤销所需参数; - 存动作:
Action表以 JSONB 持久化params,以session+scope+user+undone_at四个维度索引和筛选; - 撤动作:
ActionHandler.undo/redo依据"用户 + 会话 + 作用域"定位最新动作,按动作组原子撤销/重做,失败动作写入error并跳过,形成可重试的容错循环。
对于需要新增可撤销功能的开发者,最直接的参考实现就是 backend/src/baserow/contrib/database/table/actions.py 中的CreateTableActionType、DeleteTableActionType、UpdateTableActionType等,它们共同构成了 Baserow 全平台撤销/重做能力的样板。
【免费下载链接】baserowBuild databases, automations, apps & agents with AI — no code. Open source platform available on cloud and self-hosted. GDPR, HIPAA, SOC 2 compliant. Best Airtable alternative.项目地址: https://gitcode.com/GitHub_Trending/ba/baserow
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考