前言

本文介绍Hyperledger项目提供的区块链网络性能测试工具Caliper的架构、安装和在Fabric测试网络中的使用。
各章节具体参考来源如下:

  • 介绍和架构
    https://hyperledger.github.io/caliper/v0.6.0/architecture/
  • 安装说明和CLI命令说明
    https://hyperledger.github.io/caliper/v0.6.0/installing-caliper/
  • 通过样例组件对测试网络进行性能测试
    https://github.com/hyperledger/caliper-benchmarks/tree/main/networks/fabric
  • 手动编写组件对测试网络进行性能测试
    https://hyperledger.github.io/caliper/v0.6.0/fabric-tutorial/tutorials-fabric-existing/

更多内容详见原文。


一、介绍

Caliper是针对不同区块链平台执行基准测试的通用框架,允许用户使用自定义用例测试不同的区块链解决方案,并获得一组性能测试结果。

它支持的区块链平台包括:

  • Ethereum
  • FISCO BCOS
  • Hyperledger Besu
  • Hyperledger Fabric

它支持的性能测试指标包括:

  • 交易/读取的吞吐量
  • 交易/读取的时延(最小值、最大值、平均值、百分比)
  • 资源消耗(CPU、内存、网络IO等)

二、架构

2.1. 概述

简单来说,Caliper是一种针对特定的SUT(被测系统,system under test)生成工作负载并持续监控其响应的服务。最后,Caliper根据观察到的SUT响应生成一份报告。这种简易视角如下图所示。

在这里插入图片描述

Caliper需要几个输入来运行一个基准测试,独立于所使用的SUT:

  • 基准测试配置文件Benchmark configuration file

基准测试配置文件描述了应该如何执行基准测试。它告诉Caliper应该执行多少轮,应该以什么速率提交TX,以及哪个模块将生成TX内容等。可以将此文件视为基准测试的“流程协调器”。在大多数情况下,这些设置独立于SUT,因此在针对不同的SUT类型或版本执行多个基准测试时,可以轻松地重用它们。

  • 网络配置文件Network configuration file

网络配置文件的内容是特定于SUT的。该文件通常描述SUT的拓扑结构,其节点在哪里(其端点地址),网络中存在哪些身份/客户端,以及Caliper应该与哪些智能合约交互。

  • 工作负载模块Workload modules

工作负载模块是基准测试的大脑。当Caliper为给定的一轮调度TX时,该轮的工作负载模块的任务是生成TX的内容并提交它。每一轮都可以有不同的关联工作负载模块,因此根据阶段/行为分离工作负载实现应该很容易。

  • 其它组件Benchmark artifacts

运行基准测试可能需要额外的组件,这些组件在不同的基准测试之间可能有所不同,包括与SUT交互所需的加密材料等。

2.2. 进程

Caliper由两种不同的进程组成:一个管理进程和许多工作进程。

管理进程初始化SUT,协调基准测试的运行(即调度配置的轮次),并根据观察到的TX统计信息处理性能报告的生成。

工作进程相互独立地执行实际的工作负载生成。即使一个工作进程达到了其主机的限制,使用更多的工作进程(在多台机器上)也会进一步提高Caliper的工作负载率。

在这里插入图片描述

  • 管理进程

管理进程是整个基准测试运行的协调器。它包含几个阶段,如下图所示。

在这里插入图片描述

  1. 在第一阶段,Caliper从网络配置文件执行启动脚本(如果存在)。此步骤主要适用于本地Caliper和SUT部署,因为它提供了一种一步启动网络和Caliper的方便方法。
  2. 在第二阶段,Caliper初始化SUT。此处执行的任务高度依赖于SUT和SUT连接器的功能。例如,Hyperledger Fabric连接器使用此阶段创建通道并注册新用户。
  3. 在第三阶段,Caliper将智能合约部署到SUT,前提是SUT和连接器支持此类操作(如Hyperledger Fabric连接器)。
  4. 在第四阶段,Caliper通过工作进程安排和执行配置的轮次。这是工作负载生成的阶段。
  5. 在最后一个阶段,在执行完全部轮次并生成报告后,Caliper会执行网络配置文件中的清理脚本(如果存在)。此步骤主要适用于本地Caliper和SUT部署,因为它提供了一种方便的方式来关闭网络和任何临时组件。
  • 工作进程

工作进程的重要组成部分如下图所示。

在这里插入图片描述

