基于Mybatis及Mybatis-Plus的多数据源解决方案

引言

最近有项目需要支持多租户(多租户之后会单独开一篇文章说),多租户架构中需要用到多数据源,即物理隔离,需要不同租户对应不同的RMDB数据库实例,故本篇文章先行对多数据源的进行探讨。

通常我们的工程仅存在唯一数据源以及对应的一套数据库连接池,如SpringBoot应用中如下配置:

# 基础配置
spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/my_db?useUnicode=true&characterEncoding=utf-8&useSSL=false
    username: root
    password: 123456
    # Hikari 连接池配置
    hikari:
      # 最小空闲连接数量
      minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
      idle-timeout: 180000
      # 连接池最大连接数,默认是10
      maximum-pool-size: 10
      # 此属性控制从池返回的连接的默认自动提交行为,默认值:true
      auto-commit: true
      # 连接池名称
      pool-name: MyHikariCP
      # 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟
      max-lifetime: 1800000
      # 数据库连接超时时间,默认30秒,即30000
      connection-timeout: 30000
      connection-test-query: SELECT 1

以Mybatis生态为例,支持多数据源的方式有如下2种。

方式1 - 使用原生Mybatis分包的方式

此种方式需按照数据源对Mapper接口及mapper.xml进行分包,
如下图存在2个数据源,则需要分成2个包,如ds1和ds2:
在这里插入图片描述

同时比较重要的是需要对Mybatis中不同包下的Mapper注入不同的DataSource,因此每个数据源都需要单独进行配置,如截图中存在2个数据源分别对应DataSourceConfig1和DataSourceConfig2两个配置类,同时需要将一个数据源设置为主数据源,避免Spring启动无法注入数据源报错。

多数据源配置application.yml规划如下:

spring:
  # DataSource Config
  datasource:
    ds1: # 数据源1
      # Hikari 连接池配置,具体配置属性同spring.datasource.hikari.*
      jdbc-url: jdbc:mysql://localhost:3306/multi-ds-1?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
      username: root
      password: 123456
      driver-class-name: com.mysql.cj.jdbc.Driver
      type: com.zaxxer.hikari.HikariDataSource
      # 最小空闲连接数量
      minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
      idle-timeout: 180000
      # 连接池最大连接数,默认是10
      maximum-pool-size: 10
      # 此属性控制从池返回的连接的默认自动提交行为,默认值:true
      auto-commit: true
      # 连接池名称
      pool-name: DS1-POOL
      # 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟
      max-lifetime: 1800000
      # 数据库连接超时时间,默认30秒,即30000
      connection-timeout: 30000
      connection-test-query: SELECT 1
    ds2: # 数据源2
      # Hikari 连接池配置,具体配置属性同spring.datasource.hikari.*
      jdbc-url: jdbc:mysql://localhost:3306/multi-ds-2?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
      username: root
      password: 123456
      driver-class-name: com.mysql.cj.jdbc.Driver
      type: com.zaxxer.hikari.HikariDataSource
      # 最小空闲连接数量
      minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
      idle-timeout: 180000
      # 连接池最大连接数,默认是10
      maximum-pool-size: 10
      # 此属性控制从池返回的连接的默认自动提交行为,默认值:true
      auto-commit: true
      # 连接池名称
      pool-name: DS2-POOL
      # 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟
      max-lifetime: 1800000
      # 数据库连接超时时间,默认30秒,即30000
      connection-timeout: 30000
      connection-test-query: SELECT 1

多数据源配置类定义如下:

/**
 * 数据源1 - 配置
 *
 * 注:默认仅@Primary主数据源支持事务@Transactional
 *
 * @author luohq
 * @date 2022-08-06
 */
@Configuration
//注意此处需扫描对应数据源包下的mapper接口,且sqlSessionFactory为当前类中定义的SqlSessionFactory
@MapperScan(basePackageClasses = {
    
    MyDataMapper1.class}, sqlSessionFactoryRef = "ds1SqlSessionFactory")
public class DataSourceConfig1 {
    
    

