Fabric Dapp开发指南

Dapp架构及概要设计

什么是Dapp

如果我们熟悉web应用开发,一个经典的web应用架构是B/S结构的
devf_fabric_01.png

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

这个B/S架构如此流行,是因为它适用于绝大多数应用场景。但是也有例外,当用户或者服务提供者认为数据比应用更有价值,又或者服务提供者想提供的是数据服务,并想有效控制用户对数据写的权利。此时基于B/S结构的应用会显得力不从心。
和中心化的web应用不同,区块链使用一套开源的网络规范,提供了数据P2P实时共享同步的特性。终端客户可以随时将一个客户端设备接入区块链P2P网络,基于标准的协议,区块链终端将实时同步到最新的数据。在这个数据服务中,不再有中心化的服务端存在,这就是所谓的“对等无中心”特性。

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

下图是一个典型的Dapp架构。
devf_fabric_02.png

我们要实现什么Dapp

我们要实现的是一个基于区块链的便签板应用。页面效果图如下
devf_fabric_03.png
用户可以在便签板上添加新的便签或者修改已有的便签,所有在浏览器上打开了这个Dapp的用户都可以同步看到便签的更新,便签被更新的通知。
这是一个实现起来相对简单的Dapp,本文档会详细介绍它的实现过程。

标签板Dapp简介

这个Dapp主要由两部分组成,包括部署在Fabric网络中的智能合约以及依赖智能合约负责业务功能交互的web应用。Fabric中的智能合约被称作链码,本Dapp中链码采用golang开发,而web应用使用react框架+JavaScript+Node.js实现。
Dapp组件间的调用关系如图。
devf_fabric_04.png

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

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

链码开发及部署

IDE环境准备

本文采用Go语言开发Fabric链码,Golang IDE请读者自行准备。

链码实现

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

链码对象

Golang实现的链码必须定义合约对象,对象定义为空即可,代码如下

type SmartContract struct {
}

便签对象的实现

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

type Note struct {
    Id        int        `json:"id"`
    Title    string    `json:"title"`
    Content    string    `json:"content"`
}

便签由三部分构成,string类型的标题,string类型的便签正文以及一个64位(也有可能为32位,与CPU位数有关)的有符号整数类型表示的便签ID。

Init接口的实现

在本合约中,并不需要初始化部分便签数据,所以Init接口直接返回成功,具体实现方法如下

func (s *SmartContract) Init(APIstub shim.ChaincodeStubInterface) sc.Response {
    return shim.Success(nil)
}

Invoke接口的实现

Fabric链码的核心接口是invoke,这也是用户实现业务逻辑的地方。在本示例中,主要实现的业务逻辑包括新增便签、更新便签、获取所有的便签。以下就这三个业务进行详细说明。

  • 新增便签的实现

新增便签需要构建一个Note对象,对象中的title和content由用户定义传入,因为本Dapp只是用于示例,所以Id由提交时的时间戳生成,实际生产环境中可以采用其他算法,保证Id的唯一性。Fabric的链上的数据必须为key-value格式,而且key必须为string类型,value必须为byte数组。在本合约中,我们使用Id作为key,所以在调用该方法时,传入的三个参数,Id、title、content,都为字符串类型。在构建Note对象时需要先将string类型的Id转换为int类型,并将Note对象转换为byte数组格式。
新增便签的函数如下

func (s *SmartContract) insert(APIstub shim.ChaincodeStubInterface, args []string) sc.Response {
    if len(args) != 3 {
        return shim.Error("Incorrect number of arguments. Expecting 3")
    }

    noteId, transErr := strconv.Atoi(args[0])
    if transErr != nil {
        fmt.Printf("Error trans args to get note id: %s", transErr)
        return shim.Error("Incorrect type of arguments. Id expecting int")
    }

    var note = Note {
        Id:    noteId,
        Title: args[1],
        Content: args[2],
    }

    carAsBytes, _ := json.Marshal(note)
    err := APIstub.PutState(args[0], carAsBytes)
    if err != nil {
        return shim.Error(err.Error())
    }

    return shim.Success(nil)
}
  • 更新便签的实现