工作进程的大部分时间都花在工作负载生成循环中。循环由两个重要步骤组成:

  1. 等待速率控制器启用下一个TX。根据使用的速率控制器的类型,它会在启用下一个TX之前延迟/停止工作程序的执行(以异步方式)。例如,如果配置了固定的每秒50个TX(TPS)速率,则速率控制器将在每个TX之间停止20ms。
  2. 一旦速率控制器启用下一个TX,工作进程就将控制权交给工作负载模块。工作负载模块组装TX的参数(特定于SUT和智能合约API),并调用SUT连接器的简单API,后者将向SUT发送TX请求(可能使用SUT的SDK)。

2.3. 分布式模型

根据工作进程的启动方式以及管理进程和工作进程之间使用的消息传递方法,可以将Caliper的部署模型分成如下三种:

  • 在同一主机上自动派生工作进程,使用进程间通信(IPC)与管理进程交互。
  • 在同一主机上自动派生工作进程,使用远程消息传递机制和管理进程交互。
  • 在任意数量的主机上手动启动工作进程,使用远程消息传递机制和管理进程交互。

三、安装说明

Caliper通过NPM包或者Docker镜像来发布,两者都包含了一个CLI二进制文件。

NPM包的URL是:

https://www.npmjs.com/package/@hyperledger/caliper-cli

Docker镜像仓库的URL是:

https://hub.docker.com/r/hyperledger/caliper

最新的Caliper版本为v0.6.0。相比之前的版本,它提供了对Fabric v2.5的支持。

3.1. 从NPM安装

从NPM安装时,要先安装Node.js,v0.6.0版本要求为v18 LTS,详见入门笔记(三)第3.6节有关内容。

此外,还要先安装make和g++:

sudo apt install make
sudo apt install g++

 
  • 1
  • 2

从NPM安装Caliper CLI又可以细分为两种安装方式:本地NPM安装;全局NPM安装。前者意味着安装好的Caliper CLI只能在当前目录下使用。两种安装方式中,建议的方法是本地安装,此时项目是自包含的,可以轻松地(在多个目录中)设置多个项目,每个项目都针对不同的SUT(或只是不同的SUT版本)。全局安装的一些设置可能会很棘手,除非有充分的理由,否则不要这么做。

本地NPM安装的方法是在任意目录下执行如下命令:

npm install --only=prod @hyperledger/caliper-cli@0.6.0

 
  • 1

安装完成后检查CLI的版本:

npx caliper --version

 
  • 1

注:本地安装会包含一个由Node.js官方提供的用于快速执行npm包中的可执行文件的工具npx,它可以在不全局安装某些包的情况下,直接运行该包提供的命令行工具,后续通过npx来使用Caliper CLI。

3.2. 使用Docker镜像

使用docker pull命令拉取v0.6.0版本的caliper镜像:

docker pull hyperledger/caliper:0.6.0

 
  • 1

注:caliper镜像没有名为’latest’的默认tag,需要明确指定tag。


四、CLI命令说明

  • bind命令

获取Caliper就像安装一个NPM包或拉取一个Docker镜像一样简单。然而,这种单点安装需要额外的步骤来告诉Caliper要针对哪个平台以及要使用哪个平台SDK版本。此步骤称为绑定,由bind CLI命令提供。

  • unbind命令

在测试或项目开发过程中,可能需要在不同的SUT SDK版本之间切换。根据SUT SDK的不同,简单地重新绑定到不同的版本可能会留下不必要的包,从而导致模糊的错误。为了避免这种情况,CLI提供了unbind命令删除之前存在的包,不留下以前绑定的痕迹。
注:建议全局地bind/unbind,或者在执行bind/unbind时使用–Caliper bind args="–save dev"参数。这样可以确保npm正确地删除这些包。

  • launch命令

Caliper通过使用工作进程来生成工作负载,并通过使用管理进程来协调工作进程之间的不同基准。因此,CLI提供用于启动管理进程和工作进程的命令launch manager和launch worker。


五、通过样例组件对测试网络进行性能测试

Fabric官方提供了一个名为caliper-benchmarks的项目仓库,其URL是:

https://github.com/hyperledger/caliper-benchmarks/tree/main

该项目包含了许多用于基准测试的样例组件,其中就有针对Fabric测试网络的内容。

首先获取该项目,建议将其和fabric-samples放在同级目录内:

cd ~/hyfa
git clone https://github.com/hyperledger/caliper-benchmarks.git
cd caliper-benchmarks

 
  • 1
  • 2
  • 3

下面使用本地NPM安装的方式在caliper-benchmarks目录下安装Caliper CLI并检查CLI的版本:

npm install --only=prod @hyperledger/caliper-cli@0.6.0
npx caliper --version

 
  • 1
  • 2

将Caliper绑定到所需平台:

npx caliper bind --caliper-bind-sut fabric:fabric-gateway

 
  • 1

启动测试网络:

pushd ../fabric-samples/test-network
./network.sh up createChannel

 
  • 1
  • 2

项目caliper-benchmarks自带了多个用于Fabric的链码,包括Fabcar、Marbles、Simple等。

在测试网络中部署Fabcar链码:

./network.sh deployCC -ccn fabcar -ccp ../../caliper-benchmarks/src/fabric/samples/fabcar/go -ccl go

 
  • 1

返回caliper-benchmarks目录下:

popd

 
  • 1

最后使用网络配置文件test-network.yaml和Fabcar链码的基准测试配置文件config.yaml执行基准测试:

npx caliper launch manager --caliper-workspace ./ --caliper-networkconfig networks/fabric/test-network.yaml --caliper-benchconfig benchmarks/samples/fabric/fabcar/config.yaml --caliper-flow-only-test --caliper-fabric-gateway-enabled

 
  • 1

部分结果如下:
在这里插入图片描述


六、手动编写组件对测试网络进行性能测试

下面针对项目fabric-samples中的asset-transfer-basic链码进行性能分析。

首先启动测试网络并部署basic链码:

pushd ../fabric-samples/test-network
./network.sh up createChannel
./network.sh deployCC -ccn basic -ccp ../asset-transfer-basic/chaincode-go -ccl go

 
  • 1
  • 2
  • 3

6.1. 创建Caliper工作区

在fabric-samples文件夹的同级目录内创建名为caliper-workspace的文件夹作为工作区,在工作区内分别创建三个名为networks、benchmarks和workload的文件夹:

cd ~/hyfa && mkdir caliper-workspace && cd caliper-workspace
mkdir networks benchmarks workload

 
  • 1
  • 2

使用本地NPM安装的方式在caliper-benchmarks目录下安装Caliper CLI并检查CLI的版本:

npm install --only=prod @hyperledger/caliper-cli@0.6.0
npx caliper --version

 
  • 1
  • 2

将Caliper绑定到所需平台:

npx caliper bind --caliper-bind-sut fabric:fabric-gateway

 
  • 1

6.2. 编写网络配置文件

网络配置文件是Caliper工作进程在Hyperledger Fabric网络上提交和评估交易所需的文件。该文件可以是YAML或JSON格式,其中包含以下内容:

  • name
    配置的名称,例如“Caliper test”。
  • version
    正在使用的配置文件的版本。必须是“2.0.0”。
  • caliper
    向Caliper指示目标SUT,并且可以包含其它开始/结束命令或SUT特定选项。
  • channels
    描述Hyperledger Fabric通道以及部署在这些通道上以进行基准测试的智能合约。
  • organizations
    Hyperledger Fabric的组织列表,其中包含与每个组织关联的身份(私钥和证书)和通用连接配置文件。

注:通用连接配置文件是一种YML或JSON格式的文件。由于Caliper使用Fabric的Node SDK连接到网络,因此需要使用这些连接配置文件。测试网络启动时已经创建了这些文件。

在networks文件夹下创建一个名为networkConfig.yaml的文件,内容如下所示:

name: Calier test
version: "2.0.0"

caliper:
blockchain: fabric

channels:
- channelName: mychannel
contracts:
- id: basic

organizations:
- mspid: Org1MSP
identities:
certificates:
- name: ‘User1’
clientPrivateKey:
path: ‘…/fabric-samples/test-network/organizations/peerOrganizations/org1.example.com/users/User1@org1.example.com/msp/keystore/priv_sk’
clientSignedCert:
path: ‘…/fabric-samples/test-network/organizations/peerOrganizations/org1.example.com/users/User1@org1.example.com/msp/signcerts/User1@org1.example.com-cert.pem’
connectionProfile:
path: ‘…/fabric-samples/test-network/organizations/peerOrganizations/org1.example.com/connection-org1.yaml’
discover: true

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

6.3. 编写工作负载模块

在基准测试阶段,工作负载模块与部署的智能合约进行交互。工作负载模块从caliper-core项目扩展了Caliper类WorkloadModuleBase,并提供三种重载(override):

  • initializeWorkloadModule
    用于初始化基准测试的任何必需项。
  • submitTransaction
    用于在基准测试的监控阶段与智能合约方法交互。
  • cleanupWorkloadModule
    用于在基准测试完成后进行清理。

