关于solidity:智能合约编写之Solidity的编程攻略-FISCO-BCOS超话区块链专场篇5

4次阅读

共计 15060 个字符,预计需要花费 38 分钟才能阅读完成。

前言


作为一名搬砖多年的资深码农,刚开始接触 Solidity 便感觉无从下手:低廉的计算和存储资源、简陋的语法个性、令人抓狂的 debug 体验、近乎瘠薄的类库反对、一言不合就插入汇编语句……让人不禁狐疑,这都曾经过了 9012 年了,竟然还有这种反人类的语言?

对于习惯应用各类日益“傻瓜化”的类库和自动化高级框架的码农而言,学习 Solidity 的过程就是一场一言难尽的劝退之旅。

但随着对区块链底层技术的深刻学习,大家会缓缓了解作为运行在“The World Machine”上的 Solidity 语言,必须要严格遵循的设计准则以及衡量后必须付出的代价。

正如黑客帝国中那句驰名的 slogan:“Welcome to the dessert of the real”,在顽劣艰辛的环境背后,最重要的是学会如何适应环境、保留本身并疾速进化。

本文总结了一些 Solidity 编程的攻略,期待各位读者不吝分享交换,达到抛砖引玉之效。

上链的准则


“如无必要,勿增实体”。

基于区块链技术及智能合约倒退现状,数据的上链需遵循以下准则:

  • 须要分布式合作的重要数据才上链,非必须数据不上链;
  • 敏感数据脱敏或加密后上链(视数据窃密水平抉择合乎隐衷爱护安全等级要求的加密算法);
  • 链上验证,链下受权。

如果在智能合约中定义了简单的逻辑,特地是合约内定义了简单的函数入参、变量和返回值,就会在编译的时候碰到以下谬误:

​Compiler error: Stack too deep, try removing local variables.

这也是社区中的高频技术问题之一。造成这个问题的起因就是 EVM 所设计用于最大的栈深度为 16。

所有的计算都在一个栈内执行,对栈的拜访只限于其顶端,限度形式为:容许拷贝最顶端 16 个元素中的一个到栈顶,或者将栈顶元素和上面 16 个元素中的一个替换。

所有其余操作都只能取最顶的几个元素,运算后,把后果压入栈顶。当然能够把栈上的元素放到存储或内存中。但无奈只拜访栈上指定深度的那个元素,除非先从栈顶移除其余元素。如果一个合约中,入参、返回值、外部变量的大小超过了 16 个,显然就超出了栈的最大深度。

因而,咱们能够应用构造体或数组来封装入参或返回值,达到缩小栈顶元素应用的目标,从而防止此谬误。

例如以下代码,通过应用 bytes 数组来封装了本来 16 个 bytes 变量。

function doBiz(bytes[] paras) public {require(paras.length >= 16);
        // do something
}

保障参数和行为合乎预期

心怀“Code is law”的远大理想,极客们设计和发明了区块链的智能合约。

在联盟链中,不同的参与者能够应用智能合约来定义和书写一部分业务或交互的逻辑,以实现局部社会或商业活动。

相比于传统软件开发,智能合约对函数参数和行为的安全性要求更为严格。在联盟链中提供了身份实名和 CA 证书等机制,能够无效定位和监管所有参与者。不过,智能合约不足对破绽和攻打的事先干涉机制。正所谓字字珠玑,如果不谨严地查看智能合约输出参数或行为,有可能会触发一些意想不到的 bug。

因而,在编写智能合约时,肯定要留神对合约参数和行为的查看,尤其是那些对外部凋谢的合约函数。

Solidity 提供了 require、revert、assert 等关键字来进行异样的检测和解决。一旦检测并发现错误,整个函数调用会被回滚,所有状态批改都会被回退,就像从未调用过函数一样。

以下别离应用了三个关键字,实现了雷同的语义。

require(_data == data, "require data is valid");
​
if(_data != data) {revert("require data is valid"); }
​
assert(_data == data);

