cryptozombies源码解析二

cryptozombies源码解析一中对以太坊开发中的事件合约交互进行了介绍,看过这篇文章的同学我相信对Dapp开发也有了一个初步的了解,下面我们继续学习一些智能合约开发中特性。

如何减少gas

gas是EVM中代码执行所消耗资源的计量单位,gas是需要用以太币购买的,现在你是不是已经get到了为什么要减少gas了,嘿嘿。。。

使用合适的数据结构

这里以uint为例,在Solidity中除了有基本版的uint外,还有其他变种uint:uint8,uint16,uint32等。通常情况下我们不会考虑使用uint变种,因为无论如何定义 uint的大小,Solidity为它保留256位的存储空间。例如,使用uint8而不使用uint(uint256),并不会为你节省任何gas,因为Solidity始终保留了256的空间。

那么为什么还要有其他uint的变种呢?是因为在struct里,可以通过声明具体的uint变种来节省存储空间
在struct里,相同类型的uint要紧邻,例如uint8的变量要放在一起,uint16的要放在一起,这样可以将这些uint打包在一起,从而占用较少的存储空间。

1
2
3
4
5
6
7
8
struct Zombie {
string name;
uint dna;
uint32 level;
uint32 readyTime;
uint16 winCount;
uint16 lossCount;
}

注意view和pure的使用场景

之所以要注意view和pure的使用场景,是因为从合约外部调用这两个修饰符修饰的函数的时候不花费任何gas,注意这里说的是在合约外部调用,它们在被内部其他函数调用的时候将会耗费gas

下面解释下为什么view和pure从合约外部调用不需要花费gas。
是因为view修饰符的意思是不会改变区块链上的任何数据,只是从区块链上读取数据。标记为view的函数,意味着告诉web3.js,运行这个函数只需要查询你的本地以太坊节点,而不需要在区块链上创建一个事务(事务需要运行在每个节点上,因此花费gas)。
所以在所有能只读的函数上标记上表示只读external view声明,就能为你的玩家减少在DApp中gas用量。

pure与view类似,pure修饰符的意思是不但不会往区块链写数据,它甚至不从区块链读取数据,所以pure也不会消耗gas。

尽可能的规避昂贵的操作

Solidity中使用storage(存储)是相当昂贵的,”写入”操作尤其贵。这是因为,无论是写入还是更改一段数据,这都将永久性地写入区块链。”永久性”啊!需要在全球数千个节点的硬盘上存入这些数据,随着区块链的增长,拷贝份数更多,存储量也就越大。这是需要成本的!

所以在开发中,为了降低成本,不到万不得已,避免将数据写入存储。虽然这样做会导致效率低下,但是在有些场景下还是可取的。

代码中有一个getZombiesByOwner的函数,功能是得到某个用户的僵尸军团,常规逻辑是在ZombieFactory中存入ownerzombies的映射mapping (address => uint[]) public ownerToZombies,然后我们每次创建新僵尸时,执行ownerToZombies[owner].push(zombieId)将其添加到主人的僵尸数组中。而 getZombiesByOwner函数也非常简单:

1
2
3
function getZombiesByOwner(address _owner) external view returns (uint[]) {
return ownerToZombies[_owner];
}

可是如果我们需要一个函数来把一头僵尸转移到另一个主人名下,又会发生什么?
这个”换主”函数要做到:

  1. 将僵尸push到新主人的ownerToZombies映射中的数组,
  2. 从旧主的ownerToZombies数组中移除僵尸,
  3. 将旧主僵尸数组中”换主僵尸”之后的的每头僵尸都往前挪一位,把挪走”换主僵尸”后留下的”空槽”填上,
  4. 将数组长度减1。
    但是第三步实在是太贵了因为每挪动一头僵尸,我们都要执行一次写操作。如果一个主人有20头僵尸,而第一头被挪走了,那为了保持数组的顺序,我们得做19个写操作。
    由于写入存储是Solidity中最费gas的操作之一,使得换主函数的每次调用都非常昂贵。更糟糕的是,每次调用的时候花费的gas都不同!具体还取决于用户在原主军团中的僵尸头数,以及移走的僵尸所在的位置。以至于用户都不知道应该支付多少gas。