本次基准测试旨在对状态数据库中现有资产的查询进行基准测试。因此将使用工作负载模块中可用的所有三种重载:

  • 在initializeWorkloadModule中,创建可以在submitTransaction阶段查询的资产。
  • 在submitTransaction中,查询在initializeWorkloadModule阶段创建的资产。
  • 在cleanupWorkloadModule中,删除在initializeWorkloadModule阶段创建的资产,以便可以重复基准测试。

在workload文件夹下创建一个名为readAsset.js的文件,文件框架如下所示:

'use strict';

const { WorkloadModuleBase } = require(‘@hyperledger/caliper-core’);

class MyWorkload extends WorkloadModuleBase {
constructor() {
super();
}

<span class="token keyword">async</span> <span class="token function">initializeWorkloadModule</span><span class="token punctuation">(</span><span class="token parameter">workerIndex<span class="token punctuation">,</span> totalWorkers<span class="token punctuation">,</span> roundIndex<span class="token punctuation">,</span> roundArguments<span class="token punctuation">,</span> sutAdapter<span class="token punctuation">,</span> sutContext</span><span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span>
    <span class="token keyword">await</span> <span class="token keyword">super</span><span class="token punctuation">.</span><span class="token function">initializeWorkloadModule</span><span class="token punctuation">(</span>workerIndex<span class="token punctuation">,</span> totalWorkers<span class="token punctuation">,</span> roundIndex<span class="token punctuation">,</span> roundArguments<span class="token punctuation">,</span> sutAdapter<span class="token punctuation">,</span> sutContext<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token keyword">async</span> <span class="token function">submitTransaction</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span>
    <span class="token comment">// NOOP</span>
<span class="token punctuation">}</span>

<span class="token keyword">async</span> <span class="token function">cleanupWorkloadModule</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span>
    <span class="token comment">// NOOP</span>
<span class="token punctuation">}</span>

}

function createWorkloadModule() {
return new MyWorkload();
}

module.exports.createWorkloadModule = createWorkloadModule;

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

三种重载都通过填充一个定义了交易主体的参数对象,并使用Caliper的API sendRequests发送。

参数对象包含如下内容:

  • contractId
    要使用并且存在于Caliper网络配置文件中的智能合约的名称。
  • contractFunction
    智能合约中要调用的特定函数。
  • contractArguments
    传递给智能合约函数的参数。
  • invokerIdentity
    要使用并且存在于Caliper网络配置文件中的身份。
  • readOnly
    是否执行查询操作。
6.3.1. 完善initializeWorkloadModule()方法