不过,这三个关键字个别实用于不同的应用场景:

  • require:最罕用的检测关键字,用来验证输出参数和调用函数后果是否非法。
  • revert:实用在某个分支判断的场景下。
  • assert: 查看后果是否正确、非法,个别用于函数结尾。

在一个合约的函数中,能够应用函数润饰器来形象局部参数和条件的查看。在函数体内,能够对运行状态应用 if-else 等判断语句进行查看,对异样的分支应用 revert 回退。在函数运行完结前,能够应用 assert 对执行后果或中间状态进行断言查看。

在实践中,举荐应用 require 关键字,并将条件查看移到函数润饰器中去;这样能够让函数的职责更为繁多,更专一到业务逻辑中。同时,函数润饰器等条件代码也更容易被复用,合约也会更加平安、层次化。

在本文中,咱们以一个水果店库存管理系统为例,设计一个水果超市的合约。这个合约只蕴含了对店内所有水果品类和库存数量的治理,setFruitStock 函数提供了对应水果库存设置的函数。在这个合约中,咱们须要查看传入的参数,即水果名称不能为空。

pragma solidity ^0.4.25;
​
contract FruitStore {mapping(bytes => uint) _fruitStock;
    modifier validFruitName(bytes fruitName) {require(fruitName.length > 0, "fruite name is invalid!");
        _;
    }
    function setFruitStock(bytes fruitName, uint stock) validFruitName(fruitName) external {_fruitStock[fruitName] = stock;
    }
}

如上所述,咱们增加了函数执行前的参数查看的函数润饰器。同理,通过应用函数执行前和函数执行后查看的函数润饰器,能够保障智能合约更加平安、清晰。智能合约的编写须要设置严格的前置和后置函数查看,来保障其安全性。

严控函数的执行权限


如果说智能合约的参数和行为检测提供了动态的合约安全措施,那么合约权限管制的模式则提供了动静拜访行为的管制。

因为智能合约是公布到区块链上,所有数据和函数对所有参与者都是公开通明的,任一节点参与者都可发动交易,无奈保障合约的隐衷。因而,合约发布者必须对函数设计严格的拜访限度机制。

Solidity 提供了函数可见性修饰符、润饰器等语法,灵便地应用这些语法,可帮忙构建起非法受权、受控调用的智能合约零碎。

还是以方才的水果合约为例。当初 getStock 提供了查问具体水果库存数量的函数。

pragma solidity ^0.4.25;
​
contract FruitStore {mapping(bytes => uint) _fruitStock;
    modifier validFruitName(bytes fruitName) {require(fruitName.length > 0, "fruite name is invalid!");
        _;
    }
    function getStock(bytes fruit) external view returns(uint) {return _fruitStock[fruit];
    }
    function setFruitStock(bytes fruitName, uint stock) validFruitName(fruitName) external {_fruitStock[fruitName] = stock;
    }
}

水果店老板将这个合约公布到了链上。然而,公布之后,setFruitStock 函数可被任何其余联盟链的参与者调用。

尽管联盟链的参与者是实名认证且可预先追责;但一旦有歹意攻击者对水果店发动攻打,调用 setFruitStock 函数就能任意批改水果库存,甚至将所有水果库存清零,这将对水果店失常经营治理产生严重后果。

因而,设置某些预防和受权的措施很必要:对于批改库存的函数 setFruitStock,可在函数执行前对调用者进行鉴权。

相似的,这些查看可能会被多个批改数据的函数复用,应用一个 onlyOwner 的润饰器就能够形象此查看。_owner 字段代表了合约的所有者,会在合约构造函数中被初始化。应用 public 润饰 getter 查问函数,就能够通过_owner()函数查问合约的所有者。

contract FruitStore {
    address public  _owner;
    mapping(bytes => uint) _fruitStock;
  
    constructor() public {_owner = msg.sender;} 
  
    modifier validFruitName(bytes fruitName) {require(fruitName.length > 0, "fruite name is invalid!");
        _;
    }
    // 鉴权函数润饰器
    modifier onlyOwner() {require(msg.sender == _owner, "Auth: only owner is authorized.");
        _; 
    }
    function getStock(bytes fruit) external view returns(uint) {return _fruitStock[fruit];
    }
    // 增加了 onlyOwner 润饰器
    function setFruitStock(bytes fruitName, uint stock) 
        onlyOwner validFruitName(fruitName) external {_fruitStock[fruitName] = stock;
    }
}