更新便签与新增便签稍有差别,需要先从给定的Note ID中找到note对象,如果对象不存在进行报错,如果对象存在再进行更新。
具体代码如下

func (s *SmartContract) update(APIstub shim.ChaincodeStubInterface, args []string) sc.Response {
    if len(args) != 3 {
        return shim.Error("Incorrect number of arguments. Expecting 3")
    }

    noteAsBytes, _ := APIstub.GetState(args[0])
    if noteAsBytes == nil {
        return shim.Error("Note not found.")
    }
    note := Note{}

    json.Unmarshal(noteAsBytes, &note)
    note.Title = args[1]
    note.Content = args[2]

    noteAsBytes, _ = json.Marshal(note)
    err := APIstub.PutState(args[0], noteAsBytes)
    if err != nil {
        return shim.Error(err.Error())
    }
    return shim.Success(nil)
}
  • 获取所有的便签

Fabric链码提供的接口中支持遍历功能,通过指定start和endkey,以字典排序查询到相应记录,如果start和endkey都设置为空字符串,查询的是所有记录。与新增便签一样,查询出来的note对象也是以byte数组方式存储的,需要进行解码。之后以Id为key,以note对象为value构建Map,并将Map编码为byte数组返回。
具体的代码如下

func (s *SmartContract) queryAll(APIstub shim.ChaincodeStubInterface) sc.Response {
    resultsIterator, err := APIstub.GetStateByRange("", "")
    if err != nil {
        return shim.Error(err.Error())
    }
    defer resultsIterator.Close()

    // buffer is a JSON array containing QueryResults
    notes := map[int]Note{}

    for resultsIterator.HasNext() {
        queryResponse, err := resultsIterator.Next()
        if err != nil {
            return shim.Error(err.Error())
        }

        var tmpNote Note
        noteId, keyTransErr := strconv.Atoi(queryResponse.Key)
        if keyTransErr != nil {
            fmt.Printf("Error trans note id: %s", keyTransErr)
            continue
        }
        if transErr := json.Unmarshal(queryResponse.Value, &tmpNote); err != nil {
            fmt.Printf("Error trans note: %s", transErr)
            notes[noteId] = Note{}
        } else {
            notes[noteId] = tmpNote
        }
    }

    result, _ := json.Marshal(notes)
    return shim.Success(result)
}

链码完整代码

package main

import (
    "encoding/json"
    "fmt"
    "strconv"

    "github.com/hyperledger/fabric/core/chaincode/shim"
    sc "github.com/hyperledger/fabric/protos/peer"
)

// Define the Smart Contract structure
type SmartContract struct {
}

type Note struct {
    Id        int        `json:"id"`
    Title    string    `json:"title"`
    Content    string    `json:"content"`
}


func (s *SmartContract) Init(APIstub shim.ChaincodeStubInterface) sc.Response {
    return shim.Success(nil)
}

func (s *SmartContract) Invoke(APIstub shim.ChaincodeStubInterface) sc.Response {
    function, args := APIstub.GetFunctionAndParameters()
    if function == "queryAll" {
        return s.queryAll(APIstub)
    } else if function == "insert" {
        return s.insert(APIstub, args)
    } else if function == "update" {
        return s.update(APIstub, args)
    }

    return shim.Error("Invalid Smart Contract function name.")
}

func (s *SmartContract) queryAll(APIstub shim.ChaincodeStubInterface) sc.Response {
    resultsIterator, err := APIstub.GetStateByRange("", "")
    if err != nil {
        return shim.Error(err.Error())
    }
    defer resultsIterator.Close()

    // buffer is a JSON array containing QueryResults
    notes := map[int]Note{}

    for resultsIterator.HasNext() {
        queryResponse, err := resultsIterator.Next()
        if err != nil {
            return shim.Error(err.Error())
        }

        var tmpNote Note
        noteId, keyTransErr := strconv.Atoi(queryResponse.Key)
        if keyTransErr != nil {
            fmt.Printf("Error trans note id: %s", keyTransErr)
            continue
        }
        if transErr := json.Unmarshal(queryResponse.Value, &tmpNote); err != nil {
            fmt.Printf("Error trans note: %s", transErr)
            notes[noteId] = Note{}
        } else {
            notes[noteId] = tmpNote
        }
    }

    result, _ := json.Marshal(notes)
    return shim.Success(result)
}

