Lorentz:类型安全的存储迁移

88 阅读8分钟

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的模型进行了如下转换。

  1. name 字段被重命名为 。theNamed
  2. sum 字段改变了它的类型。
  3. count 字段被添加。
  4. 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_mapget,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 来填充adminpendingAdmin 字段,而是写:

  ...

  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黑客技术,以及那里有哪些开发技术。
  • 最近增加的一些内容--表达式和嵌套字段访问等高级功能与手动堆栈管理结缘。
  • 多态性元编程能力,或者如何编写可配置和可扩展的合同,其中所有的版本都是一次性的类型检查。

和我们在一起吧!