这样一来,咱们能够将相应的函数调用权限查看封装到润饰器中,智能合约会主动发动对调用者身份验证查看,并且只容许合约部署者来调用 setFruitStock 函数,以此保障合约函数向指定调用者凋谢。

形象通用的业务逻辑


剖析上述 FruitStore 合约,咱们发现合约里仿佛混入了奇怪的货色。参考繁多职责的编程准则,水果店库存治理合约多了上述函数性能查看的逻辑,使合约无奈将所有代码专一在本身业务逻辑中。

对此,咱们能够形象出可复用的性能,利用 Solidity 的继承机制继承最终形象的合约。

基于上述 FruitStore 合约,可形象出一个 BasicAuth 合约,此合约蕴含之前 onlyOwner 的润饰器和相干性能接口。

contract BasicAuth {
    address public _owner;
​
    constructor() public {_owner = msg.sender;}
​
    function setOwner(address owner)
        public
        onlyOwner
{_owner = owner;}
​
    modifier onlyOwner() {require(msg.sender == _owner, "BasicAuth: only owner is authorized.");
        _; 
    }
}

FruitStore 能够复用这个润饰器,并将合约代码收敛到本身业务逻辑中。

import "./BasicAuth.sol";
​
contract FruitStore is BasicAuth {mapping(bytes => uint) _fruitStock;
​
    function setFruitStock(bytes fruitName, uint stock) 
        onlyOwner validFruitName(fruitName) external {_fruitStock[fruitName] = stock;
    }
}

这样一来,FruitStore 的逻辑被大大简化,合约代码更精简、聚焦和清晰。

预防私钥的失落


在区块链中调用合约函数的形式有两种:外部调用和内部调用。

出于隐衷爱护和权限管制,业务合约会定义一个合约所有者。假如用户 A 部署了 FruitStore 合约,那上述合约 owner 就是部署者 A 的内部账户地址。这个地址由内部账户的私钥计算生成。

然而,在事实世界中,私钥泄露、失落的景象亘古未有。一个商用区块链 DAPP 须要庄重思考私钥的替换和重置等问题。

这个问题最为简略直观的解决办法是增加一个备用私钥。这个备用私钥可反对权限合约批改 owner 的操作,代码如下:

contract BasicAuth {
    address public  _owner;
    address public _bakOwner;
    
    constructor(address bakOwner) public {
        _owner = msg.sender;
        _bakOwner = bakOwner;
    }
​
    function setOwner(address owner)
        public
        canSetOwner
{_owner = owner;}
​
    function setBakOwner(address owner)
        public
        canSetOwner
{_bakOwner = owner;}
​
    // ...
    
    modifier isAuthorized() {require(msg.sender == _owner || msg.sender == _bakOwner, "BasicAuth: only owner or back owner is authorized.");
        _; 
    }
}

这样,当发现私钥失落或泄露时,咱们能够应用备用内部账户调用 setOwner 重置账号,复原、保障业务失常运行。

面向接口编程


上述私钥备份理念值得推崇,不过其具体实现形式存在肯定局限性,在很多业务场景下,显得过于简略粗犷。

对于理论的商业场景,私钥的备份和保留须要思考的维度和因素要简单得多,对应密钥备份策略也更多元化。

以水果店为例,有的连锁水果店可能心愿通过品牌总部来治理私钥,也有的可能通过社交关系重置帐号,还有的可能会绑定一个社交平台的治理帐号……

面向接口编程,而不依赖具体的实现细节,能够无效躲避这个问题。例如,咱们利用接口性能首先定义一个判断权限的形象接口:

contract Authority {
    function canCall(address src, address dst, bytes4 sig) public view returns (bool);
}

