深度学习
票据背书的实现分为两个部分,即基于Hyperledger Fabric Node.js SDK的应用程序和链码功能的实现。本章所有的代码托管到Github上:https://github.com/ChainNova/trainingProjects/tree/master/billEndorse。后面只介绍部分业务逻辑的实现。
12.4.1 应用程序实现
应用程序分为Web应用前端和后端服务。这里只介绍后端服务的实现,Web应用前端部分请参考Github上的实 现。特别说明一下,本示例中的代码只用来演示和说明如何开发基于Hyperledger Fabric 1.0的区块链应用程序,接口的设计和代码实现都不严格,在实际的项目中需要做优化。
1.后端服务提供的接口定义
后端服务给Web应用提供的是RESTful的接口,全部的请求都是POST请求,Content-Type是“application/json”。
接口主要分为用户登录接口、票据发布接口、查询本人持有票据接口、票据背书请求接口、查询待签收票据接口、查询票 据信息接口、票据背书回复接口等。下面逐一以例子形式展示接口的使用,测试可以采用wget、curl等支持RESTful接口的工具,也可以采用 Postman等可视化工具,也可以编程实现。
(1)用户登录接口
所有的操作都需要先登录并获取token,作为下一次操作的凭证。用户登录接口URL是http://ip:port/login,其中,ip和port是Web应用的地址,这些参数都需要根据实际的部署做修改。
输入的Body信息如下:
{
"username": "alice",
"orgName": "org1",
"password": "123456"
}
返回的信息如下:
{
"success": true,
"secret": "BGjQXLFbHgGJ",
"message": "alice enrolled Successfully",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIxNDM0MzAsInVzZ
XJuYW1lIjoiYWxpY2UiLCJvcmdOYW1lIjoib3JnMSIsInBhc3N3b3JkIjoiMTIzNDU2IiwiaWF0IjoxNTEyMTA3NDMwfQ.QnIyaxuq8G4JlolBNq3DMKYfs6q8zjLUYwRjxS1GdxU",
"user": {
"username": "alice",
"name": "A公司",
"passwd": "123456",
"cmId": "ACMID",
"Acct": "A公司"
}
}
其中,token是基于JSON Web Token实现的,详细的介绍参考https://jwt.io。
(2)票据发布接口
新票据需要先通过发布接口发布到区块链上,发布成功后,初始状态为“新发布”,标记成000001。若区块链上已经有该票据,输出为错误,提示为“票据重复发布”。
票据操作接口的URL都是相同的:http://ip:port/channels/mychannel/chaincodes/mycc/invoke,其中,ip和port是Web应用的地址,mychannel是通道名称,mycc是链码的名称,这些参数都需要根据实际的部署做修改。
票据操作调用的接口通过Body信息来区分:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIxNDM0MzAsI
nVzZXJuYW1lIjoiYWxpY2UiLCJvcmdOYW1lIjoib3JnMSIsInBhc3N3b3JkIjoiMTIzNDU2IiwiaWF0IjoxNTEyMTA3NDMwfQ.QnIyaxuq8G4JlolBNq3DMKYfs6q8zjLUYwRjxS1GdxU",
"peers": ["peer1"],
"fcn":"issue",
"args":["{\"BillInfoID\":\"POC10000998\",\"BillInfoAmt\":\"222\",\"BillInfoType\":\"111\",\"BillInfoIsseDate\":\"20170910\",\"BillInfoDueDate\":\"20171112\",\"DrwrCmID\":\"111\",\"DrwrAcct\":\"111\",\"AccptrCmID\":\"111\",\"AccptrAcct\":\"111\",\"PyeeCmID\":\"111\",\"PyeeAcct\":\"111\",\"HodrCmID\":\"ACMID\",\"HodrAcct\":\"A公司\"}"]
}
其中,票据操作接口是通用的结构,各参数的含义如表12-3所示。
表12-3 后端服务的接口参数定义
返回信息如下:
{
"success": true,
"message": "9a1525ef5a388530c1757c9c1c565bf52422e9a775a03d20e9aa2273b008aa31"
}
(3)票据背书接口
票据背书接口输入的Body信息如下:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIxNDM0MzAsInVzZXJuYW1lIjoiYWxpY2UiLCJvcmdOYW1lIjoib3JnMSIsInBhc3N3b3JkIjoiMTIzNDU2IiwiaWF0IjoxNTEyMTA3NDMwfQ.QnIyaxuq8G4JlolBNq3DMKYfs6q8zjLUYwRjxS1GdxU",
"peers": ["peer1"],
"fcn":"endorse",
"args":["POC10000998","BCMID","B公司"]
}
其中,票据背书的fcn是endorse,args参数的信息参考12.4.2节。
返回的信息如下:
{
"success": true,
"message": "ae87c2e1d51f22125e9c16375420aee68acd0bb3dbcb5950a787e4a5cba3b080"
}
(4)票据背书签收接口
票据背书签收接口输入的Body信息如下:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIxNDM4MDMsI
nVzZXJuYW1lIjoiYm9iIiwib3JnTmFtZSI6Im9yZzEiLCJwYXNzd29yZCI6IjEyMzQ1NiIsImlhdCI6MTUxMjEwNzgwM30.RMxkdTOP2e6K03hD_6GpkHV3mcZpeqjcxfdqshb7gKk",
"peers": ["peer1"],
"fcn":"accept",
"args":["POC10000998","BCMID","B公司"]
}
其中,票据背书签收的用户bob需要先登录并获取token,票据背书签收的fcn是accept,args参数的信息参考12.4.2节。
返回的信息如下:
{
"success": true,
"message": "3710f07807521218f4ccadcdb06c7ffba21f44fc2f70a773d3bc707fe48d7f99"
}
(5)票据背书拒收接口
票据背书拒收接口输入的Body信息如下:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIwODkwMjcsI
nVzZXJuYW1lIjoiYWxpY2UiLCJvcmdOYW1lIjoib3JnMSIsInBhc3N3b3JkIjoiMTIzNDU2IiwiaWF0IjoxNTEyMDUzMDI3fQ.oqDRAAhuD8KSgFFuW9wCxlYpGMXXxGHV18SLMyVBAMo",
"peers": ["peer1"],
"fcn":"reject",
"args":["POC10000998","BCMID","B公司"]
}
其中,票据背书拒收的fcn是reject,args参数的信息参考12.4.2节。
返回信息如下:
{
"success": true,
"message": "183b9ea86804f1fbf1cdd172210b612c89514e9266b082d54b5acda5be4b2f69"
}
(6)查询持票人的票据列表接口
查询持票人的票据列表接口输入的Body信息如下:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIxNDM4MDMsI
nVzZXJuYW1lIjoiYm9iIiwib3JnTmFtZSI6Im9yZzEiLCJwYXNzd29yZCI6IjEyMzQ1NiIsImlhdCI6MTUxMjEwNzgwM30.RMxkdTOP2e6K03hD_6GpkHV3mcZpeqjcxfdqshb7gKk",
"peers": ["peer1"],
"fcn":"queryMyBill",
"args":["BCMID"]
}
其中,查询持票人的票据列表的fcn是queryMyBill,args参数的信息参考12.4.2节。
返回的信息如下:
{
"success": true,
"message": "[
{
'BillInfoID': 'POC10000998',
'BillInfoAmt': '222',
'BillInfoType': '111',
'BillInfoIsseDate': '111',
'BillInfoDueDate': '111',
'DrwrCmID': '111',
'DrwrAcct': '111',
'AccptrCmID': '111',
'AccptrAcct': '111',
'PyeeCmID': '111',
'PyeeAcct': '111',
'HodrCmID': 'BCMID',
'HodrAcct': 'B公司',
'WaitEndorserCmID': '',
'WaitEndorserAcct': '',
'RejectEndorserCmID': '',
'RejectEndorserAcct': '',
'State': 'EndrSigned',
'History': null
}
]"
}
说明一下,为了方便阅读,上面的返回信息对结果做了格式化处理,把原始的结果中双引号的转义“\"”替换成了单引号“'”,后面的展示结果也做了相同的处理。
(7)查询待签收票据列表接口
查询待签收票据列表接口输入的Body信息如下:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIxNDM4MDMsI
nVzZXJuYW1lIjoiYm9iIiwib3JnTmFtZSI6Im9yZzEiLCJwYXNzd29yZCI6IjEyMzQ1NiIsImlhdCI6MTUxMjEwNzgwM30.RMxkdTOP2e6K03hD_6GpkHV3mcZpeqjcxfdqshb7gKk",
"peers": ["peer1"],
"fcn":"queryMyWaitBill",
"args":["BCMID"]
}
其中,查询待签收票据列表的fcn是queryMyWaitBill,args参数的信息参考12.4.2节。
返回的信息如下:
{
"success": true,
"message": "[
{
'BillInfoID': 'POC10000999',
'BillInfoAmt': '222',
'BillInfoType': '111',
'BillInfoIsseDate': '111',
'BillInfoDueDate': '111',
'DrwrCmID': '111',
'DrwrAcct': '111',
'AccptrCmID': '111',
'AccptrAcct': '111',
'PyeeCmID': '111',
'PyeeAcct': '111',
'HodrCmID': 'ACMID',
'HodrAcct': 'A公司',
'WaitEndorserCmID': 'BCMID',
'WaitEndorserAcct': 'B公司',
'RejectEndorserCmID': '',
'RejectEndorserAcct': '',
'State': 'EndrWaitSign',
'History': null
}
]"
}
(8)根据票据号码查询票据信息接口
根据票据号码查询票据信息接口输入的Body信息如下:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MTIxNDM4MDMsI
nVzZXJuYW1lIjoiYm9iIiwib3JnTmFtZSI6Im9yZzEiLCJwYXNzd29yZCI6IjEyMzQ1NiIsImlhdCI6MTUxMjEwNzgwM30.RMxkdTOP2e6K03hD_6GpkHV3mcZpeqjcxfdqshb7gKk",
"peers": ["peer1"],
"fcn":"queryByBillNo",
"args":["POC10000998"]
}
其中,根据票据号码查询票据信息的fcn是queryByBillNo,args参数的信息参考12.4.2节。
返回的信息如下:
{
"success": true,
"message": "{
'BillInfoID': 'POC10000998',
'BillInfoAmt': '222',
'BillInfoType': '111',
'BillInfoIsseDate': '111',
'BillInfoDueDate': '111',
'DrwrCmID': '111',
'DrwrAcct': '111',
'AccptrCmID': '111',
'AccptrAcct': '111',
'PyeeCmID': '111',
'PyeeAcct': '111',
'HodrCmID': 'BCMID',
'HodrAcct': 'B公司',
'WaitEndorserCmID': '',
'WaitEndorserAcct': '',
'RejectEndorserCmID': '',
'RejectEndorserAcct': '',
'State': 'EndrSigned',
'History': [
{
'txId': '9a1525ef5a388530c1757c9c1c565bf52422e9a775a03d20e9aa2273
b008aa31',
'bill': {
'BillInfoID': 'POC10000998',
'BillInfoAmt': '222',
'BillInfoType': '111',
'BillInfoIsseDate': '111',
'BillInfoDueDate': '111',
'DrwrCmID': '111',
'DrwrAcct': '111',
'AccptrCmID': '111',
'AccptrAcct': '111',
'PyeeCmID': '111',
'PyeeAcct': '111',
'HodrCmID': 'ACMID',
'HodrAcct': 'A公司',
'WaitEndorserCmID': '',
'WaitEndorserAcct': '',
'RejectEndorserCmID': '',
'RejectEndorserAcct': '',
'State': 'NewPublish',
'History': null
}
},
{
'txId': 'ae87c2e1d51f22125e9c16375420aee68acd0bb3dbcb5950a787e4a5
cba3b080',
'bill': {
'BillInfoID': 'POC10000998',
'BillInfoAmt': '222',
'BillInfoType': '111',
'BillInfoIsseDate': '111',
'BillInfoDueDate': '111',
'DrwrCmID': '111',
'DrwrAcct': '111',
'AccptrCmID': '111',
'AccptrAcct': '111',
'PyeeCmID': '111',
'PyeeAcct': '111',
'HodrCmID': 'ACMID',
'HodrAcct': 'A公司',
'WaitEndorserCmID': 'BCMID',
'WaitEndorserAcct': 'B公司',
'RejectEndorserCmID': '',
'RejectEndorserAcct': '',
'State': 'EndrWaitSign',
'History': null
}
},
{
'txId': '3710f07807521218f4ccadcdb06c7ffba21f44fc2f70a773d3bc707f
e48d7f99',
'bill': {
'BillInfoID': 'POC10000998',
'BillInfoAmt': '222',
'BillInfoType': '111',
'BillInfoIsseDate': '111',
'BillInfoDueDate': '111',
'DrwrCmID': '111',
'DrwrAcct': '111',
'AccptrCmID': '111',
'AccptrAcct': '111',
'PyeeCmID': '111',
'PyeeAcct': '111',
'HodrCmID': 'BCMID',
'HodrAcct': 'B公司',
'WaitEndorserCmID': '',
'WaitEndorserAcct': '',
'RejectEndorserCmID': '',
'RejectEndorserAcct': '',
'State': 'EndrSigned',
'History': null
}
}
]
}"
}
2.HFC Node.js SDK的使用
HFC Node.js SDK的使用包括创建通道、加入通道、安装链码、实例化链码、调用链码等。
(1)创建通道
首先介绍getOrgAdmin函数,其目的是根据传入的orgName将client实例设置为对应该组织,针对后面的操作进行组织的配置,它作为util函数会在后面多次用到,具体实现内容如下:
1)将client实例的CryptoSuit切换为传入组织的CryptoSuit;
2)将client实例的StateStore切换为传入组织的StateStore;
3)返回传入组织的admin用户实例。
var getOrgAdmin = function(userOrg) {
var admin = ORGS[userOrg].admin;
var keyPath = path.join(__dirname, admin.key);
var keyPEM = Buffer.from(readAllFiles(keyPath)[0]).toString();
var certPath = path.join(__dirname, admin.cert);
var certPEM = readAllFiles(certPath)[0].toString();
var client = getClientForOrg(userOrg);
var cryptoSuite = hfc.newCryptoSuite();
if (userOrg) {
cryptoSuite.setCryptoKeyStore(hfc.newCryptoKeyStore({path: getKeyStoreFo
rOrg(getOrgName(userOrg))}));
client.setCryptoSuite(cryptoSuite);
}
return hfc.newDefaultKeyValueStore({
path: getKeyStoreForOrg(getOrgName(userOrg))
}).then((store) => {
client.setStateStore(store);
return client.createUser({
username: 'peer'+userOrg+'Admin',
mspid: getMspID(userOrg),
cryptoContent: {
privateKeyPEM: keyPEM,
signedCertPEM: certPEM
}
});
});
}
下面介绍创建通道的步骤:
1)首先根据传入的channelConfigPath,获取通道配置文件,提取为字节;
2)把client实例切换到传入组织;
3)使用传入组织的加密材料对通道配置字节签名;
4)构建request,向orderer发送创建通道请求;
5)创建成功则返回成功的结构对象,失败则抛出异常。
var createChannel = function(channelName, channelConfigPath, username, orgName) {
logger.debug('\n====== Creating Channel \'' + channelName + '\' ======\n');
var client = helper.getClientForOrg(orgName);
var channel = helper.getChannelForOrg(orgName);
// 取到通道配置文件
var envelope = fs.readFileSync(path.join(__dirname, channelConfigPath));
// 提取通道配置文件字节
var channelConfig = client.extractChannelConfig(envelope);
return helper.getOrgAdmin(orgName).then((admin) => {
logger.debug(util.format('Successfully acquired admin user for
the organization "%s"', orgName));
// 对通道配置字节签名为"背书",这是由orderer的通道创建策略所要求的
let signature = client.signChannelConfig(channelConfig);
let request = {
config: channelConfig,
signatures: [signature],
name: channelName,
orderer: channel.getOrderers()[0],
txId: client.newTransactionID()
};
// 将创建通道请求发送给orderer
return client.createChannel(request);
}, (err) => {
logger.error('Failed to enroll user \''+username+'\'. Error: ' +
err);
throw new Error('Failed to enroll user \''+username+'\'' + err);
}).then((response) => {
logger.debug(' response ::%j', response);
if (response && response.status === 'SUCCESS') {
logger.debug('Successfully created the channel.');
let response = {
success: true,
message: 'Channel \'' + channelName + '\' created
Successfully'
};
return response;
} else {
logger.error('\n!!!!!!!!! Failed to create the channel \''
+ channelName +
'\' !!!!!!!!!\n\n');
throw new Error('Failed to create the channel \'' +
channelName + '\'');
}
}, (err) => {
logger.error('Failed to initialize the channel: ' + err.stack ?
err.stack :
err);
throw new Error('Failed to initialize the channel: ' + err.stack ?
err.stack : err);
});
};
exports.createChannel = createChannel;
(2)加入通道
加入通道包括如下步骤:
1)client实例切换到传入组织;
2)基于之前创建的通道,获取该通道的创世区块;
3)向要加入通道的peers发送加入通道的请求;
4)在向peers发送加入通道请求的同时,为各个peer分别注册block eventhub来监听区块产生的过程是否正常;
5)通过校验第一个peer的response status是否为200来判断加入通道的结果,成功则返回成功的结构对象,失败则抛出异常。
var joinChannel = function(channelName, peers, username, org) {
// 断开与event hub连接的函数
var closeConnections = function(isSuccess) {
if (isSuccess) {
logger.debug('\n============ Join Channel is SUCCESS ====
========\n');
} else {
logger.debug('\n!!!!!!!! ERROR: Join Channel FAILED !!!!!
!!!\n');
}
logger.debug('');
for (var key in allEventhubs) {
var eventhub = allEventhubs[key];
if (eventhub && eventhub.isconnected()) {
//logger.debug('Disconnecting the event hub');
eventhub.disconnect();
}
}
};
//logger.debug('\n============ Join Channel ============\n')
logger.info(util.format(
'Calling peers in organization "%s" to join the channel', org));
var client = helper.getClientForOrg(org);
var channel = helper.getChannelForOrg(org);
var eventhubs = [];
return helper.getOrgAdmin(org).then((admin) => {
logger.info(util.format('received member object for admin of the
organization "%s": ', org));
tx_id = client.newTransactionID();
let request = {
txId : tx_id
};
return channel.getGenesisBlock(request);
}).then((genesis_block) => {
tx_id = client.newTransactionID();
var request = {
targets: helper.newPeers(peers, org),
txId: tx_id,
block: genesis_block
};
eventhubs = helper.newEventHubs(peers, org);
for (let key in eventhubs) {
let eh = eventhubs[key];
eh.connect();
allEventhubs.push(eh);
}
var eventPromises = [];
eventhubs.forEach((eh) => {
let txPromise = new Promise((resolve, reject) => {
let handle = setTimeout(reject, parseInt(config.
eventWaitTime));
eh.registerBlockEvent((block) => {
clearTimeout(handle);
// 一个peer可能属于多个通道,所以必须检查这个配置block是否来自于我们请求加入的通道
if (block.data.data.length === 1) {
// 配置block只包括一个交易
var channel_header = block.data.
data[0].payload.header.channel_header;
if (channel_header.channel_id
=== channelName) {
resolve();
}
else {
reject();
}
}
});
});
eventPromises.push(txPromise);
});
let sendPromise = channel.joinChannel(request);
return Promise.all([sendPromise].concat(eventPromises));
}, (err) => {
logger.error('Failed to enroll user \'' + username + '\' due to
error: ' +
err.stack ? err.stack : err);
throw new Error('Failed to enroll user \'' + username +
'\' due to error: ' + err.stack ? err.stack : err);
}).then((results) => {
logger.debug(util.format('Join Channel R E S P O N S E : %j',
results));
if (results[0] && results[0][0] && results[0][0].response &&
results[0][0]
.response.status == 200) {
logger.info(util.format(
'Successfully joined peers in organization %s to
the channel \'%s\'',
org, channelName));
closeConnections(true);
let response = {
success: true,
message: util.format(
'Successfully joined peers in organization
%s to the channel \'%s\'',
org, channelName)
};
return response;
} else {
logger.error(' Failed to join channel');
closeConnections();
throw new Error('Failed to join channel');
}
}, (err) => {
logger.error('Failed to join channel due to error: ' + err.stack ?
err.stack :
err);
closeConnections();
throw new Error('Failed to join channel due to error: ' + err.
stack ? err.stack :
err);
});
};
exports.joinChannel = joinChannel;
(3)安装链码
安装链码包括如下步骤:
1)client实例切换到传入组织;
2)client实例发出安装链码请求,请求中包括目标peers、链码路径、链码名称和链码版本;
3)对目标peers返回的proposalResponses结果依次校验,所有peers都成功则返回成功的结构对象,有peer失败则抛出异常。
var installChaincode = function(peers, chaincodeName, chaincodePath,
chaincodeVersion, username, org) {
logger.debug(
'\n============ Install chaincode on organizations ============
\n');
helper.setupChaincodeDeploy();
var channel = helper.getChannelForOrg(org);
var client = helper.getClientForOrg(org);
return helper.getOrgAdmin(org).then((user) => {
var request = {
targets: helper.newPeers(peers, org),
chaincodePath: chaincodePath,
chaincodeId: chaincodeName,
chaincodeVersion: chaincodeVersion
};
return client.installChaincode(request);
}, (err) => {
logger.error('Failed to enroll user \'' + username + '\'. ' + err);
throw new Error('Failed to enroll user \'' + username + '\'. ' + err);
}).then((results) => {
var proposalResponses = results[0];
var proposal = results[1];
var all_good = true;
for (var i in proposalResponses) {
let one_good = false;
if (proposalResponses && proposalResponses[i].response &&
proposalResponses[i].response.status === 200) {
one_good = true;
logger.info('install proposal was good');
} else {
logger.error('install proposal was bad');
}
all_good = all_good & one_good;
}
if (all_good) {
logger.info(util.format(
'Successfully sent install Proposal and received
ProposalResponse: Status - %s',
proposalResponses[0].response.status));
logger.debug('\nSuccessfully Installed chaincode on
organization ' + org +
'\n');
return 'Successfully Installed chaincode on organization '
+ org;
} else {
logger.error(
'Failed to send install Proposal or receive valid
response. Response null or status is not 200. exiting...'
);
return 'Failed to send install Proposal or receive valid
response. Response null or status is not 200. exiting...';
}
}, (err) => {
logger.error('Failed to send install proposal due to error: ' +
err.stack ?
err.stack : err);
throw new Error('Failed to send install proposal due to error: ' +
err.stack ?
err.stack : err);
});
};
exports.installChaincode = installChaincode;
(4)实例化链码
实例化链码包括如下步骤:
1)client实例切换到传入组织;
2)channel调用initialize(),该方法会使用对应组织的MSPs实例化channel对象;
3)发送背书proposal给endorsers(args里面指定的背书节点);
4)对目标endorsers返回的proposalResponses结果依次校验,所有endorsers都背书成功才进入下一步,有endorsers背书失败则抛出异常;
5)endorsers背书成功后,应用端将背书proposalResponses和之前的proposal打 包成request,调用sendTransaction发给orderer,这时因为orderer经过order后再通知peers进行实例化的操作 是异步的,需要注册transaction event来监听实例化的最终结果;
6)在sendTransaction和transaction event都成功返回的情况下,才说明实例化链码成功,此时返回成功的结构对象,若transaction event监听到失败则抛出异常。
var instantiateChaincode = function(channelName, chaincodeName, chaincodeVersion,
functionName, args, username, org) {
logger.debug('\n============ Instantiate chaincode on organization ' + org +
' ============\n');
var channel = helper.getChannelForOrg(org);
var client = helper.getClientForOrg(org);
return helper.getOrgAdmin(org).then((user) => {
// channel实例从orderer读取该通道的配置区块,并基于所加入的组织实例化验证MSPs
return channel.initialize();
}, (err) => {
logger.error('Failed to enroll user \'' + username + '\'. ' + err);
throw new Error('Failed to enroll user \'' + username + '\'. ' + err);
}).then((success) => {
tx_id = client.newTransactionID();
// 发送背书proposal给endorser
var request = {
chaincodeId: chaincodeName,
chaincodeVersion: chaincodeVersion,
args: args,
txId: tx_id
};
if (functionName)
request.fcn = functionName;
return channel.sendInstantiateProposal(request);
}, (err) => {
logger.error('Failed to initialize the channel');
throw new Error('Failed to initialize the channel');
}).then((results) => {
var proposalResponses = results[0];
var proposal = results[1];
var all_good = true;
for (var i in proposalResponses) {
let one_good = false;
if (proposalResponses && proposalResponses[i].response &&
proposalResponses[i].response.status === 200) {
one_good = true;
logger.info('instantiate proposal was good');
} else {
logger.error('instantiate proposal was bad');
}
all_good = all_good & one_good;
}
if (all_good) {
logger.info(util.format(
'Successfully sent Proposal and received
ProposalResponse: Status - %s, message - "%s", metadata - "%s", endorsement signature: %s',
proposalResponses[0].response.status,
proposalResponses[0].response.message,
proposalResponses[0].response.payload,
proposalResponses[0].endorsement
.signature));
var request = {
proposalResponses: proposalResponses,
proposal: proposal
};
// 设置一个transaction listener并且设置30秒timeout
// 如果在timeout的时限内,transaction没有被有效提交则返回错误
var deployId = tx_id.getTransactionID();
eh = client.newEventHub();
let data = fs.readFileSync(path.join(__dirname, ORGS[org].
peers['peer1'][
'tls_cacerts'
]));
eh.setPeerAddr(ORGS[org].peers['peer1']['events'], {
pem: Buffer.from(data).toString(),
'ssl-target-name-override': ORGS[org].peers['peer1']
['server-hostname']
});
eh.connect();
let txPromise = new Promise((resolve, reject) => {
let handle = setTimeout(() => {
eh.disconnect();
reject();
}, 30000);
eh.registerTxEvent(deployId, (tx, code) => {
logger.info(
'The chaincode instantiate
transaction has been committed on peer ' +
eh._ep._endpoint.addr);
clearTimeout(handle);
eh.unregisterTxEvent(deployId);
eh.disconnect();
if (code !== 'VALID') {
logger.error('The chaincode
instantiate transaction was invalid, code = ' + code);
reject();
} else {
logger.info('The chaincode
instantiate transaction was valid.');
resolve();
}
});
});
var sendPromise = channel.sendTransaction(request);
return Promise.all([sendPromise].concat([txPromise])).
then((results) => {
logger.debug('Event promise all complete and
testing complete');
return results[0]; // Promise all队列的第一个返回值
是'sendTransaction()'的调用结果
}).catch((err) => {
logger.error(
util.format('Failed to send instantiate
transaction and get notifications within the timeout period. %s', err)
);
return 'Failed to send instantiate transaction and
get notifications within the timeout period.';
});
} else {
logger.error(
'Failed to send instantiate Proposal or receive
valid response. Response null or status is not 200. exiting...'
);
return 'Failed to send instantiate Proposal or receive
valid response. Response null or status is not 200. exiting...';
}
}, (err) => {
logger.error('Failed to send instantiate proposal due to error: '
+ err.stack ?
err.stack : err);
return 'Failed to send instantiate proposal due to error: ' +
err.stack ?
err.stack : err;
}).then((response) => {
if (response.status === 'SUCCESS') {
logger.info('Successfully sent transaction to the orderer.');
return 'Chaincode Instantiation is SUCCESS';
} else {
logger.error('Failed to order the transaction. Error code:
' + response.status);
return 'Failed to order the transaction. Error code: ' +
response.status;
}
}, (err) => {
logger.error('Failed to send instantiate due to error: ' + err.
stack ? err
.stack : err);
return 'Failed to send instantiate due to error: ' + err.stack ?
err.stack :
err;
});
};
exports.instantiateChaincode = instantiateChaincode;
(5)调用链码
调用链码和之前实例化链码步骤类似,也是需要先发出背书,包括如下步骤。
1)client实例切换到传入组织。
2)发送proposal给endorsers(args里面指定的背书节点)。
3)对目标endorsers返回的proposalResponses结果依次校验,所有endorsers都背书成功才进入下一步,有endorsers背书失败则抛出异常。
4)endorsers背书成功后,应用端将背书proposalResponses和之前的proposal打 包成request,调用sendTransaction发给orderer,这时因为orderer经过order后再通知peers进行调用链码的操 作是异步的,需要注册transaction event来监听调用链码的最终结果。
5)在sendTransaction和transaction event都成功返回的情况下,才说明调用链码成功,此时返回成功的结构对象,若transaction event监听到失败则抛出异常。
var invokeChaincode = function(peerNames, channelName, chaincodeName, fcn, args,
username, org) {
logger.debug(util.format('\n============ invoke transaction on
organization %s ============\n', org));
var client = helper.getClientForOrg(org);
var channel = helper.getChannelForOrg(org);
var targets = (peerNames) ? helper.newPeers(peerNames, org) : undefined;
var tx_id = null;
var txRequest = null;
return helper.getRegisteredUsers(username, org).then((user) => {
tx_id = client.newTransactionID();
logger.debug(util.format('Sending transaction "%j"', tx_id));
// 发送背书proposal给endorser
var request = {
chaincodeId: chaincodeName,
fcn: fcn,
args: args,
chainId: channelName,
txId: tx_id
};
if (targets)
request.targets = targets;
var txRequest = channel.sendTransactionProposal(request)
return txRequest;
}, (err) => {
logger.error('Failed to enroll user \'' + username + '\'. ' + err);
throw new Error('Failed to enroll user \'' + username + '\'. ' + err);
}).then((results) => {
var proposalResponses = results[0];
var proposal = results[1];
var all_good = true;
for (var i in proposalResponses) {
let one_good = false;
if (proposalResponses && proposalResponses[i].response &&
proposalResponses[i].response.status === 200) {
one_good = true;
logger.info('transaction proposal was good');
} else {
logger.error(proposalResponses[i]);
logger.error('transaction proposal was bad');
if (proposalResponses[i].message != null) {
return proposalResponses[i].message;
}
}
all_good = all_good & one_good;
}
if (all_good) {
logger.debug(util.format(
'Successfully sent Proposal and received
ProposalResponse: Status - %s, message - "%s", metadata - "%s", endorsement signature: %s',
proposalResponses[0].response.status,
proposalResponses[0].response.message,
proposalResponses[0].response.payload,
proposalResponses[0].endorsement
.signature));
var request = {
proposalResponses: proposalResponses,
proposal: proposal
};
// 设置一个transaction listener并且设置30秒timeout
// 如果在timeout的时限内,transaction没有被有效提交则返回错误
var transactionID = tx_id.getTransactionID();
var eventPromises = [];
if (!peerNames) {
peerNames = channel.getPeers().map(function(peer) {
return peer.getName();
});
}
var eventhubs = helper.newEventHubs(peerNames, org);
for (let key in eventhubs) {
let eh = eventhubs[key];
eh.connect();
let txPromise = new Promise((resolve, reject) => {
let handle = setTimeout(() => {
eh.disconnect();
reject();
}, 30000);
eh.registerTxEvent(transactionID, (tx, code) => {
clearTimeout(handle);
eh.unregisterTxEvent(transactionID);
eh.disconnect();
if (code !== 'VALID') {
logger.error(
'The balance transfer transaction was invalid, code = ' + code);
reject();
} else {
logger.info(
'The balance transfer transaction has been committed on peer ' +
eh._ep._endpoint.addr);
resolve();
}
});
});
eventPromises.push(txPromise);
};
var sendPromise = channel.sendTransaction(request);
return Promise.all([sendPromise].concat(eventPromises)).
then((results) => {
logger.debug(' event promise all complete and
testing complete');
return results[0]; // Promise all队列的第一个返回值
// 是'sendTransaction()'的调用结果
}).catch((err) => {
logger.error(
'Failed to send transaction and get
notifications within the timeout period.'
);
return 'Failed to send transaction and get
notifications within the timeout period.';
});
} else {
logger.error(
'Failed to send Proposal or receive valid response.
Response null or status is not 200. exiting...'
);
return 'Failed to send Proposal or receive valid response.
Response null or status is not 200. exiting...';
}
}, (err) => {
logger.error('Failed to send proposal due to error: ' + err.
stack ? err.stack :
err);
return 'Failed to send proposal due to error: ' + err.stack ?
err.stack :
err;
}).then((response) => {
if (response.status === 'SUCCESS') {
logger.info('Successfully sent transaction to the
orderer.');
return tx_id.getTransactionID();
} else {
if (response.status != null) {
logger.error('Failed to order the transaction. Error code: ' + response.status);
return 'Failed to order the transaction. Error code: ' + response.status;
}else {
return response;
}
}
}, (err) => {
logger.error('Failed to send transaction due to error: ' + err.
stack ? err .stack : err);
return 'Failed to send transaction due to error: ' + err.stack ?
err.stack :err;
});
};
exports.invokeChaincode = invokeChaincode;
12.4.2 链码功能实现
本节我们来看链码对外提供的功能接口和每个功能接口的实现过程。
1.链码接口定义
链码接口由两部分组成,即调用函数名称和调用参数。
(1)票据发布接口
票据发布的函数名称是issue,只有一个参数,是JSON结构的Bill对象:
{
"BillInfoID": "POC10000998",
"BillInfoAmt": "222",
"BillInfoType": "111",
"BillInfoIsseDate": "20170910",
"BillInfoDueDate": "20171112",
"DrwrCmID": "111",
"DrwrAcct": "111",
"AccptrCmID": "111",
"AccptrAcct": "111",
"PyeeCmID": "111",
"PyeeAcct": "111",
"HodrCmID": "ACMID",
"HodrAcct": "A公司"
}
各字段参数说明如表12-4所示。
表12-4 票据发布接口参数
(2)票据背书接口
票据背书的函数名称是endorse,有3个参数按表12-5所示顺序。
2.链码接口实现
链码初始化默认实现即可:
// chaincode Init 接口
func (a *BillChaincode) Init(stub shim.ChaincodeStubInterface) pb.Response {
return shim.Success(nil)
}
链码调用接口包含票据发布、票据背书、票据签收、票据拒收、票据查询等:
// chaincode Invoke 接口
func (a *BillChaincode) Invoke(stub shim.ChaincodeStubInterface) pb.Response {
function,args := stub.GetFunctionAndParameters()
// invoke
if function == "issue" {
return a.issue(stub, args)
} else if function == "endorse" {
return a.endorse(stub, args)
} else if function == "accept" {
return a.accept(stub, args)
} else if function == "reject" {
return a.reject(stub, args)
}
// query
if function == "queryMyBill" {
return a.queryMyBill(stub, args)
} else if function == "queryByBillNo" {
return a.queryByBillNo(stub, args)
} else if function == "queryMyWaitBill" {
return a.queryMyWaitBill(stub, args)
}
res := getRetString(1,"ChainnovaChaincode Unkown method!")
chaincodeLogger.Infof("%s",res)
return shim.Error(res)
}
链码用到的一些通用接口:
// 链码返回结构
type chaincodeRet struct {
Code int // 0 成功 1 其他
Des string // 描述
}
// 根据返回码和描述返回序列号后的字节数组
func getRetByte(code int,des string) []byte {
var r chaincodeRet
r.Code = code
r.Des = des
b,err := json.Marshal(r)
if err!=nil {
fmt.Println("marshal Ret failed")
return nil
}
return b
}
// 根据返回码和描述返回序列号后的字符串
func getRetString(code int,des string) string {
var r chaincodeRet
r.Code = code
r.Des = des
b,err := json.Marshal(r)
if err!=nil {
fmt.Println("marshal Ret failed")
return ""
}
chaincodeLogger.Infof("%s",string(b[:]))
return string(b[:])
}
// 根据票号取出票据
func (a *BillChaincode) getBill(stub shim.ChaincodeStubInterface,bill_No string)
(Bill, bool) {
var bill Bill
key := Bill_Prefix + bill_No
b,err := stub.GetState(key)
if b==nil {
return bill, false
}
err = json.Unmarshal(b,&bill)
if err!=nil {
return bill, false
}
return bill, true
}
// 保存票据
func (a *BillChaincode) putBill(stub shim.ChaincodeStubInterface, bill Bill) ([]
byte, bool) {
byte,err := json.Marshal(bill)
if err!=nil {
return nil, false
}
err = stub.PutState(Bill_Prefix + bill.BillInfoID, byte)
if err!=nil {
return nil, false
}
return byte, true
}
(1)票据发布
票据发布的实现如下:
// 票据发布
// args: 0 - {Bill Object}
func (a *BillChaincode) issue(stub shim.ChaincodeStubInterface, args []string)
pb.Response {
if len(args)!=1 {
res := getRetString(1,"ChainnovaChaincode Invoke issue args!=1")
return shim.Error(res)
}
var bill Bill
err := json.Unmarshal([]byte(args[0]), &bill)
if err!=nil {
res := getRetString(1,"ChainnovaChaincode Invoke issue unmarshal
failed")
return shim.Error(res)
}
// 根据票号查找是否票号已存在
_, existbl := a.getBill(stub, bill.BillInfoID)
if existbl {
res := getRetString(1,"ChainnovaChaincode Invoke issue failed :
the billNo has exist ")
return shim.Error(res)
}
if bill.BillInfoID == "" {
bill.BillInfoID = fmt.Sprintf("%d", time.Now().UnixNano())
}
// 更改票据信息和状态并保存票据:票据状态设为新发布
bill.State = BillInfo_State_NewPublish
// 保存票据
_, bl := a.putBill(stub, bill)
if !bl {
res := getRetString(1,"ChainnovaChaincode Invoke issue put bill
failed")
return shim.Error(res)
}
// 以持票人ID和票号构造复合key 向search表中保存 value为空即可 以便持票人批量查询
holderNameBillNoIndexKey, err := stub.CreateCompositeKey(IndexName, []
string{bill.HodrCmID, bill.BillInfoID})
if err != nil {
res := getRetString(1,"ChainnovaChaincode Invoke issue put search
table failed")
return shim.Error(res)
}
stub.PutState(holderNameBillNoIndexKey, []byte{0x00})
res := getRetByte(0,"invoke issue success")
return shim.Success(res)
}
(2)票据背书
票据背书的实现如下:
// 背书请求
// args: 0 - Bill_No ; 1 - Endorser CmId ; 2 - Endorser Acct
func (a *BillChaincode) endorse(stub shim.ChaincodeStubInterface, args []string)
pb.Response {
if len(args)<3 {
res := getRetString(1,"ChainnovaChaincode Invoke endorse args<3")
return shim.Error(res)
}
// 根据票号取得票据
bill, bl := a.getBill(stub, args[0])
if !bl {
res := getRetString(1,"ChainnovaChaincode Invoke endorse get bill
error")
return shim.Error(res)
}
if bill.HodrCmID == args[1] {
res := getRetString(1,"ChainnovaChaincode Invoke endorse failed:
Endorser should not be same with current Holder")
return shim.Error(res)
}
// 更改票据信息和状态并保存票据: 添加待背书人信息,重置已拒绝背书人,票据状态改为待背书
bill.WaitEndorserCmID = args[1]
bill.WaitEndorserAcct = args[2]
bill.RejectEndorserCmID = ""
bill.RejectEndorserAcct = ""
bill.State = BillInfo_State_EndrWaitSign
// 保存票据
_, bl = a.putBill(stub, bill)
if !bl {
res := getRetString(1,"ChainnovaChaincode Invoke endorse put bill
failed")
return shim.Error(res)
}
// 以待背书人ID和票号构造复合key 向search表中保存 value为空即可 以便待背书人批量查询
holderNameBillNoIndexKey, err := stub.CreateCompositeKey(IndexName, []
string{bill.WaitEndorserCmID, bill.BillInfoID})
if err != nil {
res := getRetString(1,"ChainnovaChaincode Invoke endorse put search
table failed")
return shim.Error(res)
}
stub.PutState(holderNameBillNoIndexKey, []byte{0x00})
res := getRetByte(0,"invoke endorse success")
return shim.Success(res)
}
(3)票据背书签收
票据背书签收的实现如下:
// 背书人接受背书
// args: 0 - Bill_No ; 1 - Endorser CmId ; 2 - Endorser Acct
func (a *BillChaincode) accept(stub shim.ChaincodeStubInterface, args []string)
pb.Response {
if len(args)<3 {
res := getRetString(1,"ChainnovaChaincode Invoke accept args<3")
return shim.Error(res)
}
// 根据票号取得票据
bill, bl := a.getBill(stub, args[0])
if !bl {
res := getRetString(1,"ChainnovaChaincode Invoke accept get bill
error")
return shim.Error(res)
}
// 维护search表: 以前手持票人ID和票号构造复合key 从search表中删除该key 以便前手持
票人无法再查到该票据
holderNameBillNoIndexKey, err := stub.CreateCompositeKey(IndexName, []
string{bill.HodrCmID, bill.BillInfoID})
if err != nil {
res := getRetString(1,"ChainnovaChaincode Invoke accept put search
table failed")
return shim.Error(res)
}
stub.DelState(holderNameBillNoIndexKey)
// 更改票据信息和状态并保存票据: 将前手持票人改为背书人,重置待背书人,票据状态改为背
书签收
bill.HodrCmID = args[1]
bill.HodrAcct = args[2]
bill.WaitEndorserCmID = ""
bill.WaitEndorserAcct = ""
bill.State = BillInfo_State_EndrSigned
// 保存票据
_, bl = a.putBill(stub, bill)
if !bl {
res := getRetString(1,"ChainnovaChaincode Invoke accept put bill
failed")
return shim.Error(res)
}
res := getRetByte(0,"invoke accept success")
return shim.Success(res)
}
(4)票据背书拒收
票据背书拒收的实现如下:
// 背书人拒绝背书
// args: 0 - Bill_No ; 1 - Endorser CmId ; 2 - Endorser Acct
func (a *BillChaincode) reject(stub shim.ChaincodeStubInterface, args []string)
pb.Response {
if len(args)<3 {
res := getRetString(1,"ChainnovaChaincode Invoke reject args<3")
return shim.Error(res)
}
// 根据票号取得票据
bill, bl := a.getBill(stub, args[0])
if !bl {
res := getRetString(1,"ChainnovaChaincode Invoke reject get bill
error")
return shim.Error(res)
}
// 维护search表: 以当前背书人ID和票号构造复合key 从search表中删除该key 以便当
前背书人无法再查到该票据
holderNameBillNoIndexKey, err := stub.CreateCompositeKey(IndexName, []
string{args[1], bill.BillInfoID})
if err != nil {
res := getRetString(1,"ChainnovaChaincode Invoke reject put search
table failed")
return shim.Error(res)
}
stub.DelState(holderNameBillNoIndexKey)
// 更改票据信息和状态并保存票据:将拒绝背书人改为当前背书人,重置待背书人,票据状态改
为背书拒绝
bill.WaitEndorserCmID = ""
bill.WaitEndorserAcct = ""
bill.RejectEndorserCmID = args[1]
bill.RejectEndorserAcct = args[2]
bill.State = BillInfo_State_EndrReject
// 保存票据
_, bl = a.putBill(stub, bill)
if !bl {
res := getRetString(1,"ChainnovaChaincode Invoke reject put bill
failed")
return shim.Error(res)
}
res := getRetByte(0,"invoke accept success")
return shim.Success(res)
}
(5)票据信息查询
获取自己持有的票据的实现如下:
// 查询我的票据:根据持票人编号 批量查询票据
// 0 - Holder CmId ;
func (a *BillChaincode) queryMyBill(stub shim.ChaincodeStubInterface, args []
string) pb.Response {
if len(args)!=1 {
res := getRetString(1,"ChainnovaChaincode queryMyBill args!=1")
return shim.Error(res)
}
// 以持票人ID从search表中批量查询所持有的票号
billsIterator, err := stub.GetStateByPartialCompositeKey(IndexName, []
string{args[0]})
if err != nil {
res := getRetString(1,"ChainnovaChaincode queryMyBill get bill list error")
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,"ChainnovaChaincode queryMyBill
SplitCompositeKey error")
return shim.Error(res)
}
// 根据票号取得票据
bill, bl := a.getBill(stub, compositeKeyParts[1])
if !bl {
res := getRetString(1,"ChainnovaChaincode queryMyBill get
bill error")
return shim.Error(res)
}
billList = append(billList, bill)
}
// 取得并返回票据数组
b, err := json.Marshal(billList)
if err != nil {
res := getRetString(1,"ChainnovaChaincode Marshal queryMyBill
billList error")
return shim.Error(res)
}
return shim.Success(b)
}
查询我的待背书票据实现如下:
// 查询我的待背书票据: 根据背书人编号 批量查询票据
// 0 - Endorser CmId ;
func (a *BillChaincode) queryMyWaitBill(stub shim.ChaincodeStubInterface, args []
string) pb.Response {
if len(args)!=1 {
res := getRetString(1,"ChainnovaChaincode queryMyWaitBill args!=1")
return shim.Error(res)
}
// 以背书人ID从search表中批量查询所持有的票号
billsIterator, err := stub.GetStateByPartialCompositeKey(IndexName, []
string{args[0]})
if err != nil {
res := getRetString(1,"ChainnovaChaincode queryMyWaitBill
GetStateByPartialCompositeKey error")
return shim.Error(res)
}
defer billsIterator.Close()
var billList = []Bill{}
for billsIterator.HasNext() {
kv, _ := billsIterator.Next()
// 从search表中批量查询与背书人有关的票号
_, compositeKeyParts, err := stub.SplitCompositeKey(kv.Key)
if err != nil {
res := getRetString(1,"ChainnovaChaincode queryMyWaitBill
SplitCompositeKey error")
return shim.Error(res)
}
// 根据票号取得票据
bill, bl := a.getBill(stub, compositeKeyParts[1])
if !bl {
res := getRetString(1,"ChainnovaChaincode queryMyWaitBill
get bill error")
return shim.Error(res)
}
// 取得状态为待背书的票据 并且待背书人是当前背书人
if bill.State == BillInfo_State_EndrWaitSign && bill.
WaitEndorserCmID == args[0] {
billList = append(billList, bill)
}
}
// 取得并返回票据数组
b, err := json.Marshal(billList)
if err != nil {
res := getRetString(1,"ChainnovaChaincode Marshal queryMyWaitBill
billList error")
return shim.Error(res)
}
return shim.Success(b)
}
根据票据号码查询票据的详细信息实现如下:
// 根据票号取得票据 以及该票据背书历史
// 0 - Bill_No ;
func (a *BillChaincode) queryByBillNo(stub shim.ChaincodeStubInterface, args []string) pb.Response {
if len(args)!=1 {
res := getRetString(1,"ChainnovaChaincode queryByBillNo args!=1")
return shim.Error(res)
}
// 取得该票据
bill, bl := a.getBill(stub, args[0])
if !bl {
res := getRetString(1,"ChainnovaChaincode queryByBillNo get bill error")
return shim.Error(res)
}
// 取得背书历史: 通过fabric api取得该票据的变更历史
resultsIterator, err := stub.GetHistoryForKey(Bill_Prefix+args[0])
if err != nil {
res := getRetString(1,"ChainnovaChaincode queryByBillNo GetHistoryForKey error")
return shim.Error(res)
}
defer resultsIterator.Close()
var history []HistoryItem
var hisBill Bill
for resultsIterator.HasNext() {
historyData, err := resultsIterator.Next()
if err != nil {
res := getRetString(1,"ChainnovaChaincode queryByBillNo
resultsIterator.Next() error")
return shim.Error(res)
}
var hisItem HistoryItem
hisItem.TxId = historyData.TxId //copy transaction id over
json.Unmarshal(historyData.Value, &hisBill)
// un stringify it aka JSON.parse()
if historyData.Value == nil { //bill has been deleted
var emptyBill Bill
hisItem.Bill = emptyBill //copy nil marble
} else {
json.Unmarshal(historyData.Value, &hisBill)
// un stringify it aka JSON.parse()
hisItem.Bill = hisBill //copy bill over
}
history = append(history, hisItem) //add this tx to the list
}
// 将背书历史作为票据的一个属性 一同返回
bill.History = history
b, err := json.Marshal(bill)
if err != nil {
res := getRetString(1,"ChainnovaChaincode Marshal queryByBillNo
billList error")
return shim.Error(res)
}
return shim.Success(b)
}
来源:我是码农,转载请保留出处和链接!
本文链接:http://www.54manong.com/?id=1044
微信号:qq444848023 QQ号:444848023
加入【我是码农】QQ群:864689844(加群验证:我是码农)
全站首页 | 数据结构 | 区块链| 大数据 | 机器学习 | 物联网和云计算 | 面试笔试
var cnzz_protocol = (("https:" == document.location.protocol) ? "https://" : "http://");document.write(unescape("%3Cspan id='cnzz_stat_icon_1276413723'%3E%3C/span%3E%3Cscript src='" + cnzz_protocol + "s23.cnzz.com/z_stat.php%3Fid%3D1276413723%26show%3Dpic1' type='text/javascript'%3E%3C/script%3E"));本站资源大部分来自互联网,版权归原作者所有!
评论专区