注意:当然,我们也可以把数组中最后一个僵尸往前挪来填补空槽,并将数组长度减少一。但这样每做一笔交易,都会改变僵尸军团的秩序。

此时我们就应该避免这种高昂的操作,使用另一种方案来解决。上面提到view修饰的函数从外部调用是免费的,而且我们也只是从区块链中读取数据,所以我们可以在getZombiesByOwner函数中一个for循环遍历整个僵尸数组zombies,把属于某个主人的僵尸挑出来构建出僵尸数组。那么我们的transfer函数将会便宜得多,因为我们不需要挪动存储里的僵尸数组重新排序,总体上这个方法会更便宜,就是有点反常。

在大多数编程语言中,遍历大数据集合都是昂贵的。但是在Solidity中,使用一个标记了external view的函数,遍历比storage要便宜太多,因为view函数不会产生任何花销。 (gas可是真金白银啊!)。

最后getZombiesByOwner的实现为:

1
2
3
4
5
6
7
8
9
10
11
function getZombiesByOwner(address _owner) external view returns(uint[]) {
uint[] memory result = new uint[](ownerZombieCount[_owner]);
uint counter = 0;
for (uint i = 0; i < zombies.length; i++) {
if (zombieToOwner[i] == _owner) {
result[counter] = i;
counter++;
}
}
return result;
}

随机数

相信大家在日常开发中免不了用随机数,但是在区块链中如何使用随机数呢?区块链中的随机数与传统程序中的随机数不一样是因为,区块链是一个分布式执行环境,并且为了不可篡改性,需要n台机器在同一时刻的执行结果是一样的,也就是说在同一时刻随机数是一样的。

Solidity中最好的随机数生成器是keccak256哈希函数。
我们可以这样来生成一些随机数:

1
2
3
4
5
// 生成一个0到100的随机数:
uint randNonce = 0;
uint random = uint(keccak256(now, msg.sender, randNonce)) % 100;
randNonce++;
uint random2 = uint(keccak256(now, msg.sender, randNonce)) % 100;

这个方法首先拿到now的时间戳、msg.sender、以及一个自增数nonce(一个仅会被使用一次的数,这样我们就不会对相同的输入值调用一次以上哈希函数了)。
然后利用keccak把输入的值转变为一个哈希值, 再将哈希值转换为uint, 然后利用%100来取最后两位, 就生成了一个0到100之间随机数了。

这个方法很容易被不诚实的节点攻击

不诚实节点是利用随机函数进行攻击的,假设我们有一个硬币翻转合约–正面你赢双倍钱,反面你输掉所有的钱。假如它使用上面的方法来决定是正面还是反面(random>=50算正面, random<50算反面)。
此时如果我正运行一个节点,我可以只对我自己的节点发布一个事务,且不分享它。我可以运行硬币翻转方法来偷窥我的输赢。
如果我输了,我就不把这个事务包含进我要解决的下一个区块中去。我可以一直运行这个方法,直到我赢得了硬币翻转并解决了下一个区块,然后获利。

当然,因为网络上成千上万的以太坊节点都在竞争解决下一个区块,我能成功解决下一个区块的几率非常之低。这将花费我们巨大的计算资源来开发这个获利方法,但是如果奖励异常地高(比如我可以在硬币翻转函数中赢得1个亿),那就很值得去攻击了。

所以尽管这个方法在以太坊上不安全,在实际中,除非我们的随机函数有一大笔钱在上面,你游戏的用户一般是没有足够的资源去攻击的。

因此我们决定接受这个不足之处,使用这个简单的随机数生成函数。但是要谨记它是不安全的。

游戏中的随机函数应用在与其它僵尸打架的场景上,用来决定是否取胜,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pragma solidity ^0.4.19;

import "./zombiehelper.sol";

