学院首页
区块链教程
技术进阶
以太坊智能合约编程(3):编程

以太坊智能合约编程(3):编程

等一轮残月管理员船龄 7.5年来源 以太坊爱好者
 25640  0
第三部分. 编程

在Truffle中进行测试

Truffle用来做智能合约的测试驱动开发(TDD)非常棒,我强烈推荐你在学习中使用它。它也是学习使用JavaScript Promise的一个好途径,例如deferred和异步调用。Promise机制有点像是说“做这件事,如果结果是这样,做甲,如果结果是那样,做乙... 与此同时不要在那儿干等着结果返回,行不?”。Truffle使用了包装web3.js的一个JS Promise框架Pudding(因此它为为你安装web3.js)。(译注:Promise是流行于JavaScript社区中的一种异步调用模式。它很好的封装了异步调用,使其能够灵活组合,而不会陷入callback hell.)

Transaction times. Promise对于DApp非常有用,因为交易写入以太坊区块链需要大约12-15秒的时间。即使在测试网络上看起来没有那么慢,在正式网络上却可能会要更长的时间(例如你的交易可能用光了Gas,或者被写入了一个孤儿块)。

下面让我们给一个简单的智能合约写测试用例吧。

使用Truffle

首先确保你 1.安装好了solc以及 2.testrpc。(testrpc需要Python和pip。如果你是Python新手,你可能需要用virtualenv来安装,这可以将Python程序库安装在一个独立的环境中。)

