以太坊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) {
        const insertNoteFunction = this.client.methods.InsertNote(title, content);
        this._sendContractTransaction(insertNoteFunction, callback);
    }

    /**
     * @param id {int} 便签id
     * @param title {string} 便签标题
     * @param content {string} 便签正文
     * @param callback {function} 回调函数
     */
    updateNote(id, title, content, callback) {
        const updateNoteFunction = this.client.methods.UpdateNote(id, title, content);
        this._sendContractTransaction(updateNoteFunction, callback);
    }

    _sendContractTransaction(func, callback) {
        const functionAbi = func.encodeABI();
        let nonce;

        func.estimateGas({
            from: this.sender
        }).then(gasAmount => {
            this.web3.eth.getTransactionCount(this.sender).then(nonceInt => {
                nonce = nonceInt.toString(16);
                const txParams = {
                    gasPrice: 0,
                    gasLimit: 30000000,
                    to: this.address,
                    data: functionAbi,
                    from: this.sender,
                    nonce: '0x' + nonce
                };
                const tx = new Tx(txParams);
                tx.sign(this.senderPrivateKey);
                const serializedTx = tx.serialize();
                this.web3.eth.sendSignedTransaction('0x' + serializedTx.toString('hex'))
                    .on('receipt', receipt => {
                        callback(null, receipt);
                    })
                    .on('error', err => {
                        callback(err, {});
                    });
            });
        });
    }

3、非交易类方法调用

非交易类方法主要为view类型的合约方法,调用这些合约方法我们可以直接用contract类来完成。

在具体用例中,我们需要粘连合约中GetNoteIds和GetNote方法。从而满足页面渲染时直接展示所有合约的需求。

这里我们定义一个getAllNotes方法:

/**
     * callback的参数为notes
     * notes格式为map[noteId] {
     *     id {string}
     *     title {string}
     *     content {string}
     * }
     */
     getAllNotes(callback) {
        this.client.methods.GetNoteIds().call().then(async result => {
            let noteIds = result;
            for (const noteId of noteIds) {
                await this.client.methods.GetNote(noteId).call().then(result => {
                    this.notes[noteId] = {
                        id: noteId,
                        title: result[0],
                        content: result[1]
                    };
                });
            }
            return this.notes;
        }).then(notes => {
            callback(notes);
        });
    }

到此,与合约交互部分的逻辑都已经开发完成了。NoteWallWeb3Client向外暴露三个方法分别是getAllNotes、insertNote、updateNote。

页面开发

开发页面前,我们首先将页面交互元素做一个拆分。主要的交互模块有:

  1. 登录表单,登录表单需要用户填写合约地址、发起交易的账户信息等。用户只有提交了登录表单才能查看合约。
  2. 便签,每一个便签样式类似,但内容不同,一个便签对应合约存储中的一个便签实例。
  3. 便签板,上面挂载所有的便签元素,并控制其他模块是否渲染。
  4. 编辑器,编辑器用来给用户创建和更新便签时写入内容用。应该允许用户输入标题、内容。

1、导入样式依赖

本项目会依赖到bootstrap样式表,所以在public/index.html中我们插入bootstrap.css

<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/css/bootstrap.min.css" integrity="sha384-PDle/QlgIONtM1aqA2Qemk5gPOE7wFq8+Em+G/hmo5Iq0CCmYZLv3fVRDJ4MMwEA" crossorigin="anonymous">

2、登录表单

登录表单对应到web3Client的参数,需要用户输入以太坊JSONRPC地址、合约地址、交易账号、交易秘钥四个参数。如图:
图片
我们直接使用react-bootstrap类库构建表单,当用户点击登录时,会将变量反传到父组件。

/**
 * @file login form component
 * @author BaaS Team
 */
import React, {Component} from 'react';
import {
    Button,
    Form,
    Jumbotron
} from 'react-bootstrap';
import './LoginForm.css';

export default class LoginForm extends Component {
    constructor(props) {
        super(props);

        this.state = {
            httpProvider: '',
            contractAddress: '',
            senderAddress: '',
            senderPrivateKey: ''
        };
    }

