Hyperledger Fabric 2.x之后逐步减少Java SDK API的使用频率,并希望大家的客户端开发集中使用Gateway来完成。本篇博客将从具体实现的角度带大家串一遍使用Gateway进行链码调用的流程。如果大家只是想直接开发的话,其实不用在意每个接口是如何实现的,直接查API文档看接口即可,我这篇里面结合了一些具体实现去讲解,有兴趣的可以看看。
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证书。
最后,还需指定要调用的链码所在的通道,链码名称、调用的合约方法名称,以及要给方法传递的参数,这个自不必说。
材料配置齐全,我们就可以开始了。
gRPC里的类与方法其实不属于Gateway API。但是我们知道,Fabric网络中实体之间通信使用的就是gRPC。在Gateway API中,创建一个Gateway对象必须需要提供一个gRPC channel
对象(gRPC channel这个类,与Fabric中的channel概念不同,可以理解为gRPC的一个连接,),并且同一个gRPC channel可以被用来创建多个Gateway对象。gRPC channel 在创建时,需要指定目标端点的IP地址
与端口号
,以及用于TLS协议的TLS证书
,创建方法如下所示:
private static ManagedChannel newGrpcConnection() throws IOException, CertificateException {
//读取TLS证书
var tlsCertReader = Files.newBufferedReader(tlsCertPath);
var tlsCert = Identities.readX509Certificate(tlsCertReader);
//返回gRPC channel实例
return NettyChannelBuilder.forTarget(peerEndpoint)
.sslContext(GrpcSslContexts.forClient().trustManager(tlsCert).build())
.overrideAuthority(overrideAuth)
.build();
}
上面用到的类ManagedChannel
,其实就是 gRPC Channel ,只是在其基础上做了封装,使用时需要注意。
Gateway支持我们以一个特定的身份与Fabric网络进行通信。在具体实现中,Gateway其实是一个接口,其被GatewayImpl类实现,
//截取自Gateway源码
final class GatewayImpl implements Gateway {
private final GatewayClient client;
private final SigningIdentity signingIdentity;
private GatewayImpl(Builder builder) {
this.signingIdentity = new SigningIdentity(builder.identity, builder.hash, builder.signer);
this.client = new GatewayClient(builder.grpcChannel, builder.optionsBuilder.build());
}
//... ...此处省略
}
我们可以通过Gateway接口的静态方法newInstance()返回GatewayImpl类的内置类Builder
的实例。
//截取自Gateway源码
public interface Gateway extends AutoCloseable {
static Builder newInstance() {
return new GatewayImpl.Builder();//Builder()是GatewayImpl类的内置类`Builder`的无参构造方法
}
//... ...此处省略
}
//这就是Builder构造函数的内容,即上面调用的那个GatewayImpl.Builder()
public Builder() {
this.signer = UNDEFINED_SIGNER;
this.hash = Hash::sha256;
this.optionsBuilder = DefaultCallOptions.newBuiler();
}
Gateway的实现使用了Builder模式,Gateway接口的内部接口Gateway.Builder被GatewayImpl类的内部类Builder实现,支持我们分步对GatewayImpl对象添加所需要的属性。我们接下来围绕要为Gateway添加的属性,以及对应的Gateway.Builder接口中的方法展开介绍。
用到的方法原型如下:
Gateway.Builder identity(Identity identity)
设置Identity其实就是向Gateway中导入账户的身份证书。传参类型 Identity 也是一个接口,该接口在Gateway实现中被X509Identity这个类所实现。我们需要构造并传进去的,也是这个X509Identity。这个类的构造方法比较简单,需要指定账户所属组织的mspID,以及X509Certificate。搞过这方面的应该会熟悉,X509Certificate这个类并不是Gateway实现的类,而是java.security.cert包里的类。创建一整个X509Identity类实例的方法可以参考下面:
private static Identity newIdentity() throws IOException, CertificatException {
// certPath为证书路径
var certReader = Files.newBufferedReader(certPath);
// 创建 X509Certificate
var certificate = Identities.readX509Certificate(certReader);
return new X509Identity(mspID, certificate);
}
这里创建X509Certificate用到的,是Identities类中的方法,注意区分 Identity接口 和 Identities类。Identities类并没有实现 Identity接口,也没有自己的属性,而是实现了一些方便进行证书、密钥读写的静态方法。
用到的方法原型如下:
Gateway.Builder signer(Signer signer)
设置Signer其实就是向Gateway中导入账户与证书相匹配的私钥。 只要熟悉了上面 Identity 的套路,Signer以及之后的其他设定都是类似的。传参类型中的Signer是一个接口,它只声明了一个方法,byte sign(byte[] digest)即对消息摘要进行签名。这个接口被ECPrivateKeySigner 类实现,整个Gateway实现中,实际负责签名的,便是这个ECPrivateKeySigner 类的实例。
//截取自Gateway源码
final class ECPrivateKeySigner implements Signer {
private static final Provider PROVIDER = new BouncyCastleProvider();
private static final String ALGORITHM_NAME = "NONEwithECDSA";
private final ECPrivateKey privateKey;
private final BigInteger curveN;
private final BigInteger halfCurveN;
ECPrivateKeySigner(ECPrivateKey privateKey) {
this.privateKey = privateKey;
this.curveN = privateKey.getParams().getOrder();
this.halfCurveN = this.curveN.divide(BigInteger.valueOf(2L));
}
public byte[] sign(byte[] digest) throws GeneralSecurityException {
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 类。创建方法如下所示:
private static Signer newSigner() throws Exception {
// keyPath是私钥路径
var keyReader = Files.newBufferedReader(keyPath);
// 和2.2.1一样,使用Identies类中的方法读取私钥,并生成PrivateKey类型的私钥对象
var privateKey = Identities.readPrivateKey(keyReader);
// 构造ECPrivateKeySigner实例
return Signers.newPrivateKeySigner(privateKey);
}
还记得2.1中我们创建的 gRPC Channel 吗?这里导进来就行,函数原型如下:
Gateway.Builder connection(Channel grpcChannel)
这一步是可选的,目的是为了设置各项任务的超时时间。这其中常用方法的函数原型如下:
default Gateway.Builder evaluateOptions(CallOption... options);
default Gateway.Builder endorseOptions(CallOption... options);
default Gateway.Builder submitOptions(CallOption... options);
default Gateway.Builder commitStatusOptions(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的内置类
public static final class Builder implements Gateway.Builder {
private static final Signer UNDEFINED_SIGNER = (digest) -> {
throw new UnsupportedOperationException("No signing implementation supplied");
};
private Channel grpcChannel;
private Identity identity;
private Signer signer;
private Function<byte[], byte[]> hash;
private final DefaultCallOptions.Builder optionsBuilder;
public Builder() {
this.signer = UNDEFINED_SIGNER;
this.hash = Hash::sha256;
this.optionsBuilder = DefaultCallOptions.newBuiler();
}
//注意看这里,其实设置的是Builder类中的属性,并没有对外面的GatewayImpl对象的静态属性产生影响
public Builder connection(Channel grpcChannel) {
Objects.requireNonNull(grpcChannel, "connection");
this.grpcChannel = grpcChannel;
return this;
}
}
每一次设置方法的调用,对于GatewayImpl对象的静态属性都没有造成实质更改(其实GatewayImpl没有什么静态属性哈哈哈
)。当所有的设置配置完毕,可以通过调用connect
方法应用上面的配置。我们看一下Builder类中 connect方法的实现:
public GatewayImpl connect() {
return new GatewayImpl(this);
}
connect()方法其实就是调用GatewayImpl类的有参构造方法,将Builder对象传入,以将其中的属性,配置到新创建的GatewayImpl对象中,并将这个对象返回。
经过上面的讲解,这里直接给出一个创建Gateway的示例:
//创建 gRPC channel
private static ManagedChannel newGrpcConnection(String tlsCertPath) throws IOException, CertificateException {
var tlsCertReader = Files.newBufferedReader(tlsCertPath);
var tlsCert = Identities.readX509Certificate(tlsCertReader);
return NettyChannelBuilder.forTarget(peerEndpoint)
.sslContext(GrpcSslContexts.forClient().trustManager(tlsCert).build()).overrideAuthority(overrideAuth)
.build();
}
// 创建X509Identity证书
private static Identity newIdentity(String certPath) throws IOException, CertificateException {
var certReader = Files.newBufferedReader(certPath);
var certificate = Identities.readX509Certificate(certReader);
return new X509Identity(mspID, certificate);
}
// 创建ECPrivateKeySigner对象
private static Signer newSigner(String keyPath) throws Exception {
var keyReader = Files.newBufferedReader(keyPath);
var privateKey = Identities.readPrivateKey(keyReader);
return Signers.newPrivateKeySigner(privateKey);
}
// 创建网关
public Gateway gateway() throws Exception {
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();
}
为了将本文结合具体实现讲解开发方法的风格贯彻到底,在讲链码如何执行与提交之前,有必要对网关的具体实现类GatewayImpl再往下挖一步。首先我们回顾一下GatewayImpl的构造函数,就是Gateway.Builder.connet(Builder builder)
方法里调用的那个。
//截取自Gateway源码
final class GatewayImpl implements Gateway {
private final GatewayClient client;
private final SigningIdentity signingIdentity;
private GatewayImpl(Builder builder) {
this.signingIdentity = new SigningIdentity(builder.identity, builder.hash, builder.signer);
this.client = new GatewayClient(builder.grpcChannel, builder.optionsBuilder.build());
}
//... ...此处省略
}
看到了吗,构造时,Builder对象中的identity(存储证书,X509Identity类型
)、signer(存储私钥,ECPrivateKeySigner类型
),会被用于创建SigningIdentity类型
的成员变量signingIdentity;而Grpc Channel
和optionBuilder
(包含了我们上面绑定的每个超时对象以及对应的任务)会被用于创建GatewayClient类型
的成员client。这两种类型也是Gateway实现的一部分,他们只是原封不动的把上述参数包含到了自己的属性当中,并且将功能的实现职责转移到了自己身上,其实本质上还是调用其对应类的方法实现的。讲起来绕,举个例子,看一下SigningIdentity类型
的实现源码(看下里面注释)。
//截取自Gateway源码
final class SigningIdentity {
private final Identity identity;
private final Function<byte[], byte[]> hash;
private final Signer signer;
private final byte[] creator;
SigningIdentity(Identity identity, Function<byte[], byte[]> hash, Signer signer) {
this.identity = identity; // X509Identity类型
this.hash = hash; //这个Hash指定了使用的hash算法,在Builder构造函数时被指定为sha256
this.signer = signer; // ECPrivateKeySigner类型
this.creator = GatewayUtils.serializeIdentity(identity);
}
public byte[] sign(byte[] digest) {
try {
return this.signer.sign(digest);//本质还是调用ECPrivateKeySigner对象的sign方法实现签名
} catch (GeneralSecurityException var3) {
throw new RuntimeException(var3);
}
}
}
这两种类在之后各种类中,会被反复包含封装。我认为这才是Gateway真正重要的数据结构,即 身份
+ gRPC连接
。
在之前的步骤里,我们配置完成了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);
在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 中将会分别拆解上面两个过程。
首先简单复习一下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上的执行结果。
同理。上面调用的submitTransaction
方法同样可以拆分为以下几步。
contract.newProposal("test")
.addArguments(arg1, arg2)
.build() //返回Proposal类型
.endorse() //返回Transaction类型
.submit(); //返回交易提交结果byte[]类型
可以看到,Proposal被构造后,通过调用endorse
方法向peer节点请求背书,并将提案与背书一并打包后形成Transaction类型
的对象,我们可以通过Transaction接口
中的submit
方法提交交易,并接收结果。之前第二节里的超时设置,在这里对应上了吧?因为endorse
,submit
,evaluate
等方法都是同步的,线程会在这里陷入阻塞,通过设置合理的超时时间,以停止等待,抛出异常。
但是,之前设置的还有一个commit
任务的超时时间,这是什么呢?还有,有无异步的交易提交方法呢?下一节讲。
在 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());
}
我们之前设置的超时,其实都是远程过程调用的超时时间。远程过程调用的思想是使调用远程服务就像调用本地服务一样自然,但该方法的实际执行过程却是在peer节点上完成的,(例如Commit),当我们调用该方法超时后(实际上相当于远程Peer节点上的方法执行超时),那么这次本地调用将会抛出gRPC超时异常,以便程序意识到问题。
最后,再补充一个小问题。
有过使用CLI调用过链码的道友可能有经验,在使用peer chaincode invoke命令提交交易时,我们需要使用–peerAddresses指定背书节点,并在后面用–TLSRootCertFile指定与该背书节点通信所用的TLS根证书,当我们需要多个节点进行背书时时,CLI需要在命令中逐个指定。但是在用Gateway API开发客户端的时候,有没有发现除了指定要和哪个peer节点通信外,我们没有指定过任何和背书有关的信息?
既然如此,为什么我们的客户端也能成功背书?这项功能得益于服务发现(Service Discovery)。
客户端要想完成与Fabric网络交互的一系列任务,那么就有必要获知网络的拓扑以及相关通道的各种策略(例如背书策略)。早期这些是由程序员静态配置在客户端本地的,但是网络拓扑易变,一个成熟的客户端应用不可能每次连接都手动修改客户端的配置。因此开发者们搞出了服务发现这个东西。有关服务发现的具体理解与配置方法我后面会写一篇博客专门介绍,这里只是给大家讲一下Gateway Client提交交易如何借助服务发现来完成背书。
服务发现属于peer节点功能的一部分。可以通过服务发现获知网络拓扑、背书政策以及在线节点等内容。我们的Gateway客户端通过Peer节点调用服务发现来获取目标链码的背书策略,并且获知目前在线的可以满足背书政策的背书节点集合(包括IP地址和端口),Gateway默认选取满足背书政策且节点数量最少的一组节点发送背书请求,如果每个集合节点数量一样将随机选取。
因此如果背书失败,请检查节点状态以及服务发现是否启动。
在下水平有限,如有不当之处,烦请不吝赐教。
[1] Fabirc 服务发现官方文档
[2] Hyperledger Fabric Java Gateway API文档
[3] Hyperledger Fabric Samples示例程序