这个 canCall 函数涵盖了函数调用者地址、指标调用合约的地址和函数签名,函数返回一个 bool 的后果。这蕴含了合约鉴权所有必要的参数。

咱们可进一步批改之前的权限治理合约,并在合约中依赖 Authority 接口,当鉴权时,润饰器会调用接口中的形象办法:

contract BasicAuth {
    Authority  public  _authority;
​
    function setAuthority(Authority authority)
        public
        auth
    {_authority = authority;}
​
    modifier isAuthorized() {require(auth(msg.sender, msg.sig), "BasicAuth: only owner or back owner is authorized.");
        _; 
    }
    
    function auth(address src, bytes4 sig) public view returns (bool) {if (src == address(this)) {return true;} else if (src == _owner) {return true;} else if (_authority == Authority(0)) {return false;} else {return _authority.canCall(src, this, sig);
        }
    }
}

这样,咱们只须要灵便定义实现了 canCall 接口的合约,在合约的 canCall 办法中定义具体判断逻辑。而业务合约,例如 FruitStore 继承 BasicAuth 合约,在创立时只有传入具体的实现合约,就能够实现不同判断逻辑。

正当预留事件


迄今为止,咱们已实现弱小灵便的权限管理机制,只有事后受权的内部账户能力批改合约 owner 属性。

不过,仅通过上述合约代码,咱们无奈记录和查问批改、调用函数的历史记录和明细信息。而这样的需要在理论业务场景中亘古未有。比方,FruitStore 水果店须要通过查问历史库存批改记录,计算出不同节令的滞销与畅销水果。

一种办法是依靠链下保护独立的台账机制。不过,这种办法存在很多问题:放弃链下台账和链上记录统一的老本开销十分高;同时,智能合约面向链上所有参与者凋谢,一旦其余参与者调用了合约函数,相干交易信息就存在不能同步的危险。

针对此类场景,Solidity 提供了 event 语法。event 不仅具备可供 SDK 监听回调的机制,还能用较低的 gas 老本将事件参数等信息残缺记录、保留到区块中。FISCO BCOS 社区中,也有 WEBASE-Collect-Bee 这样的工具,在预先实现区块历史事件信息的残缺导出。

WEBASE-Collect-Bee 工具参考链接如下:
https://webasedoc.readthedocs…_CN/latest/docs/WeBASE-Collect-Bee/index.html

基于上述权限治理合约,咱们能够定义相应的批改权限事件,其余事件以此类推。

event LogSetAuthority (Authority indexed authority, address indexed from);
}

接下来,能够调用相应的事件:

function setAuthority(Authority authority)
        public
        auth
{
        _authority = authority;
        emit LogSetAuthority(authority, msg.sender);
    }

当 setAuthority 函数被调用时,会同时触发 LogSetAuthority,将事件中定义的 Authority 合约地址以及调用者地址记录到区块链交易回执中。当通过控制台调用 setAuthority 办法时,对应事件 LogSetAuthority 也会被打印进去。

基于 WEBASE-Collect-Bee,咱们能够导出所有该函数的历史信息到数据库中。也可基于 WEBASE-Collect-Bee 进行二次开发,实现简单的数据查问、大数据分析和数据可视化等性能。

遵循平安编程标准


每一门语言都有其相应的编码标准,咱们须要尽可能严格地遵循 Solidity 官网编程格调指南,使代码更利于浏览、了解和保护,无效地缩小合约的 bug 数量。

Solidity 官网编程格调指南参考链接如下:
https://solidity.readthedocs….

除了编程标准,业界也总结了很多平安编程指南,例如重入破绽、数据结构溢出、随机数误区、构造函数失控、为初始化的存储指针等等。器重和防备此类危险,采纳业界举荐的平安编程标准至关重要,例如 Solidity 官网平安编程指南。参考链接如下:
https://solidity.readthedocs….

同时,在合约公布上线后,还须要留神关注、订阅 Solidity 社区内平安组织或机构公布的各类安全漏洞、攻打手法,一旦呈现问题,及时做到亡羊补牢。

