以太坊Dapp开发指南

Dapp架构及概要设计

什么是Dapp

web应用开发的一个经典web应用架构B/S结构为例:
图片

一般来说,我们开发完web应用后会把web服务部署到公网上,现在比较流行的方式是部署到云端VPS。无论在服务端我们用了什么负载均衡、容器化等技术,又无论客户端用户用的是浏览器还是一个API构造器。实质上所有的客户都将通过请求我们部署的服务器暴露的HTTP接口来访问应用。在服务器上,我们写的web应用程序会再做一些数据操作,比如对数据库或缓存做CRUD操作,最终将结果反馈给客户端。

这个B/S架构如此流行,是因为它适用于绝大多数应用场景。但是也有例外,当用户或者服务提供者认为数据比应用更有价值,又或者服务提供者想提供的是数据服务,并想有效控制用户对数据写的权利。此时基于B/S结构的应用会显得力不从心。

和中心化的web应用不同,区块链使用一套开源的网络规范,提供了数据P2P实时共享同步的特性。终端客户可以随时将一个客户端设备接入区块链P2P网络,基于标准的协议,区块链终端将实时同步到最新的数据。在这个数据服务中,不再有中心化的服务端存在,这就是所谓的“对等无中心”特性。

Dapp是基于区块链构建的web应用,区别于B/S结构的传统web服务,Dapp充分吸取了区块链“对等无中心”的特点,采用了Browser/Blockchain的结构。对于服务商而言,只需将数据同步到区块链上,并定义一套详细的数据写规则。而数据的调用读取展示逻辑完全由数据的用户定义。这样既通过区块链保证了数据的可信,同时不同的数据用户也可以按需实现不同的Dapp来挖掘数bb据的价值。和中心化的web应用不同,区块链使用一套开源的网络规范,提供了数据P2P实时共享同步的特性。终端客户可以随时将一个客户端设备接入区块链P2P网络,基于标准的协议,区块链终端将实时同步到最新的数据。在这个数据服务中,不再有中心化的服务端存在,这就是所谓的“对等无中心”特性。

下图是一个典型的Dapp架构:
图片

我们要实现什么Dapp

我们要实现的是一个基于区块链的便签板应用。页面效果图如下:
图片

用户可以在便签板上添加新的便签或者修改已有的便签,所有在浏览器上打开了这个Dapp的用户都可以同步看到便签的更新,便签被更新的通知。

这是一个实现起来相对简单的Dapp,本文档会详细介绍它的实现过程。

标签板Dapp简介

这个Dapp主要由两部分组成,包括部署在以太坊网络中的智能合约以及依赖智能合约负责业务功能交互的web应用。智能合约采用solidity开发,而web应用没有后端服务器,而是直接调用以太坊节点接口,所以直接采用javascript开发,使用react框架。

Dapp组件间的调用关系如图:
图片

我们所要开发便签板应用较为简单,核心功能是将用户创建的文本便签存储到区块链上。需要实现的关键用例只有三个:

  1. 用户新建便签
    用户点击新增,在编辑窗口中写入便签题目和便签内容,点击“上链”。应用将便签(便签题目、便签内容)存入,再将编辑窗口关闭。
  2. 用户更新便签
    用户点击需要更新的便签,在编辑窗口中修改便签题目或便签内容,点击“上链”。应用将便签(便签题目、便签正文)更新,再将编辑窗口关闭。
    3. 用户查看所有便签
    用户打开应用即可看到所有链上的便签,即便签板。便签分为便签题目和便签内容,在便签板上由于空间有限,每个便签题目和正文只能显示最多20个字符。用户可以点击某一个便签打开编辑更新面板,即可查看全部内容。

智能合约开发及部署

IDE环境准备

本文采用Solidity语言开发以太坊智能合约。Solidity的开发环境我们直接使用BBE系统中提供的Remix web开发平台。因为BBE中提供的Remix IDE默认直接与目标以太坊网络相连,所以Remix IDE同时也可以作为部署、测试工具使用。

按照以下方法进入Remix IDE:

  1. 进入以太坊列表页,找到想要部署智能合约的目标以太坊网络,点击右侧的智能合约编辑器,系统会弹出新浏览器tab
    图片
  2. 等待Remix IDE页面加载完成,智能合约IDE主面板已经可以使用
    图片
  3. 在Run面板中确认已经连接目标以太坊的Web3 Provider
    图片
  4. 在Settings面板选择0.4.24版本的Solidity
    图片
  5. 在最左侧点击“+”,新建一个名为NoteWall.sol的智能合约
    图片

智能合约实现

