超级账本实践——应用开发?

www.liankuai.tech

本节分享开发一个区块链票据系统,从链码到前端的这个过程。

前言

本节分享开发一个区块链票据系统,从链码到前端的这个过程。

目录

1. 票据知识梳理

2. 项目背景

3. 票据系统演示

4. 链码讲解

5. SDK代码分析

6. 前端代码梳理

1. 票据知识

票据交易的转让方式:

1.1 贴现

贴现是指持票人在需要资金时,将其持有的未到期的商业汇票,经过背书转让给银行,并贴付利息,银行从票面金额中扣除贴现利息后,将余款支付给汇票持有人的票据行为。

贴现既是一种票据转让行为,也是一种银行授信行为,可以视作银行通过接受汇票给持票人短期贷款,如果汇票付款期满,银行能收回汇票资金的,该贷款就自动冲销,如果银行不能得到汇票付款,则可以向汇票付款人和所有的债务人追索。

1.2 背书转让

本质是兑换权利的转让, 票据法规定,持票人将票据权利转让给他人,应当背书并交付票据。所以,当持票人为了转让票据权利,而在票据背面或者粘单上记载有关事项并签章,就是在进行背书转让。

如下图所示,把盖章之后的粘单贴在票据背面的行为就叫背书转让。纸质的粘单有很多弊端,比如易丢失和伪造而且流转环节繁琐效率不高。所以把纸质的电子化,变成电子票据会有很多优势,那么电子票据如果部署在中心化系统上也可以被黑客侵入篡改甚至销毁,而利用区块链的分散式存储和不可篡改特性,把电子票据存储在这种系统上就大大降低了风险。本文就是基于这个痛点开发一套票据背书转让系统。

1.3 汇票质押

汇票质押是指以设定质权、提供债务担保为目的而进行的背书。它是由背书人通过背书的方式,将票据转移给质权人,以票据金额的给付作为对被背书人债务清偿保证的一种方式。

2.票据背书转让案例

2.1 【案情介绍】

4月28日,家具销售公司向光华沙发厂签发了一张金额为50万元、到期日为10月28日的汇票。光华沙发厂根据合同约定发送了沙发,接受了汇票。4月30日,光华沙发厂将汇票背书转让给某皮革厂,该皮革厂又将汇票于6月21日背书转让给畜牧场,以抵销所欠货款。在汇票到期日,畜牧场向家具销售公司提示付款,因家具公司在开户行存款不足而遭退票。畜牧场向皮革厂追索票款,皮革厂拒付。畜牧场遂以票据债务人即出票人家具销售公司、背书人光华沙发厂、皮革厂为被告向法院起诉,要求被告承担连带责任,清偿票款。

原告:某畜牧厂

被告:B市某家具销售公司(以下简称家具销售公司)

被告:A市光华沙发厂(以下简称光华沙发厂)

被告:某皮革厂

以上就暴露了纸质票据的弊端,如果换做区块链票据,畜牧场向皮革厂索要票款的时候,不管皮革厂同意不同意,区块链系统上的智能合约会根据日期判断,只要到期会强制把皮革厂的款项支付给畜牧场。

3.系统架构设计

在这套系统中,共分四层。最底层是基于超级账本的区块链网路,在这层网路上部署智能合约,就是上节所讲的链码(电子合同,强制执行)。再往上就是传统的部署架构,应用层就是俗称的前端,业务层就是后端服务。用户通过前端界面输入内容并提交,通过restful协议与后端通信,后端再通过sdk与智能合约交互,最终把区块链数据逐层返回给前端界面,表现为用户看到提交后反馈的结果。

而节点的组织关系则如图所示,共有两个组织,每个组织里有两个peer节点,这四个peer节点都与同一个orderer节点通信,他们处在同一个通道内。

3.1 搭建网路

网路搭建可以参考之前发布的搭建联盟链的文章,或者参考官方提供的fabric-sample里的first-network案例,启动网路。并分别创建通道,然后把两个组织(org1和org2)加入通道,并且更新锚节点。

4.链码分析

首先分析一下代码结构组成:

其中shim是链码开发所用到的主要模块,和其它模块一样都是通过import关键词导入。

4.1 模块注入

package main
import (
"encoding/json"
"fmt"
"time"
"github.com/hyperledger/fabric/core/chaincode/shim"
pb "github.com/hyperledger/fabric/protos/peer"
)

