Spring Cloud Alibaba Seata (Hoxton版) 使用

  • Post author:
  • Post category:其他

Spring Cloud Hoxton.SR4 Spring Cloud Alibaba 2.2.2.RELEASE Spring Boot 2.3.0.RELEASE

GitHub:shpunishment/spring-cloud-learning/spring-cloud-seata-test

1. 简介

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

单体应用
单体应用中,一个业务操作需要调用三个模块完成。它们使用单个本地数据源,本地事务自然能保证数据的一致性。
单体应用
微服务应用
在微服务架构中发生了变化。上面提到的3个模块被设计为在3个不同数据源之上的3个服务。本地事务只能保证单个服务中的数据一致性。但是全局的数据一致性没法保证。
微服务应用

Seata只是上述问题的解决方案。

分布式事务
分布式事务是由一批分支事务组成的全局事务,通常分支事务只是本地事务。
分布式事务
Seata有3个基本组成部分:

  1. 事务协调器(TC):维护全局事务和分支事务的状态,驱动全局提交或回滚。
  2. 事务管理器(TM):定义全局事务的范围,开始全局事务,提交或回滚全局事务。
  3. 资源管理器(RM):管理分支事务正在处理的资源,与TC进行对话以注册分支事务并报告分支事务的状态,并驱动分支事务的提交或回滚。

在这里插入图片描述

Seata管理的分布式事务的典型生命周期:

  1. TM 向 TC 申请开启一个全局事务,TC 创建全局事务并生成一个全局唯一的 XID;
  2. XID 在微服务调用链路的上下文中传播;
  3. RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖;
  4. TM 向 TC 发起针对 XID 的全局提交或回滚请求;
  5. TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。
    在这里插入图片描述

以下以 AT 模式为例,完成基本的下单流程。

2. 安装

2.1 nacos

官网下载

使用Windows启动时报错,修改startup.cmd的mode从cluster为standalone,还是启动失败。

所以这里使用Linux。

使用单体模式启动Nacos,访问http://ip:8848/nacos。默认账号密码为nacos。

./bin/startup.sh -m standalone

2.2 seata-server

GitHub 下载,这里使用seata-server-1.3.0。

这里使用nacos作为seata的注册中心和配置中心。

修改conf/registry.conf

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"

  nacos {
    application = "seata-server"
    serverAddr = "192.168.110.40:8848"
    group = "SEATA_GROUP"
    # nacos命名空间id,""为nacos保留public空间控件,用户勿配置namespace = "public"
    namespace = ""
    # seata-server在nacos的集群名
    cluster = "seata-server-cluster"
    username = "nacos"
    password = "nacos"
  }
}

config {
  # file、nacos 、apollo、zk、consul、etcd3
  type = "nacos"

  nacos {
    serverAddr = "192.168.110.40:8848"
    # nacos命名空间id,""为nacos保留public空间控件,用户勿配置namespace = "public"
    namespace = ""
    group = "SEATA_GROUP"
    # seata-server在nacos的集群名
    cluster = "seata-server-cluster"
    username = "nacos"
    password = "nacos"
  }
}

2.2.1 上传配置

使用nacos作为配置中心,需要先将seata的配置上传上去。

GitHub seata中下载config.txt文件和nacos文件夹下的脚本。这里使用nacos-config.sh。
下载脚本和配置
修改config.txt

# 事务分组 seata_tx_group,可自定义
# seata-server-cluster 为seata-server在nacos的集群名,要和registry.conf中配置一致。
service.vgroupMapping.seata_tx_group=seata-server-cluster
# #事务信息存储到数据库
store.mode=db
store.db.url=jdbc:mysql://192.168.4.23:4306/seata-server?useUnicode=true&characterEncoding=UTF-8&useSSL=false
store.db.user=root
store.db.password=root

上传配置,注意config.txt文件的位置。

sh nacos-config.sh -h 192.168.110.40 -p 8848 -g SEATA_GROUP -u nacos -w nacos

上传配置
上传配置到nacos

2.2.2 初始化数据库

创建数据库seata-server。

GitHub Seata中下载Server端的数据库SQL。