    // 这里我们只做最简单的字符串长度校验,有兴趣的读者可以引用web3库的addressFormatter做更标准的地址检查
    validateForm() {
        return this.state.httpProvider.length > 0
            && this.state.contractAddress.length > 0
            && this.state.senderAddress.length > 0
            && this.state.senderPrivateKey.length > 0;
    }

    // 输入元素内容有变更时即赋值
    handleChange = event => {
        this.setState({
            [event.target.id]: event.target.value
        });
    }

    // 这里会调用父组件传入的saveContractInfo方法,通知上层表单提交
    handleSubmit = event => {
        this.props.saveContractInfo(this.state.httpProvider, this.state.contractAddress,
            this.state.senderAddress, this.state.senderPrivateKey);
    }

    render() {
        return (
            <div className='LoginForm'>
            <Jumbotron>
                <form onSubmit={this.handleSubmit}>
                    <Form.Group controlId='httpProvider'>
                        <Form.Label>JSONRPC地址</Form.Label>
                        <Form.Control
                            autoFocus
                            type='text'
                            value={this.state.httpProvider}
                            onChange={this.handleChange}
                            size='sm'
                        />
                    </Form.Group>
                    <Form.Group controlId='contractAddress'>
                        <Form.Label>合约地址</Form.Label>
                       <Form.Control
                            value={this.state.contractAddress}
                            onChange={this.handleChange}
                            type='text'
                            size='sm'
                        />
                    </Form.Group>
                    <Form.Group controlId='senderAddress'>
                        <Form.Label>交易账号</Form.Label>
                        <Form.Control
                            value={this.state.senderAddress}
                            onChange={this.handleChange}
                            type='text'
                            size='sm'
                        />
                    </Form.Group>
                    <Form.Group controlId='senderPrivateKey'>
                        <Form.Label>交易秘钥</Form.Label>
                        <Form.Control
                            value={this.state.senderPrivateKey}
                            onChange={this.handleChange}
                            type='password'
                            size='sm'
                        />
                    </Form.Group>
                    <Form.Group>
                            <Button block
                                size="large"
                                type="submit"
                                variant="primary"
                                disabled={!this.validateForm()}
                                className="mt-5"
                                >登录</Button>
                    </Form.Group>
                </form>
                </Jumbotron>
            </div>
        );
    }
}

便签

便签模块主要是在样式控制上,这里我们将便签设计成不同颜色区分,会有不同倾斜角的正方形卡片。标题文字加粗。如效果图:
图片
Note元素组件定义:

/**
 * @file note component
 * @author BaaS Team
 */
import React, {Component} from 'react';
import './Note.css';

/**
 * @props
 *   id {int}
 *   title {string}
 *   content {string}
 *   onClick {function} 被点击时需要调用,上层组件做响应
 */
export default class Note extends Component {
    render() {
        const {id, title, content} = this.props;
        return (
            <div className="note" onClick={() =>
                this.props.onClick(id, title, content)}>
                <div className="note-panel">
                    <h2>{this.props.title}</h2>
                    <p>{this.props.content.substring(0, 20)}</p>
                </div>
            </div>
        );
    }
}

样式控制代码:

body {
    margin: 1em;
}

.note-wall,
.note {
    list-style: none;
}

.note-wall {
    overflow: hidden;
    padding: 3em;

}

.note-panel {
    display: block;
    width: 5em;
    height: 5em;
    padding: 2px;
    text-decoration: none;
    color: #000;
    background: #ffc;
    -webkit-box-shadow: 5px 5px 7px rgba(33, 33, 33, .7);
       -moz-box-shadow: 5px 5px 7px rgba(33, 33, 33, 1);
            box-shadow: 5px 5px 7px rgba(33, 33, 33, .7);
    -webkit-transition: -webkit-transform .15s linear;
       -moz-transition:    -moz-transform .15s linear;
         -o-transition:      -o-transform .15s linear;
}

.note {
    float: left;
    margin: 1em;
}

.note-panel h2 {
    padding-bottom: 10px;
    font-size: 20%;
    font-weight: 700;
}