4.2 状态变数的定义

4.2.1 票据状态分为四种:

新出票:NewPublish

等待背书:EndrWaitSign

已签名背书:EndrSigned

背书拒绝:EndrReject

4.2.2 另外定义一组状态资料库中表的前缀,方便查询:

Bill_、holderName~billNo、holderId~dayTime-billType-billNo

4.2.3 定义结构体

票据:Bill

历史背书:HistoryItem

链码响应结构:chaincodeRet

以及最主要的记录票据背书情况的结构:BillChaincode

4.2.4 定义函数

根据票号取出票据:getBill

保存票据:putBill

链码初始化:Init

票据发布:issue

背书请求:endorse

背书人接受背书:accept

背书人拒绝背书:reject

根据持票人编号批量查询票据:queryMyBill

查询等待背书的票据: queryMyWaitBill

根据票号查询票据以及该票据背书历史:queryByBillNo

链码invoke介面:Invoke

主函数:main

虽然函数比较多,但是函数之间有很多相似点,因此限于篇幅,只分析三个主要的函数:Invoke、issue、queryMyBill

4.2.5 Invoke函数

链码在实例化的时候会调用main和init函数,这两个函数仅仅初始化代码并没有实际对数据操作。而当调用函数的时候,比如客户端输入peer chaincode invoke...的时候就会调用invoke函数。

这个函数通过function, args := stub.GetFunctionAndParameters()获取应用层传过来的参数。然后根据function的值来调用对用的函数。

switch function {
case "issue":
return a.issue(stub, args)
case "endorse":
return a.endorse(stub, args)
case "accept":
return a.accept(stub, args)
case "reject":
return a.reject(stub, args)
case "queryMyBill":
return a.queryMyBill(stub, args)
case "queryByBillNo":
return a.queryByBillNo(stub, args)
case "queryMyWaitBill":
return a.queryMyWaitBill(stub, args)
}

4.2.6 issue函数

票据发布的主函数, 这个是记录在区块链数据里,因此需要至少两个背书节点。

func (a *BillChaincode) issue(stub shim.ChaincodeStubInterface, args []string) pb.Response {
//判断参数是否等于一,阻止多参数攻击。
if len(args) != 1 {
res := getRetString(1, "LiankuaiChaincode Invoke issue 参数不等于一")
return shim.Error(res)
}
//定义Bill类型的变数,存储票据信息
var bill Bill

//把传过来的json数据反编列与bill合并
err := json.Unmarshal([]byte(args[0]), &bill)

//及时抛出异常中断程序也为了方便调试
if err != nil {
res := getRetString(1, "LiankuaiChaincode Invoke issue 反编列异常")
return shim.Error(res)
}

// 查询是否有重复的票据
_, existbl := a.getBill(stub, bill.BillInfoID)
//如果有相同的也抛出异常
if existbl {
res := getRetString(1, "LiankuaiChaincode Invoke issue failed : 票号已经存在 ")
return shim.Error(res)
}

//获取当前时间戳
var dayTime = time.Now().Format("2018-10-10")

//根据混合key获取状态变数,返回的是一个迭代器,执行.Next()就可以逐条遍历数据
resultIterator, err := stub.GetStateByPartialCompositeKey(HolderIdDayTimeBillTypeBillNoIndexName, []string{bill.HodrCmID, dayTime, bill.BillInfoType})
if err != nil {
res := getRetString(1, "LiankuaiChaincode Invoke issue 获取列表错误")
return shim.Error(res)
}
defer resultIterator.Close()

var count = 0
for resultIterator.HasNext() {
_, _ = resultIterator.Next()

count++

if count >= 5 {
res := getRetString(1, "LiankuaiChaincode Invoke issue 每天不能出大于5个相同类型的票据")
return shim.Error(res)
}
}

//创建一个混合的key方便查询
holderIdDayTimeBillNoIndexKey, err := stub.CreateCompositeKey(HolderIdDayTimeBillTypeBillNoIndexName, []string{bill.HodrCmID, dayTime, bill.BillInfoType, bill.BillInfoID})
if err != nil {
res := getRetString(1, "LiankuaiChaincode Invoke issue 更新查询表错误")
return shim.Error(res)
}
//在更新状态资料库
stub.PutState(holderIdDayTimeBillNoIndexKey, []byte(time.Now().Format("2018-11-20 12:56:56")))

// 更改票据信息和状态并保存票据:票据状态设为新发布
bill.State = BillInfo_State_NewPublish
// 保存票据
_, bl := a.putBill(stub, bill)
if !bl {
res := getRetString(1, "LiankuaiChaincode Invoke issue 更新票据错误")
return shim.Error(res)
}
// 以持票人ID和票号构造复合key 向search表中保存 value为空即可 以便持票人批量查询
holderNameBillNoIndexKey, err := stub.CreateCompositeKey(IndexName, []string{bill.HodrCmID, bill.BillInfoID})
if err != nil {
res := getRetString(1, "LiankuaiChaincode Invoke issue 更新索引表错误")
return shim.Error(res)
}
//更新状态资料库
stub.PutState(holderNameBillNoIndexKey, []byte{0x00})

res := getRetByte(0, "invoke issue success")
return shim.Success(res)
}

