news 2026/9/17 19:49:20

Baserow 撤销/重做(Undo/Redo)技术指南:ActionType、Action 表与 ActionHandler 全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Baserow 撤销/重做(Undo/Redo)技术指南:ActionType、Action 表与 ActionHandler 全解析

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:组合UndoableActionTypeMixinActionType的最终基类,绝大部分可撤销动作继承它;
  • UndoableActionCustomCleanupMixin:可选混入,允许在动作被清理时执行额外的数据清理逻辑;
  • action_type_registry:模块级注册表实例(ActionTypeRegistryname = "action_type")。

必须实现的三个方法

一个ActionType必须实现以下三个方法(通过UndoableActionTypeMixin约定):

  1. do:在用户请求执行动作时执行实际操作,必须调用cls.register_action保存一条Action记录
  2. undo:撤销do所做的事,不得保存任何新的Action记录(否则撤销本身会污染历史栈)。签名固定为undo(cls, user, params, action_being_undone)
  3. 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
  • undooriginal_table_name把名字改回去;
  • redoparams.table_name再次改名;
  • type = "update_table"作为注册表中的唯一标识,也直接写入Action表的type字段。

二、Action 表:撤销/重做的持久化存储

每次do成功,UndoableActionTypeMixin.register_action都会创建一条Action记录。模型定义在 backend/src/baserow/core/action/models.py,与文档中的示例表结构一一对应:

字段类型说明
idserial自增主键
user_idFK -> user 表,可空执行动作的用户,撤销/重做时据此做权限校验
workspaceFK -> core.Workspace,可空动作关联的工作区
sessiontext,可空,带索引客户端会话 ID(ClientSessionId头)
typetext,带索引动作类型名,如update_tableworkspace_created
paramsJSONB撤销/重做所需的参数,JSON 序列化存储
scopetext,带索引动作所在作用域,如rootworkspace1application2
created_onauto_now_add DateTimeField动作创建时间(来自CreatedAndUpdatedOnMixin
undone_atnullable DateTimeField,带索引撤销时间戳,null表示尚未撤销
errortext,可空撤销/重做失败时写入的异常堆栈
action_groupUUID,可空,带索引动作组 ID,用于把一组原子动作绑定在一起整体撤销/重做

文档给出的示例行:

iduser_idsessioncategory(scope)created_ontypeparamsundone_aterror
12'some-uuid-from-client''root'datetime'workspace_created''{created_workspace_id:10}'nullnull

模型上还提供了两个便捷方法:is_undone()(判断undone_at是否非空)和has_error()(判断error是否非空),并被Meta.ordering = ("-created_on",)及三个组合索引(-created_on/-id-undone_at/-idupdated_on/id)支撑查询性能。

注意:文档中"category"列在现版本源码中已演化为scope字段,语义相同——描述动作发生在 Baserow 的哪个逻辑区域。后文统一使用scope

三、ActionHandler:undo / redo 的执行引擎

ActionHandler定义在 backend/src/baserow/core/action/handler.py,它提供undoredo两个类方法,对应两个 REST 端点(文档中为/api/user/undo/api/user/redo,新版路由定义于 backend/src/baserow/api/urls.py 下的 user 视图)。

触发一次 undo/redo 需要三方面信息:

  1. 触发者用户(user):用于校验其是否仍有权撤销/重做该动作。例如用户正在重做一次工作区删除,但如果他期间已被移出该工作区,则应阻止这次重做。源码中所有查询都以user=user为第一过滤条件。
  2. 客户端会话 ID(client session id):每次用户执行动作时,后端检查ClientSessionId请求头(backend/src/baserow/api/sessions.py 中读取settings.CLIENT_SESSION_ID_HEADER),若存在则把动作关联到该会话。undo/redo 时前端也携带该头,后端只允许撤销/重做同一会话内的动作。这使得每个浏览器标签页拥有独立的撤销/重做历史——每个标签页生成唯一的ClientSessionId
  3. 作用域(scope/category):每次动作执行时都会关联一个作用域,本质是Action表上的文本列,取值形如roottable10workspace20,由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 中可以看到:

  • RootActionScopeTypetype = "root"):value()直接返回"root",请求序列化字段为 BooleanField(设为 true 才纳入撤销范围);
  • WorkspaceActionScopeTypetype = "workspace"):value(workspace_id)返回"workspace" + str(workspace_id),如workspace6
  • ApplicationActionScopeTypetype = "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)的核心步骤:

  1. user.web_socket_id置空,确保执行撤销的用户能收到该动作触发的实时事件;
  2. 查询"该用户、该会话、undone_at IS NULL、作用域命中、按-created_on, -id排序"的最新一条动作,并加select_for_update行锁防止并发竞争;
  3. 若该动作属于某个action_group,则把整个动作组(上限settings.MAX_UNDOABLE_ACTIONS_PER_ACTION_GROUP条)一起撤销,保证一组原子动作要么整体撤销、要么整体回滚
  4. transaction.atomic()内逐个调用_undo_action:反序列化params→ 调用action_type.undo(user, params, action)→ 将undone_at置为datetime.now(tz=timezone.utc)并保存;
  5. 任一步抛异常(LockConflict除外)则整体回滚,并把相同的错误写入该组所有动作的error字段、同时把undone_at置为当前时间(跳过失败动作);
  6. 最后刷新动作列表并广播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 重命名表格"为例完整走了一遍撤销流程,结合源码可整理为如下时间线:

  1. 页面加载:用户 A 打开位于应用 2、工作区 1 内的表格 10。前端生成一个ClientSessionId(通常为 UUID)存入authstore;undoRedostore 把当前页面作用域设为{root: true, table_id: 10, application_id: 2, workspace_id: 1}
  2. 执行动作:用户 A 修改表格名称,请求发往表格更新端点:
    • 请求头携带ClientSessionId: example_client_session_id
    • API 调用action_type_registry.get(UpdateTableActionType).do(user, ...)
    • do完成改名,register_action写入新Action
      • scope=application2UpdateTableActionType.scope调用ApplicationActionScopeType.value(database.id),对应文档所描述的workspace1层级归属);
      • session=example_client_session_id(取自请求头);
      • user= 用户 A;
      • params= 包含新旧表名的 JSON,供 undo/redo 使用。
  3. 按下撤销:用户 A 在界面上按 Undo:
    • 请求发往 undo 端点,请求体category(scope)取undoRedostore 中的当前作用域集合;
    • 请求头携带同一ClientSessionId
    • ActionHandler.undo执行:
      • 在会话example_client_session_id、作用域["root", "workspace1", "application2", "table10"]内查找用户 A 的最新未撤销动作;
      • 命中"表格重命名"动作:会话匹配、作用域命中、用户匹配、undone_at为 null;
      • 从表中反序列化paramsUpdateTableActionType.Params
      • 调用action_type_registry.get(UpdateTableActionType).undo(user, params, action_to_undo)
      • undooriginal_table_name恢复旧名;
      • Action.undone_at置为datetime.now(tz=timezone.utc),动作标记为已撤销。

