mcpField
M
migrating-motoko-actors

migrating-motoko-actors

github.com/dfinity开发3986 次安装今天更新
代码仓库

关于 migrating-motoko-actors

面向使用正交持久化升级 Motoko canister 的开发者,说明稳定状态变更何时需要显式迁移,以及如何编写由 mops 管理的迁移链。

主要功能

  1. 01区分隐式与显式迁移
  2. 02说明迁移文件命名规则
  3. 03解释稳定状态初始化
  4. 04提供状态转换模式

适用场景

  1. 01判断升级是否需要迁移
  2. 02新增或重构稳定 Actor 状态
  3. 03编写 canister 升级迁移

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dfinity/icskills --skill migrating-motoko-actors

安装后在 Claude Code 中输入/migrating-motoko-actors确认已加载。

附带文件references/examples.md

详细介绍

Motoko Actor 迁移

在正交持久化中迁移 Actor 状态,以便在 canister 升级期间使用的专家指南。

关键:切勿使用经典持久化或升级前后钩子

不要使用经典(旧版)持久化,也不要使用 system func preupgrade / system func postupgrade。这些方式容易出错,发生陷阱时可能导致 canister 卡住,也无法扩展。

也不要使用旧版 (with migration = ...) Actor 附加语法。本指南介绍的是 由 mops 管理的迁移。如果某项变更需要显式迁移,请将其写成 src/backend/migrations/ 中带时间戳的文件;迁移链会自动重放。

稳定状态的初始化方式

稳定 Actor 字段在 Actor 主体中没有初始值设定项。为这些字段赋值的是 src/backend/migrations/ 中的迁移链:运行时会按字典序遍历该链,在全新安装时运行所有文件,在升级时只运行尚未应用于 canister 的文件。

何时使用本指南

  • 规划或实现会更改 Actor 状态的 canister 升级
  • 决定使用隐式迁移还是显式迁移
  • 编写或重构迁移函数
  • 首次引入稳定状态(使用 OldActor = {} 的迁移,或者如果第一项迁移仍待处理,则将其合并进去)

迁移文件夹规则

  • 所有迁移都放在 src/backend/migrations/ 中。 全新安装时,按迁移文件名的字典序重放完整迁移链;升级时,只运行晚于已部署链末项的迁移。(该目录在 mops.toml 的 [canisters.<name>.migrations] 下通过 chain = ... 声明;使用非默认 canister 名称的导入项目可能会将其放在其他位置——请查看 mops.toml,不要想当然。)
  • 每次构建最多只能有一项待处理迁移(mops.toml 中的 check-limit = 1)。如果已有迁移待处理(尚未应用),请编辑该文件以合并后续变更,而不是新增迁移。超出限制时,mops check 会比较已部署的 .most 基线,并指出应合并到哪个最新待处理文件中。如果托管平台负责管理迁移部分和 check-limit,切勿通过编辑它们来清除错误。
  • 新文件名只使用 UTC 时间戳,不加后缀: YYYYMMDD_HHMMSS.mo。时间戳必须晚于所有现有文件。请勿在文件名中注明变更内容(不要使用 AddPriority、AddTags、Init 等名称)——任何类似功能名称的文件名都会诱使你在下次变更时新增文件,而不是编辑待处理的文件。
  • 切勿修改、删除或重命名已应用的迁移。 已应用的迁移按模块名称跟踪,因此重命名会让运行时将该文件视为从未应用;修改已应用的文件也不会执行。在某些平台上,已应用的迁移为只读,写入会直接失败。尚未应用的迁移属于待处理迁移:请编辑它,而不是为同一变更再新增一项迁移。
  • 迁移必须自包含。 在迁移文件中内联旧类型和新类型。只允许导入 mo:core/... 和 mops 包,绝不要导入 ../types 或任何项目模块。迁移链会永久重放;冻结的迁移如果导入了 Types.Note,一旦 Note 以不兼容的方式发生变化,就会变得不再正确。允许导入包的原因是组件自有的不透明状态:AccessControl.initState() 只能通过导入包来构造,包路径为 mo:caffeineai-authorization/access-control。
  • mops check --fix 会自动验证升级兼容性。

