Hyperledger Fabric 2.x之后逐步减少Java SDK API的使用频率,并希望大家的客户端开发集中使用Gateway来完成。本篇博客将从具体实现的角度带大家串一遍使用Gateway进行链码调用的流程。如果大家只是想直接开发的话,其实不用在意每个接口是如何实现的,直接查API文档看接口即可,我这篇里面结合了一些具体实现去讲解,有兴趣的可以看看。
1. 场景理解
Fabric提供两类客户端来与Fabric网络进行交互。一类是CLI,即命令行接口,我们在黑乎乎的窗口里敲类似于“peer [flag] ”这样的命令,以达到我们的目的;另一类则是基于API开发的客户端程序,以对Java语言的支持为例,Fabric早些时候提供了一整套Java SDK以供调用,通过Java SDK我们可以实现身份标识注册、链码部署、链码调用等多种功能,在1.4版本之后,Fabric推出了Java Gateway,以实现更清爽的编程风格,Gateway实际上相当于是对Java SDK接口的封装,其在功能上只支持链码执行、交易提交等操作,而不支持通道创建、身份标识注册等复杂功能。所以一套功能完善的客户端,往往会同时使用Gateway与SDK。
当然,上面讲的和本文没关系。我们今天的主要目标,是如何使用Gateway连接网络、调用链码、提交交易,以及弄清楚中间用到的类与方法之间的关系。在这之前,我们先要了解使用Gateway搭建客户端我们需要准备哪些材料。
首先,要想提交交易,我们需要以一个合法的身份标识(Identities) 去连接到Fabric网络。大家在创建Identies时,会为节点与账户分别生成密钥证书材料,这里的账户就是我们在客户端中需要的身份标识。与咱们熟悉的基于账号和密码的登录方式不同,Fabric中以私钥与证书(包含被信任实体签名后的公钥)标识一个身份Identity。因此,我们需要提供一个被当前通道所信任账户的注册证书与公钥。
其次,与peer节点进行通信,需要指定peer的endpoint(ip address : port),Fabric通信过程通常启用TLS协议以确保安全,因此我们还需要提供上述账户的TLS证书。
最后,还需指定要调用的链码所在的通道,链码名称、调用的合约方法名称,以及要给方法传递的参数,这个自不必说。
材料配置齐全,我们就可以开始了。
2. 网关配置
2.1 创建 gRPC channel
gRPC里的类与方法其实不属于Gateway API。但是我们知道,Fabric网络中实体之间通信使用的就是gRPC。在Gateway API中,创建一个Gateway对象必须需要提供一个
gRPC channel
对象(gRPC channel这个类,与Fabric中的channel概念不同,可以理解为gRPC的一个连接,),并且同一个gRPC channel可以被用来创建多个Gateway对象。gRPC channel 在创建时,需要指定
目标端点的IP地址
与
端口号
,以及用于TLS协议的
TLS证书
,创建方法如下所示:
privatestaticManagedChannelnewGrpcConnection()throwsIOException,CertificateException{//读取TLS证书var tlsCertReader =Files.newBufferedReader(tlsCertPath);var tlsCert =Identities.readX509Certificate(tlsCertReader);//返回gRPC channel实例returnNettyChannelBuilder.forTarget(peerEndpoint).sslContext(GrpcSslContexts.forClient().trustManager(tlsCert).build()).overrideAuthority(overrideAuth).build();}
上面用到的类
ManagedChannel
,其实就是 gRPC Channel ,只是在其基础上做了封装,使用时需要注意。
2.2 Gateway配置串讲
Gateway支持我们以一个特定的身份与Fabric网络进行通信。在具体实现中,Gateway其实是一个接口,其被GatewayImpl类实现,
//截取自Gateway源码finalclassGatewayImplimplementsGateway{privatefinalGatewayClient client;privatefinalSigningIdentity signingIdentity;privateGatewayImpl(Builder builder){this.signingIdentity =newSigningIdentity(builder.identity, builder.hash, builder.signer);this.client =newGatewayClient(builder.grpcChannel, builder.optionsBuilder.build());}//... ...此处省略}
我们可以通过Gateway接口的静态方法newInstance()返回GatewayImpl类的内置类
Builder
的实例。
//截取自Gateway源码publicinterfaceGatewayextendsAutoCloseable{staticBuildernewInstance(){returnnewGatewayImpl.Builder();//Builder()是GatewayImpl类的内置类`Builder`的无参构造方法}//... ...此处省略}//这就是Builder构造函数的内容,即上面调用的那个GatewayImpl.Builder()publicBuilder(){this.signer =UNDEFINED_SIGNER;this.hash =Hash::sha256;this.optionsBuilder =DefaultCallOptions.newBuiler();}
Gateway的实现使用了Builder模式,Gateway接口的内部接口Gateway.Builder被GatewayImpl类的内部类Builder实现,支持我们分步对GatewayImpl对象添加所需要的属性。我们接下来围绕要为Gateway添加的属性,以及对应的Gateway.Builder接口中的方法展开介绍。
2.2.1 设置Identity
用到的方法原型如下:
Gateway.Builderidentity(Identity identity)
设置Identity其实就是向Gateway中导入账户的身份证书。传参类型 Identity 也是一个接口,该接口在Gateway实现中被X509Identity这个类所实现。我们需要构造并传进去的,也是这个X509Identity。这个类的构造方法比较简单,需要指定账户所属组织的mspID,以及X509Certificate。搞过这方面的应该会熟悉,X509Certificate这个类并不是Gateway实现的类,而是java.security.cert包里的类。创建一整个X509Identity类实例的方法可以参考下面:
privatestaticIdentitynewIdentity()throwsIOException,CertificatException{// certPath为证书路径var certReader =Files.newBufferedReader(certPath);// 创建 X509Certificatevar certificate =Identities.readX509Certificate(certReader);returnnewX509Identity(mspID, certificate);}
这里创建X509Certificate用到的,是Identities类中的方法,注意区分 Identity接口 和 Identities类。Identities类并没有实现 Identity接口,也没有自己的属性,而是实现了一些方便进行证书、密钥读写的静态方法。
2.2.2 设置Signer
用到的方法原型如下:
Gateway.Buildersigner(Signer signer)
设置Signer其实就是向Gateway中导入账户与证书相匹配的私钥。 只要熟悉了上面 Identity 的套路,Signer以及之后的其他设定都是类似的。传参类型中的Signer是一个接口,它只声明了一个方法,byte sign(byte[] digest)即对消息摘要进行签名。这个接口被ECPrivateKeySigner 类实现,整个Gateway实现中,实际负责签名的,便是这个ECPrivateKeySigner 类的实例。
//截取自Gateway源码finalclassECPrivateKeySignerimplementsSigner{privatestaticfinalProviderPROVIDER=newBouncyCastleProvider();privatestaticfinalStringALGORITHM_NAME="NONEwithECDSA";privatefinalECPrivateKey privateKey;privatefinalBigInteger curveN;privatefinalBigInteger halfCurveN;ECPrivateKeySigner(ECPrivateKey privateKey){this.privateKey = privateKey;this.curveN = privateKey.getParams().getOrder();this.halfCurveN =this.curveN.divide(BigInteger.valueOf(2L));}publicbyte[]sign(byte[] digest)throwsGeneralSecurityException{byte[] rawSignature =this.generateSignature(digest);ECSignature signature =ECSignature.fromBytes(rawSignature);
signature =this.preventMalleability(signature);return signature.getBytes();}// ... ...此处省略 generateSignature、preventMalleability的具体实现}
那么怎么创建这个类呢?我们得借助另一个类,讨厌的命名又来了,这个类叫做Signers,和上面的Identities类相似,Signers没有自己的属性,而是给出了静态方法newPrivateKeySigner(PrivateKey privateKey)以供我们创建ECPrivateKeySigner 类。创建方法如下所示:
privatestaticSignernewSigner()throwsException{// keyPath是私钥路径var keyReader =Files.newBufferedReader(keyPath);// 和2.2.1一样,使用Identies类中的方法读取私钥,并生成PrivateKey类型的私钥对象var privateKey =Identities.readPrivateKey(keyReader);// 构造ECPrivateKeySigner实例returnSigners.newPrivateKeySigner(privateKey);}
2.2.3 设置gRPC Channel
还记得2.1中我们创建的 gRPC Channel 吗?这里导进来就行,函数原型如下:
Gateway.Builderconnection(Channel grpcChannel)
2.2.4 设置gRPC任务超时时间
这一步是可选的,目的是为了设置各项任务的超时时间。这其中常用方法的函数原型如下:
defaultGateway.BuilderevaluateOptions(CallOption... options);defaultGateway.BuilderendorseOptions(CallOption... options);defaultGateway.BuildersubmitOptions(CallOption... options);defaultGateway.BuildercommitStatusOptions(CallOption... options);
他们各自的作用在后面 3. 链码的执行与提交 部分会提到,我们先只需要知道他们各自设置了一项任务的超时时间。
这里我们先看看传入参数类型 CallOption 类,这个也是Gateway实现中独有的类,目的是设置gRPC运行时的行为,目前主要用来设置超时。我们可以通过 CallOption 类中的
public static CallOption deadlineAfter(long duration, TimeUnit unit)
方法来构建一个超时设置对象,并通过上面接口中声明的超时方法,将超时设置对象与特定的任务绑定到一起。例如,指定背书超时时间为5分钟可以这么写:
// builder是通过Gateway创建的一个GatewayImpl实例
builder.endorseOptions(CallOption.deadlineAfter(5,TimeUnit.SECONDS))
现在我们已经完成了对Gateway属性的设置。
**其实从源码来看,我们在调用connect()方法之前,从始至终操作的就是同一个
Builder
对象,调用上面的设置方法,实际上是调用中GatewayImpl中
Builder内置类
的方法,以完成对该Builder对象中各个属性的设置。** 可以拿connection方法举个例子:
// Builder为GatewayImpl的内置类publicstaticfinalclassBuilderimplementsGateway.Builder{privatestaticfinalSignerUNDEFINED_SIGNER=(digest)->{thrownewUnsupportedOperationException("No signing implementation supplied");};privateChannel grpcChannel;privateIdentity identity;privateSigner signer;privateFunction<byte[],byte[]> hash;privatefinalDefaultCallOptions.Builder optionsBuilder;publicBuilder(){this.signer =UNDEFINED_SIGNER;this.hash =Hash::sha256;this.optionsBuilder =DefaultCallOptions.newBuiler();}//注意看这里,其实设置的是Builder类中的属性,并没有对外面的GatewayImpl对象的静态属性产生影响publicBuilderconnection(Channel grpcChannel){Objects.requireNonNull(grpcChannel,"connection");this.grpcChannel = grpcChannel;returnthis;}}
每一次设置方法的调用,对于GatewayImpl对象的静态属性都没有造成实质更改(**
其实GatewayImpl没有什么静态属性哈哈哈
**)。当所有的设置配置完毕,可以通过调用
connect
方法应用上面的配置。我们看一下Builder类中 connect方法的实现:
publicGatewayImplconnect(){returnnewGatewayImpl(this);}
connect()方法其实就是调用GatewayImpl类的有参构造方法,将Builder对象传入,以将其中的属性,配置到新创建的GatewayImpl对象中,并将这个对象返回。
2.3 创建Gateway
经过上面的讲解,这里直接给出一个创建Gateway的示例:
//创建 gRPC channelprivatestaticManagedChannelnewGrpcConnection(String tlsCertPath)throwsIOException,CertificateException{var tlsCertReader =Files.newBufferedReader(tlsCertPath);var tlsCert =Identities.readX509Certificate(tlsCertReader);returnNettyChannelBuilder.forTarget(peerEndpoint).sslContext(GrpcSslContexts.forClient().trustManager(tlsCert).build()).overrideAuthority(overrideAuth).build();}// 创建X509Identity证书privatestaticIdentitynewIdentity(String certPath)throwsIOException,CertificateException{var certReader =Files.newBufferedReader(certPath);var certificate =Identities.readX509Certificate(certReader);returnnewX509Identity(mspID, certificate);}// 创建ECPrivateKeySigner对象privatestaticSignernewSigner(String keyPath)throwsException{var keyReader =Files.newBufferedReader(keyPath);var privateKey =Identities.readPrivateKey(keyReader);returnSigners.newPrivateKeySigner(privateKey);}// 创建网关publicGatewaygateway()throwsException{ManagedChannel channel =newGrpcConnection();Gateway.Builder builder =Gateway.newInstance().identity(newIdentity()).signer(newSigner()).connection(channel).evaluateOptions(options -> options.withDeadlineAfter(5,TimeUnit.SECONDS)).endorseOptions(options -> options.withDeadlineAfter(15,TimeUnit.SECONDS)).submitOptions(options -> options.withDeadlineAfter(5,TimeUnit.SECONDS)).commitStatusOptions(options -> options.withDeadlineAfter(1,TimeUnit.MINUTES));return builder.connect();}
3. 链码执行与提交
3.1 GatewayImpl类的属性
为了将本文结合具体实现讲解开发方法的风格贯彻到底,在讲链码如何执行与提交之前,有必要对网关的具体实现类GatewayImpl再往下挖一步。首先我们回顾一下GatewayImpl的构造函数,就是
Gateway.Builder.connet(Builder builder)
方法里调用的那个。
//截取自Gateway源码finalclassGatewayImplimplementsGateway{privatefinalGatewayClient client;privatefinalSigningIdentity signingIdentity;privateGatewayImpl(Builder builder){this.signingIdentity =newSigningIdentity(builder.identity, builder.hash, builder.signer);this.client =newGatewayClient(builder.grpcChannel, builder.optionsBuilder.build());}//... ...此处省略}
看到了吗,构造时,Builder对象中的identity(存储证书,
X509Identity类型
)、signer(存储私钥,
ECPrivateKeySigner类型
),会被用于创建
SigningIdentity类型
的成员变量signingIdentity;而
Grpc Channel
和
optionBuilder
(包含了我们上面绑定的每个超时对象以及对应的任务)会被用于创建
GatewayClient类型
的成员client。这两种类型也是Gateway实现的一部分,他们只是原封不动的把上述参数包含到了自己的属性当中,并且将功能的实现职责转移到了自己身上,其实本质上还是调用其对应类的方法实现的。讲起来绕,举个例子,看一下
SigningIdentity类型
的实现源码(看下里面注释)。
//截取自Gateway源码finalclassSigningIdentity{privatefinalIdentity identity;privatefinalFunction<byte[],byte[]> hash;privatefinalSigner signer;privatefinalbyte[] creator;SigningIdentity(Identity identity,Function<byte[],byte[]> hash,Signer signer){this.identity = identity;// X509Identity类型this.hash = hash;//这个Hash指定了使用的hash算法,在Builder构造函数时被指定为sha256this.signer = signer;// ECPrivateKeySigner类型this.creator =GatewayUtils.serializeIdentity(identity);}publicbyte[]sign(byte[] digest){try{returnthis.signer.sign(digest);//本质还是调用ECPrivateKeySigner对象的sign方法实现签名}catch(GeneralSecurityException var3){thrownewRuntimeException(var3);}}}
这两种类在之后各种类中,会被反复包含封装。我认为这才是Gateway真正重要的数据结构,即 **
身份
** + **
gRPC连接
**。
3.2 获取Network与Contract
在之前的步骤里,我们配置完成了Gateway,实现了客户端与Fabric网络连接方式的设置。然而想要调用链码,还需要指明需要连接到的通道名称,以及链码的名称、参数等。
在Gateway实现中,由
Network接口
指代一个通道中所有Fabric节点的集合,而
Contrac接口
指代一个特定的智能合约。通过
Gateway接口
中的
getNetwork(String networkName)
指定通道名称并返回
Network
类型对象(实际类型为
NetworkImpl
)。再通过Network接口中的
getConract(String chaincodeName)
方法指定链码名称并返回
Contract
类型对象(实际类型为
ContractImpl
),借助Contract可以实现对链码的调用与交易的提交。
//gateway是之前生成的Gateway对象Network network = gateway.getNetwork(channelName);Conract contract = gateway.getContract(chaincodeName);
3.3 执行交易&提交交易
在Fabric 的 peer CLI工具中,peer chaincode 常用的
f
l
a
g
flag
flag 有两个,一个是
query
,另一个是
invoke
。区别在于
query
只是预执行链码,并返回执行结果,这相当于向
p
e
e
r
peer
peer 查询当前账本内容(即所谓的
w
o
r
l
d
world
world
s
t
a
t
e
state
state),而并不会真正的提交交易,链码的执行结果不会真正存储到账本中,因此也不需要做背书;而
invoke
则是实实在在需要提交一个新的交易,背书、排序共识、VSCC验证、账本提交等过程一个都不能少。
在Fabric 的Java Gateway中也是类似。query在Gateway中叫做
evaluate
(执行),invoke在Gateway中叫
submit
(提交)。
我们可以通过
Contract接口
中提供的
evaluateTransaction
和
submitTransaction
简单的实现上面两个过程。假设我们要调用mychannel上名为bcerts的链码中的test方法,并传入参数arg1,arg2,则调用示例如下:
Network network = gateway.getNetwork(mychannel);Conract contract = gateway.getContract(bcerts);// 执行交易(第一个参数为方法名,后面的参数是方法的参数列表,可以是String类型也可以是byte数组类型// 但是要注意所有参数的类型要一致)byte[] res_eval = contract.evaluateTransaction("test", arg1, arg2);// 提交交易byte[] res_subm = contract.submitTransaction("test", arg1, arg2);
但这只是简略的写法,执行交易和提交交易都是由一些子过程组成的,我们可以通过手动调用每个子过程,来实现更贴合我们需求的定制功能。在 3.4 和 3.5 中将会分别拆解上面两个过程。
3.4 执行交易拆解
首先简单复习一下Fabric的交易提交流程。客户端会先构造一个Propoasl,发送给背书节点(指定的peer集合)做背书,背书(
e
n
d
o
r
s
e
endorse
endorse)相当于是把proposal里指定的链码执行一下,把结果(读写集)和对结果的签名打包,形成背书(
e
n
d
o
r
s
e
m
e
n
t
endorsement
endorsement),发还给客户端。客户端收集到足够背书后,将所有背书与proposal组装成真正的Transaction(交易),发送给orderer进行排序。排序组织对交易进行排序,打包成块发送给peer,peer验证块中交易是否有效,在检查过后将背书里的读写集更新到账本中,并将块链接到区块链上。
在Gateway中,也是一样。执行交易(
e
v
a
l
u
a
t
e
evaluate
evaluate)只需要客户端构造一个Proposal,然后交由peer执行,返回结果即可。因此上面的
evaluateTransaction
调用等价于:
contract.newProposal("test")//返回ProposalBuilder类型.addArguments(arg1, arg2)//返回ProposalBuilder类型.build()//返回Proposal类型.evaluate();//返回byte[]类型
这意味着,我们实际上是先构造了一个
ProposalBuilder
对象(注意这里的ProposalBuilder以及后面的诸多类型都为接口,我们不再细究实现接口的实际类型),通过这个对象指定调用链码名称和参数,然后再生成真正的
Proposal
对象,通过
Proposal
的
evaluate()
方法调用链码并返回链码在peer上的执行结果。
3.5 提交交易拆解
同理。上面调用的
submitTransaction
方法同样可以拆分为以下几步。
contract.newProposal("test").addArguments(arg1, arg2).build()//返回Proposal类型.endorse()//返回Transaction类型.submit();//返回交易提交结果byte[]类型
可以看到,Proposal被构造后,通过调用
endorse
方法向peer节点请求背书,并将提案与背书一并打包后形成
Transaction类型
的对象,我们可以通过
Transaction接口
中的
submit
方法提交交易,并接收结果。之前第二节里的超时设置,在这里对应上了吧?因为
endorse
,
submit
,
evaluate
等方法都是同步的,线程会在这里陷入阻塞,通过设置合理的超时时间,以停止等待,抛出异常。
但是,之前设置的还有一个
commit
任务的超时时间,这是什么呢?还有,有无异步的交易提交方法呢?下一节讲。
3.6 异步提交交易
在 3.5 中我们得知,提案
Proposal
在调用
endorse
方法进行背书后,会被打包为交易
Transaction
类型,接下来将通过peer节点转发给orderer节点,进行排序共识,打包成块后发送给peer节点,以供验证(validate)和提交(commit),这里的提交分为两步,① 将块放到区块链上(无论交易是否有效,都会上链,无效交易会被打上无效标识)② 将背书中的读写集,存储到账本中(即数据库,默认为level-DB,可选用couch-DB)。排序和commit都是需要时间的,在某些场景下我们希望交易在发送到排序节点之后,立刻结束线程的阻塞,之后异步的获取commit的结果。
Transaction接口
提供了
submitAsync
方法来实现交易的异步提交。调用方法与submit方法相同,区别在于客户端与peer取得联系后,由peer节点将交易转发给orderer节点,之后立刻返回给客户端结果,返回的是一个
SubmittedTransaction类型
(接口)的对象。
SubmittedTransaction
接口继承了
Commit接口
,我们可以通过其提供的以下几种方法来进行后续操作:
①
byte[] getResult()
获取交易结果,这个就类似于evaluate返回的内容。背书实际上就是peer节点对链码的预执行,由于这个方法返回的内容源自于背书,因此调用该方法无需等待交易Commit结束,而是可以直接获知的。这也是异步的意义之一,我们不必等到交易commit结束才能获知执行结果。
②
Status getStatus()
获取交易提交状态。该方法是阻塞方法,只有等到交易所在区块在peer上commit结束后,才能返回
Status类型
的结果。这个Status是Gateway提供的另一种接口,其提供的方法如下图所示:
几种方法的用法在上面的Description字段中都有。我们较常的就是isSuccessful(),它可以告知我们交易是否commit成功。
下面将展示一个案例,在异步提交交易后,先获知结果,再检查交易是否提交成功:
SubmittedTransaction st = contract.newProposal("test").addArguments(arg1, arg2).build()//返回Proposal类型.endorse()//返回Transaction类型.submitAsync();//返回SubmittedTransaction类型 //获取交易结果byte[] result = st.getResult();//获取交易状态对象【阻塞】Status status = st.getStatus();//获知交易是否提交成功if(status.isSuccessful()){System.out.println("交易提交成功!交易ID为"+ status.getTransactionID()+ \
",交易所在区块号为:"+ status.getBlockNumber());}
4. 问题分享
4.1 超时理解再谈
我们之前设置的超时,其实都是远程过程调用的超时时间。远程过程调用的思想是使调用远程服务就像调用本地服务一样自然,但该方法的实际执行过程却是在peer节点上完成的,(例如Commit),当我们调用该方法超时后(实际上相当于远程Peer节点上的方法执行超时),那么这次本地调用将会抛出gRPC超时异常,以便程序意识到问题。
4.2 Gateway Client 请求背书原理(为什么整个过程没有指定过背书节点?)
最后,再补充一个小问题。
有过使用CLI调用过链码的道友可能有经验,在使用peer chaincode invoke命令提交交易时,我们需要使用–peerAddresses指定背书节点,并在后面用–TLSRootCertFile指定与该背书节点通信所用的TLS根证书,当我们需要多个节点进行背书时时,CLI需要在命令中逐个指定。但是在用Gateway API开发客户端的时候,有没有发现除了指定要和哪个peer节点通信外,我们没有指定过任何和背书有关的信息?
既然如此,为什么我们的客户端也能成功背书?这项功能得益于服务发现(Service Discovery)。
客户端要想完成与Fabric网络交互的一系列任务,那么就有必要获知网络的拓扑以及相关通道的各种策略(例如背书策略)。早期这些是由程序员静态配置在客户端本地的,但是网络拓扑易变,一个成熟的客户端应用不可能每次连接都手动修改客户端的配置。因此开发者们搞出了服务发现这个东西。有关服务发现的具体理解与配置方法我后面会写一篇博客专门介绍,这里只是给大家讲一下Gateway Client提交交易如何借助服务发现来完成背书。
服务发现属于peer节点功能的一部分。可以通过服务发现获知网络拓扑、背书政策以及在线节点等内容。我们的Gateway客户端通过Peer节点调用服务发现来获取目标链码的背书策略,并且获知目前在线的可以满足背书政策的背书节点集合(包括IP地址和端口),Gateway默认选取满足背书政策且节点数量最少的一组节点发送背书请求,如果每个集合节点数量一样将随机选取。
因此如果背书失败,请检查节点状态以及服务发现是否启动。
5. 文档参考
在下水平有限,如有不当之处,烦请不吝赐教。
[1] Fabirc 服务发现官方文档
[2] Hyperledger Fabric Java Gateway API文档
[3] Hyperledger Fabric Samples示例程序
版权归原作者 旖风刈草 所有, 如有侵权,请联系我们删除。