contract ZombieBattle is ZombieHelper {
uint randNonce = 0;
uint attackVictoryProbability = 70;

function randMod(uint _modulus) internal returns(uint) {
randNonce++;
return uint(keccak256(now, msg.sender, randNonce)) % _modulus;
}

function attack(uint _zombieId, uint _targetId) external ownerOf(_zombieId) {
Zombie storage myZombie = zombies[_zombieId];
Zombie storage enemyZombie = zombies[_targetId];
uint rand = randMod(100);
if (rand <= attackVictoryProbability) {
myZombie.winCount++;
myZombie.level++;
enemyZombie.lossCount++;
feedAndMultiply(_zombieId, enemyZombie.dna, "zombie");
} else {
myZombie.lossCount++;
enemyZombie.winCount++;
_triggerCooldown(myZombie);
}
}
}

订阅事件

一个完整可用的DApp除了智能合约必然也会包括前端应用,这样才能够更方便的让用户使用,那么前端应用如何及时的感知合约上的变化呢?答案是通过订阅事件

cryptozombies源码解析一中zombiefactory.sol有个事件NewZombie,每次新建一个僵尸之后,都会触发这个时间,那么我们可以在前端中通过Web3.js订阅一个事件,这样Web3提供者就可以在每次事件发生后触发一些代码逻辑,如下:

1
2
3
4
5
cryptoZombies.events.NewZombie()
.on("data", function(event) {
let zombie = event.returnValues;
console.log("一个新僵尸诞生了!", zombie.zombieId, zombie.name, zombie.dna);
}).on('error', console.error);

NewZombie事件在每个新建僵尸的时候都会调用,而上述代码是监听NewZombie的每次触发,所以无论谁的僵尸新建我都会收到一次弹窗信息,这对我可很不友好,我只关心我当自己的僵尸军团增加成员时提醒我,这个怎么实现呢?

indexed关键字可用于过滤event

为了筛选仅和当前用户相关的事件,Solidity合约必须使用indexed关键字,就像我们在ERC721实现中的Transfer事件中那样:
event Transfer(address indexed _from, address indexed _to, uint256 _tokenId);
在这种情况下,因为_from和_to都是indexed,这就意味着我们可以在前端事件监听中过滤事件:

1
2
3
4
5
cryptoZombies.events.Transfer({ filter: { _to: userAccount } })
.on("data", function(event) {
let data = event.returnValues;
// 当前用户更新了一个僵尸!更新界面来显示
}).on('error', console.error);

看到了吧,使用eventindexed字段对于监听合约中的更改并将其反映到DApp的前端界面中是非常有用的做法

也可以查询过去的事件,查询过去事件使用getPastEvents方法,使用过滤器fromBlocktoBlock给Solidity一个事件日志的时间范围(“block” 在这里代表以太坊区块编号):

1
2
3
4
5
cryptoZombies.getPastEvents("NewZombie", { fromBlock: 0, toBlock: 'latest' })
.then(function(events) {
// events 是可以用来遍历的 `event` 对象
// 这段代码将返回给我们从开始以来创建的僵尸列表
});

有些牛逼的同学看到这里可能就想到了一个非常有趣的用例:用事件来作为一种更便宜的存储。但是这里的短板是,事件不能从智能合约本身读取。但是,如果你有一些数据需要永久性地记录在区块链中以便可以在应用的前端中读取,这将是一个很好的用例。这些数据不会影响智能合约向前的状态。

僵尸游戏中就有这样一个场景,用事件来作为僵尸战斗的历史纪录–我们可以在每次僵尸攻击别人以及有一方胜出的时候产生一个事件。智能合约不需要这些数据来计算任何接下来的事情,但是这对我们在前端向用户展示来说是非常有用的东西。

上面的示例代码是针对Web3.js最新版1.0的,此版本使用了WebSockets来订阅事件。但是,MetaMask尚且不支持最新的事件API。
所以现在我们必须使用一个单独Web3提供者,它针对事件提供了WebSockets支持。 我们可以用Infura来像实例化第二份拷贝:

1
2
var web3Infura = new Web3(new Web3.providers.WebsocketProvider("wss://mainnet.infura.io/ws"));
var czEvents = new web3Infura.eth.Contract(cryptoZombiesABI, cryptoZombiesAddress);

然后我们将使用czEvents.events.Transfer来监听事件,而不再使用cryptoZombies.events.Transfer

这是我跟完这个教程之后的一些总结吧,还有一些内容没有总结,随后有时间再总结下,项目的源代码可从github上浏览。

您的肯定,是我装逼的最大的动力!