.note-panel p {
    font-family: "STXingkai", "Reenie Beanie", "Microsoft Yahei", arial, sans-serif;
    font-size: 20%;
}

.note-panel {
    -webkit-transform: rotate(-6deg);
       -moz-transform: rotate(-6deg);
         -o-transform: rotate(-6deg);
}

.note-wall div:nth-child(even) .note-panel {
    position: relative;
    top: 5px;
    background: #cfc;
    -webkit-transform: rotate(4deg);
       -moz-transform: rotate(4deg);
         -o-transform: rotate(4deg);
}

.note-wall div:nth-child(3n) .note-panel {
    position: relative;
    top: -5px;
    background: #ccf;
    -webkit-transform: rotate(-3deg);
       -moz-transform: rotate(-3deg);
         -o-transform: rotate(-3deg);
}

.note-wall div:nth-child(5n) .note-panel {
    position: relative;
    top: -10px;
    -webkit-transform: rotate(5deg);
       -moz-transform: rotate(5deg);
         -o-transform: rotate(5deg);
}

.note-panel:focus {
    position: relative;
    z-index: 5;
    -webkit-box-shadow: 10px 10px 7px rgba(0, 0, 0, .7);
       -moz-box-shadow: 10px 10px 7px rgba(0, 0, 0, .7);
            box-shadow: 10px 10px 7px rgba(0, 0, 0, .7);
    -webkit-transform: scale(1.25);
       -moz-transform: scale(1.25);
         -o-transform: scale(1.25);
}

1、便签板

便签板组件负责挂载其余组件,并负责通过子组件的事件回调完成子组件的通信。主要有以下核心控制逻辑:
1.当用户未登录时,不显示便签板,只显示登陆表单。登陆表单提交后,初始化web3Client,为后续渲染便签做准备。

/**
 * @file notewall class. main component in app.
 * @author BaaS Team
 */
import React, {Component} from 'react';
import LoginForm from './LoginForm.js';
import NoteWallWeb3Client from '../web3/client';

export default class NoteWall extends Component {
    constructor(props) {
        super(props);
        this.state = {
            login: false
        };
        this.saveContractInfo = this.saveContractInfo.bind(this);
        this.web3Client = null;
        // 控制是否登陆
        this.login = false;
    }

    // 定义了LoginForm回调方法,初始化web3Client
    // web3Client将被用于获取便签和创建、更新便签
    saveContractInfo(httpProvider, contractAddress, senderAddress, senderPrivateKey) {
        this.web3Client = new NoteWallWeb3Client(httpProvider, contractAddress, senderAddress, senderPrivateKey);
        this.setState({login: true});
    }

    render() {
        if (!this.state.login) {
            return (
                <div className='note-wall'>
                    <LoginForm saveContractInfo={this.saveContractInfo} />
                </div>
            );
        }
    }
}

2.当用户登录后,增加渲染便签。全量便签数据通过web3Client周期性获取

/**
 * @file notewall class. main component in app.
 * @author BaaS Team
 */
import React, {Component} from 'react';
import LoginForm from './LoginForm.js';
import Note from './Note.js';
import NoteWallWeb3Client from '../web3/client';

export default class NoteWall extends Component {
    constructor(props) {
        super(props);
        this.state = {
            editorShow: false,
            login: false,
            noteList: []
        };

        this.periodicGetList = this.periodicGetList.bind(this);
        this.saveContractInfo = this.saveContractInfo.bind(this);
        this.web3Client = null;
        this.login = false;
    }

    saveContractInfo(httpProvider, contractAddress, senderAddress, senderPrivateKey) {
        this.web3Client = new NoteWallWeb3Client(httpProvider, contractAddress, senderAddress, senderPrivateKey);
        this.setState({login: true});
    }

    // Fetch the list on first mount
    componentWillMount() {
        this.getListFromWeb3();
        this.periodicGetList();
    }

    // 每三秒获取一次全量note列表
    periodicGetList() {
        setTimeout(function () {
            this.getListFromWeb3();
            this.periodicGetList();
        }
            .bind(this),
            3000
        );
    }

