关于“以太猫”的流行,相信不少人都有所耳闻,甚至入手养过几只。从游戏性来说,其本质就是一个简单的收集交换类游戏,然鹅,是区块链赋予了它魅力,让用户每一只猫永远不会消失、不被篡改,更重要的是可以炒(滑稽脸),于是今天借此机会一探以太坊应用DApp的开发过程以及开发中遇到的坑。
以太坊是一个区块链公有链平台,和比特币类似,以太坊也有其代币--以太币,可在挖矿、交易中获得,然而,说到以太坊和比特币的区别就是其支持智能合约,一个智能合约由代码和数据组成,和其他编程语言中的类类似,一个以太坊分布式应用DApp由众多智能合约组成,每个智能合约都有其独特的地址,可以看做以太坊上的一个账户,可以存取以太币,作用就像一个裁判、中间人。一个简单但不是很恰当的例子就是赌博,我和小明打赌明天会下雨,输的人给赢的人一百块,这种情况我们在现实中一般会以下面两种方法实现:
OK,智能合约就是为了解决以上的信任问题而诞生的,由于智能合约存放于区块链,而区块链具有的不可抵赖和不可篡改性,使得智能合约比现实中任意一个机构的公信力都强。其实,区块链去中心化思想最大的优势就是解决了信任问题,而现实中最常见需要解决信任问题的场景莫过于涉及货币交易,从以太坊的众多DApp列表https://www.stateofthedapps.com/ 中看到,大多数都是关于交易、赌博性质的应用,可以说“以太猫”的横空出世刷新了人们对于区块链应用的固有认知。
$ brew install node
以太坊DApp其他开发工具都是通过npm安装的,node.js大法好,mac用户可通过homebrew安装。$ npm install ethereumjs-testrpc
以太坊提供的区块链测试环境,所有节点都是虚拟的存在内存中,启动后默认创建10个账户。读者也可以选择安装geth
搭建私有链,使用真实节点存储。$ npm install web3
以太坊提供读写区块链数据的JavaScript接口,源码地址:https://github.com/ethereum/web3.js/ ,通过web3.js我们可以访问各个账户、部署智能合约、调用合约方法、发起交易等等。$ npm install truffle
第三方提供的开源以太坊DApp集成工具,源码地址:https://github.com/trufflesuite/truffle ,truffle工具会帮助我们编译、测试、打包和部署DApp项目中的所有合约,类似的还有Meteor(官方推荐工具,但实用下来感觉没有truffle方便,而且文档也较少)。$ npm install truffle-contract
基于web3.js封装的JavaScript与智能合约交互接口,通过链式调用将对合约的各个操作串联在一起,具体API参考源码地址:https://github.com/trufflesuite/truffle-contract$ npm install express
node.js社区中基于connect
流行的服务器开发框架,本文使用该框架搭建后台服务器,读者可自行选择其他框架。编写一个DApp可以说是包括两部分,合约部分和业务逻辑部分。
业务逻辑部分即提供客户端与智能合约交互的接口,相当于目前BS结构中的后台逻辑,因此业务逻辑部分可部署在中心服务器中,而且在以太坊中每个智能合约函数的每一行代码都有固定的gas费用以及延时的,一些简单的逻辑应该交由业务逻辑处理,编写业务逻辑目前提供有以下几种语言:
废话不多说,下面我们通过一个DApp例子来窥探一下区块链智能合约的魅力,demo源码地址:https://github.com/Dave1991/QzoneBlockPet。
该demo是一个卡片收集类游戏,业务场景为每个用户都拥有一只随机的宠物,用户通过收集卡片作用于宠物身上进行装扮,而卡片的收集来源分三种:
我们通过$ truffle init
命令创建一个DApp项目,truffle会帮我们组织好一个DApp的目录结构,如下所示,其中app目录为笔者添加的,用于存放业务逻辑代码。
var Migrations = artifacts.require("./Migrations.sol");
var PetCard = artifacts.require("./PetCard.sol");
var UserCenter = artifacts.require("./UserCenter.sol");
module.exports = function(deployer) {
deployer.deploy(Migrations);
deployer.deploy(PetCard);
deployer.deploy(UserCenter);
};
1_initial_migration.js
,该demo包含两个合约,加上truffle部署时需要使用的合约,一共三个合约,代码如下所示,当添加一个合约时需要在该文件中添加合约变量而且需要通过deployer部署到区块链,需要注意的是这里当前目录是contracts目录。module.exports = {
networks: {
development: {
host: "localhost",
port: 8545,
network\_id: "\*" // Match any network id
}
}
};
$ testrpc
启动区块链测试环境,可以看到testrpc在内存中为我们创建了10个虚拟账户以及对应的私钥。
$ truffle compile
编译智能合约,底层调用的是solc
编译器,该编译方式是增量的,如果要全量编译,可加上--all
参数。
$ truffle migrate --reset
部署所有智能合约,部署的环境由truffle.js定义,和compile类似,migrate也是增量部署,如果要重新部署所有合约,可加上--reset
参数。
$ cd app
$ npm start
启动服务器接口名称 | 方法 | 路由参数 |
---|---|---|
createRandomCard | GET | 无 |
例子 | 返回 |
---|---|
/createRandomCard | {"cardId":"2","code":"0x616161666","owner":"0x5727b589bca4500e896ffc82e3fedf56cae7017f","value":"52"} |
接口名称 | 方法 | 路由参数 |
---|---|---|
getAllCardsForUser | GET | /:address |
例子 | 返回 |
---|---|
/getAllCardsForUser/0xc3d9b7ea1e42b04dddf3475b464bb1abd5f8451f | {"cardId":"0","code":"0x616161666","value":"4"} |
需要注意的是上面两个方法调用前都需要设置gas(以太坊交易手续费),不过由于demo运行在testrpc中所有账户的balance都是虚拟的,业务逻辑直接从接口调用方账户扣除了gas,对其屏蔽了该过程,但如果正式部署到生产环境我们需要先询问用户是否愿意付该笔gas然后再真正调用合约接口,因此,以太坊的web3.js提供了estimateGas
方法来预估合约函数执行所需的gas。
智能合约使用Solidity语言编写,语法有点类似于JavaScript,文件名以.sol结尾,通常来说一个.sol文件定义一个合约,相当于Java中一个文件定义一个public class。一个合约通常包含两部分,成员变量和成员函数。
进入本demo的contracts目录,可以看见里面包含了以下文件:
下面展示的是宠物卡片合约的部分代码。
pragma solidity ^0.4.17;
contract PetCard {
struct Card {
bytes32 code; //卡片代码,决定卡片的功能
uint256 value;
address owner;
bool isSelling;
uint sellingPrice;
uint cardId;
}
enum ErrorCode {ERROR_NO_ERROR, ERROR_INDEX_OUT_OF_RANGE, ERROR_WRONG_OWNER, ERROR_CARD_IS_SELLING, ERROR_CARD_IS_NOT_SELLING, ERROR_PRICE_NOT_ENOUGH}
Card[] cards;
address CEO;
function PetCard() public payable {
CEO = msg.sender;
}
// 匿名函数,当外部调用找不到时调用该函数
event FallbackTrigged(bytes data);
function() public payable {
FallbackTrigged(msg.data);
}
event BuyCardEvent(uint cardId, bool isSuccess, ErrorCode errorCode);
// 从卡片商城中购买卡片
function buyCard(uint cardId) public payable {
address buyer = msg.sender;
// 判断card下标是否合法,不合法时退款给买家
if (cardId >= cards.length || cardId < 0) {
buyer.transfer(msg.value);
BuyCardEvent(cardId, false, ErrorCode.ERROR_INDEX_OUT_OF_RANGE);
return;
}
Card storage card = cards[cardId];
// 判断消费金额是否小于card价格
if (msg.value < card.sellingPrice) {
buyer.transfer(msg.value);
BuyCardEvent(cardId, false, ErrorCode.ERROR_PRICE_NOT_ENOUGH);
return;
}
// 判断卡片是否正在销售
if (!card.isSelling) {
buyer.transfer(msg.value);
BuyCardEvent(cardId, false, ErrorCode.ERROR_CARD_IS_NOT_SELLING);
return;
}
// 将卡片卖的钱还给卖家
if (this.balance >= card.sellingPrice) {
card.owner.transfer(card.sellingPrice);
}
card.owner = buyer;
card.isSelling = false;
card.sellingPrice = 0;
BuyCardEvent(cardId, true, ErrorCode.ERROR_NO_ERROR);
}
// 获取用户所有卡片
function getAllCardsForUser() public constant returns (uint[] cardIds, bytes32[] codes, uint[] values, uint len) {
cardIds = new uint[](cards.length);
codes = new bytes32[](cards.length);
values = new uint[](cards.length);
// codes = new string[](cards.length);
len = 0;
for (uint i = 0; i < cards.length; i++) {
if (cards[i].owner == msg.sender) {
cardIds[len] = cards[i].cardId;
codes[len] = cards[i].code;
values[len] = cards[i].value;
len++;
}
}
}
event CreateNewCardEvent(uint cardId, bytes32 code, address owner, uint value);
// 给用户掉落新卡片
function createNewCardForUser(bytes32 code, uint value) public {
Card memory card = Card({code: code, value: value, owner: msg.sender, isSelling: false, cardId: cards.length, sellingPrice: 0});
cards.push(card);
CreateNewCardEvent(card.cardId, card.code, card.owner, card.value);
}
}
合约内部可以定义多个结构体,关键字为struct,结构体内部也可定义成员变量,允许的类型和合约一样。此外,合约支持数据类型包括以下几种:
balance
方法获取地址对应账户的余额,transfer
方法转账以太币到地址对应的账户中,转账者为调用者,收款者为address,另一个方法send
类似于transfer
也是转账,但值得注意的是,当transfer
失败时,会回滚交易并抛出异常,而send
方法则不会。根据上述的数据类型,我们定义卡片的结构体,包括卡片代码、卡片价值、卡片拥有者、卡片是否正在出售、卡片出售价格以及卡片id。然后,定义了函数执行可能会发生的错误码,还有一个卡片的集合以及合约的创建者CEO。
struct Card {
bytes32 code; //卡片代码,决定卡片的功能
uint256 value;
address owner;
bool isSelling;
uint sellingPrice;
uint cardId;
}
enum ErrorCode {ERROR_NO_ERROR, ERROR_INDEX_OUT_OF_RANGE, ERROR_WRONG_OWNER, ERROR_CARD_IS_SELLING, ERROR_CARD_IS_NOT_SELLING, ERROR_PRICE_NOT_ENOUGH}
Card[] cards;
address CEO;
在Solidity中函数的定义语法是
function 函数名(参数列表) 修饰符 returns (返回值列表)
这里值得注意的是,在函数生命中返回值列表我们可以声明返回值的名字,类似于形参,当在函数体中给返回值变量赋值后,我们可以不用写return
,但如果写了还是以return
为主,同时,一个函数返回值支持多个,调用者拿到的将是一个返回值数组,和python有点像。
另外,EVM会给每个合约的函数传入一个名为msg的对象,该对象包含几个属性,如sender是调用者账户地址、value是调用者执行该函数支付的以太币(单位是wei)、data是函数调用的描述。除了data外,其他属性的值是由调用者传入,详见业务逻辑代码的介绍。
和大部分语言一样,Solidity中每个合约也有构建函数,在构建函数中我们可以做一些初始化的操作,在下面的代码中我们注意到函数后有两个修饰符,分别是public
和payable
,其中public
说明该函数外部合约也可见,对应的还有external
,private
,internal
,要说到这四者的区别,需要查看函数的调用方式和可见性,本文就不展开了。然后payable
说明该函数会涉及货币交易,同时当我们在一个合约的其他函数中调用了转账操作,那么构建函数必须也得声明为payable
。
匿名函数,也就是没有名字的函数,每个合约中最多可定义一个,当其他地方调用该合约不存在的函数或者出现异常时,EVM(以太坊智能合约执行虚拟机)会自动调用合约的匿名函数,同样地,当合约内其他函数有转账操作时匿名函数也需要加上payable
修饰。
function PetCard() public payable {
CEO = msg.sender;
}
// 匿名函数,当外部调用找不到时调用该函数
event FallbackTrigged(bytes data);
function() public payable {
FallbackTrigged(msg.data);
}
代码中我们定义了多个event
,每个event
只需要定义其名字和参数列表即可以,其作用相当于其他语言中的log,在函数中传入实参即可记录,虽说event
的作用和log一样,但在Solidity中作用却非同小可,因为当一个函数是以transaction的形式被调用,调用者是无法拿到返回值的,因为transaction的调用是异步的,EVM无法立刻执行给出返回值,所以调用者只能通过event
的记录取得函数执行后的数据,具体操作流程见业务逻辑代码的介绍。
定义购买卡片的函数,函数一开始我们写了三个是否合法的判断,这里可以使用require关键字对这些条件进行限定,但由于笔者希望调用者可以接收到错误信息,这里就使用了四个if判断,并且使用了事件通知调用者,同时当条件不满足时我们需要做一些回滚操作,例如将金额退还给调用者账户。而当条件满足后,我们将卡片定价转给卖家,转移卡片拥有者。
event BuyCardEvent(uint cardId, bool isSuccess, ErrorCode errorCode);
// 从卡片商城中购买卡片
function buyCard(uint cardId) public payable {
address buyer = msg.sender;
// 判断card下标是否合法,不合法时退款给买家
if (cardId >= cards.length || cardId < 0) {
buyer.transfer(msg.value);
BuyCardEvent(cardId, false, ErrorCode.ERROR_INDEX_OUT_OF_RANGE);
return;
}
Card storage card = cards[cardId];
// 判断消费金额是否小于card价格
if (msg.value < card.sellingPrice) {
buyer.transfer(msg.value);
BuyCardEvent(cardId, false, ErrorCode.ERROR_PRICE_NOT_ENOUGH);
return;
}
// 判断卡片是否正在销售
if (!card.isSelling) {
buyer.transfer(msg.value);
BuyCardEvent(cardId, false, ErrorCode.ERROR_CARD_IS_NOT_SELLING);
return;
}
// 将卡片卖的钱还给卖家
if (this.balance >= card.sellingPrice) {
card.owner.transfer(card.sellingPrice);
}
card.owner = buyer;
card.isSelling = false;
card.sellingPrice = 0;
BuyCardEvent(cardId, true, ErrorCode.ERROR_NO_ERROR);
}
该函数的作用是获取所有属于调用者账户的卡片,值得注意的是,该函数在EVM中是一个昂贵的操作,首先我们声明了三个定长数组(定长是和临时变量存储的地方有关),每个长度都等于所有卡片数组的大小,因此每个数组都已经开销了不少gas,然后遍历又是一个耗时操作,又需要花费gas,而且函数在编译时并不知道cards的长度,所以即使调用者使用estimategas函数预估该函数所需gas也是不准确的,这对于调用者是危险的,随时都可能因为gas不够而执行失败。
function getAllCardsForUser() public constant returns (uint[] cardIds, bytes32[] codes, uint[] values, uint len) {
cardIds = new uint[](cards.length);
codes = new bytes32[](cards.length); //这里不能用string,solidity不支持定长的变长数组
values = new uint[](cards.length);
// codes = new string[](cards.length);
len = 0;
for (uint i = 0; i < cards.length; i++) {
if (cards[i].owner == msg.sender) {
cardIds[len] = cards[i].cardId;
codes[len] = cards[i].code;
values[len] = cards[i].value;
len++;
}
}
}
这里生成卡片的逻辑交给业务层,合约只负责根据参数创建一个新的卡片,最后通知调用者即业务层。
event CreateNewCardEvent(uint cardId, bytes32 code, address owner, uint value);
// 给用户掉落新卡片
function createNewCardForUser(bytes32 code, uint value) public {
Card memory card = Card({code: code, value: value, owner: msg.sender, isSelling: false, cardId: cards.length, sellingPrice: 0});
cards.push(card);
CreateNewCardEvent(card.cardId, card.code, card.owner, card.value);
}
合约编写完成后,可先到Remix上测试,测试通过后再使用truffle编译和部署到区块链上。之后,便是业务逻辑的编写了。
由于truffle
,web3
等都是依赖于node.js,为了一致性与方便性,本demo也是使用node.js构建业务服务器,主要依赖的模块是express
和truffle-contract
,前者用于更方便的业务路由和模块化,后者用于更方便调用合约。
打开app目录,我们会看到一下的文件结构:
下面我们主要看PetCard.js中业务层是如何与合约层进行交互的。
这一步我们首先获取宠物卡片合约和用户中心合约的实例,便于下面调用合约,这里我们需要依赖truffle-contract
还有本地的Web3Provider
模块。而truffle-contract
的用法都是链式调用,通过then
函数连接起来。
contract = require('truffle-contract');
provider = require('./Web3Provider.js');
express = require("express");
const PetCard = contract(require('../../build/contracts/PetCard.json'));
PetCard.setProvider(provider);
var petCard;
PetCard.deployed().then(function(instance){
petCard = instance;
});
var userCenter;
require('./UserCenterCore.js').then(function(instance) {
userCenter = instance;
});
var app = module.exports = express();
从下面代码中可以看到,业务层接受客户端传递的路由参数,再传入合约层,这里合约层函数的参数分两种,一种是自定义参数,另一种就是EVM预设参数,而预设参数是一个对象,需要在最后传入,正如上面Solidity函数介绍,预设参数对象需要包括from
为调用者地址,value
为传入合约的以太币。最后,由于这是直接通过合约实例调用函数,是一个transaction操作,因此如上面Solidity事件介绍,我们需要从返回值的日志中获取合约执行后的数据。由于日志拿到的事件参数是一个对象,所以我们直接以json形式返回给客户端即可,例如下面的返回就表示卡片购买失败,原因是卡片当前不在销售:{"cardId":"1","isSuccess":false,"errorCode":"4"}。
app.get('/buyCard/:address/:cardId/:price', function(req, res) {
petCard.buyCard(req.params.cardId, {from: req.params.address, value: req.params.price}).then(function(result) {
if (result.logs.length > 0) {
var eventObj = result.logs[0].args;
res.send(JSON.stringify(eventObj));
}
});
});
遍历卡片的操作并不涉及永久写入合约数据的操作,因此遍历卡片这里我们不使用transaction,而使用call的形式,因此我们可以直接拿到函数的返回值,然后由于函数返回多个值,因此result是一个数组。这里需要注意的是,上面我们说到遍历卡片时合约需要创建三个未知长度的数组,而且遍历的次数也是未知的,因此,estimategas函数预估的gas会不准确,我们这里直接给一个比较大的gas值。该接口返回的例子如:{"cardId":"0","code":"0x616161666","value":"4"}。
app.get('/getAllCardsForUser/:address', function(req, res) {
// 因为这需要创建未知长度数组,estimate 估计的gas会不准确,该方法慎调
petCard.getAllCardsForUser.call({from: req.params.address, gas: 3000000}).then(function(result) {
if (result.length >= 4) {
var cardIds = result[0], codes = result[1], values = result[2];
var len = result[3];
var cards = [];
for (var i = 0; i < len; i ++) {
cards.push({cardId: cardIds[i], code: codes[i], value: values[i]});
}
res.send(JSON.stringify(cards));
}
});
});
生成卡片的逻辑是在所有用户随机挑选一个用户作为卡片的拥有者,然后卡片的code这里先简单地写死了一串,后续可以想更好玩的code生成逻辑,接着就是调用estimateGas函数估计所需的gas,最后才是真正调用合约函数,传入预估的gas,其实比较好的交互应该像以太猫那样,在进行真正的调用之前告知用户交易所需的gas,并可以让用户调整,用户确认后再执行合约函数。下面是生成卡片调用后返回的一个例子:{"cardId":"2","code":"0x616161666","owner":"0x5727b589bca4500e896ffc82e3fedf56cae7017f","value":"52"}。
app.get('/createRandomCard', function(req, res) {
var allUsers,
randomUser;
userCenter.showAllPlayers.call().then(function(result){
allUsers = result;
randomIdx = Math.floor(Math.random() * allUsers.length);
randomUser = allUsers[randomIdx];
if (randomUser != undefined) {
var cardCode = "aaaforestlinbbb";
var cardValue = Math.floor(Math.random() * 100 + 1);
petCard.createNewCardForUser.estimateGas(cardCode, cardValue).then(function(esti_gas) {
return petCard.createNewCardForUser(cardCode, cardValue, {from: randomUser, gas: esti_gas});
}).then(function(rest) {
if (rest.logs.length > 0) {
var eventObj = rest.logs[0].args;
res.send(JSON.stringify(eventObj));
}
});
} else {
res.send("random user is undefined");
}
});
});
一个DApp开发流程介绍到此结束,下面总结一下开发中值得注意的地方:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。