    @Primary // 表示这个数据源是默认数据源, 这个注解必须要加,因为不加的话spring将分不清楚那个为主数据源(默认数据源)
    @Bean("ds1DataSource")
    @ConfigurationProperties(prefix = "spring.datasource.ds1") //读取application.yml中的配置参数映射成为一个对象
    public DataSource ds1DataSource1() {
    
    
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean("ds1SqlSessionFactory")
    public SqlSessionFactory ds1SqlSessionFactory(@Qualifier("ds1DataSource") DataSource dataSource) throws Exception {
    
    
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        // mapper的xml形式文件位置必须要配置,不然将报错:no statement (这种错误也可能是mapper的xml中,namespace与项目的路径不一致导致)
        bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/ds1/*.xml"));
        return bean.getObject();
    }

    @Primary
    @Bean("ds1SqlSessionTemplate")
    public SqlSessionTemplate ds1SqlSessionTemplate(@Qualifier("ds1SqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
    
    
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

----------------------------------------------------------------------------

/**
 * 数据源2 - 配置
 * 注:默认仅@Primary主数据源支持事务@Transactional,当前非主数据源不支持事务
 * @author luohq
 * @date 2022-08-06
 */
@Configuration
//注意此处需扫描对应数据源包下的mapper接口,且sqlSessionFactory为当前类中定义的SqlSessionFactory
@MapperScan(basePackageClasses = {
    
    MyDataMapper2.class}, sqlSessionFactoryRef = "ds2SqlSessionFactory")
public class DataSourceConfig2 {
    
    

    @Bean("ds2DataSource")
    @ConfigurationProperties(prefix = "spring.datasource.ds2")
    public DataSource ds2DataSource(){
    
    
        return DataSourceBuilder.create().build();
    }

    @Bean("ds2SqlSessionFactory")
    public SqlSessionFactory ds2SqlSessionFactory(@Qualifier("ds2DataSource") DataSource dataSource) throws Exception {
    
    
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/ds2/*.xml"));
        return bean.getObject();
    }

    @Bean("ds2SqlSessionTemplate")
    public SqlSessionTemplate ds2SqlSessionTemplate(@Qualifier("ds2SqlSessionFactory") SqlSessionFactory sqlSessionFactory){
    
    
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

在使用多数据源时,仅需将不同数据源包下的Mapper接口注入使用即可,如:

/**
 * <p>
 * 我的数据 服务实现类
 * </p>
 *
 * @author luohq
 * @since 2022-08-06
 */
@Service
public class MyDataServiceImpl implements IMyDataService {
    
    

    @Resource
    private MyDataMapper1 myDataMapper1;
    @Resource
    private MyDataMapper2 myDataMapper2;

    @Override
    public MyData findByIdFromDs1(Long id) {
    
    
        return this.myDataMapper1.selectById(id);
    }

    @Override
    public MyData findByIdFromDs2(Long id) {
    
    
        return this.myDataMapper2.selectById(id);
    }

    /**
     * 仅@Primary主数据源ds1支持事务,非主数据源ds2不支持事务
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Integer addBothData(MyData myData1, MyData myData2) {
    
    
        Integer retCount1 = this.myDataMapper1.insert(myData1);
        Integer retCount2 = this.myDataMapper2.insert(myData2);
        if (true) {
    
    
            throw new RuntimeException("业务异常 - 制造数据库回滚!");
        }
        return retCount1 + retCount2;
    }

    /**
     * 仅@Primary主数据源支持事务
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Integer addData1(MyData myData) {
    
    
        Integer retCount = this.myDataMapper1.insert(myData);
        return retCount;
    }

    /**
     * 非主数据源不支持事务
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Integer addData2(MyData myData) {
    
    
        Integer retCount = this.myDataMapper2.insert(myData);
        return retCount;
    }
}

以上方式确实可以实现多数据源,但是此种方式存在如下问题:

扫描二维码关注公众号,回复: 15273311 查看本文章
  • @Transactional开启的事务 仅支持之前声明为 @Primary的主数据源,不支持其他 非@Primary数据源
    • 单独调用 @Primary数据源 支持事务,参见上例代码中的MyDataServiceImpl.addData1
    • 单独调用 非@Primary数据源 不支持事务,参见上例代码中的MyDataServiceImpl.addData2
    • 组合调用多数据源也仅 @Primary数据源 支持事务,如上例代码中addBothData同时调用myDataMapper1和myDataMapper2,实际测试myDataMapper1的操作支持事务,而myDataMapper2完全脱离了当前事务的管理。
    • 综上此种事务控制场景比较适合读写分离的场景(一主一从)@Primary主数据源仅作写操作,如MyDataWriteMapper.java,而其他非@Primary数据源仅作读操作,如MyDataReadMapper.java。
  • 若不同数据源对应不同的DB数据结构,又或者之前提到的读写SQL分离,则不同数据源包下定义不同的Mapper接口、Mapper.xml这种没有问题,但是对于类似多租户场景下,仅是对数据存储位置进行隔离,而不同数据源间的数据结构都是一样的,这时再在不同数据源包下维护多套相同的Mapper接口、Mapper.xml显然是不合理的。
    • 综上通过原生Mybatis分包划分多数据源的架构也不适合多租户架构

以上示例源码参见:
https://gitee.com/luoex/multi-datasource-demo/tree/master/mb-package-multi-ds

方式2 - 使用Mybatis-Plus及对应的Dynamic-Datasource扩展【推荐】

在实际开发时,我这边多数都是直接使用Mybatis-Plus作为DAO层,Mybatis-Plus作为Mybatis的增强,提供了很多开箱即用的方便特性,比如内建的CRUD操作、强大的基于Wrapper的条件构造器、分页、ID生成等等。在Mybatis-Plus生态中作者也提供了多数据源方案,即基于dynamic-datasource-spring-boot-starter的实现:

<!-- Mybatis-Plus依赖 -->
<dependency>
	<groupId>com.baomidou</groupId>
	<artifactId>mybatis-plus-boot-starter</artifactId>
	<version>3.5.2</version>
</dependency>

<!-- Dynamic-DataSource多数据源依赖 -->
<dependency>
	<groupId>com.baomidou</groupId>
	<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
	<version>3.5.1</version>
</dependency>

该dynamic-datasource扩展代码是开源的:
https://github.com/baomidou/dynamic-datasource-spring-boot-starter
https://gitee.com/baomidou/dynamic-datasource-spring-boot-starter
但是文档是付费的:
https://www.kancloud.cn/tracy5546/dynamic-datasource/2264611
我看的文档是公司小伙伴付费买的,公司内是可以传播的,但不可以在网络上传播。

dynamic-datasource集成还是比较方便的,同时支持Druid、HikariCP等诸多连接池。
以集成HikariCP连接池为例,application.yml配置如下:

# dynamic-datasource多数据源配置
spring:
  datasource:
    dynamic:
      primary: ds1 #设置默认的数据源或者数据源组,默认值即为master
      strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
      hikari: # 全局hikariCP参数,所有值和默认保持一致。(现已支持的参数如下,不清楚含义不要乱设置)
        connection-timeout: 30000
        max-pool-size: 10
        min-idle: 5
        idle-timeout: 180000
        max-lifetime: 1800000
        connection-test-query: SELECT 1
      datasource:
        ds1: # 数据源名称即对应连接池名称
          url: jdbc:mysql://localhost:3306/multi-ds-1?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
          username: root
          password: 123456
          driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
          hikari: # 当前数据源HikariCP参数(继承全局、部分覆盖全局)
            max-pool-size: 20
        ds2:
          url: jdbc:mysql://localhost:3306/multi-ds-2?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
          username: root
          password: 123456
          driver-class-name: com.mysql.cj.jdbc.Driver
          hikari:
            max-pool-size: 15

# Mybatis-Plus相关配置
mybatis-plus:
  global-config:
    db-config:
      id-type: assign_id

代码结构如下图,对比之前提到的基于原生Mybatis分包的方式,此种方式不需要对Mapper接口、Mapper.xml进行分包:
在这里插入图片描述
切换数据源时,可通过在Service实现类中对应方法通过@DS("具体配置中的数据源名称")指定对应的数据源:

import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.luo.demo.multi.ds.dynamic.dto.MyDataQueryDto;
import com.luo.demo.multi.ds.dynamic.entity.MyData;
import com.luo.demo.multi.ds.dynamic.mapper.MyDataMapper;
import com.luo.demo.multi.ds.dynamic.service.IMyDataService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;
import java.util.Objects;

/**
 * <p>
 * 我的数据 服务实现类
 * </p>
 *
 * @author luohq
 * @since 2022-08-07
 */
@Service
public class MyDataServiceImpl implements IMyDataService {
    
    
    @Resource
    private MyDataMapper myDataMapper;

    @Override
    @DS("ds1")
    public MyData findByIdFromDs1(Long id) {
    
    
        //selectById - 支持自动拼接租户Id参数
        return this.myDataMapper.selectById(id);
    }

    @Override
    @DS("ds1")
    public MyData findByQueryFromDs1(MyDataQueryDto myDataQueryDto) {
    
    
        //QueryWrapper - 支持自动拼接租户Id参数
        return this.myDataMapper.selectOne(Wrappers.<MyData>lambdaQuery()
                .eq(Objects.nonNull(myDataQueryDto.getId()), MyData::getId, myDataQueryDto.getId())
                .like(StringUtils.hasText(myDataQueryDto.getMyName()), MyData::getMyName, myDataQueryDto.getMyName()));
    }

    @Override
    public MyData findByName(String myName) {
    
    
        //mapper.xml自定义查询 - 支持自动拼接租户Id参数
        return this.myDataMapper.selectByName(myName);
    }

    @Override
    @DS("ds2")
    public MyData findByIdFromDs2(Long id) {
    
    
        return this.myDataMapper.selectById(id);
    }

    /**
     * 单@Transactional内不支持切换数据源,
     * 即先使用ds1,则后续一直使用同一ds1连接,
     * 当前事务生效,但都会插入ds1中
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Integer addBothData(MyData myData1, MyData myData2) {
    
    
        Integer retCount1 = this.addData1(myData1);
        Integer retCount2 = this.addData2(myData2);
        //if (true) {
    
    
        //    throw new RuntimeException("业务异常 - 制造数据库回滚!");
        //}
        return retCount1 + retCount2;
    }

    /**
     * 支持事务
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    @DS("ds1")
    public Integer addData1(MyData myData) {
    
    
        //支持自动设置tenantId
        Integer retCount = this.myDataMapper.insert(myData);
        return retCount;
    }

    /**
     * 支持事务
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    @DS("ds2")
    public Integer addData2(MyData myData) {
    
    
        //支持自动设置tenantId
        Integer retCount = this.myDataMapper.insert(myData);
        return retCount;
    }
}

关于@DS注解需要注意:

  • @DS注解基于AOP实现
  • @DS推荐放在Service实现类的方法上,亦可以注解在Mapper接口上(不是Mapper方法上)
  • @DS+@Transactional支持事务,但 @Transactional方法内不支持切换数据源,参见上面示例代码中的MyDataServiceImpl.addBothData,即先使用ds1,则后续一直使用同一ds1连接,当前事务生效,但都会插入ds1中。
    • 开启了事务后,spring事务管理器会保证在事务下整个线程后续拿到的都是同一个connection。

以上示例源码参见:
https://gitee.com/luoex/multi-datasource-demo/tree/master/mp-dynamic-ds

2.1 @DSTransactional

dynamic-datasource提供自定义的 @DSTransactional 注解,

  • 支持多数据源间的本地事务
  • @DSTransactional方法内支持切换数据源,需跨服务调用切换DS,否则仅使用第一个数据源
  • @DSTransactional核心原理就是代理connection,并根据不同数据库获取到一个connection放入ConnectionFactory。 如果成功了整体提交,失败了整体回滚。

关于使用@DSTransactional支持多数据源本地事务的示例代码如下:

/**
 * 本地多数据源事务 - 测试服务实现类<br/>
 *
 * @author luohq
 * @date 2022-08-09 13:42
 */
@Service
public class MyDataMultiDsLocalTxServiceImpl implements IMyDataMultiDsLocalTxService {
    
    

    @Resource
    private IMyDataService myDataService;

    /**
     * 此处需使用@DSTransactional,需注意不是Spring @Transactional,
     * 使用@DSTransactional支持切换数据源,而@Transactional方法中无法切换数据源
     * 注:需跨服务调用切换DS,否则仅使用第一个数据源,即2条记录都插入到ds1中
     */
    @Override
    @DSTransactional
    //@DS("ds1") //如果ds1是默认数据源则不需要DS注解。
    public Integer addBothData(MyData myData1, MyData myData2) {
    
    
        Integer retCount1 = this.myDataService.addData1(myData1);
        Integer retCount2 = this.myDataService.addData2(myData2);
        //if (true) {
    
    
        //    throw new RuntimeException("测试多数据源异常回滚!");
        //}
        return retCount1 + retCount2;
    }

}

------------------------------------------------------------------------------------

/**
 * <p>
 * 我的数据 服务实现类
 * </p>
 *
 * @author luohq
 * @since 2022-08-07
 */
@Service
public class MyDataServiceImpl extends ServiceImpl<MyDataMapper, MyData> implements IMyDataService {
    
    

    @Resource
    private MyDataMapper myDataMapper;

    /**
     * 支持事务 - @DSTransactional区别于Spring @Transactional
     */
    @Override
    @DSTransactional
    @DS("ds1")
    public Integer addData1(MyData myData) {
    
    
        //支持自动设置tenantId
        Integer retCount = this.myDataMapper.insert(myData);
        return retCount;
    }
    /**
     * 支持事务 - @DSTransactional区别于Spring @Transactional
     */
    @DS("ds2")
    @Override
    @DSTransactional
    public Integer addData2(MyData myData) {
    
    
        //支持自动设置tenantId
        Integer retCount = this.myDataMapper.insert(myData);
        return retCount;
    }
}

示例代码中MyDataMultiDsLocalTxServiceImpl主服务,该主服务中addBothData方法跨服务调用MyDataServiceImpl中的使用@DS(“ds1)的addData1和使用@DS(“ds2”)的addData2,即主服务使用默认数据源(主服务方法亦可通过@DS(”…")指定数据源,未指定则使用默认),调用不同数据源的服务。
关于各服务方法上事务注解及最终效果总结为下表:

主服务
MyDataMultiDsLocalTxServiceImpl.addBothData
ds1服务
@DS(“ds1”) MyDataServiceImpl.addData1
ds2服务
@DS(“ds1”) MyDataServiceImpl.addData1
效果
@DSTransactional @DSTransactional @DSTransactional 调用主服务支持全局事务提交、回滚,
单独调用ds服务各自支持事务
@DSTransactional @Transactional @Transactional 调用主服务支持全局事务提交、回滚,
单独调用ds服务各自支持事务
@DSTransactional 调用主服务支持全局事务提交、回滚,
单独调用ds服务不支持事务
@DSTransactional @DSTransactional 不支持全局事务,
调用ds服务各自管理自身事务
@Transactional Spring @Transactional不支持切换数据源

2.2 多数据源事务扩展

上面提到的 @DSTransactional 支持多数据源本地事务,如何定义多数据源本地事务
参考如下服务分布图:
在这里插入图片描述
其中橙黄色的ServiceA|B|C为服务实例,而蓝色Resource即为各自服务实例对应数据库存储实例,
整个服务调用链组成了一个分布式事务,而各自Service实例自身的事务管理即为本地事务
如下图中的绿框即标记出了各自的本地事务,其中ServiceA和ServiceC仅包含唯一的数据源,而ServiceB同时使用2个数据源,即ServiceB的需要管理的事务即为多数据源本地事务
在这里插入图片描述
之前提到Dynamic-Datasource中的 @DSTransactional 注解,

  • 单独使用此注解是可以解决 单体应用 或 不涉及分布式事务的单个微服务应用多数据源本地事务这种场景的

而如上图中的完整的 分布式事务(且 存在单个服务包含多个数据源的情况) 场景,可以结合Seata:

注:
在引入多数据源时需谨慎,尤其是微服务场景下,可以尽早考虑将多个数据源各自拆分到不同的单个服务中,

  • 拆分后单个服务中仅包含唯一数据源,降低开发难度,便于针对单数据源进行优化,
  • 此时可直接使用Spring原生@Transactional管理单数据源本地事务(即无需引入dynamic-datasource扩展),
  • 若有分布式事务管理场景,可再引入Seata。

2.3 多租户

使用Mybatis-Plus及对应的Dynamic-Datasource扩展,

  • 可以共用一套Mapper接口和Mapper.xml文件,
  • 而且其除了上面提到的支持使用@DS注解切换数据源,
  • 还支持程序在运行时手动切换数据源DynamicDataSourceContextHolder.push("ds1")
    • 还支持内建的基于@DS的动态解析数据源的能力
      • @DS(“#session.tenantName”) - 从session中获取
      • @DS(“#header.tenantName”) - 从请求头中获取
      • @DS(“#user.tenantName”) - 使用SPEL从参数中获取
  • 最重要的是还支持动态添加、移除数据源

通过上述特性描述,不难发现其和多租户架构相当契合:

  • 单个租户对应单独的数据源,可通过租户ID(如对应请求头TENANT-ID)获取对应的数据源,如
    • 租户ID直接对应数据源名称
    • 或者通过租户ID关联出对应的数据源名称
  • 不同租户用户访问系统时支持根据租户ID动态切换数据源
  • 租户支持添加或移除,同时租户对应的数据源在应用中也支持动态添或移除
    • 需考虑分布式部署中的数据源同步问题

以上基于Mybatis-Plus及Dynamic-Datasource扩展的方案即可实现一套多租户(物理隔离)架构,多租户实现方案后续会单开一遍文章介绍,本文不做重点描述。


以上仅介绍Mybatis-Plus及Dynamic-Datasource扩展的部分功能,感兴趣的小伙伴可以继续深入研究:

  • 如基于数据源分组的读写分离(一主多从)实现
    • master, slave_1, slave_2
    • MasterSlaveAutoRoutingPlugin
  • 自定义实现
  • 集成P6spy、Quratz、ShardingJdbc等
  • 集成Seata

参考:

Mybatis分包:
springboot-整合多数据源配置(MapperScan分包、mybatis plus - dynamic-datasource-spring-boot-starter)

Mybatis-Plus Dynamic Datasource:
https://baomidou.com/pages/a61e1b/
https://www.kancloud.cn/tracy5546/dynamic-datasource/2264611

猜你喜欢

转载自blog.csdn.net/luo15242208310/article/details/121330039