两种迁移类型

1. 隐式迁移(无需代码)

如果新程序与旧程序兼容,运行时允许升级。无需迁移函数。

对于下面隐式列表中的更改,不要添加迁移文件。 每个迁移文件都会在全新安装时永久重放;添加一个空操作文件会使迁移链变臃肿、拖慢全新安装,并生成一个无法删除的冻结产物。迁移链足够长时,部署会变得无法完成,且无论重试多少次都无法恢复;mops check 也不会发出警告。迁移越少越好。

身份函数体 = 无需迁移。 如果你的 (old : OldActor) : NewActor 函数体只会返回 old(或逐字段重建相同的记录,且不做转换),那么此更改具有稳定兼容性——删除该文件。

无需显式迁移即可进行的更改(常见的兼容性更改):

  • 更改字段的可变性(var 改为 let,或反之)
  • 添加变体构造器
  • 将 Nat 改为 Int(以及其他安全的超类型)
  • 其他符合 Motoko 稳定子类型规则的更改

需要显式迁移的更改:

  • 删除上一版本公开的任何 Actor 字段——在 OldActor 中接收该字段,并在 NewActor 中省略
  • 提升为 Any 或其他有损超类型
  • 重命名字段(在迁移中将旧字段映射到新字段)
  • 非简单转换(例如,将 Int 转为 Float、重构记录)
  • 添加新的稳定字段(初始值必须来自迁移;不允许使用内联初始化器)
  • 首次引入稳定状态(迁移负责提供初始值;Actor 声明稳定字段时只能写类型,不得使用内联初始化器)
2. 显式迁移(迁移函数)

当新状态的形状或类型不是对旧状态的简单兼容扩展时使用。

在 src/backend/migrations/ 中添加一个带时间戳的新文件;迁移链会自动重放。

规则:

  • 迁移函数类型:(old : OldActor) : NewActor。两个记录类型都必须是稳定的(不能包含共享函数或本地函数;使用基本类型、记录、变体、Option、Map、List 等)。
  • 定义域 OldActor:旧稳定字段的记录(名称和类型应与上一版本一致)。如果已停用的 Actor 缺少定义域所需的字段,升级会触发陷阱并中止。
  • 陪域 NewActor:新稳定字段的记录;每个字段都必须存在于新 Actor 中,且名称相同,类型是陪域类型的超类型。根据 Actor 中的 var 或 let,在输出中使用 var x = ... 或 x = ...。
  • 全新安装时,迁移链会按顺序从空 Actor 开始重放(第一个迁移的 OldActor = {});升级时,只运行比已部署尾部更新的条目。例外:在从旧版持久化方式转换的项目中,第一个文件的 OldActor 是转换前的稳定形状,而不是 {}——请保持原样(参见 troubleshooting-motoko-migrations)。
  • 每个 NewActor 字段的值都来自迁移函数体。在增强模式下,Actor 函数体中没有初始化器。
  • 为清晰起见,规范写法是在 OldActor 和 NewActor 中列出每个稳定字段(包括未更改的字段)。也支持只列出发生更改字段的部分形式:未更改的字段会自动保留。无论采用哪种形式,OldActor 中有而 NewActor 中没有的字段都表示显式丢弃(可能导致数据丢失)。
  • 如果迁移函数触发陷阱,升级会中止,canister 将保留在旧版本。迁移应保持纯粹,避免执行可能意外触发陷阱的操作。

多步升级(例如从 v1 升级到 v2,再到 v3):每个升级步骤都对应一个从上次部署版本开始的迁移。下一版本可以使用新的迁移(如果更改具有稳定兼容性,也可以不使用迁移)。

编写迁移

选择一个不带后缀的 UTC 时间戳文件名(YYYYMMDD_HHMMSS.mo),确保其排序位置晚于所有现有条目,然后编写模块:

module {
  type OldActor = { /* old stable fields, inlined */ };
  type NewActor = { /* new stable fields */ };

  public func migration(old : OldActor) : NewActor {
    { /* produce new fields from old */ };
  };
};

  • 必须是一个 module,导出 public func migration(old : OldActor) : NewActor(函数名是必需的——迁移链运行器会按名称查找它)。
  • OldActor 和 NewActor 都必须内联写在文件中——不得导入项目中的内容。