    // 获取全量note列表
    getListFromWeb3() {
        // 未登陆时由于没有client,跳过查询
        if (!this.state.login) {
            return;
        }
        this.web3Client.getAllNotes(notes => {
            this.setState({
                noteList: notes
            });
        });
    }

    render() {
        let list = this.state.noteList;
        // 如果已登录,则渲染便签
        if (this.state.login) {
            return (
                <div className='note-wall'>
                    <ToastContainer className='toast-notification'/>
                        {
                            Object.keys(list).map(noteId => {
                                let note = list[noteId];
                                return (
                                    <Note key={note.id} id={note.id} title={note.title}
                                        content={note.content} onClick={this.showEditor} />
                                );
                            })
                        }
                </div>
            );
        }
        return (
            <div className='note-wall'>
                <LoginForm saveContractInfo={this.saveContractInfo} />
            </div>
        );
    }
}

3.增加editor便签编辑器的控制。便签编辑器在新建便签和更新便签时需要显示。当更新便签时需要知道便签的ID、标题和正文,所以需要当做参数传入。另外只有当用户需要创建和更新时才需要显示编辑器,所以编辑器需要能够响应这些事件,控制是否显示。同时,编辑器负责便签的存入,需要调用合约接口,需要用到web3Client。另外,编辑器存入便签后的Receipt或者错误数据应该能够通知给用户,所以需要传入回调用的通知函数。

目前为止,因为编辑器还没有实现,我们先假定编辑器组件名字为Editor。这样NoteWall.js文件将更新成。

/**
 * @file notewall class. main component in app.
 * @author BaaS Team
 */
import React, {Component} from 'react';
import LoginForm from './LoginForm.js';
import Note from './Note.js';
import Editor from './Editor.js';
import NoteWallWeb3Client from '../web3/client';

export default class NoteWall extends Component {
    constructor(props) {
        super(props);
        this.state = {
            editorShow: false,
            login: false,
            noteList: []
        };
        this.showEditor = this.showEditor.bind(this);
        this.closeEditor = this.closeEditor.bind(this);
        this.periodicGetList = this.periodicGetList.bind(this);
        this.saveContractInfo = this.saveContractInfo.bind(this);
        this.notify = this.notify.bind(this);
        this.errNotify = this.notify.bind(this);
        this.web3Client = null;
        this.login = false;
    }

    // 开关编辑器用
    showEditor(id, title, content) {
        this.setState({
            editorShow: !this.state.editorShow,
            id: id,
            title: title,
            content: content
        });
    }

    // 关闭编辑器用,关闭编辑器后将立即获取便签一次
    closeEditor() {
        this.setState({
            editorShow: false
        }, () => {
            this.getListFromWeb3();
        });
    }

    componentWillMount() {
        this.getListFromWeb3();
        this.periodicGetList();
    }

    periodicGetList() {
        setTimeout(function () {
            this.getListFromWeb3();
            this.periodicGetList();
        }
            .bind(this),
            3000
        );
    }

    getListFromWeb3() {
        if (!this.state.login) {
            return;
        }
        this.web3Client.getAllNotes(notes => {
            this.setState({
                noteList: notes
            });
        });
    }

    saveContractInfo(httpProvider, contractAddress, senderAddress, senderPrivateKey) {
        this.web3Client = new NoteWallWeb3Client(httpProvider, contractAddress, senderAddress, senderPrivateKey);
        this.setState({login: true});
    }

    // 通知函数,调用toast库,通知用户入链信息
    notify(msg) {
        toast(msg);
    }

    // 异常通知函数,调用toast.error方法,通知用户入链异常信息
    errNotify(msg) {
        toast.error(msg, {
            autoClose: 18000
        });
    }

