智能合约的设计和传统的应用设计有点不同。传统应用一般为了快速迭代是在产品之后考虑安全,但是 DApp 则需要在产品出来之前就考虑安全问题,它将会关系到账户资产、用户数据等问题,而且对 DApp 来讲,升级是个比较麻烦的事情,因此在智能合约设计时,结构是非常重要的部分。本文将为大家阐释如何来设计这个结构。
目前 DApp 面临问题
首先,是关于 DApp 和 App。事物发展将会遵循技术为王、产品为王、最后到运营为王三个发展阶段。现在,区块链和 DApp 正处于技术为王阶段。整个市场上的 DApp,在性能和用户友好性上,都不如 App。DApp 的优势显而易见:去中心化,它是依附区块链的应用。但是我们认为很多 DApp 的短板,其实是因为底层区块链的限制。
其次,是关于安全。现在 DApp 爆发的安全漏洞很多,主要原因是区块链仍处于发展早期。开发 DApp 的基础设施和相关工具都很不成熟,但是黑客是很成熟的,在互联网上久经沙场,对 DApp 世界影响很大。所以,在设计 DApp 时,要了解区块链相关知识,这些是出于安全考虑。
最后,是关于成本。在以太坊中就是 Gas,部署智能合约将消耗一定 Gas。这是因为 DApp 很消耗 Gas,特别是部署一个大型 DApp(包括后面的维护、升级)。Gas 是什么?是资金。那么,有没有一种结构能够暂时忽略 Gas。这就分成两种方向,一是思考节约 gas 到细微处,用一种怪异不太舒服的写法来节约 Gas;第二种是走向宏观,整个结构是清晰明了的,但是可能会存在浪费 Gas 的行为。
解决办法
第一,是优美结构。一个优美的结构会带来 Gas 的节约,这是我一直相信的。那整个结构是包含哪些方面呢?最宏观的说指分层。这里分层和一般的 app 分层是相通的,比如应用层、逻辑层、数据层。
第二,是友好。因为现在一个大型的 DApp,如果有很好的模块化,可能会有十几个智能合约,他们中间可能还有依赖。那就要求你在部署时,需要格外小心。(友好就是说能够一键部署。 )
第三,是支持升级。这个是在 App 上很常见的,因为我们在开发一个 App 时,必须要进行版本迭代,新功能的增加,Bug fix...这个就是升级。而我们知道 DApp 在区块链上,由于区块链的不可篡改性,部署的合约就在那里了。这里面涉及升级的问题就比较复杂。简而言之,我们这个结构采取了支持升级的方式,升级比较简单的部分是算法部分,算法部分是纯粹的逻辑,替换可以做到无感知,只需要修改逻辑合约的地址即可。比较麻烦的是对数据结构的升级,数据结构涉及到实际数据,如果轻易改动就要兼容以前的数据(这种需求很常见)。数据迁移是一个比较麻烦的事情,在设计数据结构之初尽量确定数据结构,避免频繁的变动。数据结构确定之后,包括 CURD 以及一些 Check 的接口其实也可以确定。这些可以作为一个最小的数据合约单元。
第四,是访问控制。要对整个结构中的数据流转进行控制,这是出于安全性的考虑。
第五,是模块化。一方面,是出于安全考虑,所有东西在一个合约里会增加合约的复杂性,会对安全造成隐患。毕竟复杂是安全的天敌,越简单越安全,这是软件开发中的常识。所以我们要保证合约的简洁,一眼看过去就知道这个合约是干什么。另一方面,是保证函数的简单。一样的道理,函数简单意味着组成的合约也是简单的。
整个分层就是应用层、逻辑层和数据层,这个结构里面的三层,整个 DApp 是偏向后端的,这里三层是智能合约里面的三层。
结构设计
根据之前 hackathon 上做的项目,叫做 summerWar(区块链沙盒游戏),这个项目里的结构基本上遵循这种设计。
summer War GitHub 仓库地址:
https://github.com/CryptapeHa...
an Arch : layer
这个是我们整个游戏的结构图,最左边的是 off-chain,是关于链下用户操作。register 是应用层、Permission 是访问控制、后面是逻辑层和数据层,后面是数据流转和调用的图 。
分层:register 是应用层,operator 是单纯逻辑 ,和数据没有关系。后面是数据层,整个数据单独和逻辑拆分。
权限刚才已经阐述了。在整个结构流转中,如果 operator 是操作层的话,用户需要先访问操作层合约,然后由操作层访问底层数据合约。这里限制访问控制,一是指控制各个层之间的调用关系,比如数据类合约严格控制只能让操作类合约来控制,不可能是随便的合约就能访问的。这里数据不仅是指数据,还包括数据的简单存取接口,相当于一个数据库的概念,包含存储和查询。 二是可以控制具体用户的操作。
An arch: auth
auth 模块实现了上述说的两种访问控制,在结构上是散布在各个层级之间及整个结构与外部用户之间。
对用户进行访问控制是指:假设有 id,就会先检查哪些地址可以操作,这里 id 是指整个 DApp 的身份,和区块链身份不影响,因为区块链由于去中心化原因是没有身份的。但是开发的 DApp 里面可以定义一个用户名,这个在 DApp 里是可行的,和区块链没有关系。所以这里可以对这个地址进行检查,允许哪些地址进行操作。至于层之间的访问控制,和用户访问控制类似,用户 id 是Dapp 通行的身份,各层合约可以在 register 查询到也可以把这些合约地址作为整个结构中通行的身份。
具体在底层实现要用到两种特性,modifier 和 函数的可见性。通过这两种特性结合能够达到以上效果:某个合约只能某些合约调用,某些合约只能由某些 sender 调用,这样的控制。
modifier:
https://solidity.readthedocs....
函数的可见性:
https://solidity.readthedocs....
整个下来,代码的组织结构可能就如下图所示:
App: register
整个结构的分层,我们先从上往下我们开始讲。首先是 App 层的 register。regitser 是什么角色?他是注册中心,是一个 hub,我们期望它能够保存所有 DApp 智能合约的相关信息(地址等),这也是一个用户接入的入口。在这里,除 operator 后续升级和注册外,它相当于一个交互枢纽,可以进行部署、初始化、注册和升级。这里能够实现在 regiter 部署之后,整个 DApp 的智能合约部分也就部署上去了。我们看一下 register 在整个结构中的位置,是在 off-chain 与 后面操作、数据层之间。
为什么要这样做呢?不能直接逻辑层+数据层?这样一个中心化的东西会不会影响整个 DApp 的去中心化?
DApp 去中心化属性是依靠后面区块链实现的,有一个中心化的东西并不影响。它的一个优点是简单。通过一个智能合约能够管理所有模块,这个 register 是不变的,相当于一个不变的点,用来链接各个模块,保证稳定,相当于 DApp 在区块链上一直会有一个稳定的地址长期进行服务。如果,需要支持升级那么很多模块都可改变。然而,如果所有东西可以改变,则会变得很难维护,所以使用 register,能够随时通过这个东西进行查询和操作。还有一个优点是能够节约 Gas。只部署 register,然后就完成了整个 DApp 的部署。是怎么实现的?这里合约可以用 new 来生成其他智能合约。new 并不节约 Gas,节约的是交易相关的消耗。
Register 包含三类接口
第一类接口:初始化
也就是 Solidity 里面的 constructor,合约的构造函数。它的功能是在部署智能合约时,一次性执行然后销毁。所以初始化时,要存入什么?刚说 register 是一个稳定的东西,那就能把整个结构中,一些相对稳定的东西放在这里初始化。比如多个用户的操作层合约是固定的,而数据层的合约随着用户的注册注销而变化,那么就可以把操作层合约在这里初始化,随着 register 的部署由其进行创建。
第二类接口:注册
注册是 web 调用,由外部用户来使用的。在初始完之后,相当于整个 DApp 上线了,在用户使用时,可能就有些功能上的注册操作。比如 identity,用户需要注册一个用户名之类。在 register 部署之后,你能够完成初始化的其他操作,类比我们现在的应用运行之后提供的功能。
第三类接口:查询
这类接口的使用者分为两类:
外部用户可以查询一些 register 保存的各种模块信息。
当一个合约与其他模块通信时,它只知道 register 地址,而更多模块合约地址可能是通过 register 来生成的随机地址,这个时候就可以通过 register 获得其他模块的地址进行之间的交互。
Logic:operator
接着往下看是操作层的合约。这里可以做一些模块化的东西,支持升级也是在这里做的,是因为简单。我们通常讲的支持升级包括两个方面,一个是函数或者接口的升级。另外一个是数据的升级或者说迁移。接口的升级比较容易,在区块链上数据升级比较困难,因为数据复制的操作很贵。存数据一字大概 2w gas。我们这里优先考虑 operator 的升级。
升级有两种方式,对应 evm 的智能合约里面进行交互两种指令,分别有两种升级方式
一个是 call,是消息调用的一种。调用 call 时,相当于把主动权交给另一个合约了,这个合约在一个新的 evm 执行之后返回一个结果给我。利用这个指令可以完成一个支持升级的方式,就是在 register 做一个类似 router 的东西,记录每一个操作类合约的版本号,然后用户就在访问操作类合约的时候选择版本进行下一次的调用,或者 register 帮你转。
第二个是用 delegate call,相对于 call,用 delegate 时主动权并没有交出去,整个智能合约代码还是在我现在的执行环境中执行,这是智能合约库使用的基本指令,很多库的实现都是基于 delegate call 的方式来做的。支持升级就是用一个代理类的合约,用户调用时帮你进行转发。这里会有一个副作用,必须把操作的数据留在 proxy 里面,这是 delegate 的属性。因为这个属性就需要支持升级的合约。有两个要求,一个是纯逻辑的,没有对状态的改变,第二个是没有在对数据留存在外面的要求,没有对数据进行分开的要求,所有版本的数据在 proxy 保存 。
我更推荐前者。后者把数据都存在 proxy 里面,前者是把数据也分开了,更模块化。我个人觉得是比较清晰的用法,这里用的也是这个。
DATA: data
关于数据,这方面的升级其实比较麻烦,会有一些问题。所以我们设计数据结构时尽量稳定一点,变动小一些,提前预留好以后要用的字段,避免以后的升级。要升级的话也有两种方法
一种方法是类似于插件的东西,旧的比如是 map 结构,是地址结构体,后面要多加一个字段。那么涉及旧数据怎么办?我可以定义一个插件类的合约,定义一个多余字段加一个指针指回原来的地方,相当于数据分开存。,但是保存一个指针指向旧数据并且能够找到他,能够做一些操作,这样的好处是不会变动数据,但是会增加操作的逻辑,比较复杂,而且不是所有的数据结构都能做的。
第二个是迁移,如果很有钱的话,可以直接拷贝过来,如果不在乎钱这是最简单的方式。
整个结构大概是这样。
对于升级的一点建议:升级时 copy 数据很贵,所以我们尽量避免这样的消耗,前期 gas 消耗也是注意的一个点。第二个是使用库来封装这些逻辑,就是说模块化。尽可能逻辑都能成库,可以找比较好的库来用。就是说很多模块交互需要用接口,让合约不依赖模块本身实现而依赖接口,这样保持接口不变的前提下就能升级合约。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。