这部分在测试的初始化阶段使用智能合约创建一系列资产,要创建的资产数量由roundArguments.assets参数给出。

    async initializeWorkloadModule(workerIndex, totalWorkers, roundIndex, roundArguments, sutAdapter, sutContext) {
        await super.initializeWorkloadModule(workerIndex, totalWorkers, roundIndex, roundArguments, sutAdapter, sutContext);
    <span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token keyword">let</span> i<span class="token operator">=</span><span class="token number">0</span><span class="token punctuation">;</span> i<span class="token operator">&lt;</span><span class="token keyword">this</span><span class="token punctuation">.</span>roundArguments<span class="token punctuation">.</span>assets<span class="token punctuation">;</span> i<span class="token operator">++</span><span class="token punctuation">)</span> <span class="token punctuation">{<!-- --></span>
        <span class="token keyword">const</span> assetID <span class="token operator">=</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${<!-- --></span><span class="token keyword">this</span><span class="token punctuation">.</span>workerIndex<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">_</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${<!-- --></span>i<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>
        console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">Worker </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${<!-- --></span><span class="token keyword">this</span><span class="token punctuation">.</span>workerIndex<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">: Creating asset </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${<!-- --></span>assetID<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">;</span>
        <span class="token keyword">const</span> request <span class="token operator">=</span> <span class="token punctuation">{<!-- --></span>
            contractId<span class="token operator">:</span> <span class="token keyword">this</span><span class="token punctuation">.</span>roundArguments<span class="token punctuation">.</span>contractId<span class="token punctuation">,</span>
            contractFunction<span class="token operator">:</span> <span class="token string">'CreateAsset'</span><span class="token punctuation">,</span>
            invokerIdentity<span class="token operator">:</span> <span class="token string">'User1'</span><span class="token punctuation">,</span>
            contractArguments<span class="token operator">:</span> <span class="token punctuation">[</span>assetID<span class="token punctuation">,</span><span class="token string">'blue'</span><span class="token punctuation">,</span><span class="token string">'20'</span><span class="token punctuation">,</span><span class="token string">'penguin'</span><span class="token punctuation">,</span><span class="token string">'500'</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
            readOnly<span class="token operator">:</span> <span class="token boolean">false</span>
        <span class="token punctuation">}</span><span class="token punctuation">;</span>

        <span class="token keyword">await</span> <span class="token keyword">this</span><span class="token punctuation">.</span>sutAdapter<span class="token punctuation">.</span><span class="token function">sendRequests</span><span class="token punctuation">(</span>request<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
6.3.2. 完善submitTransaction()方法

这部分在基准测试阶段重复运行,通过查询在initializeWorkloadModule()方法中创建的资产来评估智能合约ReadAsset的性能。

传递给智能合约函数的参数为资产的assetID字段,由工作负载的索引和从零与创建的资产数量之间的随机整数串联而成。

    async submitTransaction() {
        const randomId = Math.floor(Math.random()*this.roundArguments.assets);
        const myArgs = {
            contractId: this.roundArguments.contractId,
            contractFunction: 'ReadAsset',
            invokerIdentity: 'User1',
            contractArguments: [`${this.workerIndex}_${randomId}`],
            readOnly: true
        };
    <span class="token keyword">await</span> <span class="token keyword">this</span><span class="token punctuation">.</span>sutAdapter<span class="token punctuation">.</span><span class="token function">sendRequests</span><span class="token punctuation">(</span>myArgs<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
6.3.3. 完善initializeWorkloadModule()方法

这部分用于在测试后进行清理,它通过使用智能合约的函数DeleteAsset来删除initializeWorkloadModule函数中创建的资产。

   async cleanupWorkloadModule() {
        for (let i=0; i<this.roundArguments.assets; i++) {
            const assetID = `${this.workerIndex}_${i}`;
            console.log(`Worker ${this.workerIndex}: Deleting asset ${assetID}`);
            const request = {
                contractId: this.roundArguments.contractId,
                contractFunction: 'DeleteAsset',
                invokerIdentity: 'User1',
                contractArguments: [assetID],
                readOnly: false
            };
        <span class="token keyword">await</span> <span class="token keyword">this</span><span class="token punctuation">.</span>sutAdapter<span class="token punctuation">.</span><span class="token function">sendRequests</span><span class="token punctuation">(</span>request<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

6.4. 编写基准测试配置文件

基准测试配置文件指定了生成负载时要使用的工作进程的数量、测试轮次、每轮次的持续时间、每轮次中应用于交易负载的速率控制以及与监控器相关的选项。此外,它还引用了定义的工作负载模块。

在benchmarks文件夹下创建名为myAssetBenchmark.yaml的文件,内容如下所示:

test:
    name: basic-contract-benchmark
    description: test benchmark
    workers:
      number: 2
    rounds:
      - label: readAsset
        description: Read asset benchmark
        txDuration: 30
        rateControl:
          type: fixed-load
          opts:
            transactionLoad: 2
        workload:
          module: workload/readAsset.js
          arguments:
            assets: 10
            contractId: basic

 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

这里面,rounds字段是一系列不同的测试回合,将按顺序进行。rounds可以用于对不同的智能合约方法进行基准测试,或者以不同的方式对相同的方法进行基准测量。每个rounds包含以下内容:

  • label
    用于round的唯一标签。
  • description
    正在运行的轮次的描述。
  • txDuration
    测试持续时间的,以秒为单位。
  • rateControl
    速率控制类型。
  • workload
    要使用的工作负载模块,带有要传递给模块的参数。所有传递的参数都作为roundArguments参数在工作负载模块中使用。

6.5. 运行Caliper基准测试

性能基准测试将使用Caliper CLI运行,需要为其提供工作区的路径以及网络配置文件和基准测试配置文件在工作区的相对路径,分别由标志–caliper workspace、–caliper-networkconfig和–caliperbenchconfig指定。由于智能合约已经安装并实例化,Caliper只需要执行测试阶段,由标志–caliper-flow-only-test指定。

现在,使用上述组件来运行性能基准测试:

npx caliper launch manager --caliper-workspace ./ --caliper-networkconfig networks/networkConfig.yaml --caliper-benchconfig benchmarks/myAssetBenchmark.yaml --caliper-flow-only-test

 
  • 1

部分结果如下:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