本节分享开发一个区块链票据系统,从链码到前端的这个过程。
1. 票据知识梳理
2. 项目背景
3. 票据系统演示
4. 链码讲解
5. SDK代码分析
6. 前端代码梳理
票据交易的转让方式:
贴现是指持票人在需要资金时,将其持有的未到期的商业汇票,经过背书转让给银行,并贴付利息,银行从票面金额中扣除贴现利息后,将余款支付给汇票持有人的票据行为。
本质是兑换权利的转让, 票据法规定,持票人将票据权利转让给他人,应当背书并交付票据。所以,当持票人为了转让票据权利,而在票据背面或者粘单上记载有关事项并签章,就是在进行背书转让。
汇票质押是指以设定质权、提供债务担保为目的而进行的背书。它是由背书人通过背书的方式,将票据转移给质权人,以票据金额的给付作为对被背书人债务清偿保证的一种方式。
4月28日,家具销售公司向光华沙发厂签发了一张金额为50万元、到期日为10月28日的汇票。光华沙发厂根据合同约定发送了沙发,接受了汇票。4月30日,光华沙发厂将汇票背书转让给某皮革厂,该皮革厂又将汇票于6月21日背书转让给畜牧场,以抵销所欠货款。在汇票到期日,畜牧场向家具销售公司提示付款,因家具公司在开户行存款不足而遭退票。畜牧场向皮革厂追索票款,皮革厂拒付。畜牧场遂以票据债务人即出票人家具销售公司、背书人光华沙发厂、皮革厂为被告向法院起诉,要求被告承担连带责任,清偿票款。
原告:某畜牧厂
被告:B市某家具销售公司(以下简称家具销售公司)
被告:A市光华沙发厂(以下简称光华沙发厂)
被告:某皮革厂
以上就暴露了纸质票据的弊端,如果换做区块链票据,畜牧场向皮革厂索要票款的时候,不管皮革厂同意不同意,区块链系统上的智能合约会根据日期判断,只要到期会强制把皮革厂的款项支付给畜牧场。
在这套系统中,共分四层。最底层是基于超级账本的区块链网路,在这层网路上部署智能合约,就是上节所讲的链码(电子合同,强制执行)。再往上就是传统的部署架构,应用层就是俗称的前端,业务层就是后端服务。用户通过前端界面输入内容并提交,通过restful协议与后端通信,后端再通过sdk与智能合约交互,最终把区块链数据逐层返回给前端界面,表现为用户看到提交后反馈的结果。
而节点的组织关系则如图所示,共有两个组织,每个组织里有两个peer节点,这四个peer节点都与同一个orderer节点通信,他们处在同一个通道内。
网路搭建可以参考之前发布的搭建联盟链的文章,或者参考官方提供的fabric-sample里的first-network案例,启动网路。并分别创建通道,然后把两个组织(org1和org2)加入通道,并且更新锚节点。
首先分析一下代码结构组成:
package main import ( "encoding/json" "fmt" "time" "github.com/hyperledger/fabric/core/chaincode/shim" pb "github.com/hyperledger/fabric/protos/peer" )
新出票:NewPublish
等待背书:EndrWaitSign
已签名背书:EndrSigned
背书拒绝:EndrReject
Bill_、holderName~billNo、holderId~dayTime-billType-billNo
票据:Bill
历史背书:HistoryItem
链码响应结构:chaincodeRet
以及最主要的记录票据背书情况的结构:BillChaincode
根据票号取出票据:getBill
保存票据:putBill
链码初始化:Init
票据发布:issue
背书请求:endorse
背书人接受背书:accept
背书人拒绝背书:reject
根据持票人编号批量查询票据:queryMyBill
查询等待背书的票据: queryMyWaitBill
根据票号查询票据以及该票据背书历史:queryByBillNo
链码invoke介面:Invoke
主函数:main
虽然函数比较多,但是函数之间有很多相似点,因此限于篇幅,只分析三个主要的函数:Invoke、issue、queryMyBill
链码在实例化的时候会调用main和init函数,这两个函数仅仅初始化代码并没有实际对数据操作。而当调用函数的时候,比如客户端输入peer chaincode invoke...的时候就会调用invoke函数。
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) }
票据发布的主函数, 这个是记录在区块链数据里,因此需要至少两个背书节点。
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) }
根据持票人的编号,批量查询票据
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) }
链码完成之后,再参考之前配置网路的文章,分别安装和实例化链码。
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去写。一些不涉及安全的业务逻辑也可以放在前端处理。这样也减轻了服务端的压力。
本例使用Angularjs框架编写前端,关于Angular基础知识可以自行查询。
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-