    render() {
        let list = this.state.noteList;
        if (this.state.login) {
            return (
                <div className='note-wall'>
                    <ToastContainer className='toast-notification'/>
                        {
                            Object.keys(list).map(noteId => {
                                let note = list[noteId];
                                return (
                                    <Note key={note.id} id={note.id} title={note.title}
                                        content={note.content} onClick={this.showEditor} />
                                );
                            })
                        }
                    {/* 参数作用将在Editor组件时看到 */}
                    <Editor key={this.state.id}
                            show={this.state.editorShow}
                            id={this.state.id}
                            title={this.state.title}
                            content={this.state.content}
                            onClick={this.showEditor}
                            closeEditor={this.closeEditor}
                            web3Client={this.web3Client}
                            notify={this.notify}
                            errNotify={this.errNotify}
                    />
                </div>
            );
        }
        return (
            <div className='note-wall'>
                <LoginForm saveContractInfo={this.saveContractInfo} />
            </div>
        );
    }
}

4.最后我们在便签板上还需要增加一个“新增便签”的按钮,用户点击后弹出编辑器。所以完整的便签板代码如下:

/**
 * @file notewall class. main component in app.
 * @author BaaS Team
 */
import React, {Component} from 'react';
import LoginForm from './LoginForm.js';
import Note from './Note.js';
import Editor from './Editor.js';
import NoteWallWeb3Client from '../web3/client';
import {ToastContainer, toast} from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

export default class NoteWall extends Component {
    constructor(props) {
        super(props);
        this.state = {
            editorShow: false,
            login: false,
            noteList: []
        };
        this.showEditor = this.showEditor.bind(this);
        this.closeEditor = this.closeEditor.bind(this);
        this.periodicGetList = this.periodicGetList.bind(this);
        this.createNote = this.createNote.bind(this);
        this.saveContractInfo = this.saveContractInfo.bind(this);
        this.notify = this.notify.bind(this);
        this.errNotify = this.notify.bind(this);
        this.web3Client = null;
        this.login = false;
    }

    createNote() {
        if (!this.state.editorShow) {
            this.showEditor(null, '', '');
        }
    }

    showEditor(id, title, content) {
        this.setState({
            editorShow: !this.state.editorShow,
            id: id,
            title: title,
            content: content
        });
    }

    closeEditor() {
        this.setState({
            editorShow: false
        }, () => {
            this.getListFromWeb3();
        });
    }

    componentWillMount() {
        this.getListFromWeb3();
        this.periodicGetList();
    }

    periodicGetList() {
        setTimeout(function () {
            this.getListFromWeb3();
            this.periodicGetList();
        }
            .bind(this),
            3000
        );
    }

    getListFromWeb3() {
        if (!this.state.login) {
            return;
        }
        this.web3Client.getAllNotes(notes => {
            this.setState({
                noteList: notes
            });
        });
    }

    saveContractInfo(httpProvider, contractAddress, senderAddress, senderPrivateKey) {
        this.web3Client = new NoteWallWeb3Client(httpProvider, contractAddress, senderAddress, senderPrivateKey);
        this.setState({login: true});
    }

    notify(msg) {
        toast(msg);
    }

    errNotify(msg) {
        toast.error(msg, {
            autoClose: 18000
        });
    }

    render() {
        let list = this.state.noteList;
        if (this.state.login) {
            return (
                <div className='note-wall'>
                    <ToastContainer className='toast-notification'/>
                    <button className='editor-btn header-btn' onClick={this.createNote}>新建便签</button>
                        {
                            Object.keys(list).map(noteId => {
                                let note = list[noteId];
                                return (
                                    <Note key={note.id} id={note.id} title={note.title}
                                        content={note.content} onClick={this.showEditor} />
                                );
                            })
                        }
                    <Editor key={this.state.id}
                            show={this.state.editorShow}
                            id={this.state.id}
                            title={this.state.title}
                            content={this.state.content}
                            onClick={this.showEditor}
                            closeEditor={this.closeEditor}
                            web3Client={this.web3Client}
                            notify={this.notify}
                            errNotify={this.errNotify}
                    />
                </div>
            );
        }
        return (
            <div className='note-wall'>
                <LoginForm saveContractInfo={this.saveContractInfo} />
            </div>
        );
    }
}

便签板完成后,效果图如下:
图片

2、编辑器

编辑器本身需要完成四个功能:

1.如果是已有便签更新,那么需要显示便签标题、便签正文;
2.右上角需要有一个“上链”的按钮,用户更新完成后可以点击这个按钮完成便签存入;
3.当便签上链交易完成后,编辑器退出;
4.当用户放弃编辑时,点击编辑器外即可退出编辑。

编辑Editor.js,代码如下:

/**
 * @file editor component
 * @author BaaS Team
 */
import React, {Component} from 'react';
import './Editor.scss';

const titleRef = React.createRef();
const contentRef = React.createRef();

export default class Editor extends Component {
    constructor(props) {
        super(props);
        this.buttonClicked = this.buttonClicked.bind(this);
        this.saveNoteWithWeb3 = this.saveNoteWithWeb3.bind(this);
        this.saveNoteWithAPI = this.saveNoteWithAPI.bind(this);
        this.clickMargin = this.clickMargin.bind(this);
        // 上链中,编辑器不应退出,直到上链结束
        this.marginLock = false;
        this.state = {
            btnMessage: '上链'
        };
        this.web3Client = this.props.web3Client;
    }

    componentDidMount() {
        titleRef.current.focus();
    }

    buttonClicked() {
        if (this.marginLock) {
            return;
        }
        // 修改按钮显示
        this.setState({
            btnMessage: '上链中'
        });
        this.marginLock = true;
        this.saveNoteWithWeb3();
    }

    saveNoteWithWeb3() {
        // 因为已有id,所以是更新便签
        if (this.props.id) {
            this.web3Client.updateNote(this.props.id,
                titleRef.current.innerText,
                contentRef.current.innerText,
                (err, receipt) => {
                    if (!err) {
                        this.props.notify(`已上链. 区块高度${receipt.blockNumber}, 区块内交易编号${receipt.transactionIndex}`);
                        this.props.closeEditor();
                    }
                    else {
                        this.props.errNotify(`区块链交易遇到问题: ${err}`);
                    }
                    // 回置按钮显示
                    this.setState({
                        btnMessage: '上链'
                    });
                    this.marginLock = false;
                });
        }
        // 新增便签
        else {
            this.web3Client.insertNote(titleRef.current.innerText,
                contentRef.current.innerText,
                (err, receipt) => {
                    if (!err) {
                        this.props.notify(`已上链. 区块高度${receipt.blockNumber}, 区块内交易编号${receipt.transactionIndex}`);
                        this.props.closeEditor();
                    }
                    else {
                        this.props.errNotify(`区块链交易遇到问题: ${err}`);
                    }
                    this.setState({
                        btnMessage: '上链'
                    });
                    this.marginLock = false;
                });
        }
    }

    // 如果点击编辑器外部,编辑器退出
    clickMargin() {
        if (!this.marginLock) {
            this.props.onClick(this.props.title, this.props.content);
        }
    }

    render() {
        return (
            <div className={this.props.show ? 'editor display-block' : 'editor display-none'}
            onClick={() => this.clickMargin()}>
                <div id='notebook-paper' onClick={e => {
                    e.stopPropagation();
                }
            }>
                    <header>
                        <h1 id='title-text' contentEditable='true' suppressContentEditableWarning={true}
                            ref={titleRef} autoFocus>{this.props.title}</h1>
                        <button className='editor-btn header-btn' onClick={this.buttonClicked}>
                            {this.state.btnMessage}
                        </button>
                    </header>
                    <div id='content'>
                        <div id='content-text' contentEditable='true'
                            suppressContentEditableWarning={true} ref={contentRef}>
                            {this.props.content}
                        </div>
                    </div>
                </div>
            </div>
        );
    }
}

Editor的样式表请参考源码。实现完成后效果如下
编辑便签
图片
上链完成,编辑器退出,弹出通知条
图片

到此Dapp应用层开发完毕,我们可以在本地手动测试一下创建便签、更新便签、退出编辑等功能。接下来我们准备将Dapp进行部署。

Dapp部署

Dapp部署是指将我们开发完成的Dapp应用层进行部署。因为Dapp应用层只是一个前端React App,理论上我们只是寻找一个Dapp js的托管服务器。当用户访问Dapp的线上地址时,浏览器获得HTML+CSS+JS,之后所有的交互响应都不再与托管服务器有关。

