关于 migrating-motoko-actors
面向使用正交持久化升级 Motoko canister 的开发者,说明稳定状态变更何时需要显式迁移,以及如何编写由 mops 管理的迁移链。
主要功能
- 01区分隐式与显式迁移
- 02说明迁移文件命名规则
- 03解释稳定状态初始化
- 04提供状态转换模式
适用场景
- 01判断升级是否需要迁移
- 02新增或重构稳定 Actor 状态
- 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 文档:兼容性