Actor 主体:只声明类型,不写初始化器

稳定变量只声明类型,不提供初始值。临时 let/var 字段则照常使用初始化器。

此处的无初始化器规则适用于增强型迁移项目(本技能的主题),这类项目的初始值由迁移链负责。在未使用增强型迁移链的普通项目中,稳定字段照常在 Actor 主体中使用内联值初始化(例如:persistent actor { let m = Map.empty<Nat, Text>(); })。

actor {
  let tasks : Map.Map<Text, Task>;
  var nextId : Nat;

  transient var cache = List.empty<Text>();
};

初始值来自迁移链。首次引入稳定状态时,编写一个迁移,其中 OldActor = {},而 NewActor 列出 main.mo 中声明的所有稳定字段。迁移主体必须为每个字段生成一个值。缺少字段会触发兼容性警告,并导致后续升级失败。

如果示例中的任何位置展示了直接在 Actor 主体中使用内联初始化器,例如 let accessControlState = AccessControl.initState(); 或 let users = Map.empty<Principal, User>();,那么这些示例早于增强型迁移。在增强型迁移下,这会导致编译错误(M0014、M0250)。这些示例只能作为状态结构的参考:将字段名和类型复制到 Actor 中(不带初始化器),并将初始化表达式移入迁移函数的 NewActor 输出中。

稳定兼容(定义)

旧签名与新签名稳定兼容,是指对于旧签名中的每个稳定字段 id : T:

  • 新签名包含一个稳定字段 id : U,且 T 是 U 的稳定子类型(旧值可作为新字段的初始值使用,不会丢失数据)。
  • 新签名可以包含旧签名中没有的其他字段(每个字段都由迁移提供)。
  • 匹配的字段可以具有不同的可变性(var 与非 var)。

因此:你可以扩大类型范围(例如,将 Nat 改为 Int),也可以更改可变性,而无需显式迁移。你不能删除字段、重命名字段或缩窄类型。添加稳定字段始终需要迁移:不允许使用内联初始化器,因此新字段的初始值必须来自迁移文件的 NewActor。

模式

添加带默认值的记录字段

使用 record with newField = value 为每个元素添加一个字段:

// Add required field zipCode to each taxpayer
old.taxPayers.map<Nat, OldTaxPayer, NewTaxPayer>(
  func(_id, oldTaxPayer) { { oldTaxPayer with zipCode = "" } }
);

对于新的可选字段,将其设为 null:

{ task with var status = task.status; var assignee = null : ?Principal }

添加或扩展变体类型

添加变体标签——将旧值转换为新的变体类型:

var status = bounty.status : NewTaskStatus // OldTaskStatus had #open;#claimed;#completed; New adds #expired

将 Bool 替换为变体:

var status = if (task.completed) { #completed } else { #pending };

遍历集合

使用 map<K, OldV, NewV>(func(key, oldVal) { ... }) 转换 Map 中的每个值:

let todos = old.todos.map<TaskID, OldTask, NewTask>(
  func(_, task) {
    {
      task with
      due = 0;
      var status = if (task.completed) { #completed } else { #pending };
    };
  }
);
{ var todos; var nextTaskId = old.nextTaskId };

添加新的顶层稳定字段(根据旧状态计算)

在迁移输出中包含新字段,并根据旧字段计算其值:

let times = List.repeat<Time.Time>(0, old.messages.size());
{ var messages = old.messages; var times };

重构:更改状态结构

当新状态的结构不同时,提供一个将旧字段类型转换为新字段类型的转换函数,并按照应用程序的迁移逻辑实现它。在迁移中根据旧状态构建新值,并返回与 NewActor 匹配的记录。

更改字段类型

重命名/概念变更——例如,将 artist : Text 改为 artists : Set.Set<Text>:

{ oldPainting with artists = Set.singleton<Text>(oldPainting.artist) }

重命名或更改单个字段的类型

输入记录使用旧名称/类型,输出记录使用新名称/类型:

// src/backend/migrations/YYYYMMDD_HHMMSS.mo
import Int "mo:core/Int";

module {
  type OldActor = { var state : Int };
  type NewActor = { var newState : Float };

  public func migration(old : OldActor) : NewActor {
    { var newState = old.state.toFloat() };
  };
};

有意删除字段

在迁移输入中接收该字段,但不要将其包含在输出中。预期会出现编译器警告;请确保数据丢失是有意的。

添加新字段

在 main.mo 中添加该字段,仅指定类型(不添加初始化值);同时在下一个迁移文件的 NewActor 中添加该字段,其值应根据旧字段计算得出,或使用常量。在增强迁移中,Actor 主体不能提供初始值。

迁移时语义

  • 迁移函数会在升级时的升级上下文中运行一次。迁移主体中的 Time.now() 等值表示升级时间,而非每条原始记录的创建时间。
  • 迁移中不要使用常量或魔法值;应根据迁移输入或定义明确的应用类型来推导默认值。

兼容性与工具

升级安全检查会将新的 Actor 主体与上一次部署的稳定签名进行比较:.most 文件(稳定签名的编码快照,包含字段名称和类型),该文件在 mops.toml 的 [canisters.<name>.check-stable] 下引用。配置完成后,mops check --fix 会自动识别该文件。托管平台通常会为你配置此项,并在项目中保留上一个 .most 文件。

若要通过检查:新迁移的 OldActor 必须与上一次部署的签名一致,而其 NewActor 必须与新的 Actor 主体一致,或是其稳定超类型。

要确定性地推导 OldActor:你的 OldActor 应等于 src/backend/migrations/ 中排在你的文件之前的文件(按词典顺序)的 NewActor;如果你的文件是链中的第一个文件,则为 {}。只有当迁移链从空 canister 开始时才适用 {}。从旧版持久化方式转换而来的项目,应从转换前的稳定结构开始;其现有的第一个文件已反映这一点。

升级检查清单

  • 决定采用隐式迁移(兼容性变更)还是显式迁移(新建迁移文件)
  • 每次构建最多只能有一个待处理的迁移文件。 创建文件前,检查 src/backend/migrations/——如果已有待处理的迁移,请编辑该文件,而不是再添加第二个文件
  • 如果采用显式迁移:选择一个纯 UTC 时间戳文件名(YYYYMMDD_HHMMSS.mo,不带后缀),确保其排序晚于所有现有文件;不要在文件名中说明变更内容
  • 将 OldActor 设为 src/backend/migrations/ 中排在你的文件之前的文件(按词典顺序)的 NewActor;如果你的文件是从一开始就有迁移链的项目中的第一个文件,则设为 {}。绝不能使用当前 main.mo 中的类型,也不能使用该文件自身的 NewActor。
  • 如果目录为空(初始化迁移),NewActor 必须列出 main.mo 中声明的所有稳定字段,并为每个字段提供值
  • 内联定义 OldActor(使用旧类型)和 NewActor(使用新类型)——不要导入项目模块
  • 实现 public func migration(old : OldActor) : NewActor
  • 绝不要修改或删除已应用的迁移;编辑待处理的迁移(不要重复添加)
  • 数据迁移不要使用 preupgrade/postupgrade 或 (with migration = ...)
  • 反复运行 mops check --fix(速度快),直到通过——它会验证编译和升级安全性
  • 最后运行一次 mops build(速度较慢),编译后端并生成更新后的 IDL 绑定

其他参考资料

  • 迁移示例:参阅 examples.md,获取简洁、独立的示例(添加字段、遍历集合进行映射、添加可选字段)
  • 遇到无法解释的问题时:对于无法解释的兼容性诊断、对冻结文件的写入失败、从旧版持久化方式转换而来的项目,以及移除迁移链的请求,请加载 troubleshooting-motoko-migrations。正常情况下无需加载。
  • Motoko 常规开发:使用 writing-motoko 了解语言基础知识、核心库参考资料和架构模式
  • mops 工具:加载 mops-cli,了解 mops.toml 配置、mops check、mops build 和工具链设置
  • Motoko 文档:数据持久化
  • Motoko 文档:兼容性

评论

支持 Markdown