对于重要的智能合约,有必要引入审计。现有的审计包含了人工审计、机器审计等办法,通过代码剖析、规定验证、语义验证和形式化验证等办法保障合约安全性。

尽管本文通篇都在强调,模块化和重用被严格审查并宽泛验证的智能合约是最佳的实际策略。但在理论开发过程,这种假如过于理想化,每个我的项目或多或少都会引入新的代码,甚至从零开始。

不过,咱们依然能够视代码的复用水平进行审计分级,显式地标注出援用的代码,将审计和查看的重点放在新代码上,以节俭审计老本。

最初,“前事不忘后事之师”,咱们须要一直总结和学习前人的最佳实际,动静和可继续地晋升编码工程程度,并一直利用到具体实际中。

积攒和复用成熟的代码


前文面向接口编程中的思维可升高代码耦合,使合约更容易扩大、利于保护。在遵循这条规定之外,还有另外一条忠告:尽可能地复用现有代码库。

智能合约公布后难以批改或撤回,而且公布到公开通明的区块链环境上,就意味着一旦呈现 bug 造成的损失和危险更甚于传统软件。因而,复用一些更好更平安的轮子远胜过从新造轮子。

在开源社区中,曾经存在大量的业务合约和库可供使用,例如 OpenZeppelin 等优良的库。

如果在开源世界和过来团队的代码库里找不到适合的可复用代码,倡议在编写新代码时尽可能地测试和欠缺代码设计。此外,还要定期剖析和审查历史合约代码,将其模板化,以便于扩大和复用。

例如,针对下面的 BasicAuth,参考防火墙经典的 ACL(Access Control List)设计,咱们能够进一步地继承和扩大 BasicAuth,形象出 ACL 合约管制的实现。

​contract AclGuard is BasicAuth {bytes4 constant public ANY_SIG = bytes4(uint(-1));
    address constant public ANY_ADDRESS = address(bytes20(uint(-1)));
    mapping (address => mapping (address => mapping (bytes4 => bool))) _acl;
​
    function canCall(address src, address dst, bytes4 sig) public view returns (bool) {return _acl[src][dst][sig]
            || _acl[src][dst][ANY_SIG]
            || _acl[src][ANY_ADDRESS][sig]
            || _acl[src][ANY_ADDRESS][ANY_SIG]
            || _acl[ANY_ADDRESS][dst][sig]
            || _acl[ANY_ADDRESS][dst][ANY_SIG]
            || _acl[ANY_ADDRESS][ANY_ADDRESS][sig]
            || _acl[ANY_ADDRESS][ANY_ADDRESS][ANY_SIG];
    }
​
    function permit(address src, address dst, bytes4 sig) public onlyAuthorized {_acl[src][dst][sig] = true;
        emit LogPermit(src, dst, sig);
    }
​
    function forbid(address src, address dst, bytes4 sig) public onlyAuthorized {_acl[src][dst][sig] = false;
        emit LogForbid(src, dst, sig);
    }
    
    function permit(address src, address dst, string sig) external {permit(src, dst, bytes4(keccak256(sig)));
    }
    
    function forbid(address src, address dst, string sig) external {forbid(src, dst, bytes4(keccak256(sig)));
    }
​
    function permitAny(address src, address dst) external {permit(src, dst, ANY_SIG);
    }
    
    function forbidAny(address src, address dst) external {forbid(src, dst, ANY_SIG);
    }
}

在这个合约里,有调用者地址、被调用合约地址和函数签名三个主要参数。通过配置 ACL 的拜访策略,能够准确地定义和管制函数拜访行为及权限。合约内置了 ANY 的常量,匹配任意函数,使拜访粒度的管制更加便捷。这个模板合约实现了弱小灵便的性能,足以满足所有相似权限管制场景的需要。

晋升存储和计算的效率


迄今为止,在上述的推演过程中,更多的是对智能合约编程做加法。但相比传统软件环境,智能合约上的存储和计算资源更加贵重。因而,如何对合约做减法也是用好 Solidity 的必修课程之一。