4.2.7 queryMyBill函数

根据持票人的编号,批量查询票据

func (a *BillChaincode) queryMyBill(stub shim.ChaincodeStubInterface, args []string) pb.Response {
//判断参数是否只有一个
if len(args) != 1 {
res := getRetString(1, "LiankuaiChaincode queryMyBill 参数不等于一")
return shim.Error(res)
}
// 以持票人ID从search表中批量查询所持有的票号
billsIterator, err := stub.GetStateByPartialCompositeKey(IndexName, []string{args[0]})
if err != nil {
res := getRetString(1, "LiankuaiChaincode queryMyBill 获取票据列表数据错误")
return shim.Error(res)
}
defer billsIterator.Close()

var billList = []Bill{}

for billsIterator.HasNext() {
kv, _ := billsIterator.Next()
// 取得持票人名下的票号
_, compositeKeyParts, err := stub.SplitCompositeKey(kv.Key)
if err != nil {
res := getRetString(1, "LiankuaiChaincode queryMyBill 分组获取混合key错误")
return shim.Error(res)
}
// 根据票号取得票据
bill, bl := a.getBill(stub, compositeKeyParts[1])
if !bl {
res := getRetString(1, "LiankuaiChaincode queryMyBill 获取票据错误")
return shim.Error(res)
}
billList = append(billList, bill)
}
// 取得并返回票据数组
b, err := json.Marshal(billList)
if err != nil {
res := getRetString(1, "LiankuaiChaincode Marshal queryMyBill 获取票据列表错误")
return shim.Error(res)
}
return shim.Success(b)
}

链码完成之后,再参考之前配置网路的文章,分别安装和实例化链码。

5. SDK代码分析

Fabric为应用开发提供了多种语言的sdk,本案例使用的是基于nodejs的sdk,官方地址是https://github.com/hyperledger/fabric-sdk-node。开发中主要使用其中的两大模块:fabric-client和fabric-ca-client。

Fabric-client的使用:

var Fabric_Client = require(fabric-client); //载入sdk模块var channel = fabric_client.newChannel(mychannel); //创建通道var peer = fabric_client.newPeer(grpc://localhost:7051); //连接peer节点
channel.addPeer(peer);var order = fabric_client.newOrderer(grpc://localhost:7050) //连接排序节点
channel.addOrderer(order);var request = {
chaincodeId: myChaincode, //调用哪个链码
fcn: testFunc, //调用链码里的哪个函数
args: [arg1, arg2, arg3], //参数
chainId: mychannel, //哪个通道的链码
txId: tx_id
};
channel.sendTransactionProposal(request); //发送交易
以上表示sdk创建通道并链接节点的过程,通道和发送数据都先发送一个提交议案调用channel.sendTransactionProposal函数。同时在终端可以检测到是否发送成功。
Fabric-ca-client的使用:
var Fabric_CA_Client = require(fabric-ca-client);var tlsOptions = {
trustedRoots: [],
verify: false
};//调用加密套件var crypto_suite = Fabric_Client.newCryptoSuite();
//使用相同的地方存放状态资料库和用户的证书以及用户的私钥var crypto_store = Fabric_Client.newCryptoKeyStore({path: store_path});
crypto_suite.setCryptoKeyStore(crypto_store);
fabric_client.setCryptoSuite(crypto_suite);//当CA启用TLS的时候把http改成httpsvar fabric_ca_client = new Fabric_CA_Client(http://localhost:7054, tlsOptions , ca.example.com, crypto_suite);//登录ca伺服器,拿到证书和私钥
fabric_ca_client.enroll({
enrollmentID: admin,
enrollmentSecret: adminpw
})

以上这段表示的是ca服务的连接使用,主要为了登录注册用户信息,返回证书和私钥。sdk代码是放置在后端,另外需要配合webserver,nodejs里使用express作为webserver的基础,在exress中设置好路由配置,监听前端发送的请求,然后调用sdk里的功能。类似java开发中的springMVC,如果是Java开发者可以使用对应的java版本的sdk放置在自己原有的系统中。

webserver代码摘要(伪代码):

var express = require(express);
...var server = http.createServer(app).listen(8080, function() {});//响应前端请求比如用户注册请求
app.post(/users, async function(req, res) {
var username = req.body.username;
var orgName = req.body.orgName;
if (!username) {
res.json(getErrorMessage(username));
return;
}
if (!orgName) {
res.json(getErrorMessage(orgName));
return;
}
//产生前端token
var token = jwt.sign({
exp: Math.floor(Date.now() / 1000) + parseInt(hfc.getConfigSetting(jwt_expiretime)),
username: username,
orgName: orgName
}, app.get(secret));
..........
//拿到CA管理员许可权去注册新用户
let adminUserObj = await client.setUserContext({username: admins[0].username, password: admins[0].secret});
let caClient = client.getCertificateAuthority();
//根据前端传过来用户名向ca节点注册
let secret = await caClient.register({
enrollmentID: username,
affiliation: userOrg.toLowerCase() + .department1
}, adminUserObj);

})

//监听调用链码的前端请求(使用sdk提供的invokeChaincode函数):

app.post(/channels/:channelName/chaincodes/:chaincodeName/invoke, async function(req, res) {
logger.debug(==================== 调用链码 ==================);
//获取前端传过来的参数
var peers = req.body.peers;
var chaincodeName = req.params.chaincodeName;
var channelName = req.params.channelName;
var fcn = req.body.fcn;
var args = req.body.args;
let message = await invoke.invokeChaincode(peers, channelName, chaincodeName, fcn, args, req.username, req.orgname);
res.send(message);
});

其它监听地址比如查询数据和发布票据的可以写类似的监听。并写好测试用例,跑一遍用例如果没有错误,就可以写前端代码了。前端可以使用Vue、React、Angularjs、当然也可以用jQuery甚至原生js去写。一些不涉及安全的业务逻辑也可以放在前端处理。这样也减轻了服务端的压力。

6. 前端代码梳理

本例使用Angularjs框架编写前端,关于Angular基础知识可以自行查询。

如何发送数据到后端? 需要编写前端的services,比如:

angular.module(app).factory(HttpService, [ $http,$q,REST_URL,$rootScope, DialogService,$state,toaster, function($http,$q,REST_URL,$rootScope,DialogService,$state,toaster){
return {
//封装post请求
post : function(url, data) {
if (data != null) {
//前端的发送之前也有一个校验,如果匹配invoke则需要对原始参数做一个编排
if(url.match(/invoke/)){
//添加两个背书节点
data.peers = ["peer0.org1.example.com","peer0.org2.example.com"];
//附带上token让后端识别用户身份
data.token = sessionStorage.getItem("token");
}else{
如果只是查询则仅仅发送给一个节点即可
data.peer = "peer0.org1.example.com"
data.token = sessionStorage.getItem("token");
}
}
//构造请求数据
var req = {
method: POST,
url: url,
headers: {
Content-Type: application/json;charset=utf-8
},
data: data
};

var deferred = $q.defer();
var promise = deferred.promise;
//最终把数据发送给后端
$http(req).then(function(response) {
...

后端接收到前端请求的数据就会根据路由解析对应的参数并经过sdk与智能合约和各个背书节点交互。以上就是完整的开发流程。

小结

本文分享了开发超级账本应用的基本思路:首先配置网路,然后创建通道、加入通道、更新锚节点。之后使用shim提供的功能编写链码,之后把链码安装到节点上并实例化。在后端代码中引入fabric的sdk模块,并监听前端路由,调用sdk对应的函数完成登录注册以及与链码调用的功能。

-END-


推荐阅读:
相关文章