func (s *SmartContract) insert(APIstub shim.ChaincodeStubInterface, args []string) sc.Response {

    if len(args) != 3 {
        return shim.Error("Incorrect number of arguments. Expecting 3")
    }

    noteId, transErr := strconv.Atoi(args[0])
    if transErr != nil {
        fmt.Printf("Error trans args to get note id: %s", transErr)
        return shim.Error("Incorrect type of arguments. Id expecting int")
    }

    var note = Note {
        Id:    noteId,
        Title: args[1],
        Content: args[2],
    }

    carAsBytes, _ := json.Marshal(note)
    err := APIstub.PutState(args[0], carAsBytes)
    if err != nil {
        return shim.Error(err.Error())
    }

    return shim.Success(nil)
}

func (s *SmartContract) update(APIstub shim.ChaincodeStubInterface, args []string) sc.Response {

    if len(args) != 3 {
        return shim.Error("Incorrect number of arguments. Expecting 3")
    }

    noteAsBytes, _ := APIstub.GetState(args[0])
    if noteAsBytes == nil {
        return shim.Error("Note not found.")
    }
    note := Note{}

    json.Unmarshal(noteAsBytes, &note)
    note.Title = args[1]
    note.Content = args[2]

    noteAsBytes, _ = json.Marshal(note)
    err := APIstub.PutState(args[0], noteAsBytes)
    if err != nil {
        return shim.Error(err.Error())
    }
    return shim.Success(nil)
}

// The main function is only relevant in unit test mode. Only included here for completeness.
func main() {
    // Create a new Smart Contract
    err := shim.Start(new(SmartContract))
    if err != nil {
        fmt.Printf("Error creating new Smart Contract: %s", err)
    }
}

链码部署

进入Fabric列表页,进入Fabric列表页,找到希望安装链码的网络,点击右侧的“通道管理”链接devf_fabric_05.png

进入通道管理页面后,找到希望安装链码的通道,点击右侧的“链码管理”链接
devf_fabric_07.png

进入链码管理页面后点击左侧的“新增链码”按钮
devf_fabric_09.png

在弹出的链码配置框中填写链码配置信息
devf_fabric_10.png

点击确认后会显示已上传的链码列表
devf_fabric_11.png

链码安装

已上传的链码可以进行安装,点击链码列表页中对应链码右侧的”安装“按钮,进行链码的安装,安装完成后链码状态会变为安装完成
devf_fabric_12.png

链码实例化

安装成功的链码可以进行实例化,点击对应链码右侧的”实例化“按钮,进行链码的实例化,实例化成功后链码状态变为运行中,至此链码安装完成
devf_fabric_13.png

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目录,目录里是一个标准的未装任何依赖的项目目录。
  4. 试着启动一下
    $ npm start
    
    npm会启动这个react项目,并且将http server监听在本地3000端口。浏览器访问http://localhost:3000可以查看结果。
    devf_fabric_14.png
    如果我们看到了如下页面显示,则代表项目环境初始化完成,我们可以开始编写应用代码了。

Dapp应用层开发

如上所述,Dapp应用层主要分为两大部分,一个是页面展示与交互逻辑,另一个是与链码的交互。页面展示与交互逻辑由前端和后端部分完成,与链码的交互由后端部分完成。与以太坊不需要后端服务器不同,Fabric与链码交互时,需要msp、证书等信息,需要由后端保存,所以Fabric Dapp多了后端服务器的实现。
我们可以规划项目目录,新建两个子目录分别为client和server。server中主要实现与合约的交互逻辑,client目录中主要实现用于前端展示的组件,为了保持实现预约上的一致性,前后端统一用nodejs实现。
devf_fabric_15.png

合约交互

由于前端页面展示风格千变万化,我们可以先从后端逻辑实现起来。与链码的交互,我们使用Fabric官方提供的Node SDK。
结合最初的用例分析,我们需要在三个事件中与链码进行交互。

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

因为生成Fabric Client需要根据网络信息进行配置,需要提前准备好并是服务启动时能够读取。我们在项目中创建artifact目录用于存放这些信息,Client配置中主要是网络信息及证书。目录如下:
devf_fabric_16.png
网络配置test_config.yaml详细内容如下:

