升级 CorDapp(非平台版本升级)

源文档地址:https://docs.corda.net/upgrading-cordapps.html

CorDapp 版本

Corda 平台没有要求每个 CorDapp 要保持同样一个版本。CorDapp 的不同元素可以分别去升级:

有些时候,当对一个元素进行改动之后,可能会需要其他元素也改动。比如,修改了一个共享的数据结构可能会需要 flow 的改动因为这个是不向后兼容的。

需要考虑的方面

这个文档将会考虑下边类型的版本:

Flow 版本

任何初始化其他 flows 的 flow 必须要使用 @InitiatingFlow 注解,像下边这样定义:

annotation class InitiatingFlow(val version: Int = 1)

version 属性默认值为1,定义了 flow 的版本。当flow 有任何一个新的 release 的时候并且这个 release 包含的变动是非向下兼容的,这个数值应该增加。一个非向下兼容的改动是一个改变了 flow 的接口的变动。

Flow 的接口是如何定义的?

Flow 的接口是通过在 InitiatingFlowInitiatedBy flow 之间有序的 sendreceive 调用来定义的,包括发送和接受的数据的类型。我们可以将 flow 的接口如下图这样表示:

Flow 接口

在上边的图中,InitiatingFlow

哪些是非向下兼容的改动?

Flow 可以有两种主要的方式会变为非向下兼容的:

当运行不兼容版本的 flows 会发生什么?

带有非兼容接口的 InitiatingFlowInitiatedBy flows 可能会出现下边的行为:

我应该如何升级我的 flows?

  1. 更新 flow 并且测试。在 InitiatingFlow 注解中增加 flow 版本号。
  2. 确保已经存在的所有版本的 flow 已经运行完了并且没有未结束的 SchedulableFlows 在网络中的任何节点中。这个可以通过清理节点的方式来实现,接下来会讲到。
  3. 关闭节点
  4. 用包含新的 flow 的 CorDapp JAR 文件替换掉原来的 CorDapp JAR
  5. 启动节点

如果你关掉了所有的节点并且同时更新了他们的 flow 的话,可能产生任何的不兼容的改动。

对于一些节点可能仍旧继续运行某个 flow 的以前版本的情况,这样你的新版本 flow 可能会跟一个旧版本进行沟通,更新的 flows 需要具备向下兼容性。这可能是任何真正的部署中都会发生的问题,你可能不会很容易地去在整个网络中去协调推出一个新的 code。

我该如何确保 flow 的向下兼容性?

InitiatingFlow 版本号会被包含在 flow session handshake 中并且通过 FlowLogic.getFlowContext 方法暴露给双方。这个方法需要一个 Party 作为输入,然后会返回一个 FlowContext 对象,这个对象描述了在对方节点上正在运行的 flow。它含有一个 flowVersion 的属性,可以使用这个属性来在不同的 flow 版本间来定制你自己的 flows,例如:

@Suspendable
override fun call() {
    val otherFlowVersion = otherSession.getCounterpartyFlowInfo().flowVersion
    val receivedString = if (otherFlowVersion == 1) {
        otherSession.receive<Int>().unwrap { it.toString() }
    } else {
        otherSession.receive<String>().unwrap { it }
    }
}

上边的代码演示了当 flow 的第一个版本期望收到一个 Int,但是后续的版本变成了期望收到一个 String。这个 flow 在跟其他仍然运行着包含旧的 flow 的旧的 CorDapp 之间还是能够进行沟通的。

我该如何处理关于 in-lined subflows 的接口变化?

下边是一个 in-lined subflow:

@StartableByRPC
@InitiatingFlow
class FlowA(val recipient: Party) : FlowLogic<Unit>() {
    @Suspendable
    override fun call() {
        subFlow(FlowB(recipient))
    }
}

@InitiatedBy(FlowA::class)
class FlowC(val otherSession: FlowSession) : FlowLogic() {
    // Omitted.
}

// Note: No annotations. This is used as an inlined subflow.
class FlowB(val recipient: Party) : FlowLogic<Unit>() {
    @Suspendable
    override fun call() {
        val message = "I'm an inlined subflow, so I inherit the @InitiatingFlow's session ID and type."
        initiateFlow(recipient).send(message)
    }
}

In-lined subflows 是当跟对方初始一个新的 flow session 的时候被调用的 flows。假设 flow A 调用 in-lined subFlow BB 初始了一个跟对方的会话(session)。对方使用的 FlowLogic 类型决定应该调用哪个对应的 flow 应该是由 A 决定的,而不是 B。这意味着 in-lined flow 的 response logic 必须要在 InitiateBy flow 里被显式地实现。这个可以通过调用一个匹配的 in-lined counter-flow,或者在对方的被初始的父的 flow 中显式地实现。In-lined subflows 也会从他们的父 flow 中继承 session IDs。

因此,一个 in-lined subflow 的一个借口的改动必须要考虑对父 flow 接口也要有一个改动。

一个 in-lined subflow 的例子是 CollectSignaturesFlow。他有一个没有 InitiateBy 注解的 response 的 flow 叫 SignTransactionFlow。这是因为这两个 flows 都是 in-lined。这两个 flows 是如何彼此交流的是通过调用他们的父 flows 来定义的。

