以太坊EVM是当前区块链行业应用最为广泛的虚拟机。其所支持的智能合约语言是图灵完备的。该语言支持各种基础类型(Booleans,Integers,Address,String,Enum,Address等)、复杂类型(Struct,Mapping,Array等)、复杂的表达式和控制结构及接口继承等面向对象的特性。
正是由于强大的智能合约语言,原本在真实世界中的复杂商业逻辑和应用都能在区块链上轻松实现。然而需要注意的是,尽管公有链可以实现合理的GAS机制自我保护,联盟链可以用其他机制替代GAS的计算及代币化来保障EVM沙盒安全,但由于区块链运行机制的原因,智能合约的运行即使是异常运行都会在所有区块链节点上独立重复运行。因此,无论是在公有链还是联盟链运行智能合约都是非常昂贵(运算资源、存储资源)的操作。
另外,智能合约与传统应用程序有一个不同的地方在于智能合约一经发布于区块链上就无法篡改,即使智能合约中有Bug需要修复或者业务逻辑变更,它也不能直接在原有的合约上直接修改再重新发布。因此在设计之初就需要结合业务场景考虑合理的升级机制。
总而言之,智能合约实现上要达到的目标是: 完备的业务功能、精悍的代码逻辑、良好的模 块抽象、清晰的合约结构、合理的安全检查、完备的升级方案 。
智能合约的生命周期主要有设计、开发、部署、运行、升级、销毁。在下文中主要是基于目标在设计阶段、升级阶段的一些梳理总结。
1. 最佳实践
从业务视角来看,智能合约只需要做两件事,其一是如何定义数据的结构和读写方式,其二是如何处理数据并对外提供服务接口。
为了更好的做好模块抽象和合约结构分层,将这两件事分开,既是将业务控制逻辑和数据从合约代码层面就做好分离,这样的处理在复杂业务逻辑场景中经过实践是当前被认为最佳的模式。
这个模式简称为CD(Controller-Data)模式。将合约分为两类:控制器合约(Controller Contract)与数据合约(Data Contract)。
控制器合约通过访问数据合约获得数据,并对数据做逻辑处理,然后写回数据合约。它专注于对数据的逻辑处理和对外提供服务。根据处理逻辑的不同,常见的有命名空间控制器合约、代理控制器合约、业务控制器合约、工厂控制器合约等。一般情况下,控制器合约不需要存储任何数据,它完全依赖外部的输入来决定对数据合约的访问。特殊情况下,控制器合约可以存储某个固定的数据合约的地址或者命名空间(通过命名空间在运行时获得合约地址)。
数据合约专注于数据结构定义与所存储数据的读写裸接口。为了达到数据统一访问管理和数据访问权限控制的目的,最好是将数据读写接口只暴露给对应的控制器合约。禁止其他方式的读写访问。
基于这个模式,遵循从上至下的分析方式,从对外提供的服务接口开始设计各类控制器合约,再逐步过渡到服务接口所需要的数据模型和存储方式,进而设计各类数据合约,可以较为快速的完成合约架构的设计。
2. 实用设计案例
在CD模式下,根据控制器合约与数据合约之间的操作关系,从逻辑上归结为四类:
-
控制器合约与数据合约 1->1
-
控制器合约与数据合约 1->N
-
控制器合约与数据合约 N->1
-
控制器合约与数据合约 N->N
假设一个业务场景:将全国所有银行的业务和信息上链。
2.1 控制器合约与数据合约: 1->1
假设全国只有两家银行,A银行和B银行。A银行只有存款业务,B银行只有取款业务。一种可能的设计是这样的:
代理控制器合约:面向Dapp,是所有业务合约的入口,提供命名空间服务,提供了命名空间到合约地址的映射。使得Dapp对链上合约升级导致的地址变更无感知。例如,Dapp对A银行的存款请求只需要(“BankA",deposit,args) 即可。对B银行的取款请求只需要(”BankB",withdraw,args)即可。代理器控制合约实现上应该是区块链底层内置的、固化的,或者是业务上极少变更的。Dapp在业务运行之前已经明确知道代理控制器合约的地址。
命名控制器合约:面向链上合约,提供命名空间服务,提供了命名空间到合约地址的映射。使得链上合约可以在运行时根据命名获得实际的合约地址。例如,A银行控制器合约向命名控制器合约请求(“BankA-Data"),可以获得A银行数据合约地址,使得A银行控制器合约可以在运行时访问A银行数据合约。它与代理控制器合约的主要不同在于服务对象的不同,代理控制器合约面向Dapp,命名控制器合约面向链上合约。另外,命名控制器合约包含有版本控制的设计(下文第3.2节介绍),可以根据需要配合灰度策略的实施。
A银行控制器合约:提供了存款服务接口deposit。部署初始化时已经明确知道自己的身份”BankA"。运行时通过命名控制合约获得”BankA-Data“的合约地址。
A银行数据合约:保存了A银行的当前余额。提供add和sub接口给A银行控制器合约来更新余额信息。
B银行控制器合约:提供了存款服务接口withdraw。部署初始化时已经明确知道自己的身份”BankB"。运行时通过命名控制合约获得”BankB“的合约地址。
B银行数据合约:保存了B银行的当前余额。提供add和sub接口给B银行控制器合约来更新余额信息。
对A银行的存款请求的流程是这样的:
-
Dapp指定代理控制器合约地址,请求存款交易(“BankA",deposit,money)
-
代理控制器合约,运行时得到”BankA"对应的A银行控制器合约地址,并向A银行控制器合约请求存款交易(deposit,money)
-
A银行控制器合约的deposit接口向命名控制器合约请求“BankA-Data"获得A银行数据数据合约地址,并访问到A银行数据合约的数据,然后执行一些存款业务逻辑。返回结果。
-
依次返回结果到Dapp。
2.2 控制器合约与数据合约: 1->N
假设全国有N家银行,所有银行都有存款业务和取款业务,并且业务流程都是一样的。一种可能的设计是这样的:
这个设计与上面的2.1不一样的地方在于,将存款服务接口和取款接口都集中归结到银行业务控制器合约里面了。这意味着任何银行的存款和取款业务都由银行业务控制器合约来统一处理,处理逻辑上不再区分是A银行还是B银行,只是在数据访问的时候需要根据入参的不同来决定访问不同的银行数据合约。
还有,于2.1相比,对于Dapp而言,它发出请求的时候只需要将请求发往固定的”Bank"就可以了,不用具体关心某个银行。
另外,由于银行有很多个,并且它们的存储结构都是一样的,因此可以设计一个银行数据合约的工厂控制器合约,来负责对新的数据合约的生成模块化。
对A银行的存款请求的流程是这样的:
-
Dapp指定代理控制器合约地址,请求存款交易(“Bank",deposit,”BankA“,money)
-
代理控制器合约,运行时得到”Bank"对应的银行业务控制器合约地址,向银行业务控制器合约请求存款交易(deposit,”BankA“,money)
-
银行业务控制器合约的deposit接口向命名控制器合约请求“BankA-Data"获得A银行数据数据合约地址,并访问到A银行数据合约的数据,然后执行一些存款业务逻辑。返回结果。
-
依次返回结果到Dapp。
2.3 控制器合约与数据合约: N->1
假设全国有N家银行,所有银行都有存款业务和取款业务,并且业务流程都是一样的,但是由于业务逻辑较为复杂,出于模块化维护的需要,需要将存款业务和取款业务做分拆。一种可能的设计是这样的:
这个设计与上面的2.2不一样的地方在于,将存款服务接口和取款接口拆分到了不同的业务控制器合约里面了。这意味着不同的业务逻辑从模块上做了清晰的切分。对于Dapp而言,它发出请求的时候需要明确指向所对应的业务接口。
对A银行的存款请求的流程是这样的:
-
Dapp指定代理控制器合约地址,请求存款交易(“deposit",”BankA“,money)
-
代理控制器合约,运行时得到”deposit"对应的存款业务控制器合约地址,向存款业务控制器合约请求存款交易(”BankA“,money)
-
存款业务控制器合约的deposit接口向命名控制器合约请求“BankA-Data"获得A银行数据数据合约地址,并访问到A银行数据合约的数据,然后执行一些存款业务逻辑。返回结果。
-
依次返回结果到Dapp。
2.4 控制器合约与数据合约: N->N
此类情况可以拆解为上面三种情况的组合。不再赘述。
2.5 总结
从Dapp视角考虑,可以总结如下:
CD模式 | 特点 |
---|---|
1->1 | 面向业务对象 |
1->N | 面向业务流程 |
N->1 | 面向业务接口 |
N->N | / |
3. 升级
在CD模式下,在业务逻辑变更需要升级合约的情况下,根据控制器合约与数据合约的升级关系来划分,可以归纳为以下三种情况:
控制器合约 | 数据合约 |
---|---|
升级 | 不升级 |
不升级 | 升级 |
升级 | 升级 |
在升级过程中,还需要考虑是全量升级还是灰度升级?如果是灰度升级,灰度策略是怎么样的?另外,在多链场景和单链场景、跨链场景,升级过程是否有不同?多链场景的灰度策略如何考虑?新旧版本数据能否共存?如果需要数据迁移,如何做到无缝迁移?
下面以最为常见的1->N 场景来介绍不同的升级情况。
3.1 控制器合约升级,数据合约不升级