编写智能合约的Solidity基础语法请读者自行阅读或参考完整源码。这里主要介绍便签板合约的主要实现。

便签对象的实现

便签板合约的实现关键就是定义一个便签在区块链上存储的数据格式。我们首先定义一个便签的结构体

struct Note {
    string title;
    string content;
    uint256 id;
}

便签由三部分构成,string类型的标题,string类型的便签正文以及一个256位的无符号整数类型表示的便签ID。

便签组的实现

接下来就是便签“对象”们如何组织化地保存下来,这里我们需要再定义一个mapping。

mapping(uint256 => Note) notes;

这个mapping管理着从ID到Note对象的索引。从用例来分析,便签板需要用到查找的场景主要有两处,一处是用户点击便签,编辑便签更新,另一处是用户查看所有的便签。由于Solidity中mapping不具有遍历性,所以我们需要再维护一个记录便签ID的数组,数组本身是可遍历的。

uint256[] noteIds;

新增便签的实现

新增一个便签除了需要新建一个Note对象,赋值title和content外,最主要的就是要为每一个Note对象分配一个不重复的ID。这里我们利用合约方法原子性的特点,通过维护一个累加的全局ID计数器来实现Note ID的唯一性。

具体原理就是当新建一个Note对象时,读取全局的ID计数器,将当前计数器值作为Note对象的ID,并将ID值++。

所以我们需要声明一个nextId,全局变量

uint256 nextId

接下来是新增便签的函数

/// Insert one new note object
function InsertNote(string _title, string _content) public {
    Note storage note = notes[nextId];
    noteIds.push(nextId);
    note.id = nextId;
    note.title = _title;
    note.content = _content;
    nextId = nextId + 1;
}

新增一个需要存储的Note对象,然后将nextId赋给note,并填入对应的标题和正文;此外将note索引到notes mapping中,插入新ID到noteIds数组中。

更新便签的实现

更新便签与新增便签稍有差别,需要从给定的Note ID中找到note对象再进行赋值。

function UpdateNote(uint256 _id, string _title, string _content) public {
    Note storage note = notes[_id];
    note.title = _title;
    note.content = _content;
}

获取所有的便签

按照用例3,用户应该会希望一次性获取所有的便签,但这在Solidity中无法通过一个方法就满足需求,原因是当前的solidity版本不支持方法返回一个struct或者struct的数组。所以我们拆分成两个函数来实现这个功能。第一个函数可以返回所有的便签ID,第二个函数则可以根据便签ID返回便签的标题和正文。

获取所有便签ID的函数:

/// Get all note ids
function GetNoteIds() public view returns(uint256[] _noteIds) {
    return noteIds;
}

读取便签标题和正文

/// Get one note object by its id
function GetNote(uint256 id) public view returns(string, string) {
    return (notes[id].title, notes[id].content);
}

这里我们使用了多值返回的特性。

所以最终获取所有的便签需要上层通过两步完成,首先调用GetNoteIds获取所有便签ID,之后再遍历便签ID,获取每个便签的标题和正文。虽然从传统软件设计看并不是最优的实现方式,但囿于EVM Solidity本身语法的局限性,智能合约的逻辑有时候需要做一些规避。

智能合约完整代码

pragma solidity ^0.4.0;
contract NoteWall {
    struct Note {
        string title;
        string content;
        uint256 id;
    }
    mapping(uint256 => Note) notes;
    uint256[] noteIds;
    uint256 nextId;

    /// Create a new ballot with $(_numProposals) different proposals.
    constructor() public {
        nextId = 0;
    }

    /// Get all note ids
    function GetNoteIds() public view returns(uint256[] _noteIds) {
        return noteIds;
    }

    /// Get one note object by its id
    function GetNote(uint256 id) public view returns(string, string) {
        return (notes[id].title, notes[id].content);
    }

    /// Insert one new note object
    function InsertNote(string _title, string _content) public {
        Note storage note = notes[nextId];
        noteIds.push(nextId);
        note.id = nextId;
        note.title = _title;
        note.content = _content;
        nextId = nextId + 1;
    }

    /// Update one note by its id
    function UpdateNote(uint256 _id, string _title, string _content) public {
        Note storage note = notes[_id];
        note.title = _title;
        note.content = _content;
    }
}

Remix IDE会自动保存合约文件

智能合约编译

在Remix中点击右侧的Compile面板
图片
点击Start to compile进行编译,编译完成后如下图:
图片

智能合约测试

Remix开发团队提供了Solidity的测试框架,可以开发完成简单的断言式单测。
新建NoteWall_test.sol合约,此合约用来对NoteWall.sol做单测覆盖。
NoteWall_test.sol的内容如下:

pragma solidity ^0.4.7;
import "remix_tests.sol"; // this import is automatically injected by Remix.
import "./NoteWall.sol";

contract testNoteWall {
    NoteWall noteWallToTest;
    function beforeAll () {
       noteWallToTest = new NoteWall();
    }

    /// test InsertNote function
    function checkInsertNote () public {
        noteWallToTest.InsertNote("note title", "note content");
        var noteIds = noteWallToTest.GetNoteIds();
        var lastNoteId = noteIds[noteIds.length - 1];
        var (title, content) = noteWallToTest.GetNote(lastNoteId);
        Assert.equal(title, "note title", "note title should be the note compositions");
        Assert.equal(content, "note content", "note content should be the note compositions");
    }

    /// test UpdateNote function
    function checkUpdateNote () public {
        noteWallToTest.InsertNote("note title", "note content");
        var noteIds = noteWallToTest.GetNoteIds();
        var lastNoteId = noteIds[noteIds.length - 1];
        noteWallToTest.UpdateNote(lastNoteId, "new title", "new content");
        var (title, content) = noteWallToTest.GetNote(lastNoteId);
        Assert.equal(title, "new title", "new title should be the updated note compositions");
        Assert.equal(content, "new content", "new content should be the updated note compositions");
    }

    /// test GetNoteIds function
    function checkGetNoteIds () public {
        noteWallToTest.InsertNote("note title 0", "note content 0");
        noteWallToTest.InsertNote("note title 1", "note content 1");
        noteWallToTest.InsertNote("note title 2", "note content 2");
        var noteIds = noteWallToTest.GetNoteIds();
        Assert.equal(noteIds.length, 3, "noteIds' length should equal 3");
    }

    /// test GetNote function
    function checkGetNote () public {
        noteWallToTest.InsertNote("note title", "note content");
        var noteIds = noteWallToTest.GetNoteIds();
        var lastNoteId = noteIds[noteIds.length - 1];
        var (title, content) = noteWallToTest.GetNote(lastNoteId);
        Assert.equal(title, "note title", "note title should be the note compositions");
        Assert.equal(content, "note content", "note content should be the note compositions");
    }
}

由于只是运用了remix_tests.sol中的断言语法,实现并不复杂,此处不做赘述。

点击右侧Test标签,进入测试面板。勾选browser/NoteWall_test.sol,点击“Run Tests”按钮开始测试。运行完单测后,测试结论应如下图:
图片

智能合约部署

单测运行完成后,我们开始进行智能合约的部署。如果开发者对合约代码没有信心,可以先将合约部署到Remix自带的EVM虚机(Javascript VM)。由于过程是类似的,这里我们将说明直接把合约部署到以太坊网络中的过程。

点击右侧面板“Run”标签,将GasLimit填为300000000。“Contract description”中可以填写开发者对合约用途的描述。

编写完成后,点击下方“Deploy”按钮。
图片
由于需要解锁keystore,Remix会弹出需要输入部署帐号的Passphrase,若您选择的是系统自带帐号,请输入123。
图片
部署时长取决于您目标区块链网络的出块时长,当部署完成后,在console面板中可以看到交易的信息。如下图:
图片
这里是详细的合约部署交易信息。

我们保留当前的窗口不要关闭,有几处重要的数据需要摘出来。

获取已部署合约的重要信息

获取合约地址

在右侧面板的“Deployed Contracts”中复制
图片

获取合约ABI

合约ABI是合约的接口描述,是一个json描述的结构体,可以从Compile面板中复制
图片

在Remix中调用已部署合约

这一步主要是为了验证已部署合约是否正常工作。我们可以在Remix中直接调用刚才部署的合约。

验证InsertNote方法

在Run面板中打开InsertNote框,在_title和_content中填入相应的参数。下图为举例:
图片
点击“transact”发起调用交易,同上文一样,passphrase输入123。点击OK后交易发起。
我们可以在Console中看到交易正在等待入块。
图片
等待片刻,当交易入块后,我们在Console面板中可以看到交易详情
图片

验证GetNoteIds方法

在Run面板中点击最下方的GetNoteIds,由于是只读方法,不需要发起交易,面板上应该会直接返回结果,应是一个元素的数组。
图片

验证GetNote方法

在Run面板中打开最下方的GetNote,在id字段中填入0,点击call。由于也是只读方法,不需要发起交易,面板上应该会直接返回结果,应是刚才新建的Note信息。
图片

验证UpdateNote方法

我们再验证UpdateNote方法,已知已有NoteId为0,填入新的标题和内容。由于需要发起新的交易,所以需要稍作等待。
图片
交易完成:
图片
重新GetNote:
图片
重新GetNoteIds,确认没有新增加Note:
图片

