文|李伟志
关于“以太猫”的流行,相信不少人都有所耳闻,甚至入手养过几只。从游戏性来说,其本质就是一个简单的收集交换类游戏,然鹅,是区块链赋予了它魅力,让用户每一只猫永远不会消失、不被篡改,更重要的是可以炒(滑稽脸),于是今天借此机会一探以太坊应用DApp的开发过程以及开发中遇到的坑。
以太坊DApp介绍
以太坊是一个区块链公有链平台,和比特币类似,以太坊也有其代币--以太币,可在挖矿、交易中获得,然而,说到以太坊和比特币的区别就是其支持智能合约,一个智能合约由代码和数据组成,和其他编程语言中的类类似,一个以太坊分布式应用DApp由众多智能合约组成,每个智能合约都有其独特的地址,可以看做以太坊上的一个账户,可以存取以太币,作用就像一个裁判、中间人。一个简单但不是很恰当的例子就是赌博,我和小明打赌明天会下雨,输的人给赢的人一百块,这种情况我们在现实中一般会以下面两种方法实现:
依靠朋友间的信任。等明天到了,根据下雨与否进行交易。但这种方法一般不可行,因为毕竟是朋友,输的人会自动把昨天的打赌作为玩笑话,而赢的人也碍于面子不好意思要钱,所以交易无法达成。
依靠公证的第三方。OK,我们这次认真点,找一个彼此都认识的朋友小方作为公证,把我和小明的赌注一百块都先存着,等明天到了再给赢的人两百块。这种方法确实比第一种要好,但还是害怕就是第三方拿着两个人的赌注夹带私逃了,这对交易双方的损失更大。
OK,智能合约就是为了解决以上的信任问题而诞生的,由于智能合约存放于区块链,而区块链具有的不可抵赖和不可篡改性,使得智能合约比现实中任意一个机构的公信力都强。其实,区块链去中心化思想最大的优势就是解决了信任问题,而现实中最常见需要解决信任问题的场景莫过于涉及货币交易,从以太坊的众多DApp列表https://www.stateofthedapps.com/中看到,大多数都是关于交易、赌博性质的应用,可以说“以太猫”的横空出世刷新了人们对于区块链应用的固有认知。
开发准备
开发以太坊DApp需要安装以下环境或工具,以Mac OS X为例
$ 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 expressnode.js社区中基于connect流行的服务器开发框架,本文使用该框架搭建后台服务器,读者可自行选择其他框架。
编程语言
编写一个DApp可以说是包括两部分,合约部分和业务逻辑部分。
智能合约
Solidity,类JavaScript,这是以太坊推荐的旗舰语言,也是最流行的智能合约语言,具体用法参考http://solidity.readthedocs.io/en/latest/,本文所有合约都使用该语言编写,另外测试、调试Solidity有一个非常好的在线IDE--Remixhttps://remix.ethereum.org/,由以太坊团队推出的。
Serpent,类Python。
LLL,类Lisp。
业务逻辑
业务逻辑部分即提供客户端与智能合约交互的接口,相当于目前BS结构中的后台逻辑,因此业务逻辑部分可部署在中心服务器中,而且在以太坊中每个智能合约函数的每一行代码都有固定的gas费用以及延时的,一些简单的逻辑应该交由业务逻辑处理,编写业务逻辑目前提供有以下几种语言:
JavaScript,主要是基于Web3.js这个库调用智能合约,本文例子也是使用JavaScript编写的。
Go,上述提到的以太坊私链搭建工具geth就是使用Go编写的。
Python
Java
Ruby
Haskell
Rust
DApp实践
废话不多说,下面我们通过一个DApp例子来窥探一下区块链智能合约的魅力,demo源码地址:https://github.com/Dave1991/QzoneBlockPet。
Demo功能介绍
该demo是一个卡片收集类游戏,业务场景为每个用户都拥有一只随机的宠物,用户通过收集卡片作用于宠物身上进行装扮,而卡片的收集来源分三种:
系统定期为随机用户生成卡片
与其他用户交换卡片
在卡片商城中购买卡片
Demo目录结构
我们通过$ truffle init命令创建一个DApp项目,truffle会帮我们组织好一个DApp的目录结构,如下所示,其中app目录为笔者添加的,用于存放业务逻辑代码。
app
业务逻辑代码,后面再展开讨论
varMigrations=artifacts.require("./Migrations.sol");varPetCard=artifacts.require("./PetCard.sol");varUserCenter=artifacts.require("./UserCenter.sol");module.exports=function(deployer){deployer.deploy(Migrations);deployer.deploy(PetCard);deployer.deploy(UserCenter);};
build
合约编译生成目录,不要手动修改
contracts
合约目录,后面展开讨论
migrationstruffle部署配置文件,新的合约需要部署需要修改里面的配置文件1_initial_migration.js,该demo包含两个合约,加上truffle部署时需要使用的合约,一共三个合约,代码如下所示,当添加一个合约时需要在该文件中添加合约变量而且需要通过deployer部署到区块链,需要注意的是这里当前目录是contracts目录。
test
合约的测试文件,我们可以在该目录中存放各个合约的测试代码,类似于其他编程语言中的单元测试,该文章不展开讨论。
module.exports={networks:{development:{host:"localhost",port:8545,network\_id:"\*"// Match any network id}}};
truffle.js
区块链网络配置文件,在truffle部署合约时会使用该文件定义的地址,目前配的是testrpc默认测试环境,如下所示:
Demo运行方式
安装上述提到的依赖(包括非必需)
$ testrpc启动区块链测试环境,可以看到testrpc在内存中为我们创建了10个虚拟账户以及对应的私钥。
image.png
$ truffle compile编译智能合约,底层调用的是solc编译器,该编译方式是增量的,如果要全量编译,可加上--all参数。
image.png
$ truffle migrate --reset部署所有智能合约,部署的环境由truffle.js定义,和compile类似,migrate也是增量部署,如果要重新部署所有合约,可加上--reset参数。
image.png
$ cd app
$ npm start启动服务器
浏览器访问localhost:8080,目前提供的接口详见INTERFACE.md文件,下面展示其中两个接口。
生成卡片
接口名称方法路由参数
createRandomCardGET无
例子返回
/createRandomCard{"cardId":"2","code":"0x616161666","owner":"0x5727b589bca4500e896ffc82e3fedf56cae7017f","value":"52"}
获取用户所有卡片
接口名称方法路由参数
getAllCardsForUserGET/: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目录,可以看见里面包含了以下文件:
Migrations.sol:truffle创建目录时创建的合约,用于部署DApp
PetCard.sol:本demo核心合约,定义了宠物卡片合约
strings.sol:第三方定义的字符串类库,本demo主要使用了其分割字符串的函数
UserCenter.sol:用户中心合约,用于注册用户和查询用户
下面展示的是宠物卡片合约的部分代码。
pragma solidity^0.4.17;contract PetCard{struct Card{bytes32 code;//卡片代码,决定卡片的功能uint256 value;address owner;bool isSelling;uint sellingPrice;uint cardId;}enumErrorCode{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;functionPetCard()publicpayable{CEO=msg.sender;}// 匿名函数,当外部调用找不到时调用该函数eventFallbackTrigged(bytes data);function()publicpayable{FallbackTrigged(msg.data);}eventBuyCardEvent(uint cardId,bool isSuccess,ErrorCode errorCode);// 从卡片商城中购买卡片functionbuyCard(uint cardId)publicpayable{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){card.owner.transfer(card.sellingPrice);}card.owner=buyer;card.isSelling=false;card.sellingPrice=0;BuyCardEvent(cardId,true,ErrorCode.ERROR_NO_ERROR);}// 获取用户所有卡片functiongetAllCardsForUser()publicconstantreturns(uint[]cardIds,bytes32[]codes,uint[]values,uint len){cardIds=newuint[](cards.length);codes=newbytes32[](cards.length);values=newuint[](cards.length);// codes = new string[](cards.length);len=0;for(uint i=0;i
定义卡片结构与成员变量
合约内部可以定义多个结构体,关键字为struct,结构体内部也可定义成员变量,允许的类型和合约一样。此外,合约支持数据类型包括以下几种:
整型,uintx / intx,其中x代表整型所占用的位数,从8到256,步长为8,如果我们直接使用uint / int,则与uint256 / int256等价。
布尔型,bool,有true/false两个值。
浮点型,fixedMxN / ufixedMxN,浮点数在Solidity中支持得不是很好,它与其他语言中的浮点数并不一样,Solidity中浮点数在声明时就必须确定长度,而其他语言是可变的,M代表的是浮点数占用的总位数,从8到256,步长为8,N代表小数部分的长度,范围是0-80。
定长字节型,bytesx,其中x代表变量所占字节长度,范围是1-32,当变量打印出来时,显示的是十六进制。
变长字节型,bytes或string,两者区别在于bytes使用十六进制标识,string是用UTF-8表示。
地址,address, 等价于bytes20,而且Solidity为地址变量预设了几个方法,例如,balance方法获取地址对应账户的余额,transfer方法转账以太币到地址对应的账户中,转账者为调用者,收款者为address,另一个方法send类似于transfer也是转账,但值得注意的是,当transfer失败时,会回滚交易并抛出异常,而send方法则不会。
枚举,enum,和其他语言一样,Solidity也支持枚举值,语法也类似,可参考代码中错误码枚举值的定义。
根据上述的数据类型,我们定义卡片的结构体,包括卡片代码、卡片价值、卡片拥有者、卡片是否正在出售、卡片出售价格以及卡片id。然后,定义了函数执行可能会发生的错误码,还有一个卡片的集合以及合约的创建者CEO。
struct Card{bytes32 code;//卡片代码,决定卡片的功能uint256 value;address owner;bool isSelling;uint sellingPrice;uint cardId;}enumErrorCode{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修饰。
functionPetCard()publicpayable{CEO=msg.sender;}// 匿名函数,当外部调用找不到时调用该函数eventFallbackTrigged(bytes data);function()publicpayable{FallbackTrigged(msg.data);}
事件
代码中我们定义了多个event,每个event只需要定义其名字和参数列表即可以,其作用相当于其他语言中的log,在函数中传入实参即可记录,虽说event的作用和log一样,但在Solidity中作用却非同小可,因为当一个函数是以transaction的形式被调用,调用者是无法拿到返回值的,因为transaction的调用是异步的,EVM无法立刻执行给出返回值,所以调用者只能通过event的记录取得函数执行后的数据,具体操作流程见业务逻辑代码的介绍。
购买卡片
定义购买卡片的函数,函数一开始我们写了三个是否合法的判断,这里可以使用require关键字对这些条件进行限定,但由于笔者希望调用者可以接收到错误信息,这里就使用了四个if判断,并且使用了事件通知调用者,同时当条件不满足时我们需要做一些回滚操作,例如将金额退还给调用者账户。而当条件满足后,我们将卡片定价转给卖家,转移卡片拥有者。
eventBuyCardEvent(uint cardId,bool isSuccess,ErrorCode errorCode);// 从卡片商城中购买卡片functionbuyCard(uint cardId)publicpayable{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){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不够而执行失败。
functiongetAllCardsForUser()publicconstantreturns(uint[]cardIds,bytes32[]codes,uint[]values,uint len){cardIds=newuint[](cards.length);codes=newbytes32[](cards.length);//这里不能用string,solidity不支持定长的变长数组values=newuint[](cards.length);// codes = new string[](cards.length);len=0;for(uint i=0;i
生成卡片
这里生成卡片的逻辑交给业务层,合约只负责根据参数创建一个新的卡片,最后通知调用者即业务层。
eventCreateNewCardEvent(uint cardId,bytes32 code,address owner,uint value);// 给用户掉落新卡片functioncreateNewCardForUser(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:宠物卡片业务路由处理以及合约交互
UserCenter.js:用户中心,负责用户注册和获取所有用户的上层调用
UserCenterCore.js:用户中心核心,负责业务层与合约层交互
Web3Provider.js:定义Web3连接的是区块链地址
package.json:定义npm运行所需要的命令和依赖
server.js:业务层总入口,负责默认页面、404页面处理,以及各业务模块的中转路由,还有定义服务器绑定的端口
下面我们主要看PetCard.js中业务层是如何与合约层进行交互的。
获取合约示例
这一步我们首先获取宠物卡片合约和用户中心合约的实例,便于下面调用合约,这里我们需要依赖truffle-contract还有本地的Web3Provider模块。而truffle-contract的用法都是链式调用,通过then函数连接起来。
contract=require('truffle-contract');provider=require('./Web3Provider.js');express=require("express");constPetCard=contract(require('../../build/contracts/PetCard.json'));PetCard.setProvider(provider);varpetCard;PetCard.deployed().then(function(instance){petCard=instance;});varuserCenter;require('./UserCenterCore.js').then(function(instance){userCenter=instance;});varapp=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){vareventObj=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){varcardIds=result[0],codes=result[1],values=result[2];varlen=result[3];varcards=[];for(vari=0;i
生成卡片
生成卡片的逻辑是在所有用户随机挑选一个用户作为卡片的拥有者,然后卡片的code这里先简单地写死了一串,后续可以想更好玩的code生成逻辑,接着就是调用estimateGas函数估计所需的gas,最后才是真正调用合约函数,传入预估的gas,其实比较好的交互应该像以太猫那样,在进行真正的调用之前告知用户交易所需的gas,并可以让用户调整,用户确认后再执行合约函数。下面是生成卡片调用后返回的一个例子:{"cardId":"2","code":"0x616161666","owner":"0x5727b589bca4500e896ffc82e3fedf56cae7017f","value":"52"}。
app.get('/createRandomCard',function(req,res){varallUsers,randomUser;userCenter.showAllPlayers.call().then(function(result){allUsers=result;randomIdx=Math.floor(Math.random()*allUsers.length);randomUser=allUsers[randomIdx];if(randomUser!=undefined){varcardCode="aaaforestlinbbb";varcardValue=Math.floor(Math.random()*100+1);petCard.createNewCardForUser.estimateGas(cardCode,cardValue).then(function(esti_gas){returnpetCard.createNewCardForUser(cardCode,cardValue,{from:randomUser,gas:esti_gas});}).then(function(rest){if(rest.logs.length>0){vareventObj=rest.logs[0].args;res.send(JSON.stringify(eventObj));}});}else{res.send("random user is undefined");}});});
总结DApp开发中遇到的坑
一个DApp开发流程介绍到此结束,下面总结一下开发中值得注意的地方:
Solidity这个语言目前还不是很完善,版本还是0.4.x,而且文档相对其他语言较少,这里除了官网,还推荐两个论坛区块链技术博客和以太坊爱好者供大家参考。
合约函数中慎用未知长度的数组以及遍历操作,比较耗费gas,而且对于调用者极不友好,无法预估gas。
对于不需要写操作的函数,我们可以加上constant修饰符或者调用时使用call的方法而非直接调用,不产生transaction,也就不需要写入区块链。
对于不需要的数组我们可以使用delete操作删除整个数组或者某个元素,可以归还一些gas,但是最好复用,使用指示器标记当前使用的长度,因为delete操作本身也是需要耗费gas的。
合约内不适合做业务过重的操作,如上面的生成卡片操作,应该将逻辑放在业务层,毕竟在EVM中没执行一行代码都是需要gas的,合约应该只有读写区块链的操作。