选取适合的变量类型

显式的问题可通过 EVM 编译器检测进去并报错;但大量的性能问题可能被暗藏在代码的细节中。

Solidity 提供了十分多准确的根底类型,这与传统的编程语言天壤之别。上面有几个对于 Solidity 根底类型的小技巧。

在 C 语言中,能够用 short\int\long 按需定义整数类型,而到了 Solidity,不仅辨别 int 和 uint,甚至还能定义 uint 的长度,比方 uint8 是一个字节,uint256 是 32 个字节。这种设计告诫咱们,能用 uint8 搞定的,相对不要用 uint16!

简直所有 Solidity 的根本类型,都能在申明时指定其大小。开发者肯定要无效利用这一语法个性,编写代码时只有满足需要就尽可能选取小的变量类型。

数据类型 bytes32 可寄存 32 个(原始)字节,但除非数据是 bytes32 或 bytes16 这类定长的数据类型,否则更举荐应用长度能够变动的 bytes。bytes 相似 byte[],但在内部函数中会主动压缩打包,更节俭空间。

如果变量内容是英文的,不须要采纳 UTF- 8 编码,在这里,举荐 bytes 而不是 string。string 默认采纳 UTF- 8 编码,所以雷同字符串的存储老本会高很多。

紧凑状态变量打包

除了尽可能应用较小的数据类型来定义变量,有的时候,变量的排列程序也十分重要,可能会影响到程序执行和存储效率。

其中根本原因还是 EVM,不论是 EVM 存储插槽(Storage Slot)还是栈,每个元素长度是一个字(256 位,32 字节)。

调配存储时,所有变量(除了映射和动静数组等非动态类型)都会按申明程序从地位 0 开始顺次写下。

在解决状态变量和构造体成员变量时,EVM 会将多个元素打包到一个存储插槽中,从而将多个读或写合并到一次对存储的操作中。

值得注意的是,应用小于 32 字节的元素时,合约的 gas 使用量可能高于应用 32 字节元素时。这是因为 EVM 每次会操作 32 个字节,所以如果元素比 32 字节小,必须应用更多的操作能力将其大小缩减到所需。这也解释了 Solidity 中最常见的数据类型,例如 int,uint,byte32,为何都刚好占用 32 个字节。

所以,当合约或构造体申明多个状态变量时,是否正当地组合安顿多个存储状态变量和构造体成员变量,使之占用更少的存储地位就非常重要。

例如,在以下两个合约中,通过理论测试,Test1 合约比 Test2 合约占用更少的存储和计算资源。

contract Test1 {
    // 占据 2 个 slot, "gasUsed":188873
    struct S {
        bytes1 b1;
        bytes31 b31;
        bytes32 b32;
    }
    S s;
    function f() public {S memory tmp = S("a","b","c");
        s = tmp;
    }
}
​
contract Test2 {
    // 占据 1 个 slot, "gasUsed":188937
    struct S {
        bytes31 b31;
        bytes32 b32;
        bytes1 b1;
    }
    // ……
}

优化查问接口

查问接口的优化点很多,比方肯定要在只负责查问的函数申明中增加 view 修饰符,否则查问函数会被当成交易打包并发送到共识队列,被全网执行并被记录在区块中;这将大大增加区块链的累赘,占用贵重的链上资源。

再如,不要在智能合约中增加简单的查问逻辑,因为任何简单查问代码都会使整个合约变得更长更简单。读者可应用上文提及的 WeBASE 数据导出组件,将链上数据导出到数据库中,在链下进行查问和剖析。

缩减合约 binary 长度

开发者编写的 Solidity 代码会被编译为 binary code,而部署智能合约的过程实际上就是通过一个 transaction 将 binary code 存储在链上,并获得专属于该合约的地址。

缩减 binary code 的长度可节俭网络传输、共识打包数据存储的开销。例如,在典型的存证业务场景中,每次客户存证都会新建一个存证合约,因而,该当尽可能地缩减 binary code 的长度。

