CryptoZombies是个在编游戏的过程中学习Solidity智能合约语言的互动教程。本教程是为了Solidity初学者而设计的,会从最基础开始教起,即便你从来没有接触过Solidity也可以学,CryptoZombies会手把手地教你。
今天我们来学习第3课高级Solidity理论,这堂课比之前要少些特效,但是会学一些非常重要的基础理论,编写真正的DApp时必知的:智能协议的所有权,Gas的花费,代码优化,和代码安全。
1. 智能协议的永固性
到现在为止,我们讲的Solidity和其他语言没有质的区别,它长得也很像 JavaScript。但是,在有几点以太坊上的DApp跟普通的应用程序有着天壤之别。
第一个例子,在你把智能协议传上以太坊之后,它就变得不可更改,这种永固性意味着你的代码永远不能被调整或更新。你编译的程序会一直、永久的、不可更改地存在以太网上。这就是Solidity代码的安全性如此重要的一个原因。如果你的智能协议有任何漏洞,即使你发现了也无法补救。你只能让你的用户们放弃这个智能协议,然后转移到一个新的修复后的合约上。
但这恰好也是智能合约的一大优势。 代码说明一切。 如果你去读智能合约的代码,并验证它,你会发现,一旦函数被定义下来,每一次的运行,程序都会严格遵照函数中原有的代码逻辑一丝不苟地执行,完全不用担心函数被人篡改而得到意外的结果。
2. Ownable Contracts
上一章中,你有没有发现任何安全漏洞呢?对申明为“外部的”(external)方法,是任何人都可以调用它的,这种情况下可能出现安全漏洞。要对付这样的情况,通常的做法是指定合约的“所有权”——就是说,给它指定一个主人,只有主人对它享有特权。
OpenZeppelin库的Ownable合约
下面是一个Ownable合约的例子:来自OpenZeppelin Solidity库的 Ownable合约。OpenZeppelin是主打安保和社区审查的智能合约库,您可以在自己的DApps中引用。这一课学完,可以看看OpenZeppelin,保管您会学到很多东西!
把楼下这个合约读通,是不是还有些没见过代码?别担心,随后会解释。
/**
* @title Ownable
* @dev The Ownable contract has an owner address, and provides basic authorization control
* functions, this simplifies the implementation of "user permissions".
*/
contract Ownable {
address public owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev The Ownable constructor sets the original `owner` of the contract to the sender
* account.
*/
function Ownable() public {
owner = msg.sender;
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
/**
* @dev Allows the current owner to transfer control of the contract to a newOwner.
* @param newOwner The address to transfer ownership to.
*/
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0));
OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
}
上面面有没有你没学过的东东?
构造函数:function Ownable()
是一个constructor
(构造函数),构造函数不是必须的,它与合约同名,构造函数一生中唯一的一次执行,就是在合约最初被创建的时候。
函数修饰符:modifier onlyOwner()
。 修饰符跟函数很类似,不过是用来修饰其他已有函数用的, 在其他语句执行前,为它检查下先验条件。 在这个例子中,我们就可以写个修饰符onlyOwner检查下调用者,确保只有合约的主人才能运行本函数。我们下一章中会详细讲述修饰符,以及那个奇怪的_;
。
indexed关键字:别担心,我们还用不到它。
所以Ownable合约基本都会这么干:
合约创建,构造函数先行,将其owner设置为msg.sender(其部署者)
为它加上一个修饰符onlyOwner,它会限制陌生人的访问,将访问某些函数的权限锁定在 owner 上。
允许将合约所有权转让给他人。
onlyOwner简直人见人爱,大多数人开发自己的Solidity DApps,都是从复制/粘贴 Ownable开始的,从它再继承出的子类,并在之上进行功能开发。
3.onlyOwner 函数修饰符
函数修饰符
函数修饰符看起来跟函数没什么不同,不过关键字modifier告诉编译器,这是个modifier(修饰符),而不是个function(函数)。它不能像函数那样被直接调用,只能被添加到函数定义的末尾,用以改变函数的行为。咱们仔细读读onlyOwner
:
/**
* @dev 调用者不是‘主人’,就会抛出异常
*/
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
onlyOwner
函数修饰符是这么用的:
contract MyContract is Ownable {
event LaughManiacally(string laughter);
//注意! `onlyOwner`上场 :
function likeABoss() external onlyOwner {
LaughManiacally("Muahahahaha");
}
}
注意likeABoss
函数上的onlyOwner
修饰符。 当你调用likeABoss
时,首先执行onlyOwner
中的代码, 执行到onlyOwner
中的_;
语句时,程序再返回并执行likeABoss
中的代码。可见,尽管函数修饰符也可以应用到各种场合,但最常见的还是放在函数执行之前添加快速的require检查。
因为给函数添加了修饰符`onlyOwner,使得唯有合约的主人(也就是部署者)才能调用它。
注意:主人对合约享有的特权当然是正当的,不过也可能被恶意使用。比如,万一,主人添加了个后门。所以非常重要的是,部署在以太坊上的DApp,并不能保证它真正做到去中心,你需要阅读并理解它的源代码,才能防止其中没有被部署者恶意植入后门;作为开发人员,如何做到既要给自己留下修复bug的余地,又要尽量地放权给使用者,以便让他们放心你,从而愿意把数据放在你的 DApp中,这确实需要个微妙的平衡。
4.Gas
现在我们懂了如何在禁止第三方修改我们合约的同时,留个后门给咱们自己去修改。让我们来看另一种使得Solidity编程语言与众不同的特征:Gas——驱动以太坊DApps的能源。
在Solidity中,你的用户想要每次执行你的DApp都需要支付一定的gas,gas可以用以太币购买,因此,用户每次跑DApp都得花费以太币。一个DApp收取多少gas取决于功能逻辑的复杂程度。每个操作背后,都在计算完成这个操作所需要的计算资源,(比如,存储数据就比做个加法运算贵得多), 一次操作所需要花费的gas等于这个操作背后的所有运算花销的总和。
由于运行你的程序需要花费用户的真金白银,在以太坊中代码的编程语言,比其他任何编程语言都更强调优化。同样的功能,使用笨拙的代码开发的程序,比起经过精巧优化的代码来,运行花费更高,这显然会给成千上万的用户带来大量不必要的开销。
为什么要用 gas 来驱动?
以太坊就像一个巨大、缓慢、但非常安全的电脑。当你运行一个程序的时候,网络上的每一个节点都在进行相同的运算,以验证它的输出——这就是所谓的“去中心化”,由于数以千计的节点同时在验证着每个功能的运行,这可以确保它的数据不会被被监控,或者被刻意修改。
可能会有用户用无限循环堵塞网络,抑或用密集运算来占用大量的网络资源,为了防止这种事情的发生,以太坊的创建者为以太坊上的资源制定了价格,想要在以太坊上运算或者存储,你需要先付费。
注意:如果你使用侧链,倒是不一定需要付费。你不会想要在以太坊主网上玩“魔兽世界”吧?所需要的gas可能会买到你破产。但是你可以找个算法理念不同的侧链来玩它。我们将在以后的课程中咱们会讨论到,什么样的 DApp应该部署在太坊主链上,什么又最好放在侧链。
省gas的招数:结构封装(Struct packing)
在第1课中,我们提到除了基本版的 uint 外,还有其他变种uint:uint8,uint16,uint32等。通常情况下我们不会考虑使用uint变种,因为无论如何定义uint的大小,Solidity为它保留256位的存储空间。例如,使用uint8而不是uint(uint256)不会为你节省任何 gas。除非,把uint绑定到struct里面。
如果一个struct中有多个uint,则尽可能使用较小的uint,Solidity会将这些 uint打包在一起,从而占用较少的存储空间。例如:
struct NormalStruct {
uint a;
uint b;
uint c;
}
struct MiniMe {
uint32 a;
uint32 b;
uint c;
}
因为使用了结构打包,mini
比 normal
占用的空间更少
NormalStruct normal = NormalStruct(10, 20, 30);
MiniMe mini = MiniMe(10, 20, 30);
所以,当uint 定义在一个struct中的时候,尽量使用最小的整数子类型以节约空间。 并且把同样类型的变量放一起(即在 struct 中将把变量按照类型依次放置),这样Solidity可以将存储空间最小化。例如,有两个 struct:
uint c; uint32 a; uint32 b; 和 uint32 a; uint c; uint32 b;
前者比后者需要的gas更少,因为前者把uint32放一起了。
5. 时间单位
时间单位
Solidity使用自己的本地时间单位。变量now将返回当前的unix时间戳(自1970年1月1日以来经过的秒数)。
注意:Unix时间传统用一个32位的整数进行存储。这会导致“2038年”问题,当这个32位的unix时间戳不够用,产生溢出,使用这个时间的遗留系统就麻烦了。所以,如果我们想让我们的DApp跑够20年,我们可以使用64位整数表示时间,但为此我们的用户又得支付更多的gas。真是个两难的设计!
Solidity还包含秒(seconds),分钟(minutes),小时(hours),天(days),周(weeks) 和年(years) 等时间单位。它们都会转换成对应的秒数放入uint 中。所以1分钟就是 60,1小时是 3600(60秒×60分钟),1天是86400(24小时×60分钟×60秒),以此类推。
下面是一些使用时间单位的实用案例:
uint lastUpdated;
// 将‘上次更新时间’ 设置为 ‘现在’
function updateTimestamp() public {
lastUpdated = now;
}
// 如果到上次`updateTimestamp` 超过5分钟,返回 'true'
// 不到5分钟返回 'false'
function fiveMinutesHavePassed() public view returns (bool) {
return (now >= (lastUpdated + 5 minutes));
}
有了这些工具,我们可以为僵尸设定”冷静时间“功能。
6. 公有函数和安全性
你必须仔细地检查所有声明为public和external的函数,一个个排除用户滥用它们的可能,谨防安全漏洞。请记住,如果这些函数没有类似onlyOwner这样的函数修饰符,用户能利用各种可能的参数去调用它们。检查完这个函数,用户就可以直接调用这个它。想要防止漏洞,最简单的方法就是设其可见性为internal。
7.进一步了解函数修饰符
接下来,我们将添加一些辅助方法。进一步学习什么是“函数修饰符”。
带参数的函数修饰符
之前我们已经读过一个简单的函数修饰符了:onlyOwner。函数修饰符也可以带参数。例如:
// 存储用户年龄的映射
mapping (uint => uint) public age;
// 限定用户年龄的修饰符
modifier olderThan(uint _age, uint _userId) {
require(age[_userId] >= _age);
_;
}
必须年满16周岁才允许开车 (至少在美国是这样的),我们可以用如下参数调用olderThan
修饰符:
function driveCar(uint _userId) public olderThan(16, _userId) {
// 其余的程序逻辑
}
看到了吧, olderThan修饰符可以像函数一样接收参数,是“宿主”函数 driveCar把参数传递给它的修饰符的。
8.利用 'View' 函数节省Gas
“view”函数不花“gas”
当玩家从外部调用一个view函数,是不需要支付gas的。这是因为view函数不会真正改变区块链上的任何数据——它们只是读取。因此用view标记一个函数,意味着告诉web3.js,运行这个函数只需要查询你的本地以太坊节点,而不需要在区块链上创建一个事务(事务需要运行在每个节点上,因此花费 gas)。
稍后我们将介绍如何在自己的节点上设置 web3.js。但现在,你关键是要记住,在所能只读的函数上标记上表示只读的“external view”声明,就能为你的玩家减少在DApp中gas用量。
注意:如果一个view函数在另一个函数的内部被调用,而调用函数与view 函数的不属于同一个合约,也会产生调用成本。这是因为如果主调函数在以太坊创建了一个事务,它仍然需要逐个节点去验证。所以标记为view的函数只有同一个合约的外部调用时才是免费的。
9.存储非常昂贵
Solidity使用storage(存储)是相当昂贵的,”写入“操作尤其贵。这是因为,无论是写入还是更改一段数据, 这都将永久性地写入区块链。需要在全球数千个节点的硬盘上存入这些数据,随着区块链的增长,拷贝份数更多,存储量也就越大。这是需要成本的!
为了降低成本,不到万不得已,避免将数据写入存储。这也会导致效率低下的编程逻辑——比如每次调用一个函数,都需要在memory(内存) 中重建一个数组,而不是简单地将上次计算的数组给存储下来以便快速查找。
在大多数编程语言中,遍历大数据集合都是昂贵的。但是在Solidity中,使用一个标记了external view的函数,遍历比storage要便宜太多,因为view函数不会产生任何花销。
在内存中声明数组
在数组后面加上memory关键字, 表明这个数组是仅仅在内存中创建,不需要写入外部存储,并且在函数调用结束时它就解散了。与在程序结束时把数据保存进storage的做法相比,内存运算可以大大节省gas开销——把这数组放在view里用,完全不用花钱。以下是申明一个内存数组的例子:
function getArray() external pure returns(uint[]) {
// 初始化一个长度为3的内存数组
uint[] memory values = new uint[](3);
// 赋值
values.push(1);
values.push(2);
values.push(3);
// 返回数组
return values;
}
这个小例子展示了一些语法规则,下一章中,我们将通过一个实际用例,展示它和for循环结合的做法。
注意:内存数组必须用长度参数(在本例中为3)创建。目前不支持 array.push()之类的方法调整数组大小,在未来的版本可能会支持长度修改。
10.For 循环
在上面我们提到过,函数中使用的数组是运行时在内存中通过for循环实时构建,而不是预先建立在存储中的。for循环的语法在Solidity和JavaScript中类似。来看一个创建偶数数组的例子:
function getEvens() pure external returns(uint[]) {
uint[] memory evens = new uint[](5);
// 在新数组中记录序列号
uint counter = 0;
// 在循环从1迭代到10:
for (uint i = 1; i <= 10; i++) {
// 如果 `i` 是偶数...
if (i % 2 == 0) {
// 把它加入偶数数组
evens[counter] = i;
//索引加一, 指向下一个空的‘even’
counter++;
}
}
return evens;
}
这个函数将返回一个形为 [2,4,6,8,10] 的数组。
总结
本章了解了智能合约一旦部署则不可修改,所以在编写完智能合约后,对智能合约的审查是非常重要的事情,如果有任何差池,将不可逆转且不再受控。不过也不要太过担心,因为有像OpenZeppelin类似的合约库,专门做智能合约安保和审查,可以减轻我们不少工作。
另外本课还有一个重要的信息就是:信息的修改存储到区块链上是需要花费gas的。这就要求我们在编写智能合约时将一些中间信息尽量在本地内存中处理,同时solidity也提供了像view
这样的函数以节省gas。
从本课知道,编写智能合约不是一件简单的事情,每一行代码都涉及到真金白银,所以除了编写者要细致外,还需要多人和专业的工具做检查和测试,才能产出一份合格的智能合约。
系列文章:
【CryptoZombies|编写区块链游戏学智能合约】Lesson1: 搭建僵尸工厂
【CryptoZombies|编写区块链游戏学智能合约】Lesson2: 僵尸攻击人类
【CryptoZombies|编写区块链游戏学智能合约】Lesson3: 搭建僵尸工厂
【CryptoZombies|编写区块链游戏学智能合约】Lesson4: 僵尸作战系统
【CryptoZombies|编写区块链游戏学智能合约】Lesson5: ERC721标准和加密收藏品