name: "config"

version: 1.0.0

client:
  organization: hdorg
  logging:
    level: debug
  credentialStore:
    path: artifacts/fabric/test_network/gosdk/msp-store
    cryptoStore:
      path: artifacts/fabric/test_network/gosdk/msp-work-dir
  BCCSP:
    security:
     enabled: true
     default:
      provider: "SW"
     hashAlgorithm: "SHA2"
     softVerify: true
     level: 256
  tlsCerts:
    systemCertPool: false
    client:
      keyfile:
      certfile:

organizations:
  org-orderer:
    mspid: org-ordererMSP
    certificateAuthorities:
      - ca.org-orderer
    adminPrivateKey:
      path: artifacts/fabric/test_network/orgs/org-orderer/users/Admin@org-orderer/msp/keystore/b75ada35c25b881ecc132742cf2f1827d61adc9944e74c04d4eb8976bdc24225_sk
    signedCert:
      path: artifacts/fabric/test_network/orgs/org-orderer/users/Admin@org-orderer/msp/admincerts/Admin@org-orderer-cert.pem
    peers:


  hdorg:
    mspid: hdorgMSP
    certificateAuthorities:
      - ca.hdorg
    adminPrivateKey:
      path: artifacts/fabric/test_network/orgs/hdorg/users/Admin@hdorg/msp/keystore/501abbb873268248d408d1f10bda7b58073cfcd0492fe212d0433841a514d4ff_sk
    signedCert:
      path: artifacts/fabric/test_network/orgs/hdorg/users/Admin@hdorg/msp/admincerts/Admin@hdorg-cert.pem
    peers:
      - hdorg-peer0



certificateAuthorities:

  ca.org-orderer:
    url: http://org-orderer-rootca.ce41f008-dbdb-4537-8ccb-e5bedcc22c55:31234
    httpOptions:
      verify: false
    tlsCACerts:
      path: artifacts/fabric/test_network/orgs/org-orderer/tlsca/tlsca.org-orderer-cert.pem

  ca.hdorg:
    url: http://hdorg-rootca.ce41f008-dbdb-4537-8ccb-e5bedcc22c55:30924
    httpOptions:
      verify: false
    tlsCACerts:
      path: artifacts/fabric/test_network/orgs/org-orderer/tlsca/tlsca.org-orderer-cert.pem


orderers:

  orderer0:
    url: grpcs://orderer0.ce41f008-dbdb-4537-8ccb-e5bedcc22c55:31755
    grpcOptions:
      ssl-target-name-override: orderer0.ce41f008-dbdb-4537-8ccb-e5bedcc22c55
      allow-insecure: 0
    tlsCACerts:
      path: artifacts/fabric/test_network/orgs/org-orderer/tlsca/tlsca.org-orderer-cert.pem


peers:

  hdorg-peer0:
    url: grpcs://hdorg-peer0.ce41f008-dbdb-4537-8ccb-e5bedcc22c55:31758
    grpcOptions:
      ssl-target-name-override: hdorg-peer0.ce41f008-dbdb-4537-8ccb-e5bedcc22c55
    tlsCACerts:
      path: artifacts/fabric/test_network/orgs/hdorg/tlsca/tlsca.hdorg-cert.pem


channels:
  hdch:
    orderers:
      - orderer0

    peers:
      hdorg-peer0:
        endorsingPeer: true
        chaincodeQuery: true
        ledgerQuery: true
        eventSource: true

    policies:
      queryChannelConfig:
        minResponses: 1
        maxTargets: 1
        retryOpts:
          attempts: 5
          initialBackoff: 500ms
          maxBackoff: 5s
          backoffFactor: 2.0
  • 后端服务启动

服务启动时会默认展示主页,具体代码如下:

/**
 * @file index js file
 * @author BaaS Team
 */
var express = require('express');
var router = express.Router();

// GET home page
router.get('/', function (req, res, next) {
    res.render('index', {
        title: 'Express'
    });
});

module.exports = router;

除此之外,后端服务启动时会构建NoteWallFabricClient对象,并定义路由,具体代码如下:

/**
 * @file /api handler
 * @author BaaS Team
 */
const express = require('express');
const NoteWallWeb3Client = require('./ethereum/web3/client.js');
const NoteWallFabricClient = require('./fabric/chaincode/client');
const router = express.Router();

let mode = process.env.BLOCKCHAIN_TYPE.toUpperCase();
let blockchainClient;
if (mode === 'ETHEREUM') {
    let httpProvider = process.env.HTTP_PROVIDER;
    if (!httpProvider) {
        console.error('HTTP_PROVIDER not defined');
        process.exit(1);
    }
    let contractAddress = process.env.CONTRACT_ADDRESS;
    if (!contractAddress) {
        console.error('CONTRACT_ADDRESS not defined');
        process.exit(1);
    }
    let senderAddress = process.env.SENDER_ADDRESS;
    if (!senderAddress) {
        console.error('SENDER_ADDRESS not defined');
        process.exit(1);
    }
    let senderPrivateKey = process.env.SENDER_PRIVATE_KEY;
    if (!senderPrivateKey) {
        console.error('SENDER_PRIVATE_KEY not defined');
        process.exit(1);
    }
    blockchainClient = new NoteWallWeb3Client(httpProvider, contractAddress, senderAddress, senderPrivateKey);
}
else if (mode === 'FABRIC') {
    let networkConfigPath = process.env.FABRIC_NETWORK_CONFIG_PATH;
    if (!networkConfigPath) {
        console.error('FABRIC_NETWORK_CONFIG_PATH not defined');
        process.exit(1);
    }
    let channelName = process.env.FABRIC_CHANNEL_NAME;
    if (!channelName) {
        console.error('FABRIC_CHANNEL_NAME not defined');
        process.exit(1);
    }
    let chaincodeName = process.env.FABRIC_CHAINCODE_NAME;
    if (!chaincodeName) {
        console.error('FABRIC_CHAINCODE_NAME not defined');
        process.exit(1);
    }
    blockchainClient = new NoteWallFabricClient(networkConfigPath, channelName, chaincodeName);
}
else {
    console.error('BLOCKCHAIN_TYPE not valid. ETHEREUM or FABRIC');
}

router.route('/note').post((req, res, next) => {
    let id = req.body.id;
    let title = req.body.title;
    let content = req.body.content;
    console.log(`${id} ${title} ${content}`);
    // update
    if (id) {
        blockchainClient.updateNote(id, title, content, (err, receipt) => {
            if (!err) {
                res.json({status: 0, message: 'ok'});
            }
            else {
                res.json({status: 1, message: err});
            }
        });
    }
    // insert
    else {
        blockchainClient.insertNote(title, content, (err, receipt) => {
            if (!err) {
                res.json({status: 0, message: 'ok'});
            }
            else {
                res.json({status: 1, message: err});
            }
        });
    }
});

router.get('/notes', (req, res, next) => {
    blockchainClient.getAllNotes(notes => {
        res.json({status: 0, message: notes});
    });
});

module.exports = router;
  • Client构造

我们将这些交互抽象到一个NoteWallFabricClient中,在routes目录中创建fabric/chaincode子目录,并在chaincode目录下创建client.js文件。
新建一个NoteWallFabricClient类

class NoteWallFabricClient {
    constructor(networkConfigPath, channelName, chaincodeName) {
        let client = fabricClient.loadFromConfig(networkConfigPath);
        client.initCredentialStores();
        let channel = client.getChannel(channelName);
        this.client = client;
        this.channel = channel;
        this.chaincodeName = chaincodeName;
    }
}

module.exports = NoteWallFabricClient;

构造函数通过Fabric网络配置文件构建了一个Client,实际使用时需要根据用户自身网络信息配置该文件。然后从client对象中获取channel对象,并将chaincode名称保存。

  • 交易类方法调用

这里我们先实现创建note交易的方法。