常见思路是裁剪不必要的逻辑,删掉冗余代码。特地是在复用代码时,可能引入一些非刚需代码。以上文 ACL 合约为例,反对管制合约函数粒度的权限。

function canCall(address src, address dst, bytes4 sig) public view returns (bool) {return _acl[src][dst][sig]
            || _acl[src][dst][ANY_SIG]
            || _acl[src][ANY_ADDRESS][sig]
            || _acl[src][ANY_ADDRESS][ANY_SIG]
            || _acl[ANY_ADDRESS][dst][sig]
            || _acl[ANY_ADDRESS][dst][ANY_SIG]
            || _acl[ANY_ADDRESS][ANY_ADDRESS][sig]
            || _acl[ANY_ADDRESS][ANY_ADDRESS][ANY_SIG];
    }

但在具体业务场景中,只须要管制合约访问者即可,通过删除相应代码,进一步简化应用逻辑。这样一来,对应合约的 binary code 长度会大大放大。

function canCall(address src, address dst) public view returns (bool) {return _acl[src][dst]
            || _acl[src][ANY_ADDRESS]
            || _acl[ANY_ADDRESS][dst];
    }

另一种缩减 binary code 的思路是采纳更紧凑的写法。

经实测,采取如上短路准则的判断语句,其 binary 长度会比采纳 if-else 语法的更短。同样,采纳 if-else 的构造,也会比 if-if-if 的构造生成更短的 binary code。

最初,在对 binary code 长度有极致要求的场景中,该当尽可能防止在合约中新建合约,这会显著减少 binary 的长度。例如,某个合约中有如下的构造函数:

constructor() public {
        // 在结构器内新建一个新对象
        _a = new A();}

咱们能够采纳在链下结构 A 对象,并基于 address 传输和固定校验的形式,来躲避这一问题。

constructor(address a) public {A _a = A(a);
        require(_a._owner == address(this));
}

当然,这样也可能会使合约交互方式变得复杂。但其提供了无效缩短 binary code 长度的捷径,须要在具体业务场景中做衡量取舍。

保障合约可降级


经典的三层构造

通过前文形式,咱们尽最大致力放弃合约设计的灵活性;翻箱倒柜复用了轮子;也对公布合约进行全方位、无死角的测试。除此之外,随着业务需要变动,咱们还将面临一个问题:如何保障合约平滑、顺利的降级?

作为一门高级编程语言,Solidity 反对运行一些简单管制和计算逻辑,也反对存储智能合约运行后的状态和业务数据。不同于 WEB 开发等场景的利用 - 数据库分层架构,Solidity 语言甚至没有形象出一层独立的数据存储构造,数据都被保留到了合约中。

然而,一旦合约须要降级,这种模式就会呈现瓶颈。

在 Solidity 中,一旦合约部署公布后,其代码就无奈被批改,只能通过公布新合约去改变代码。如果数据存储在老合约,就会呈现所谓的“孤儿数据”问题,新合约将失落之前运行的历史业务数据。

这种状况,开发者能够思考将老合约数据迁徙到新合约中,但此操作至多存在两个问题:

  1. 迁徙数据会减轻区块链的累赘,产生资源节约和耗费,甚至引入平安问题;
  2. 牵一发而动全身,会引入额定的迁徙数据逻辑,减少合约复杂度。

一种更正当的形式是形象一层独立的合约存储层。这个存储层只提供合约读写的最根本办法,而不蕴含任何业务逻辑。

在这种模式中,存在三种合约角色:

  • 数据合约:在合约中保留数据,并提供数据的操作接口。
  • 治理合约:设置管制权限,保障只有管制合约才有权限批改数据合约。
  • 管制合约:真正须要对数据发动操作的合约。

具体的代码示例如下:

数据合约:

contract FruitStore is BasicAuth {
    address _latestVersion; 
    mapping(bytes => uint) _fruitStock;
    
    modifier onlyLatestVersion() {require(msg.sender == _latestVersion);
        _;
    }
​
    function upgradeVersion(address newVersion) public {require(msg.sender == _owner);
        _latestVersion = newVersion;
    }
    
    function setFruitStock(bytes fruit, uint stock) onlyLatestVersion external {_fruitStock[fruit] = stock;
    }
}