到此,智能合约的开发、测试、部署、验证步骤已经完成了。接下来我们开始进行上层应用层的开发。

Dapp应用层开发

Dapp应用层要做什么

Dapp应用层最主要的是实现用户侧的交互逻辑,包括web页面和页面事件响应。同时不同的操作会带来不同的事件,我们还需要针对页面事件去调用后端智能合约方法,存入便签、更新便签、读取便签等。
由于是基于React框架做的实现,我们首先要准备一套Dapp应用层的开发环境。

Dapp应用层开发环境准备

  1. 首先需要安装nodejs环境,本文采用的是v9.11.2版本,npm版本为v5.6.0。我们可以使用nvm安装,也可以通过系统自带的包管理安装。具体的下载安装方法这里不再赘述,可参考官方文档:Node.js下载安装指导
  2. 准备好nodejs和npm环境后,我们可以一键创建一个React项目。根据不同的环境,我们有两种命令。
    $ npx create-react-app notewall-dapp
    or
    $ create-react-app notewall-dapp
    
  3. 执行完之后,我们可以进入notewall-dapp目录,目录里是一个标准的未装任何依赖的项目目录。这个项目里我们需要安装如下依赖:
    $ cd notewall-dapp
    $ npm install --save web3@^1.0.0-beta.37 ethereumjs-tx react-toastify react-bootstrap
    
    其中web3是以太坊提供的javascript sdk,ethereumjs-tx用来处理交易签名,react-toastify则是一个第三方的弹窗通知库。
  4. 试着启动一下
    $ npm start
    
    npm会启动这个react项目,并且将http server监听在本地3000端口。浏览器访问http://localhost:3000可以查看结果。
    图片
    如果我们看到了如下页面显示,则代表项目环境初始化完成,我们可以开始编写应用代码了。

Dapp应用层开发

如上所述,Dapp应用层主要分为两大部分,一个是页面展示与交互逻辑,另一个是与智能合约的交互。与传统web应用需要后端服务器不同的是,Dapp应用层只需要前端部分,Dapp的后端是区块链节点。我们通过调用以太坊节点上的接口向智能合约发起调用。而这些调用全部需要在前端Javascript中完成。

我们可以规划项目目录,新建两个子目录分别为web3和note。web3中主要实现与合约的交互逻辑,note目录中主要实现用于前端展示的组件。
图片

合约交互

由于前端页面展示风格千变万化,我们可以先从与合约交互这一个相对靠后的逻辑层次实现起来。
与智能合约的交互,我们使用以太坊提供的Web3,它是以太坊官方提供的Javascript SDK。根据上述的分析可知,大量的Dapp都是web前端类项目,所以以太坊标准接口对Javascript的支持总是最快最稳定的。

结合最初的用例分析,我们需要在三个事件中与智能合约进行交互。

  1. 当用户打开应用时,需要展示所有的便签
  2. 当用户新增便签点击上链后,需要将便签内容保存。相对于我们已经实现的智能合约是InsertNote操作。
  3. 当用户更新便签点击上链后,需要将便签内容的更新保存下来。在合约中是UpdateNote操作。

1、Client构造

我们将这些交互抽象到一个NotewallWeb3Client中,在web3中新建一个client.js。
新建一个NoteWallWeb3Client类

export default class NoteWallWeb3Client {
    /**
     * @param httpProvider {string} 以太坊JSONAPI endpoint地址
     * @param address {string} 合约部署地址
     * @param senderAddress {string} 发起交易的账户地址
     * @param senderPrivateKey {string} 发起交易的账户私钥
     */
    constructor(httpProvider, address, senderAddress, senderPrivateKey) {
        let web3 = new Web3(new Web3.providers.HttpProvider(httpProvider));
        this.web3 = web3;
        this.address = address;
        this.senderPrivateKey = Buffer.from(senderPrivateKey, 'hex');
        this.sender = senderAddress;
        this.client = new web3.eth.Contract(JSON.parse(ABI), this.address, {
            from: this.sender
        });
        this.notes = {};
    }
}

构造函数创建了一个client,是web3中的合约客户端,通过调用methods.${方法名}可以调用合约方法。

构造函数中依赖了ABI,ABI作为一个常量可以定义在文件中,ABI的获取方法在合约部署一章中已经介绍。

2、交易类方法调用

这里我们先实现创建note和更新note两个交易类的方法。

/**
     * @param title {string} 便签标题
     * @param content {string} 便签正文
     * @param callback {function} 回调函数
     */
    insertNote(title, content, callback) {
  &