接下来安装 3.Truffle(你可以使用NodeJS's npm来安装:npm install -g truffle, -g开关可能会需要sudo)。安装好之后,在命令行中输入truffle list来验证安装成功。然后创建一个新的项目目录(我把它命名为'conference'),进入这个目录,运行truffle init。该命令会建立如下的目录结构:


现在让我们在另一个终端里通过执行testrpc来启动一个节点(你也可以用geth):


回到之前的终端中,输入truffle deploy。这条命令会部署之前truffle init产生的模板合约到网络上。任何你可能遇到的错误信息都会在testrpc的终端或者执行truffle的终端中输出。

在开发过程中你随时可以使用truffle compile命令来确认你的合约可以正常编译(或者使用solc YourContract.sol),truffle deploy来编译和部署合约,最后是truffle test来运行智能合约的测试用例。

第一个合约

下面是一个针对会议的智能合约,通过它参会者可以买票,组织者可以设置参会人数上限,以及退款策略。本文涉及的所有代码都可以在这个代码仓库找到。

contract Conference {
  address public organizer;
  mapping (address => uint) public registrantsPaid;
  uint public numRegistrants;
  uint public quota;

  event Deposit(address _from, uint _amount);  // so you can log these events
  event Refund(address _to, uint _amount); 

  function Conference() { // Constructor
    organizer = msg.sender;
    quota = 500;
    numRegistrants = 0;
  }
  function buyTicket() public returns (bool success) {
    if (numRegistrants >= quota) { return false; }
    registrantsPaid[msg.sender] = msg.value;
    numRegistrants++;
    Deposit(msg.sender, msg.value);
    return true;
  }
  function changeQuota(uint newquota) public {
    if (msg.sender != organizer) { return; }
    quota = newquota;
  }
  function refundTicket(address recipient, uint amount) public {
    if (msg.sender != organizer) { return; }
    if (registrantsPaid[recipient] == amount) { 
      address myAddress = this;
      if (myAddress.balance >= amount) { 
        recipient.send(amount);
        registrantsPaid[recipient] = 0;
        numRegistrants--;
        Refund(recipient, amount);
      }
    }
  }
  function destroy() { // so funds not locked in contract forever
    if (msg.sender == organizer) { 
      suicide(organizer); // send funds to organizer
    }
  }
}

接下来让我们部署这个合约。(注意:本文写作时我使用的是Mac OS X 10.10.5, solc 0.1.3+ (通过brew安装),Truffle v0.2.3, testrpc v0.1.18 (使用venv))

部署合约


(译注:图中步骤翻译如下:)

使用truffle部署智能合约的步骤:
1. truffle init (在新目录中) => 创建truffle项目目录结构
2. 编写合约代码,保存到contracts/YourContractName.sol文件。
3. 把合约名字加到config/app.json的'contracts'部分。
4. 启动以太坊节点(例如在另一个终端里面运行testrpc)。
5. truffle deploy(在truffle项目目录中)

添加一个智能合约。 在truffle init执行后或是一个现有的项目目录中,复制粘帖上面的会议合约到contracts/Conference.sol文件中。然后打开config/app.json文件,把'Conference'加入'deploy'数组中。


启动testrpc。 在另一个终端中启动testrpc。

编译或部署。 执行truffle compile看一下合约是否能成功编译,或者直接truffle deploy一步完成编译和部署。这条命令会把部署好的合约的地址和ABI(应用接口)加入到配置文件中,这样之后的truffle test和truffle build步骤可以使用这些信息。

出错了? 编译是否成功了?记住,错误信息即可能出现在testrpc终端也可能出现在truffle终端。

重启节点后记得重新部署! 如果你停止了testrpc节点,下一次使用任何合约之前切记使用truffle deploy重新部署。testrpc在每一次重启之后都会回到完全空白的状态。

合约代码解读

让我们从智能合约头部的变量声明开始:

address public organizer;
mapping (address => uint) public registrantsPaid;
uint public numRegistrants;
uint public quota;

address. 地址类型。第一个变量是会议组织者的钱包地址。这个地址会在合约的构造函数function Conference()中被赋值。很多时候也称呼这种地址为'owner'(所有人)。

uint. 无符号整型。区块链上的存储空间很紧张,保持数据尽可能的小。

public. 这个关键字表明变量可以被合约之外的对象使用。private修饰符则表示变量只能被本合约(或者衍生合约)内的对象使用。如果你想要在测试中通过web3.js使用合约中的某个变量,记得把它声明为public。

Mapping或数组。(译注:Mapping类似Hash, Directory等数据类型,不做翻译。)在Solidity加入数组类型之前,大家都使用类似mapping (address => uint)的Mapping类型。这个声明也可以写作address registrantsPaid[],不过Mapping的存储占用更小(smaller footprint)。这个Mapping变量会用来保存参加者(用他们的钱包地址表示)的付款数量以便在退款时使用。

关于地址。 你的客户端(比如testrpc或者geth)可以生成一个或多个账户/地址。testrpc启动时会显示10个可用地址:


第一个地址, accounts[0],是发起调用的默认地址,如果没有特别指定的话。

组织者地址 vs. 合约地址。 部署好的合约会在区块链上拥有自己的地址(与组织者拥有的是不同的地址)。在Solidity合约中可以使用this来访问这个合约地址,正如refundTicket函数所展示的:address myAddress = this;

Suicide, Solidity的好东西。(译注:suicide意为'自杀', 为Solidity提供的关键字,不做翻译。)转给合约的资金会保存于合约(地址)中。最终这些资金通过destroy函数被释放给了构造函数中设置的组织者地址。这是通过suicide(orgnizer);这行代码实现的。没有这个,资金可能被永远锁定在合约之中(reddit上有些人就遇到过),因此如果你的合约会接受资金一定要记得在合约中使用这个方法!

如果想要模拟另一个用户或者对手方(例如你是卖家想要模拟一个买家),你可以使用可用地址数组中另外的地址。假设你要以另一个用户,accounts[1], 的身份来买票,可以通过from参数设置:

conference.buyTicket({ from: accounts[1], value: some_ticket_price_integer });

函数调用可以是交易。 改变合约状态(修改变量值,添加记录,等等)的函数调用本身也是转账交易,隐式的包含了发送人和交易价值。因此web3.js的函数调用可以通过指定{ from: __, value: __ }参数来发送以太币。在Solidity合约中,你可以通过msg.sender和msg.value来获取这些信息:

function buyTicket() public {
    ...
    registrantsPaid[msg.sender] = msg.value;
    ...
}

事件(Event)。 可选的功能。合约中的Deposit(充值)和Send(发送)事件是会被记录在以太坊虚拟机日志中的数据。它们实际上没有任何作用,但是用事件(Event)把交易记录进日志是好的做法。

好了,现在让我们给这个智能合约写一个测试,来确保它能工作。

写测试

把项目目录test/中的example.js文件重命名为conference.js,文件中所有的'Example'替换为'Conference'。

contract('Conference', function(accounts) {
  it("should assert true", function(done) {
    var conference = Conference.at(Conference.deployed_address);
    assert.isTrue(true);
    done();   // stops tests at this point
  });
});

在项目根目录下运行truffle test,你应该看到测试通过。在上面的测试中truffle通过Conference.deployed_address获得合约部署在区块链上的地址。

让我们写一个测试来初始化一个新的Conference,然后检查变量都正确赋值了。将conference.js中的测试代码替换为:

contract('Conference', function(accounts) {
  it("Initial conference settings should match", function(done) {
    var conference = Conference.at(Conference.deployed_address);  
    // same as previous example up to here
    Conference.new({ from: accounts[0]  })
    .then(function(conference) {
      conference.quota.call().then(
          function(quota) {
            assert.equal(quota, 500, "Quota doesn't match!"); 
          }).then( function() {
            return conference.numRegistrants.call();
          }).then( function(num) {
            assert.equal(num, 0, "Registrants should be zero!");
            return conference.organizer.call();
          }).then( function(organizer) {
            assert.equal(organizer, accounts[0], "Owner doesn't match!");
            done();   // to stop these tests earlier, move this up
        }).catch(done);
      }).catch(done);
    });
  });

构造函数。 Conference.new({ from: accounts[0] })通过调用合约构造函数创造了一个新的Conference实例。由于不指定from时会默认使用accounts[0],它其实可以被省略掉:

Conference.new({ from: accounts[0] }); // 和Conference.new()效果相同

Promise. 代码中的那些then和return就是Promise。它们的作用写成一个深深的嵌套调用链的话会是这样:

conference.numRegistrants.call().then(
  function(num) {
    assert.equal(num, 0, "Registrants should be zero!");
    conference.organizer.call().then(
     function(organizer) {
        assert.equal(organizer, accounts[0], "Owner doesn't match!");
        }).then(
          function(...))
            }).then(
              function(...))
            // Because this would get hairy...

Promise减少嵌套,使代码变得扁平,允许调用异步返回,并且简化了表达“成功时做这个”和“失败时做那个”的语法。Web3.js通过回调函数实现异步调用,因此你不需要等到交易完成就可以继续执行前端代码。Truffle借助了用Promise封装web3.js的一个框架,叫做Pudding,这个框架本身又是基于Bluebird的,它支持Promise的高级特性。

call. 我们使用call来检查变量的值,例如conference.quota.call().then(...,还可以通过传参数,例如call(0), 来获取mapping在index 0处的元素。Solidity的文档说这是一种特殊的“消息调用”因为 1.不会为矿工记录和 2.不需要从钱包账户/地址发起(因此它没有被账户持有者私钥做签名)。另一方面,交易/事务(Transaction)会被矿工记录,必须来自于一个账户(也就是有签名),会被记录到区块链上。对合约中数据做的任何修改都是交易。仅仅是检查一个变量的值则不是。因此在读取变量时不要忘记加上call()!否则会发生奇怪的事情。(此外如果在读取变量是遇到问题别忘记检查它是否是public。)call()也能用于调用不是交易的函数。如果一个函数本来是交易,但你却用call()来调用,则不会在区块链上产生交易。

断言。 标准JS测试中的断言(如果你不小心拼成了复数形式'asserts',truffle会报错,让你一头雾水),assert.equal是最常用的,其他类型的断言可以在Chai的文档中找到。

再一次运行truffle test确保一切工作正常。

测试合约函数调用

现在我们测试一下改变quote变量的函数能工作。在tests/conference.js文件的contract('Conference', function(accounts) {...};)的函数体中添加如下测试用例:

it("Should update quota", function(done) {
  var c = Conference.at(Conference.deployed_address);

  Conference.new({from: accounts[0] }).then(
    function(conference) {
      conference.quota.call().then( 
        function(quota) { 
          assert.equal(quota, 500, "Quota doesn't match!"); 
        }).then( function() { 
          return conference.changeQuota(300);
        }).then( function(result) {  // result here is a transaction hash
          console.log(result);  // if you were to print this out it’d be long hex - the transaction hash
          return conference.quota.call()
        }).then( function(quota) { 
          assert.equal(quota, 300, "New quota is not correct!");
          done();
        }).catch(done);
    }).catch(done);
});

这里的新东西是调用changeQuota函数的那一行。console.log对于调试很有用,用它能在运行truffle的终端中输出信息。在关键点插入console.log可以查看执行到了哪一步。记得把Solidity合约中changeQuota函数被声明为public,否则你不能调用它:

  function changeQuota(uint newquota) public {  }

测试交易

现在让我们调用一个需要发起人发送资金的函数。

Wei. 以太币有很多种单位(这里有个很有用的转换器),在合约中通常用的是Wei,最小的单位。Web3.js提供了在各单位与Wei之间互相转换的便利方法,形如web3.toWei(.05, 'ether')。JavaScript在处理很大的数字时有问题,因此web3.js使用了程序库BigNumber,并建议在代码各处都以Wei做单位,直到要给用户看的时候(文档。

账户余额。 Web3.js提供了许多提供方便的方法,其中另一个会在下面测试用到的是web3.eth.getBalance(some_address)。记住发送给合约的资金会由合约自己持有直到调用suicide。

在contract(Conference, function(accounts) {...};)的函数体中插入下面的测试用例。在高亮显示的方法中,测试用例让另一个用户(accounts[1])以ticketPrice的价格买了一张门票。然后它检查合约的账户余额增加了ticketPrice,以及购票用户被加入了参会者列表。

这个测试中的buyTicket是一个交易函数:

it("Should let you buy a ticket", function(done) {
  var c = Conference.at(Conference.deployed_address);

  Conference.new({ from: accounts[0] }).then(
    function(conference) {
      var ticketPrice = web3.toWei(.05, 'ether');
      var initialBalance = web3.eth.getBalance(conference.address).toNumber();

      conference.buyTicket({ from: accounts[1], value: ticketPrice }).then(
        function() {
          var newBalance = web3.eth.getBalance(conference.address).toNumber();
          var difference = newBalance - initialBalance;
          assert.equal(difference, ticketPrice, "Difference should be what was sent");
          return conference.numRegistrants.call();
      }).then(function(num) {
          assert.equal(num, 1, "there should be 1 registrant");
          return conference.registrantsPaid.call(accounts[1]);
      }).then(function(amount) {
          assert.equal(amount.toNumber(), ticketPrice, "Sender's paid but is not listed");
          done();
      }).catch(done);
  }).catch(done);
});

交易需要签名。 和之前的函数调用不同,这个调用是一个会发送资金的交易,在这种情况下购票用户(accounts[1])会用他的私钥对buyTicket()调用做签名。(在geth中用户需要在发送资金之前通过输入密码来批准这个交易或是解锁钱包的账户。)

toNumber(). 有时我们需要把Solidity返回的十六进制结果转码。如果结果可能是个很大的数字可以用web3.toBigNumber(numberOrHexString)来处理因为JavaScript直接对付大数要糟。

测试包含转账的合约

最后,为了完整性,我们确认一下refundTicket方法能正常工作,而且只有会议组织者能调用。下面是测试用例:

it("Should issue a refund by owner only", function(done) {
  var c = Conference.at(Conference.deployed_address);

  Conference.new({ from: accounts[0] }).then(
    function(conference) {
      var ticketPrice = web3.toWei(.05, 'ether');
      var initialBalance = web3.eth.getBalance(conference.address).toNumber(); 

      conference.buyTicket({ from: accounts[1], value: ticketPrice }).then(
        function() {
          var newBalance = web3.eth.getBalance(conference.address).toNumber();
          var difference = newBalance - initialBalance;
          assert.equal(difference, ticketPrice, "Difference should be what was sent");  // same as before up to here
          // Now try to issue refund as second user - should fail
          return conference.refundTicket(accounts[1], ticketPrice, {from: accounts[1]});  
        }).then(
          function() {
            var balance = web3.eth.getBalance(conference.address).toNumber();
            assert.equal(web3.toBigNumber(balance), ticketPrice, "Balance should be unchanged");
            // Now try to issue refund as organizer/owner - should work
            return conference.refundTicket(accounts[1], ticketPrice, {from: accounts[0]});  
        }).then(
          function() {
            var postRefundBalance = web3.eth.getBalance(conference.address).toNumber();
            assert.equal(postRefundBalance, initialBalance, "Balance should be initial balance");
            done();
        }).catch(done);
    }).catch(done);
 });

这个测试用例覆盖的Solidity函数如下:

function refundTicket(address recipient, uint amount) public returns (bool success) {
  if (msg.sender != organizer) { return false; }
  if (registrantsPaid[recipient] == amount) { 
    address myAddress = this;
    if (myAddress.balance >= amount) { 
      recipient.send(amount);
      Refund(recipient, amount);
      registrantsPaid[recipient] = 0;
      numRegistrants--;
      return true;
    }
  }
  return false;
}

合约中发送以太币。 address myAddress = this展示了如何获取该会议合约实例的地址,以变接下来检查这个地址的余额(或者直接使用this.balance)。合约通过recipient.send(amount)方法把资金发回了购票人。

交易无法返回结果给web3.js. 注意这一点!refundTicket函数会返回一个布尔值,但是这在测试中无法检查。因为这个方法是一个交易函数(会改变合约内数据或是发送以太币的调用),而web3.js得到的交易运行结果是一个交易哈希(如果打印出来是一个长长的十六进制/怪怪的字符串)。既然如此为什么还要让refundTicket返回一个值?因为在Solidity合约内可以读到这个返回值,例如当另一个合约调用refundTicket()的时候。也就是说Solidity合约可以读取交易运行的返回值,而web3.js不行。另一方面,在web3.js中你可以用事件机制(Event, 下文会解释)来监控交易运行,而合约不行。合约也无法通过call()来检查交易是否修改了合约内变量的值。

关于sendTransaction(). 当你通过web3.js调用类似buyTicket()或者refundTicket()的交易函数时(使用web3.eth.sendTransaction),交易并不会立即执行。事实上交易会被提交到矿工网络中,交易代码直到其中一位矿工产生一个新区块把交易记录进区块链之后才执行。因此你必须等交易进入区块链并且同步回本地节点之后才能验证交易执行的结果。用testrpc的时候可能看上去是实时的,因为测试环境很快,但是正式网络会比较慢。

事件/Event. 在web3.js中你应该监听事件而不是返回值。我们的智能合约示例定义了这些事件:

event Deposit(address _from, uint _amount);
event Refund(address _to, uint _amount);

它们在buyTicket()和refundTicket()中被触发。触发时你可以在testrpc的输出中看到日志。要监听事件,你可以使用web.js*****(listener)。在写本文时我还不能在truffle测试中记录事件,但是在应用中没问题:

Conference.new({ from: accounts[0] }).then(
  function(conference) {
    var event = conference.allEvents().watch({}, ''); // or use conference.Deposit() or .Refund()
    event.watch(function (error, result) {
      if (error) {
        console.log("Error: " + error);
      } else {
        console.log("Event: " + result.event);
      }
    });
    // ...

过滤器/Filter. 监听所有事件可能会产生大量的轮询,作为替代可以使用过滤器。它们可以更灵活的开始或是停止对事件的监听。更多过滤器的信息可查看Solidity文档。

总的来说,使用事件和过滤器的组合比检查变量消耗的Gas更少,因而在验证正式网络的交易运行结果时非常有用。

Gas. (译注:以太坊上的燃料,因为代码的执行必须消耗Gas。直译为汽油比较突兀,故保留原文做专有名词。)直到现在我们都没有涉及Gas的概念,因为在使用testrpc时通常不需要显式的设置。当你转向geth和正式网络时会需要。在交易函数调用中可以在{from: __, value: __, gas: __}对象内设置Gas参数。Web3.js提供了web3.eth.gasPrice调用来获取当前Gas的价格,Solidity编译器也提供了一个参数让你可以从命令行获取合约的Gas开销概要:solc --gas YouContract.sol。下面是Conference.sol的结果:


为合约创建DApp界面

下面的段落会假设你没有网页开发经验。

上面编写的测试用例用到的都是在前端界面中也可以用的方法。你可以把前端代码放到app/目录中,运行truffle build之后它们会和合约配置信息一起编译输出到build/目录。在开发时可以使用truffle watch命令在app/有任何变动时自动编译输出到build/目录。然后在浏览器中刷新页面即可看到build/目录中的最新内容。(truffle serve可以启动一个基于build/目录的网页服务器。)

app/目录中有一些样板文件帮助你开始:


index.html会加载app.js:


因此我们只需要添加代码到app.js就可以了。

默认的app.js会在浏览器的console(控制台)中输出一条"Hello from Truffle!"的日志。在项目根目录中运行truffle watch,然后在浏览器中打开build/index.html文件,再打开浏览器的console就可以看到。(大部分浏览器例如Chrome中,单击右键 -> 选择Inspect Element然后切换到Console即可。)


在app.js中,添加一个在页面加载时会运行的window.onload调用。下面的代码会确认web3.js已经正常载入并显示所有可用的账户。(注意:你的testrpc节点应该保持运行。)

window.onload = function() {
  var accounts = web3.eth.accounts;
  console.log(accounts);
}

看看你的浏览器console中看看是否打印出了一组账户地址。

现在你可以从tests/conference.js中复制一些代码过来(去掉只和测试有关的断言),将调用返回的结果输出到console中以确认代码能工作。下面是个例子:

window.onload = function() {
  var accounts = web3.eth.accounts;
  var c = Conference.at(Conference.deployed_address);

  Conference.new({ from: accounts[0] }).then(
    function(conference) {

    var ticketPrice = web3.toWei(.05, 'ether');
    var initialBalance = web3.eth.getBalance(conference.address).toNumber(); 
    console.log("The conference's initial balance is: " + initialBalance);

    conference.buyTicket({ from: accounts[1], value: ticketPrice }).then(
      function() {
        var newBalance = web3.eth.getBalance(conference.address).toNumber();
        console.log("After someone bought a ticket it's: " + newBalance);
        return conference.refundTicket(accounts[1], ticketPrice, {from: accounts[0]});
      }).then(
        function() {  
          var balance = web3.eth.getBalance(conference.address).toNumber();
          console.log("After a refund it's: " + balance);
      });
  });
};

上面的代码应该输出如下:


(console输出的warning信息可忽略。)

现在起你就可以使用你喜欢的任何前端工具,jQuery, ReactJS, Meteor, Ember, AngularJS,等等等等,在app/目录中构建可以与以太坊智能合约互动的DApp界面了!接下来我们给出一个极其简单基于jQuery的界面作为示例。


这里是index.html的代码,这里是app.js的代码

通过界面测试了智能合约之后我意识到最好加入检查以保证相同的用户不能注册两次。另外由于现在是运行在testrpc节点上,速度很快,最好是切换到geth节点并确认交易过程依然能及时响应。否则的话界面上就应该显示提示信息并且在处理交易时禁用相关的按钮。

尝试geth. 如果你使用geth, 可以尝试以下面的命令启动 - 在我这儿(geth v1.2.3)工作的很好:

build/bin/geth --rpc --rpcaddr="0.0.0.0" --rpccorsdomain="*" --mine --unlock='0 1' --verbosity=5 --maxpeers=0 --minerthreads='4'  --networkid '12345' --genesis test-genesis.json

这条命令解锁了两个账户, 0和1。1. 在geth控制台启动后你可能需要输入这两个账户的密码。2. 你需要在test-genesis.json文件里面的'alloc'配置中加入你的这两个账户,并且给它们充足的资金。3. 最后,在创建合约实例时加上gas参数:

Conference.new({from: accounts[0], gas: 3141592})

然后把整个truffle deploy, truffle build流程重来一遍。

教程中的代码。 在这篇基础教程中用到的所有代码都可以在这个代码仓库中找到。

自动为合约生成界面。 SilentCicero制作了一个叫做DApp Builder的工具,可以用Solidity合约自动生成HTML, jQuery和web.js的代码。这种模式也正在被越来越多的正在开发中的开发者工具采用。

教程到此结束! 最后一章我们仅仅学习了一套工具集,主要是Truffle和testrpc. 要知道即使在ConsenSys内部,不同的开发者使用的工具和框架也不尽相同。你可能会发现更适合你的工具,这里所说的工具可能很快也会有改进。但是本文介绍的工作流程帮助我走上了DApp开发之路。

(⊙ω⊙) wonk wonk

感谢Joseph Chow的校阅和建议,Christian Lundkvist, Daniel Novy, Jim Berry, Peter Borah和Tim Coulter帮我修改文字和debug,以及Tim Coulter, Nchinda Nchinda和Mike Goldin对DApp前端步骤图提供的帮助。
  • 全部
  • 最佳
登录 账号发表你的看法,还没有账号?立即免费 注册
推荐教程
换一批