这里我们依托百度智能云CCE容器服务来部署我们的托管服务器。

什么是CCE容器服务

CCE容器服务是基于Kubernetes构建的轻量级分布式容器编排引擎。事实上,百度智能云BBE服务提供的区块链节点也是托管在CCE集群上的。Kubernetes能够帮助用户快速部署定义好的应用服务,并且能够完全自动化地对应用实例进行健康检查、故障重启自恢复。另外还有应用实例自动水平扩缩容、持续集成构建流、配置管理、服务发现等高级功能。Kubernetes是业界服务托管的趋势。

使用CCE做应用托管,首先需要将应用打包成Docker镜像。因为Kubernetes对应用实例(Pod)的所有定义都是围绕容器实例(Container)来完成的。

Dapp镜像化

Docker是一种有助于加速开发和部署过程的技术,Docker可以很轻松地将小型独立服务运行环境封装起来。 它还有助于消除特定于环境的错误,因为我们可以在本地复制生产环境。

Docker环境安装

Docker安装请参考官方文档,一般的,可以使用系统包管理器一键安装。这里不再赘述。本文使用的Docker版本为1.9.1,是较老版本,请读者确保Docker版本大于1.9.1。

Dockerfile定义

我们在项目根目录下创建一个Dockerfile文件,内容为

FROM node:10.15.1
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN npm install --silent
COPY . /usr/src/app
RUN npm run build
RUN apt-get update && apt-get install -y nginx
RUN cp -a /usr/src/app/build/* /var/www/html/
RUN rm -rf /usr/src
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

再创建一个.dockerignore文件,过滤掉不需要载入到镜像内的文件,内容为

node_modules
.git
.gitignore
README.md

Docker镜像制作

运行docker build命令即可

$ docker build -t notewall-dapp:latest .

构建好本地镜像后,我们可以直接在本地运行这个Docker镜像,确认镜像没有问题。

$ docker run -p 8080:80 -d notewall-dapp:latest

用浏览器访问本地的8080,就能够看到我们的便签板应用啦。

Docker镜像上传

接下来我们将构建好的Dapp镜像上传推送到百度智能云镜像仓库,用来在百度智能云CCE服务上直接部署Dapp。

上传前我们需要开通镜像,请参考百度智能云镜像仓库使用说明

配合本文档,我们提供了一个已经上传好的镜像地址
hub.baidubce.com/jpaas-public/notewall-dapp:latest

如何用百度CCE服务发布Dapp

在上传完镜像后,我们开始试用CCE服务。

1.首先我们需要按照使用说明创建集群。
2.我们打开创建好的CCE集群控制台,进入集群列表页,点击集群右侧的“控制台”
图片
3.在新打开的控制台中,我们点击右上角“+创建”按钮,在“从文本输入框创建”标签页中,我们输入如下yaml内容:

---
apiVersion: v1
kind: Service
metadata:
  name: notewall
  labels:
    app: notewall
spec:
  comment or delete the following line if you want to use a LoadBalancer
  type: LoadBalancer 
  if your cluster supports it, uncomment the following to automatically create
  an external load-balanced IP for the frontend service.
  ports:
  - port: 80
  selector:
    app: notewall
---
apiVersion: apps/v1beta2
kind: Deployment
metadata:
  name: notewall
spec:
  selector:
    matchLabels:
      app: notewall
  replicas: 3
  template:
    metadata:
      labels:
        app: notewall
    spec:
      containers:
      - name: noteall-dapp
        这里替换成自行推送的镜像地址,这里填的是默认已有的镜像地址
        image: hub.baidubce.com/jpaas-public/notewall-dapp:latest
        ports:
        - containerPort: 80

图片
点击上传后,就会按照上述yaml描述的资源规范在CCE集群创建一个外网可以访问的服务。

4.稍等片刻,我们可打开左侧“服务发现与负载均衡”,“服务”列表内找到notewall服务,点击外部端点提供的IP:Port形式的URL就能访问到便签板Dapp
图片
图片

到此便签板Dapp部署完成,外网已经能够访问