-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
    `xid`                       VARCHAR(128) NOT NULL,
    `transaction_id`            BIGINT,
    `status`                    TINYINT      NOT NULL,
    `application_id`            VARCHAR(32),
    `transaction_service_group` VARCHAR(32),
    `transaction_name`          VARCHAR(128),
    `timeout`                   INT,
    `begin_time`                BIGINT,
    `application_data`          VARCHAR(2000),
    `gmt_create`                DATETIME,
    `gmt_modified`              DATETIME,
    PRIMARY KEY (`xid`),
    KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
    KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
    `branch_id`         BIGINT       NOT NULL,
    `xid`               VARCHAR(128) NOT NULL,
    `transaction_id`    BIGINT,
    `resource_group_id` VARCHAR(32),
    `resource_id`       VARCHAR(256),
    `branch_type`       VARCHAR(8),
    `status`            TINYINT,
    `client_id`         VARCHAR(64),
    `application_data`  VARCHAR(2000),
    `gmt_create`        DATETIME(6),
    `gmt_modified`      DATETIME(6),
    PRIMARY KEY (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
    `row_key`        VARCHAR(128) NOT NULL,
    `xid`            VARCHAR(96),
    `transaction_id` BIGINT,
    `branch_id`      BIGINT       NOT NULL,
    `resource_id`    VARCHAR(256),
    `table_name`     VARCHAR(32),
    `pk`             VARCHAR(36),
    `gmt_create`     DATETIME,
    `gmt_modified`   DATETIME,
    PRIMARY KEY (`row_key`),
    KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

在这里插入图片描述
启动seata-server

./bin/seata-server.sh

3. 使用

下单->减库存->减余额->完成下单。

3.1 准备数据库

创建存储账户信息的数据库seata-account及表。

CREATE TABLE `account` (
  `user_id` int(11) NOT NULL COMMENT '用户id',
  `total` decimal(10,2) DEFAULT NULL COMMENT '总余额',
  `used` decimal(10,2) DEFAULT NULL COMMENT '已用余额',
  `residue` decimal(10,2) DEFAULT NULL COMMENT '可用额度',
  PRIMARY KEY (`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='账户表';

INSERT INTO `seata-account`.`account` (`user_id`, `total`, `used`, `residue`) VALUES ('1', '1000', '0', '1000');

创建存储商品库存的数据库seata-storage及表。

CREATE TABLE `storage` (
  `product_id` int(11) NOT NULL COMMENT '产品id',
  `total` int(11) DEFAULT NULL COMMENT '总库存',
  `used` int(11) DEFAULT NULL COMMENT '已用库存',
  `residue` int(11) DEFAULT NULL COMMENT '剩余库存',
  PRIMARY KEY (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品库存表';

INSERT INTO `seata-storage`.`storage` (`product_id`, `total`, `used`, `residue`) VALUES ('1', '100', '0', '100');

创建存储订单的数据库seata-order及表。

CREATE TABLE `order` (
  `order_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '订单id',
  `user_id` int(11) DEFAULT NULL COMMENT '用户id',
  `product_id` int(11) DEFAULT NULL COMMENT '产品id',
  `count` int(11) DEFAULT NULL COMMENT '数量',
  `money` decimal(10,2) DEFAULT NULL COMMENT '金额',
  `status` int(1) DEFAULT '0' COMMENT '状态,0创建,1完成',
  PRIMARY KEY (`order_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='订单表';

AT模式在每个数据库中创建表undo,也可在GitHub Seata查看。

-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
    `branch_id`     BIGINT(20)   NOT NULL COMMENT 'branch transaction id',
    `xid`           VARCHAR(100) NOT NULL COMMENT 'global transaction id',
    `context`       VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
    `rollback_info` LONGBLOB     NOT NULL COMMENT 'rollback info',
    `log_status`    INT(11)      NOT NULL COMMENT '0:normal status,1:defense status',
    `log_created`   DATETIME(6)  NOT NULL COMMENT 'create datetime',
    `log_modified`  DATETIME(6)  NOT NULL COMMENT 'modify datetime',
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

数据库和表
seata-account
seata-storage
seata-order

3.2 创建项目

先用IDEA创建一个Spring Boot的项目,可以随意引用一个Spring Cloud的组件,之后也会删掉。

创建完,删掉除了pom.xml以外的其他文件,再修改pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.0.RELEASE</version>
    </parent>
    <groupId>com.shpun</groupId>
    <artifactId>spring-cloud-seata-test</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-cloud-seata-test</name>
    <description>spring cloud seata test</description>
    <!--修改打包方式为pom-->
    <packaging>pom</packaging>

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-cloud.version>Hoxton.SR4</spring-cloud.version>
        <spring-cloud-alibaba.version>2.2.2.RELEASE</spring-cloud-alibaba.version>
    </properties>

    <modules>
        <!--后续添加子模块用-->
    </modules>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>2.1.2</version>
            </dependency>

            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>druid-spring-boot-starter</artifactId>
                <version>1.1.23</version>
            </dependency>

            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>5.1.47</version>
                <scope>runtime</scope>
            </dependency>

            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

3.2.1 acount-service

创建子模块 acount-service

修改pom继承

<parent>
    <groupId>com.shpun</groupId>
    <artifactId>spring-cloud-seata-test</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</parent>

再添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

修改application.yml

server:
  port: 8201

spring:
  application:
    name: account-service
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:4306/seata-account?useUnicode=true&characterEncoding=UTF-8&useSSL=false
    username: root
    password: root
    # 使用阿里的Druid连接池
    type: com.alibaba.druid.pool.DruidDataSource
    # druid配置
    druid:
      # 初始化大小
      initial-size: 5
      # 最小
      min-idle: 10
      # 最大
      max-active: 30
      # 配置获取连接等待超时的时间
      max-wait: 6000
  cloud:
    nacos:
      discovery:
        # nacos地址
        server-addr: 192.168.110.40:8848
        group: SEATA_GROUP

seata:
  enabled: true
  application-id: ${spring.application.name}
  tx-service-group: seata_tx_group
  service:
    vgroup-mapping:
      seata_tx_group: seata-server-cluster
  # 配置中心地址
  config:
    type: nacos
    nacos:
      serverAddr: 192.168.110.40:8848
      group: SEATA_GROUP
      namespace:
      username: nacos
      password: nacos
  # 注册中心地址
  registry:
    type: nacos
    nacos:
      # Server和Client端的值需一致
      application: seata-server
      server-addr: 192.168.110.40:8848
      group : SEATA_GROUP
      namespace:
      username: nacos
      password: nacos

mybatis:
  typeAliasesPackage: com.shpun.model
  mapper-locations: classpath:mapper/**.xml

Model,Mapper,Service等省略。

AccountController

@RequestMapping("/api/account")
@RestController
public class AccountController {
    @Autowired
    private AccountService accountService;

    @GetMapping("/decrease")
    public void decrease(@RequestParam("userId") Integer userId, @RequestParam("money") BigDecimal money) {
        accountService.decrease(userId,money);
    }
}

在启动类上添加@EnableDiscoveryClient注解表明是一个服务发现的客户端。

3.2.2 storage-service

创建子模块 storage-service

修改pom继承

<parent>
    <groupId>com.shpun</groupId>
    <artifactId>spring-cloud-seata-test</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</parent>

再添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

修改application.yml

server:
  port: 8203

spring:
  application:
    name: storage-service
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:4306/seata-storage?useUnicode=true&characterEncoding=UTF-8&useSSL=false
    username: root
    password: root
    # 使用阿里的Druid连接池
    type: com.alibaba.druid.pool.DruidDataSource
    # druid配置
    druid:
      # 初始化大小
      initial-size: 5
      # 最小
      min-idle: 10
      # 最大
      max-active: 30
      # 配置获取连接等待超时的时间
      max-wait: 6000
  cloud:
    nacos:
      discovery:
        # nacos地址
        server-addr: 192.168.110.40:8848
        group: SEATA_GROUP

seata:
  enabled: true
  application-id: ${spring.application.name}
  tx-service-group: seata_tx_group
  service:
    vgroup-mapping:
      # 分组名,值为TC集群名
      seata_tx_group: seata-server-cluster
  # 配置中心地址
  config:
    type: nacos
    nacos:
      serverAddr: 192.168.110.40:8848
      group: SEATA_GROUP
      namespace:
      username: nacos
      password: nacos
  # 注册中心地址
  registry:
    type: nacos
    nacos:
      # Server和Client端的值需一致
      application: seata-server
      server-addr: 192.168.110.40:8848
      group : SEATA_GROUP
      namespace:
      username: nacos
      password: nacos

mybatis:
  typeAliasesPackage: com.shpun.model
  mapper-locations: classpath:mapper/**.xml

Model,Mapper,Service等省略。

StorageController

@RequestMapping("/api/storage")
@RestController
public class StorageController {
    @Autowired
    private StorageService storageService;

    @GetMapping("/decrease")
    public void decrease(@RequestParam("productId") Integer productId, @RequestParam("count") Integer count) {
        storageService.decrease(productId, count);
    }
}

在启动类上添加@EnableDiscoveryClient注解表明是一个服务发现的客户端。

3.2.3 order-service

创建子模块 order-service

修改pom继承

<parent>
    <groupId>com.shpun</groupId>
    <artifactId>spring-cloud-seata-test</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</parent>

再添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

修改application.yml

server:
  port: 8202

spring:
  application:
    name: order-service
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:4306/seata-order?useUnicode=true&characterEncoding=UTF-8&useSSL=false
    username: root
    password: root
    # 使用阿里的Druid连接池
    type: com.alibaba.druid.pool.DruidDataSource
    # druid配置
    druid:
      # 初始化大小
      initial-size: 5
      # 最小
      min-idle: 10
      # 最大
      max-active: 30
      # 配置获取连接等待超时的时间
      max-wait: 6000
  cloud:
    nacos:
      discovery:
        # nacos地址
        server-addr: 192.168.110.40:8848
        group: SEATA_GROUP

seata:
  enabled: true
  application-id: ${spring.application.name}
  tx-service-group: seata_tx_group
  service:
    vgroup-mapping:
      # 分组名,值为TC集群名
      seata_tx_group: seata-server-cluster
  # 配置中心地址
  config:
    type: nacos
    nacos:
      serverAddr: 192.168.110.40:8848
      group: SEATA_GROUP
      namespace:
      username: nacos
      password: nacos
  # 注册中心地址
  registry:
    type: nacos
    nacos:
      # Server和Client端的值需一致
      application: seata-server
      server-addr: 192.168.110.40:8848
      group : SEATA_GROUP
      namespace:
      username: nacos
      password: nacos

mybatis:
  typeAliasesPackage: com.shpun.model
  mapper-locations: classpath:mapper/**.xml

添加Feign客户端

AccountFeignService

@FeignClient("account-service")
public interface AccountFeignService {
    @GetMapping("/api/account/decrease")
    void decrease(@RequestParam("userId") Integer userId, @RequestParam("money") BigDecimal money);
}

StorageFeignService

@FeignClient("storage-service")
public interface StorageFeignService {
    @GetMapping(value = "/api/storage/decrease")
    void decrease(@RequestParam("productId") Integer productId, @RequestParam("count") Integer count);
}

OrderService

@Service("orderServiceImpl")
public class OrderServiceImpl implements OrderService {
    private static final Logger logger = LoggerFactory.getLogger(OrderServiceImpl.class);

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private AccountFeignService accountFeignService;

    @Autowired
    private StorageFeignService storageFeignService;

    @Override
    @GlobalTransactional(name = "seata-add-order", rollbackFor = Exception.class)
    public void add(Order order) {
        logger.info("seata XID:{}", RootContext.getXID());

        logger.info("下单开始");

        logger.info("创建订单开始");
        orderMapper.insertSelective(order);
        logger.info("创建订单结束");

        logger.info("扣减商品库存开始");
        storageFeignService.decrease(order.getProductId(), order.getCount());
        logger.info("扣减商品库存结束");

        logger.info("扣减用户余额开始");
        accountFeignService.decrease(order.getUserId(), order.getMoney());
        logger.info("扣减用户余额结束");

        logger.info("修改订单状态开始");
        orderMapper.completeOrder(order.getOrderId());
        logger.info("修改订单状态结束");

        logger.info("下单结束");
    }
}

Model,Mapper,Service等省略。

OrderController

@RequestMapping("/api/order")
@RestController
public class OrderController {
    @Autowired
    private OrderService orderService;

    @PostMapping("/add")
    public void add(@RequestBody Order order) {
        orderService.add(order);
    }
}

在启动类上添加@EnableDiscoveryClient注解表明是一个服务发现的客户端,@EnableFeignClients注解启用Feign的客户端功能。

3.3 测试

启动account-service,storage-service和order-service。

seata-server中可看到三个服务完成注册。
seata-server
下单测试
下单测试
账户金额和商品库存完成扣减,下单完成。
账户表
库存表
订单表

回滚测试
修改account-service中的AccountController,遇到报错,会回滚。

@RequestMapping("/api/account")
@RestController
public class AccountController {
    @Autowired
    private AccountService accountService;

    @GetMapping("/decrease")
    public void decrease(@RequestParam("userId") Integer userId, @RequestParam("money") BigDecimal money) {
        int i = 10/0;
        accountService.decrease(userId,money);
    }
}

打断点可发现,在扣减账户金额之前,没报错时都是正常的。order表中有记录,storage表也正常扣减,undo表中也有记录。

下单到扣减账户余额报错后,order表中记录被删除,storage表记录恢复,undo表记录被删除。事务完成了回滚。

参考:
Spring Cloud入门-Seata处理分布式事务问题(Hoxton版本)
seata 官方文档
Spring-Cloud-Alibaba-Seata 组件学习使用
CLOUDALIBABA分布式事务SEATA1.2.0结合注册中心NACOS1.3.0案例
阿里的 Seata(原理 + 实战)


版权声明:本文为qq_36160730原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。