Lorentz:类型安全的存储迁移
在Lorentz系列的上一篇文章中,我们考虑了可升级存储的实现。在Lorentz中,它被表示为UStore 类型,它是对big_map 的类型安全包装。它允许每个合约版本有自己的字段集和懒惰映射。
下面的问题可能会出现:我们如何使这个存储可迁移? 一个通用而方便的方法是在我们的契约中包含一个带有lambda storage storage 参数的入口。它应该只能由管理员调用,并通过提供的lambda来更新存储。 我们将集中讨论这个方法。更多关于这个的内容你可以在我们对可升级技术的分析中找到。
下一个问题:如果在Lorentz中,存储中可用的字段和地图是以类型处理的,那么我们能不能让存储迁移也是类型安全的?
在这篇文章中,我们将看到Lorentz是如何解决这个愿望的。
免责声明:这里介绍的设施仍处于原型阶段。 无论好坏,我们还没有需要迁移任何由我们编写的生产合同。一旦出现需求,迁移框架就会完成。
简单的迁移
如果框架能确保存储完全更新,那就更好了。 Lorentz为安全地编写存储迁移脚本提供了特殊的原语。
作为一个例子,让我们考虑将下面的存储迁移:
data MyTemplateV1 = MyTemplate
{ name :: UStoreField MText
, sum :: UStoreField Natural
, useless :: UStoreField ()
, bytes :: Integer |~> ByteString
} deriving stock Generic
type StorageV1 = UStore MyTemplateV1
转换成这个:
data MyTemplateV2 = MyTemplate
{ theName :: UStoreField MText
, sum :: UStoreField IntegerWe
, count :: UStoreField Natural
, bytes :: Integer |~> ByteString
} deriving stock (Eq, Show, Generic)
type StorageV2 = UStore MyTemplateV2
为了得到版本2的模型,我们对版本1的模型进行了如下转换。
name字段被重命名为 。theNamedsum字段改变了它的类型。count字段被添加。useless字段被删除。
现在让我们看看如何为我们的合同写下这些变化。
首先,我们插入一个迁移代码的模板。
migration :: UStoreMigration MyTemplateV1 MyTemplateV2
migration = mkUStoreMigration do
-- leaving empty for now
migrationFinish
这还只是一个迁移的存根。mkUStoreMigration 的内容仅仅是类型为[MUStore ...] :-> [MUStore ...] 的洛伦兹代码。它接受初始存储作为其唯一的堆栈参数,并且应该返回完全迁移的存储。存储作为一个MUStore 类型(指*可变的UStore )*被传递,在类型中跟踪迁移进度。
注意最后放置的migrationFinish 调用。migrationFinish 是一个类似于nop 的指令,它检查迁移是否完全执行,如果一些迁移步骤还没有完成,就会引发一个编译错误。(对这个函数的调用可以省略,但那样我们会得到一个不那么容易被人阅读的错误)。
在我们的例子中,我们会看到:
• Migration is incomplete, remaining diff:
+ `theName`: field of type `MText`
+ `sum`: field of type `Integer`
+ `count`: field of type `Natural`
- `name`: field of type `MText`
- `sum`: field of type `Natural`
- `useless`: field of type `()`
所以,正如预期的那样,bytes 不应该被修改。 现在我们来处理剩下的字段。
类似于big_map 有get,update, 和其他的指令,对于MUStore ,我们有专门的适合于迁移的特定方法,这说明已经执行了一些步骤。
首先,让我们添加count 字段,将其初始化为0。
migration = mkUStoreMigration do
push 0
migrateAddField #count
migrationFinish
现在,关于count 的观点已经从错误信息中删除了,这意味着我们已经覆盖了这部分的差异。
同样地,我们要删除useless 字段:
...
migrateRemoveField #useless
...
在其他情况下,我们不希望只是删除一个字段,而是要把它挑出来继续使用。在我们的迁移中,name 字段应该被取走,并作为theName 放回。
为此,有一个专门的migrateExtractField ,它可以获取一个字段的值,并将其标记为从旧存储中删除。
让我们在这里使用它:
...
migrateExtractField #name
push [mt|The |]; concat -- Prefix the name with "The "
migrateAddField #theName
...
现在的错误信息是:
• Migration is incomplete, remaining diff:
+ `sum`: field of type Integer
- `sum`: field of type Natural
由于sum 在新版本中具有不同的类型,它也必须被迁移。我们可以用与name 字段相同的方法来改变它。
在这一点上,我们的代码已经编译完毕,这意味着迁移脚本已经完成。
如果我们试图添加一个不必要的字段或者删除一个不存在的字段,会怎么样呢? 因为所有的步骤都被跟踪了,我们会得到一个编译时错误:
migration = do
...
push @Natural 0
migrateAddField #number -- ← "number" instead of "count"
...
• Failed to find plain field or submap in store template
Datatype `V2.MyTemplate` has no field "number"
• In a stmt of a 'do' block: migrateAddField #number
或者:
migration = do
...
migrateExtractField #myName -- ← "myName" instead of "name"
L.push [mt|The |]; L.concat
migrateAddField #theName
...
• Failed to find plain field or submap in store template
Datatype `V1.MyTemplate` has no field "myName"
• In a stmt of a 'do' block: migrateExtractField #myName
最后,我们如何处理构建的UStoreMigration 值?在简单的情况下,我们调用migrationToScript 来编译迁移到MigrationScript ,这只是对Lambda (UStore ...) (UStore ...) 的包装。这个迁移脚本以后可以被打印出来,但大多数情况下,它只是被传递给合同的升级接口。
即时填充
迁移的一个角落是只添加字段的迁移。为每个字段手动调用migrateAddField ,这太令人厌烦了,所以我们提供了一些帮助工具来涵盖这种情况。
例如,从V0 (空存储)迁移到V1 ,看起来如何?通常,它只是增加了V1 存储的所有字段。如果能像我们通常为不可升级的合同提供初始存储一样,轻松构建一个迁移脚本,那就更好了。
而且我们可以:
migrationToV1 :: UStoreMigration () MyTemplateV1
migrationToV1 = fullUStore MyTemplate
{ name = UStoreField "name"
, sum = UStoreField 0
, useless = UStoreField ()
, bytes = mempty
}
如果UStore 有一个嵌套结构,我们也可以用这个技巧来只填充其中的一部分。
比方说,在V2 ,我们还想为管理功能添加存储,就像这样:
data AdminTemplate = AdminTemplate
{ admin :: Address
, pendingAdmin :: Address
} deriving (Generic)
initAdminStore :: Address -> AdminTemplate
initAdminStore admin = AdminTemplate admin admin
一旦我们在MyTemplateV2 中加入一个新的admin 字段,编译器就会立即告诉我们,我们的migration 又不完整了。为了解决这个问题,我们可以不象以前那样在脚本中使用migrateAddField 来填充admin 和pendingAdmin 字段,而是写:
...
migrateFillUStore (initAdminStore myAdmin)
...
所以很多时候甚至不需要手动编写迁移脚本:我们可以直接在Haskell中构建准备好的值,然后把它们传递给迁移。 而在需要更多控制的时候,可以回退到脚本中。
这方面的一个常见用例是入口,其实现可以保留在存储中。 当存储类型改变时,更新所有的入口是比较安全的,因为其中的一些代码可能无法在新的存储格式下工作。为此,我们可以将所有的入口作为UStore ,并通过migrateFillUStore 。
考虑到目前的合约代码,最好是在运行时检查入口的更新要求。 这将在未来的Lorentz版本中得到支持。
分批迁移
生活中一个可悲的事实是,不是每一次迁移,如果放到一个交易中,都能符合Tezos的限制。 我们需要一种方法,把一个迁移分成无数个脚本,按顺序应用。
显然,框架没有办法看到如何将脚本切成碎片。 应该允许用户将明确的边界直接放到脚本中。
为了处理这一需求,Lorentz提供了一个专门的批处理接口(注意,这里的 "批处理 "概念与Tezos的交易批处理是平行的)。
如果我们用批处理块的方式重写我们上面的脚本,我们会得到:
batchedMigration :: UStoreMigration V1.MyTemplate V2.MyTemplate
batchedMigration = mkUStoreBatchedMigration $
muBlock do
L.push 0
migrateAddField #count
<-->
muBlock do
migrateRemoveField #useless
<-->
muBlock do
migrateExtractField #name
L.push [mt|The |]; L.concat
migrateAddField #theName
<-->
muBlockNamed "change type of 'sum'" do
migrateExtractField #sum
L.int
migrateAddField #sum
<-->
migrationFinish
在这一点上,我们创建了与之前非常相同的迁移。唯一不同的是,它把升级代码作为一组孤立的区块来记忆。 这里需要记住的主要不变因素是--区块的任意重新排列不应该破坏迁移的正确性。 这样,即使迁移区块在链上出现了重新排列,迁移仍然有效。
难道我们不能把所有迁移部分放在一个Tezos交易批次中,这样就能固定执行顺序吗?
交易批次也有其局限性。在撰写本文时,一个交易批次所需的气体不能超过10 * L ,其中L 是单个交易的硬气体限制。因此整个迁移可能不适合放在一个批次中。
块可以被命名;如果用户想查看的话,这将会影响到打印出来的迁移计划。 默认情况下,名字只是反映了块内执行的一组动作,比如add "count" 或remove "useless" 。对于最后一个块,我们默认会得到remove "sum", add "sum ,这可能看起来不清楚,所以我们给它分配了一个明确的名字。
现在我们使用compileMigration ,将迁移分割成若干部分。作为第一个参数,它接受一个MigrationBatching 类型的值,该值描述了将块重新排序并联合成迁移批次的方式(每个事务一个迁移批次),以尽量减少最终的事务数量。
有多种批处理策略可供选择,琐碎的策略有:1:mbNoBatching mbBatchesAsIs ,它只是将所有的区块联合起来,而每一个交易都放一个区块。
通常情况下,mbSeparateLambdas 是一个明智的选择。在这种策略下,所有的数据字段构成一个事务,而所有看起来像存储入口的附加字段构成另一个独立的事务。 特别是更聪明的策略的实现--考虑到实际气体和操作大小的限制--是留给未来工作的。
一个例子:如果我们想看看其中一个琐碎的批处理策略是如何工作的,我们可以写。
λ> import Fmt (fmt, indentF)
λ> compiledMigration = compileMigration mbBatchesAsIs migrationBatched
λ> fmt $ "For my contract:\n" <> indentF 2 (renderMigrationPlan compiledMigration)
For my contract:
Migration stages:
1) add "count"
2) remove "useless"
3) remove "name", add "theName"
4) change type of 'sum'
编译的迁移后来可以转化为一个或多个MigrationScript,这取决于使用的是哪种批处理策略。
结论
在这篇文章中,我们考虑了Lorentz是如何实现类型安全的存储迁移的。
碰巧的是,我们的生产合同中还没有一个需要迁移,所以框架的这一部分还有待完善。 例如,大地图的迁移还完全不被支持,其他一些愿望也有待实现。
尽管如此,看到该框架如何推动用户完成迁移过程并确保迁移的完成,还是很有意思的。
在关于可升级性的下一篇(第三篇)文章中,我们将考虑可升级合约的实现。
在那之后,我们计划涵盖以下主题:
- 如何在15分钟内开始用Lorentz黑客技术,以及那里有哪些开发技术。
- 最近增加的一些内容--表达式和嵌套字段访问等高级功能与手动堆栈管理结缘。
- 多态性的元编程能力,或者如何编写可配置和可扩展的合同,其中所有的版本都是一次性的类型检查。
和我们在一起吧!