治理合约:

contract Admin is BasicAuth {function upgradeContract(FruitStore fruitStore, address newController) isAuthorized external {fruitStore.upgradeVersion(newController);
    }
}

管制合约:

contract FruitStoreController is BasicAuth {function upgradeStock(bytes fruit, uint stock) isAuthorized external {fruitStore.setFruitStock(fruit, stock);
    }
}

一旦函数的管制逻辑须要变更,开发者只需批改 FruitStoreController 管制合约逻辑,部署一个新合约,而后应用治理合约 Admin 批改新的合约地址参数就可轻松实现合约降级。这种办法可打消合约降级中因业务管制逻辑扭转而导致的数据迁徙隐患。

但天下没有收费的午餐,这种操作须要在可扩展性和复杂性之间须要做根本的衡量。首先,数据和逻辑的拆散升高了运行性能。其次,进一步封装减少了程序复杂度。最初,越是简单的合约越会减少潜在攻击面,简略的合约比简单的合约更平安。

通用数据结构

到目前为止,还存在一个问题,如果数据合约中的数据结构自身须要降级怎么办?

例如,在 FruitStore 中,本来只保留了库存信息,当初因为水果销售店生意发展壮大,一共开了十家分店,须要记录每家分店、每种水果的库存和售出信息。

在这种状况下,一种解决方案是采纳内部关联治理形式:创立一个新的 ChainStore 合约,在这个合约中创立一个 mapping,建设分店名和 FruitStore 的关系。

此外,不同分店须要创立一个 FruitStore 的合约。为了记录新增的售出信息等数据,咱们还须要新建一个合约来治理。

如果在 FruitStore 中可预设一些不同类型的 reserved 字段,可帮忙躲避新建售出信息合约的开销,依然复用 FruitStore 合约。但这种形式在最开始会减少存储开销。

一种更好的思路是形象一层更为底层和通用的存储构造。

代码如下:

contract commonDB  is BasicAuth {mapping(bytes => uint) _uintMapping;
    
    function getUint(bytes key) external view returns(uint) {return _uintMapping[key];
    }
​
    function setUint(bytes key, uint value) isAuthorized onlyLatestVersion external {_uintMapping[key] = value;
    }
​
}

相似的,咱们可退出所有数据类型变量,帮忙 commonDB 应答和满足不同的数据类型存储需要。

相应的管制合约可批改如下:

contract FruitStoreControllerV2 is BasicAuth {function upgradeStock(bytes32 storeName, bytes32 fruit, uint stock) 
        isAuthorized external {commonDB.setUint(sha256(storeName, fruit), stock);
        uint result = commonDB.getUint(sha256(storeName, fruit));
    }
}

应用以上存储的设计模式,可显著晋升合约数据存储灵活性,保障合约可降级。

家喻户晓,Solidity 既不反对数据库,应用代码作为存储 entity,也无奈提供更改 schema 的灵活性。然而,通过这种 KV 设计,能够使存储自身取得弱小的可扩展性。

总之,没有一个策略是完满的,优良的架构师长于衡量。智能合约设计者须要充沛理解各种计划的利弊,并基于理论状况抉择适合的设计方案。

总结


文至于此,心愿激发读者对在 Solidity 世界生存与进化的趣味。“若有完满,必有谎话”,软件开发的世界没有银弹。本文行文过程就是从最简略的合约逐步完善和进化的过程。

在 Solidity 编程世界中,生存与进化都离不开三个关键词:平安、可复用、高效。生命不息,进化不止。短短一篇小文难以穷尽所有生存进化之术,心愿这三个关键词能帮忙大家在 Solidity 的世界里飞翔畅游,并一直书写辉煌的故事和传说:)

下期预报


FISCO BCOS 的代码齐全开源且收费
下载地址:https://github.com/FISCO-BCOS…

正文完
 0