/**
     * @param title {string} 便签标题
     * @param content {string} 便签正文
     * @param callback {function} 回调函数
     */
    async insertNote(title, content, callback) {
        // note id由当前时间戳生成,用户可以强化此处id生成方法
        let id = + new Date();
        // 用于标识是否发向所有peer的交易提案成功
        let allGood = true;
        let errorMessage = null;

        try {
            let txId = this.client.newTransactionID(true);

            // 构建交易请求
            let request = {
                chaincodeId: this.chaincodeName,
                fcn: 'insert',
                args: [id.toString(), title, content],
                txId: txId
            };

            // 发送交易提案,也即背书
            let results = await this.channel.sendTransactionProposal(request, true);
            let proposalResponses = results[0];
            let proposal = results[1];

            // 处理所有peer的返回
            for (const i in proposalResponses) {
                if (proposalResponses[i] instanceof Error) {
                    allGood = false;
                    errorMessage = util.format('invoke chaincode proposal resulted in an error :: %s',
                        proposalResponses[i].toString());
                    console.log(errorMessage);
                } else if (proposalResponses[i].response && proposalResponses[i].response.status === 200) {
                    console.info('invoke chaincode proposal was good');
                } else {
                    allGood = false;
                    errorMessage = util.format('invoke chaincode proposal failed for an unknown reason %j',
                        proposalResponses[i]);
                    console.log(errorMessage);
                }
            }

            // 如果返回都成功,将交易提交给orderer
            if (allGood) {
                const promises = [];
                let eventHubs = await this.channel.getChannelEventHubsForOrg();
                eventHubs.forEach(eh => {
                    console.log('invokeEventPromise - setting up event:' + eh.getName());
                    let invokeEventPromise = new Promise((resolve, reject) => {
                        let eventTimeout = setTimeout(() => {
                            let message = 'REQUEST_TIMEOUT:' + eh.getPeerAddr();
                            console.log(message);
                            eh.disconnect();
                        }, 3000);
                        eh.registerTxEvent(txId.getTransactionID(), (tx, code, blockNum) => {
                                console.log('The chaincode invoke chaincode transaction has been committed on peer %s',
                                    eh.getPeerAddr());
                                console.log('Transaction %s has status of %s in blocl %s', tx, code, blockNum);
                                clearTimeout(eventTimeout);

                                if (code !== 'VALID') {
                                    let message = util.format('The invoke chaincode transaction was invalid, code:%s',
                                        code);
                                    console.log(message);
                                    reject(new Error(message));
                                } else {
                                    let message = 'The invoke chaincode transaction was valid.';
                                    console.log(message);
                                    resolve(message);
                                }
                            }, err => {
                                clearTimeout(eventTimeout);
                                console.log('err:' + err);
                                reject(err);
                            },
                            // the default for 'unregister' is true for transaction listeners
                            // so no real need to set here, however for 'disconnect'
                            // the default is false as most event hubs are long running
                            // in this use case we are using it only once
                            {unregister: true, disconnect: true}
                        );
                        eh.connect();
                    });
                    promises.push(invokeEventPromise);
                });
                const ordererRequest = {
                    txId: txId,
                    proposalResponses: proposalResponses,
                    proposal: proposal
                };
                const sendPromise = this.channel.sendTransaction(ordererRequest);

                promises.push(sendPromise);
                let results = await Promise.all(promises);
                let response = results.pop();
                if (response.status === 'SUCCESS') {
                    console.log('Successfully sent transaction to the orderer.');
                } else {
                    errorMessage = util.format('Failed to order the transaction. Error code: %s', response.status);
                    console.log(errorMessage);
                }

                // 监控event hubs结果
                for (let i in results) {
                    let eventHubResult = results[i];
                    let eventHub = eventHubs[i];
                    console.log('Event results for event hub :%s', eventHub.getPeerAddr());
                    if (typeof eventHubResult === 'string') {
                        console.log(eventHubResult);
                    } else {
                        if (!errorMessage) {
                            errorMessage = eventHubResult.toString();
                        }
                        console.log(eventHubResult.toString());
                    }
                }

            }
        } catch (error) {
            console.error('Failed to invoke due to error: ' + error.stack ? error.stack : error);
            errorMessage = error.toString();
        }
        return callback(errorMessage, null);
    }

更新note的方法和新建note的方法基本一致,唯一的区别就是将链码函数从insert改为update。

  • 非交易类方法调用

非交易类方法主要为view类型的合约方法,可以直接查询而不用生成区块。具体代码如下:

/**
     * callback的参数为notes
     * notes格式为map[noteId] {
     *     id {int}
     *     title {string}
     *     content {string}
     * }
     */
    async getAllNotes(callback) {
        let request = {
            chaincodeId: this.chaincodeName,
            fcn: 'queryAll',
            args: ['']
        };
        let queryResponses = await this.channel.queryByChaincode(request, true);
        console.log('Query has completed, checking results:' + (queryResponses));
        return callback(JSON.parse(queryResponses.toString('utf8')));
    }

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

页面开发

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

  1. 便签,每一个便签样式类似,但内容不同,一个便签对应合约存储中的一个便签实例;
  2. 便签板,上面挂载所有的便签元素,并控制其他模块是否渲染;
  3. 编辑器,编辑器用来给用户创建和更新便签时写入内容用。应该允许用户输入标题、内容;
  • 主页

主页比较简单,主要内容是定义了主渲染区”root“,代码如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>React App</title>
    <link rel="shortcut icon" href="public/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1, 
    shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <!--
      manifest.json provides metadata used when your web app is added to the
      homescreen on Android. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <meta http-equiv="X-UA-Compatible" content="IE=Edge">
</head>
<body>
    <noscript>
        You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
</body>
</html>

主页默认渲染便签板,代码如下:

/**
 * @file client index
 * @author BaaS Team
 */
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();

index样式控制表:

body {
    margin: 0;
    padding: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
    "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
    sans-serif;

    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

code {
    font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
    monospace;
}

index中渲染了APP对象,渲染代码如下:

/**
 * @file app component
 * @author BCE BaaS Team
 */

import React, {Component} from 'react';
import './App.css';
import NoteWall from './NoteWall';

export default class App extends Component {
    render() {
        return (
            <div className="App">
                <header className="App-header">
                    <NoteWall />
                </header>
            </div>
        );
    }
}

APP样式控制表:

.App {
    text-align: center;
}

.App-logo {
    height: 40vmin;
    animation: App-logo-spin infinite 20s linear;
}

.App-header {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    font-size: calc(10px + 2vmin);
    color: #fff;
    background-color: #282c34;

    align-items: center;
    justify-content: center;
}

.App-link {
    color: #61dafb;
}

@keyframes App-logo-spin {
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
}
  • 便签

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

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

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}</p>
                </div>
            </div>
        );
    }
}

样式控制代码:

* {
    margin: 0;
    padding: 0;
}

body {
    margin: 1em;
    font-family: arial, sans-serif;
    font-size: 100%;
    color: #fff;
    background: #666;
}

h2,
p {
    font-size: 100%;
    font-weight: 400;
}

.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: "Reenie Beanie", 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);
}
  • 便签板

便签板组件负责挂载其余组件,并负责通过子组件的事件回调完成子组件的通信。主要有以下核心控制逻辑:

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

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

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

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

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

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

    // Retrieves the list of items from the Express app
    getList() {
        fetch('/api/notes')
            .then(res => res.json())
            .then(res => this.setState({noteList: res.message}));
    }

    render() {
        let list = this.state.noteList;
        return (
            <div className="note-wall">
                <button className="btn header-btn" onClick={e => {
                            this.showEditor(null, '', '');
                        }
                    }>新建</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 show={this.state.editorShow} id={this.state.id} title={this.state.title}
                 content={this.state.content} onClick={this.showEditor} closeEditor={this.closeEditor} />
            </div>
        );
    }
}

便签板完成后,效果图如下
devf_fabric_18.png

  • 编辑器

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

  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.saveNote = this.saveNote.bind(this);
        }

        saveNote() {
            fetch('/api/note', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    id: this.props.id,
                    title: titleRef.current.innerText,
                    content: contentRef.current.innerText
                })
            })
                .then(res => {
                    res.json();
                })
                .then(list => this.setState({
                    noteList: list
                }, () => this.props.closeEditor()));
        }

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

Editor的样式表:

.editor {
      position: fixed;
      top: 0;
      left: 0;
      width:100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.6);
    }

    .editor-main {
      position:fixed;
      background: white;
      width: 80%;
      height: auto;
      top:50%;
      left:50%;
      transform: translate(-50%,-50%);
    }

    .display-block {
      display: block;
    }

    .display-none {
      display: none;
    }

    * {
      -webkit-box-sizing:border-box;
      -moz-box-sizing:border-box;
      -ms-box-sizing:border-box;
      -o-box-sizing:border-box;
      box-sizing:border-box;
    }

    body {
      background: #f1f1f1;
      font-family:helvetica neue, helvetica, arial, sans-serif;
      font-weight:200;
    }

    notebook-paper {
      text-align: left;
      color: #050000;
      width:960px;
      height:500px;
      background: linear-gradient(to bottom,white 29px,#00b0d7 1px);
      margin:50px auto;
      background-size: 100% 30px;
      position:relative;
      padding-top:150px;
      padding-left:160px;
      padding-right:20px;
      overflow:hidden;
      border-radius:5px;
      -webkit-box-shadow:3px 3px 3px rgba(0,0,0,.2),0px 0px 6px rgba(0,0,0,.2);
      -moz-box-shadow:3px 3px 3px rgba(0,0,0,.2),0px 0px 6px rgba(0,0,0,.2);
      -ms-box-shadow:3px 3px 3px rgba(0,0,0,.2),0px 0px 6px rgba(0,0,0,.2);
      -o-box-shadow:3px 3px 3px rgba(0,0,0,.2),0px 0px 6px rgba(0,0,0,.2);
      box-shadow:3px 3px 3px rgba(0,0,0,.2),0px 0px 6px rgba(0,0,0,.2);
      &:before {
        content:'';
        display:block;
        position:absolute;
        z-index:1;
        top:0;
        left:140px;
        height:100%;
        width:1px;
        background:#db4034;
      }
      header {
        height:150px;
        width:100%;
        background:white;
        position:absolute;
        top:0;
        left:0;
        h1 {
          font-size:60px;
          line-height:60px;
          padding:127px 20px 0 160px;
        }
      }
      content {
        margin-top:67px;
        font-size:20px;
        line-height:30px;
      }

      hipsum {
        margin:0 0 30px 0;
      }
    }

    //Colours
    $green: #2ecc71;
    $red: #e74c3c;
    $blue: #3498db;
    $yellow: #f1c40f;
    $purple: #8e44ad;
    $turquoise: #1abc9c;

    // Basic Button Style
    .btn {
      box-sizing: border-box;
      appearance: none;
      background-color: transparent;
      border: 2px solid $red;
      border-radius: 0.6em;
      color: $red;
      cursor: pointer;
      display: block;
      align-self: center;
      font-size: 3px;
      font-weight: 500;
      line-height: 1;
      margin: 20px;
      padding: 5px 2px;
      text-decoration: none;
      text-align: center;
      text-transform: uppercase;
      font-family: 'Montserrat', sans-serif;
      font-weight: 700;

      &:hover,
      &:focus {
        color: #fff;
        outline: 0;
      }
    }

    .header-btn {
      border-color: $purple;
      // border: 0;
      border-radius: 0;
      color: $purple;
      position: absolute;
      top: 20px;
      right: 20px;
      width: 50px;
      height: 30px;
      overflow: hidden;
      z-index: 1;
      transition: color 150ms ease-in-out;

      &:after {
        content: '';
        position: absolute;
        display: block;
        top: 0;
        left: 50%;
        transform: translateX(-50%);
        width: 0;
        height: 100%;
        background: $purple;
        z-index: -1;
        transition: width 150ms ease-in-out;
      }

      &:hover {
        color: #fff;
        &:after {
          width: 110%;
        }
      }
    }

实现完成后效果如下
编辑便签
devf_fabric_19.png

到此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集群控制台,进入集群列表页,点击集群右侧的“控制台”
    devf_fabric_20.png
  3. 在新打开的控制台中,我们点击右上角“+创建”按钮,在“从文本输入框创建”标签页中,

我们输入如下yaml内容

apiVersion: v1
kind: Service
metadata:
  name: notewall
  labels:
    app: notewall
spec:
  type: LoadBalancer 
  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: notewall-dapp
        image: hub.baidubce.com/jpaas-public/notewall-dapp:latest
        ports:
        - containerPort: 80~~~~

devf_fabric_21.png

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

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

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