在代码中,in-lined subflows 看起来就是一个常规的 FlowLogic 的实例,但是没有 InitiatingFlow 或者 InitiatedBy 注解。

In-lined subflows 是没有版本的,因为他们的版本是继承于他们的父 flow 的(InitiatingFlowInitiatedBy)。

不是 InitiatingFlow 或者 InitiatedBy flow,也不是由一个 InitiatingFlow 或者 InitiatedBy flow 调用的 in-lined subflows ,更新的时候可以不考虑向下兼容的问题。这种类型的 flows 包括用来查询 vault 的 utility flows,或者对外部系统进行查询的 flows。

Flow 排空

一个 flow 检查点(checkpoint)是一个序列化的 flow 的堆栈结构(stack frames) 和 任何可以从堆栈中拿到的对象的 snapshot。检查点会在一个 flow 挂起后者恢复的时候被自动存到数据中,这个通常会在发送或者接收消息的时候发生。当节点重启的时候,一个 flow 可能会从最后一个检查点开始重新运行。自动的创建检查点是 Corda 提供的 一个非常规的功能,这会很大地帮助开发者编写可靠的代码来确保当节点重启或者 crash 之后节点还能够继续正常运行。这个也帮助了向上扩展(scaling up),因为当 flows 在等待一个 response 的时候,他们会被从内存中清理掉。

然而,这也意味着将 flow 从一个旧版本恢复到一个新的版本的时候,可能会造成重启失败。比如如果你从一个方法中删除了一个本地变量,这个变量在以前的版本中是有的,那么 flow 引擎是无法找出之前存储的变量值应该放在哪里的。

因此,在当前版本的 Corda 中,在做一个改变了 @Suspendable 代码更新的一个应用升级之前,你必须要排空节点。排空操作会组织开始一个的 flows,但是仍旧允许完成已经存在的 flows。因此当一次排空操作完成的时候,就不应该有任何特别的检查点或者是正在运行的 flows 了。这样升级应用才会成功。

一个节点可以使用 setFlowsDrainingModeEnabled 方法来决定要排空还是不要排空,这个可以通过 shell ,使用标准的 run 命令来调用 RPC 来实现。

Contract 和 state 版本

这里有两种类型的 contract/state 升级:

  1. 隐式的升级:使用约束(constraints)允许提前对于 contract 开发多种实现
  2. 显式的升级:创建一个特殊的更新合约的 transaction然后使用升级合约 flows 来获得 state 的所有参与者的签名

这里我们会关注显式的升级。

在显式的升级中,contracts 和 state 可以按照任何的方式来变化,这些变化仅仅在 state 的所有参与者对这个升级都同意的条件下才会生效。下边是可能的更新组合:

使用一个特殊日子(flag-day) 的方式来更新 state 或者 contract 非常简单:

更新流程

编写新的 state 和 contract 定义

由更新 contract 和/或 state 定义开始。对于如何更新 states,并没有任何的限制。但是更新 contracts 必须要实现 UpgradedContract 接口。接口定义如下:

interface UpgradedContract<in OldState : ContractState, out NewState : ContractState> : Contract {
    val legacyContract: ContractClassName
    fun upgrade(state: OldState): NewState
}

upgrade 方法描述了旧的 state 类型是如何更新成新的 state 类型的。如果 state 没有更新的话,那可以给旧的和新的 state 类型参数使用相同的 state 类型。

新的 contract 默认只能够更新在白名单中的已有的 states。如果使用了 hash 或者其他的约束类型话,新的 contract 必须要实现 UpgradedContractWithLegacyConstraint,并且需要显式地指明是哪种约束:

interface UpgradedContractWithLegacyConstraint<in OldState : ContractState, out NewState : ContractState> : UpgradedContract<OldState, NewState> {
    val legacyContractConstraint: AttachmentConstraint
}

比如,如果是 hash 约束的话,那么原始的 JAR 文件的 hash 需要被提供:

override val legacyContractConstraint: AttachmentConstraint
    get() = HashAttachmentConstraint(SecureHash.parse("E02BD2B9B010BBCE49C0D7C35BECEF2C79BEB2EE80D902B54CC9231418A4FA0C"))

升级授权 Authorising

如果新的 states 和 contracts 已经被放到了所有节点的 classpath 下之后,下一步就是每个节点去运行 ContractUpgradeFlow.Authorise flow。这个 flow 会带有一个需要更新的 StateAndRef 的 state,还有一个对新的 contract 的引用,这个 contract 必须要实现 UpgradedContract 接口。

在任何时间,节点的管理员都可以通过运行 ContractUpgradeFlow.Deauthorise flow 来不通过一个 contract 的升级。

执行升级

当所有的节点都执行完了授权流程后,必须要选择一个参与节点通过 ContractUpgradeFlow.Initiate flow 来初始对每个 state 对象的更新。这个 flow 有这样的特点:

class Initiate<OldState : ContractState, out NewState : ContractState>(
    originalState: StateAndRef<OldState>,
    newContractClass: Class<out UpgradedContract<OldState, NewState>>
) : AbstractStateReplacementFlow.Instigator<OldState, NewState, Class<out UpgradedContract<OldState, NewState>>>(originalState, newContractClass)

这个 flow 是 AbstractStateReplacementFlow 的子类(sub-class),它也可以用来对不需要更新 contract 的 state 对象进行更新。

当 flow 成功结束后,所有参与节点的旧的 state 对象应该被更新为升级过的 state 对象了,他们也会指向新的 contract code。

需要注意的点

Contract 更新 flows 的能力

编写新的 states 和 contracts

对于旧的 contract code JAR 文件

当前,所有的参与节点必须要在节点的 classpath 中保留旧的 state 和 contract 定义,因为在验证 transactions 的时候,他们始终会被要求验证以前使用的旧版本的 state 和 contract。

注意:你需要注意可能的 classpath 冲突。如果你还保留着旧版本的 JAR 文件的话,请确保新版本的不会包含同样名字的类(比如在 Kotlin 中文件级别的声明会被放在一个名字在文件名后边的静态类中)

权限 Permissioning

流程 Logistics

序列化

当前,所有的序列化格式除了 flow checkpoints(使用 Kryo-based 格式) 以外都是基于 AMQP 1.0,一个自描述(self-describing)和可控的序列化格式。AMQP 是正确的选择因为除了被序列化的数据本身,它允许我们可以定义一个 schema 来描述什么被序列化了。

编写类

虽然并不是跟版本有着很严格的联系,AMQP 序列化要求我们要以一种特别的方式来编写我们的类:

编写枚举 enums

在一个新版本的代码中元素是没有办法被添加到枚举中的。因此,枚举仅仅针对于永远不会改变(比如一周的天数)的静态数据是适用的。Buy 或者 Sell 标志是另外一个例子。但是,像类似于 Trade Type 或者 Currency Code 这样的数据可能就要改变了。对于这样的数据,更建议用另外一种方式来表示,比如字符串。

State Schemas

默认的,所有的 state 对象都会以被序列化为字节格式的字符串而存到数据库中,并且会被他们的 StateRef 引用。然而对某些特定的属性或者一些属性的集合的序列化也是可以定义自定义的 schemas 的,所以他们就可以从一个数据源被检索而不是直接检索 Corda Vault。这个是通过实现 QueryableState 接口并且对这个 state 创建一个自定义的 ORM(Object Relational Mapper) 来实现的。

针对于向后兼容性,像添加新的 columns 这样的改动,升级一个 state schema 的过程其实是对已经存在的 ORM 进行扩展。比如,我们可以将下边的 schema:

object ObligationSchemaV1 : MappedSchema(Obligation::class.java, 1, listOf(ObligationEntity::class.java)) {
    @Entity @Table(name = "obligations")
    class ObligationEntity(obligation: Obligation) : PersistentState() {
        @Column var currency: String = obligation.amount.token.toString()
        @Column var amount: Long = obligation.amount.quantity
        @Column @Lob var lender: ByteArray = obligation.lender.owningKey.encoded
        @Column @Lob var borrower: ByteArray = obligation.borrower.owningKey.encoded
        @Column var linear_id: String = obligation.linearId.id.toString()
    }
}

更新成:

object ObligationSchemaV1 : MappedSchema(Obligation::class.java, 1, listOf(ObligationEntity::class.java)) {
    @Entity @Table(name = "obligations")
    class ObligationEntity(obligation: Obligation) : PersistentState() {
        @Column var currency: String = obligation.amount.token.toString()
        @Column var amount: Long = obligation.amount.quantity
        @Column @Lob var lender: ByteArray = obligation.lender.owningKey.encoded
        @Column @Lob var borrower: ByteArray = obligation.borrower.owningKey.encoded
        @Column var linear_id: String = obligation.linearId.id.toString()
        @Column var defaulted: Bool = obligation.amount.inDefault               // NEW COLUMN!
    }
}

因此当添加一个新的 column 的时候,给它一个默认值。

对于一个非向后兼容的改动,那么必须要使用 ContractUpgradeFlow 或者 AbstractStateReplacementFlow,因为必须要对 state 也要做改动。对于一个非向后兼容的改动,比如删除了一个 column(比如因为某个属性需要从 state 对象中被删除),更新的过程应该是定义另外一个 ORM,然后将它添加到你的 QueryableStatesupportedSchemas 属性中,像下边这样:

override fun supportedSchemas(): Iterable<MappedSchema> = listOf(ExampleSchemaV1, ExampleSchemaV2)

然后在 generateMappedObject 中添加对新的 schema 的支持:

override fun generateMappedObject(schema: MappedSchema): PersistentState {
    return when (schema) {
        is DummyLinearStateSchemaV1 -> // Omitted.
        is DummyLinearStateSchemaV2 -> // Omitted.
        else -> throw IllegalArgumentException("Unrecognised schema $schema")
    }
}

通过这种方式,当 state 对象被存储到 vault 中的时候,它的代表(representation)会被分别存储到两个数据库表中,每个代表着一个支持的 schema。