之后用户再按 Redo,redo会用params.table_name再次改名,并把undone_at置回 null。

五、undo/redo 失败时会发生什么

场景:并发编辑导致撤销失败

假设两位用户同时编辑同一张表,按时间顺序:

  1. 用户 A 修改了名为 'date' 的字段中的单元格;
  2. 用户 A 修改了名为 'Name' 的字段中的单元格;
  3. 用户 B 删除了 'name' 字段;
  4. 用户 A 按撤销——当前实现下会得到错误提示:"撤销失败,已跳过";
  5. 用户 A 再次按撤销——此时用户 A 的第一次修改('date' 单元格)被成功撤销。

原因:用户 A 的最新动作作用于已被删除的 'name' 字段,该字段已不存在,无法撤销。失败处理流程对应 handler.py#L127-L151:

  1. 尝试调用ActionHandler.undo撤销该动作;
  2. 底层undo抛出异常;
  3. ActionHandler.undo捕获异常并:
    • 把异常堆栈写入动作的error字段(Action.objects.filter(pk__in=...).update(error=tb, undone_at=undone_at));
    • 把动作标记为已撤销(undone_at置为当前 UTC 时间);
    • 向用户返回"撤销失败,已跳过"的特定错误。

有趣的后续:连续两次 redo 会怎样

文档指出一个值得注意的语义:失败后用户如果连按两次 redo——

  1. 第一次 redo:成功重做用户 A 的第一个动作;
  2. 第二次 redo:尝试重做之前失败的那个动作。它带有error字段,后端检测到后向用户返回can't redo due to error, skipping.(无法重做,因存在错误,跳过)——但同时会清除该动作的 error 并把undone_at置回 null,即标记为"已重做"
  3. 此时用户再按撤销,该动作会被第二次尝试撤销。如果期间用户 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" 一词,而当前仓库源码中对应概念已命名为scopeAction.scope字段与ActionScopeType体系),语义与用途完全一致;同理文档中的 "Application" 概念与源码中的Database/Application对应,表格的撤销作用域实际落在其所属 application/database 层级。

七、小结

Baserow 的撤销/重做体系可以概括为三句话:

  • 写动作:继承UndoableActionType,实现do(执行并register_action)、undoredo,并定义Paramsdataclass 保存撤销所需参数;
  • 存动作Action表以 JSONB 持久化params,以session+scope+user+undone_at四个维度索引和筛选;
  • 撤动作ActionHandler.undo/redo依据"用户 + 会话 + 作用域"定位最新动作,按动作组原子撤销/重做,失败动作写入error并跳过,形成可重试的容错循环。

对于需要新增可撤销功能的开发者,最直接的参考实现就是 backend/src/baserow/contrib/database/table/actions.py 中的CreateTableActionTypeDeleteTableActionTypeUpdateTableActionType等,它们共同构成了 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 19:48:34

论文初稿完成后的系统修改策略与技巧

1. 论文初稿完成的真相:万里长征第一步写完论文最后一个句点的那一刻,大多数研究者都会长舒一口气,仿佛完成了最艰巨的任务。但真实情况是——当Word文档显示"字数统计符合要求"时,真正的挑战才刚刚开始。我在学术圈摸爬…

作者头像 李华
网站建设 2026/9/17 19:45:13

DeepSeek 指令公式模板化:四段式结构、批量调用与 PDF 导出

简介:《DeepSeek指令公式大全》是一份面向AI工具使用者的实战PDF手册,聚焦如何借助DeepSeek把专业概念转述成人人能懂的“大白话”,适用于教师、科普作者与内容创作者。内容围绕知识降维展开,从知识脱衣服、现实锚定、反常识检验、…

作者头像 李华
网站建设 2026/9/17 19:42:38

Scrapy+Redis构建高性能分布式爬虫实战指南

1. 项目概述:为什么需要分布式爬虫?在数据驱动的时代,爬虫技术已经成为获取互联网信息的核心手段。但传统单机爬虫在面对现代互联网的海量数据时,往往会遇到三个致命瓶颈:首先是硬件资源限制。单台机器的CPU处理能力、…

作者头像 李华