Hyperledger Fabric Java Gateway配置解析

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证书,创建方法如下所示:

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 ,只是在其基础上做了封装,使用时需要注意。

2.2 Gateway配置串讲

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接口中的方法展开介绍。

2.2.1 设置Identity

用到的方法原型如下:

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接口,也没有自己的属性,而是实现了一些方便进行证书、密钥读写的静态方法。

2.2.2 设置Signer

用到的方法原型如下:

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.2.3 设置gRPC Channel

还记得2.1中我们创建的 gRPC Channel 吗?这里导进来就行,函数原型如下:

Gateway.Builder connection(Channel grpcChannel)

2.2.4 设置gRPC任务超时时间

这一步是可选的,目的是为了设置各项任务的超时时间。这其中常用方法的函数原型如下:

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对象中,并将这个对象返回。

2.3 创建Gateway

经过上面的讲解,这里直接给出一个创建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();
	}

3. 链码执行与提交

3.1 GatewayImpl类的属性

为了将本文结合具体实现讲解开发方法的风格贯彻到底,在讲链码如何执行与提交之前,有必要对网关的具体实现类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 ChanneloptionBuilder(包含了我们上面绑定的每个超时对象以及对应的任务)会被用于创建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连接

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接口中提供的evaluateTransactionsubmitTransaction简单的实现上面两个过程。假设我们要调用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.43.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对象,通过Proposalevaluate()方法调用链码并返回链码在peer上的执行结果。

3.5 提交交易拆解

同理。上面调用的submitTransaction方法同样可以拆分为以下几步。

contract.newProposal("test")
		.addArguments(arg1, arg2)
		.build()	//返回Proposal类型
		.endorse()	//返回Transaction类型
		.submit();	//返回交易提交结果byte[]类型

可以看到,Proposal被构造后,通过调用endorse方法向peer节点请求背书,并将提案与背书一并打包后形成Transaction类型的对象,我们可以通过Transaction接口中的submit方法提交交易,并接收结果。之前第二节里的超时设置,在这里对应上了吧?因为endorsesubmitevaluate等方法都是同步的线程会在这里陷入阻塞通过设置合理的超时时间,以停止等待,抛出异常

但是,之前设置的还有一个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提供的另一种接口,其提供的方法如下图所示:
Status接口介绍
几种方法的用法在上面的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示例程序

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>