springboot2.6+Mybatis动态多数据源AOP切换(AbstractRoutingDataSource)

多数据源系列

1、springboot2.6+Mybatis静态多数据源(集成JTA(Atomikos案例)实现分布式事务控制)
2、springboot2.6+Mybatis动态多数据源AOP切换(AbstractRoutingDataSource)
3、springboot2.6+Mybatis注解多数据源使用dynamic-datasource-spring-boot-starter为依赖

说明

搭建多数据源有多种方式,上一篇博客介绍了一种最基本的方式搭建多数据源,就是把每个数据源配置了一个DataSource的Bean,这种方式显得比较繁琐,mapper也要放在不同的地方,这里介绍一种动态切换数据源的方式

为什么用动态数据源

其实换一个角度想想,我们使用多个SqlSessionFactory来各自连接不同的数据源是很有局限性的。当我们数据源数量比较多的时候类似上文的模板式的代码将充斥整个项目,配置起来比较的繁琐。而且,试想一下,我们并不是每时每刻都对各个数据源都需要进行操作,每个数据源又会保有一个基本的闲置连接数。这样对本就宝贵的系统内存和CPU等资源产生了浪费,所以,第二种方案就应运而生了–动态数据源。我举一个生活中比较形象的例子:工人使用的钻头,其实钻机是只需要一个的,我们只需要根据不同的墙壁材质和孔的形状需要去替换掉钻机上不同的钻头就可以适应各个场景了呀。而上文我们所做的事情是买了两套甚至多套的钻机(真的有点奢侈了!)

缺点是:使用AbstractRoutingDataSource无法在JTA分布式事务中切换数据源,即放弃掉jta多事务,如果强制使用,要继承template重写源码,效果跟第一篇文章差不多,所以放弃jta。

动态数据源方案

文件结构

在这里插入图片描述

maven引入:

     <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.2</version>
        <relativePath/> <!-- lookup parent from repository -->
        </parent>
         <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.2</version>
        </dependency>

application.yml 配置文件

   master1:
  url: jdbc:mysql://127.0.0.1:3306/master1?useUnicode=true&characterEncoding=UTF-8&useSSL=false&autoReconnect=true&failOverReadOnly=false&serverTimezone=GMT%2B8
  username: root
  password: 123456
  driverClassName: com.mysql.cj.jdbc.Driver
master2:
  url: jdbc:mysql://127.0.0.1:3306/master2?useUnicode=true&characterEncoding=UTF-8&useSSL=false&autoReconnect=true&failOverReadOnly=false&serverTimezone=GMT%2B8
  username: root
  password: 123456
  driverClassName: com.mysql.cj.jdbc.Driver
oracle:
  url: jdbc:oracle:thin:@10.132.212.63:1688:TESTDB
  username: flx
  password: flx202108
  driverClassName: oracle.jdbc.OracleDriver

logging:
  level:
    com.xkcoding: debug
    com.xkcoding.orm.mybatis.mapper: trace
    
server:
  port: 8080
#  servlet:
#    context-path: /demo

其实换一个角度想想,我们使用多个SqlSessionFactory来各自连接不同的数据源是很有局限性的。当我们数据源数量比较多的时候类似上文的模板式的代码将充斥整个项目,配置起来比较的繁琐。而且,试想一下,我们并不是每时每刻都对各个数据源都需要进行操作,每个数据源又会保有一个基本的闲置连接数。这样对本就宝贵的系统内存和CPU等资源产生了浪费,所以,第二种方案就应运而生了–动态数据源。我举一个生活中比较形象的例子:工人使用的钻头,其实钻机是只需要一个的,我们只需要根据不同的墙壁材质和孔的形状需要去替换掉钻机上不同的钻头就可以适应各个场景了呀。而上文我们所做的事情是买了两套甚至多套的钻机(真的有点奢侈了!)。

1.自定义一个动态数据源上下文类,该类依靠一个ThreadLocal的类变量类标识当前线程是须要访问哪个数据源

为什么要用ThreadLocal?点击下面。
ThreadLocal<String>和static String在多线程的区别

package com.orm.mybatis.dynamic.config;

/**
 * 自定义数据源切换类
 */
public class DatabaseContextHolder {

    private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>();

    /**
     * 设置数据源
     */
    public static void setDBKey(String dataSourceKey) {
        contextHolder.set(dataSourceKey);
    }

    /**
     * 取得当前数据源
     */
    public static String getDBKey() {
        return contextHolder.get();
    }

    /**
     * 清除上下文数据
     */
    public static void clearDBKey() {
        contextHolder.remove();
    }
}

2. 建立一个动态数据源

继承自spring的org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource。实现了determineCurrentLookupKey方法,该方法惟一须要作的事情就是从DynamicDataSourceContextHolder获取当前须要访问的数据库名称。

package com.orm.mybatis.dynamic.config;
/**
 * 动态数据源
 *
 */
public class DynamicDataSource extends AbstractRoutingDataSource {
    private static DynamicDataSource instance;
    private static byte[] lock=new byte[0];
    private static Map<Object,Object> dataSourceMap=new HashMap<Object, Object>();
//    private AtomicInteger count = new AtomicInteger(0);  //读写分离


    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);
        dataSourceMap.putAll(targetDataSources);
        // 必须添加该句,否则新添加数据源无法识别到
        super.afterPropertiesSet();
    }

    public Map<Object, Object> getDataSourceMap() {
        return dataSourceMap;
    }

    /**
     * 核心方法,切换数据源上下文 非读写分离
     */
    @Override
    protected Object determineCurrentLookupKey() {
        String dbKey = DatabaseContextHolder.getDBKey();
        System.out.println(Thread.currentThread().getName()+" dbKey:    "+dbKey);
        return dbKey;
    }

    /**
     * 核心方法,切换数据源上下文 读写分离
     */
//    @Override
//    protected Object determineCurrentLookupKey() {
//        String dbKey = DatabaseContextHolder.getDBKey();
//        if (dbKey.equals(DbUtil.DB_MASTER1)) // 如果是写库,直接返回
//            return dbKey;
//        // 读 简单负载均衡
//        int number = Math.abs(count.getAndAdd(1));
//        int lookupKey = number % 2;
//        if (lookupKey==1){
//        return DbUtil.DB_MASTER2;
//        }else{
//        return DbUtil.DB_MASTER3;
//        }
//    }

    @Override
    protected DataSource determineTargetDataSource() {
        return super.determineTargetDataSource();
    }

    private DynamicDataSource() {}

    public static synchronized DynamicDataSource getInstance(){
        if(instance==null){
            synchronized (lock){
                if(instance==null){
                    instance=new DynamicDataSource();
                }
            }
        }
        return instance;
    }

}

3.配置切面

在实际的项目开发中,不可能老是在访问数据库以前,调用DynamicDataSourceContextHolder.setDataSource,这样很差维护、繁琐、代码可阅读性也很差。因此,能够自定义一个注解,用于标识方法是要走从库仍是主库,而后用一个切面,切面对有相应注解的方法作加强,根据注解的属性,设置须要访问的数据源。
如果了解什么是AOP,点击下方连接

SpringBootAOP切面编程方法拦截和自定义注解拦截实现灵活的AOP切面配置

注解代码:

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
    String value() default DbUtil.DB_MASTER1;
}

DbUtil.class

public class DbUtil {

    /**数据库ds_master1**/
    public static final String DB_MASTER1 = "ds_master1";
    /**数据库ds_master2**/
    public static final String DB_MASTER2 = "ds_master2";
    /**数据库ds_master3**/
    public static final String DB_MASTER3 = "ds_master3";
}

切面代码:

package com.orm.mybatis.dynamic.config;
import com.orm.mybatis.dynamic.annotation.DataSource;
import com.orm.mybatis.dynamic.util.DbUtil;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;

@Order(-10)   //加上这个后,会先切面切换数据源再切换事务,可以在方法里叠加    @Transactional @DataSource(value = DbUtil.DB_MASTER1) 这样就会先切换数据源再添加事务,不会导致无法切换数据源
@Aspect
@Component
public class AOPAspectAnnotation {

//    private AtomicInteger count = new AtomicInteger(0);  //读写分离

    @Pointcut(value = "@annotation(com.orm.mybatis.dynamic.annotation.DataSource)")  //注意这里是全文件匹配函数
    public void getPoint() {
    }

    @Before("getPoint()")
    public void setPointAcc1(){
        System.out.println("BeforeGetPoint");
    }

    @Around("getPoint()")
    public Object getDoAround(ProceedingJoinPoint pjp){
        ThreadLocal<Long> startTime = new ThreadLocal<>();
        startTime.set(System.currentTimeMillis());
        System.out.println("我是环绕通知执行");
        Object obj;
        try{
            DataSource dataSource = null;
            MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
            Method method = methodSignature.getMethod();
            dataSource =  method.getAnnotation(DataSource.class);
            if (dataSource==null){
                System.out.println("空值");
                System.out.println(DbUtil.DB_MASTER1);
                DatabaseContextHolder.setDBKey(dataSource.value());
                obj = pjp.proceed();
                System.out.println("执行返回值 : " + obj);
            }else {
                System.out.println(dataSource.value());
//                if (dataSource.value().equals(DbUtil.DB_MASTER1)){ //读写分离 start
                DatabaseContextHolder.setDBKey(dataSource.value());
//                }else {                                            //读写分离 middle
//             // 读 简单负载均衡
//             int number = Math.abs(count.getAndAdd(1));
//             int lookupKey = number % 2;
//             if (lookupKey==1){
//                 DatabaseContextHolder.setDBKey(DbUtil.DB_MASTER2);
//             }else{
//                 DatabaseContextHolder.setDBKey(DbUtil.DB_MASTER2);
//             }
//                }                                                 //读写分离 end
                obj = pjp.proceed();
                System.out.println("执行返回值 : " + obj);
            }
            System.out.println(pjp.getSignature().getName()+"方法执行耗时: " + (System.currentTimeMillis() - startTime.get()));
        } catch (Throwable throwable) {
            System.out.println(throwable+"报错");
            obj=throwable.toString();
        }
        return obj;
    }

    @After("getPoint()")
    public void setPointAcc2(){
        System.out.println("AfterGetPoint");
        DatabaseContextHolder.clearDBKey();
    }

    /**
     * 执行完请求可以做的
     * @param result
     * @throws Throwable
     */
    @AfterReturning(returning = "result", pointcut = "getPoint()")
    public void doAfterReturning(Object result){
        System.out.println("大家好,我是@AfterReturning,他们都秀完了,该我上场了"+result);
    }
}

其中@Order是很重要的,必须确保DynamicDataSourceAspect的执行优先于TranctionInterceptor。否则数据源的指定就没法生效(数据源的指定在数据库链接的获取以后!!)

4.配置动态数据源

/**
 * 数据源配置
 */
@Configuration
// 扫描 Mapper 接口并容器管理
@MapperScan(basePackages = DatasourceConfig.PACKAGE, sqlSessionTemplateRef = "sqlSessionTemplate")
public class DatasourceConfig {
    // mapper扫描
    static final String PACKAGE = "com.orm.mybatis.dynamic.mapper.*";
    static final String MAPPER_LOCATION = "classpath:mapper/*/*.xml";

    @Value("${master1.url}")
    private String url1;

    @Value("${master1.username}")
    private String user1;

    @Value("${master1.password}")
    private String password1;

    @Value("${master1.driverClassName}")
    private String driverClass1;

    @Value("${master2.url}")
    private String url2;

    @Value("${master2.username}")
    private String user2;

    @Value("${master2.password}")
    private String password2;

    @Value("${master2.driverClassName}")
    private String driverClass2;

    @Value("${oracle.url}")
    private String oracleUrl;

    @Value("${oracle.username}")
    private String oracleUser;

    @Value("${oracle.password}")
    private String oraclePassword;

    @Value("${oracle.driverClassName}")
    private String oracleDriverClass;

    @Bean(name = "dynamicDataSource")
    @Primary
    public DynamicDataSource dynamicDataSource() {
        DynamicDataSource dynamicDataSource = DynamicDataSource.getInstance();

        // master1数据源
        DataSource dataSourceMaster1 = initDataSource(driverClass1,url1,user1,password1);  //非分布式事务锁
        // master2数据源
        DataSource dataSourceMaster2 = initDataSource(driverClass2,url2,user2,password2);  //非分布式事务锁
        // master3数据源
        DataSource dataSourceMaster3 = initDataSource(oracleDriverClass,oracleUrl,oracleUser,oraclePassword); //非分布式事务锁

        Map<Object,Object> map = new HashMap<>();
        map.put(DbUtil.DB_MASTER1, dataSourceMaster1);
        map.put(DbUtil.DB_MASTER2, dataSourceMaster2);
        map.put(DbUtil.DB_MASTER3, dataSourceMaster3);
        dynamicDataSource.setTargetDataSources(map);
        // 默认数据源
        dynamicDataSource.setDefaultTargetDataSource(dataSourceMaster1);
        return dynamicDataSource;
    }

    /**   非分布式事务
     * 初始数据源
     * @param driverClass    驱动
     * @param url       数据库连接
     * @param user  用户名
     * @param password  密码
     * @return
     */
    public HikariDataSource initDataSource(String driverClass, String url, String user, String password){
        //jdbc配置
        HikariDataSource hikariDataSource = new HikariDataSource();
        hikariDataSource.setDriverClassName(driverClass);
        hikariDataSource.setJdbcUrl(url);
        hikariDataSource.setUsername(user);
        hikariDataSource.setPassword(password);
        setPool(hikariDataSource);
        return hikariDataSource;
    }


    /**   非事务分布式
     * 连接池配置
     * @param hikariDataSource
     */
    private void setPool(HikariDataSource hikariDataSource){
        //连接池配置
        hikariDataSource.setMinimumIdle(5);
        hikariDataSource.setMaximumPoolSize(20);
        hikariDataSource.setAutoCommit(true);
        hikariDataSource.setPoolName("SpringBootDemoHikariCP");
        hikariDataSource.setMaxLifetime(1800000);
        hikariDataSource.setIdleTimeout(600000);
        hikariDataSource.setConnectionTimeout(30000);
    }


    @Bean(name = "sqlSessionFactory")
    @Primary
    public SqlSessionFactory sqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dynamicDataSource)
            throws Exception {
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(dynamicDataSource);
        sessionFactory.setTypeAliasesPackage("com.orm.mybatis.dynamic.entity");
        org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
        configuration.setMapUnderscoreToCamelCase(true);
        sessionFactory.setConfiguration(configuration);
        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources(DatasourceConfig.MAPPER_LOCATION));
        return sessionFactory.getObject();
    }

    /**
     * 设置动态数据源DynamicDataSource到会话工厂
     */
    @Bean(name = "sqlSessionTemplate")
    public SqlSessionTemplate sqlSessionTemplate(@Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }

    /**
     * 将动态数据源添加到事务管理器中,并生成新的bean
     *
     * 切库与事务注意:
     *   1.有@Transactional注解的方法,方法内部不可以做切换数据库 操作
     *   2.在同一个service其他方法调用带@Transactional的方法,事务不起作用
     *   3.在应用中因为使用了 DAO 层的切面切换数据源,所以 @Transactional 注解不能加在类上,只能用于方法;有 @Trasactional注解的方法无法切换数据源
     * @return 事务管理实例
     */
    @Bean
    public PlatformTransactionManager platformTransactionManager(@Qualifier("dynamicDataSource") DataSource dynamicDataSource) {
        return new DataSourceTransactionManager(dynamicDataSource);
    }
}

这一句很重要dynamicDataSource.setDefaultTargetDataSource(dataSourceMaster1);
当调用没有添加@DataSource注解的方法时,默认走主库。到这一步,读写分离的基础都已经有了,接下来只须要按咱们日常调用单数据源那样配置mybatis就能够

UserServiceImpl 业务层

@Service
public class UserServiceImpl {

    @Resource
    private UserMapper1 userMapper1;

    @Resource
    private UserMapper2 userMapper2;

    @Resource
    private AsusPoInfoMapper3 asusPoInfoMapper3;

    public List<User> findAllUser(){
        DatabaseContextHolder.setDBKey(DbUtil.DB_MASTER1);
        List<User> list = userMapper1.selectAllUser();
        DatabaseContextHolder.clearDBKey();
        return list;
    }

    public void testTransitional() {
    ((UserServiceImpl)AopContext.currentProxy()).saveUserMapper2();//调用本类方法如想触发事务需要增强代理。
    ((UserServiceImpl)AopContext.currentProxy()).saveUserMapper1();
    ((UserServiceImpl)AopContext.currentProxy()).saveUserMapper3();
    }

    @Transactional
    @DataSource(value = DbUtil.DB_MASTER1)
    public void saveUserMapper1(){
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String date =  simpleDateFormat.format(new Date());
        String UUID = java.util.UUID.randomUUID().toString().substring(0,5);
        User user = User.builder().email("andrew@qq.com"+UUID).name("andrew"+UUID).password("123456"+UUID).phoneNumber("123"+UUID)
                .lastUpdateTime(date).createTime(date).status(0).salt("password"+UUID).build();
        userMapper2.saveUser(user);
    }

    @Transactional
    @DataSource(value = DbUtil.DB_MASTER2)
    public void saveUserMapper2(){
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String date =  simpleDateFormat.format(new Date());
        String UUID = java.util.UUID.randomUUID().toString().substring(0,5);
        User user = User.builder().email("andrew@qq.com"+UUID).name("andrew"+UUID).password("123456"+UUID).phoneNumber("123"+UUID)
                .lastUpdateTime(date).createTime(date).status(0).salt("password"+UUID).build();
        userMapper2.saveUser(user);
    }


    @DataSource(value = DbUtil.DB_MASTER3)
    @Transactional
    public void saveUserMapper3(){
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String date =  simpleDateFormat.format(new Date());
        Random random = new Random(500);
        int a = random.nextInt();
        AsusPoInfo asusPoInfo = AsusPoInfo.builder().id(UUID.randomUUID().toString().substring(0,20))
                .woNo("andrew").po("123456").poLine("poline").cPo("cpo123456").shipType("Direct").build();
        asusPoInfoMapper3.insertAsusPoInfo(asusPoInfo);
//        throw new RuntimeException();
    }

}

测试多数据源回滚

package com.orm.mybatis.dynamic;
public class UserTest extends AndrewApplicationTests {

    @Resource
    private UserServiceImpl userService;

    @Test
    public void test1(){
        System.out.println(userService.findAllUser());
    }

    @Test
    public void testTransitionalAspect() throws InterruptedException {
       userService.testTransitional();
    }

切换数据源成功,而且事务能回滚,但如果是多数据源事务,只能回滚报错的数据源的事务。

方案的权衡

  1. 静态多数据源方案优势在于配置简单并且对业务代码的入侵性极小,缺点也显而易见:我们需要在系统中占用一些资源,而这些资源并不是一直需要,一定程度上会造成资源的浪费。如果你需要在一段业务代码中同时使用多个数据源的数据又要去考虑操作的原子性(事务)可以用spring的jta实现事务,那么这种方案无疑会适合你。
  2. (aop和dynamic)动态数据源(AbstractRoutingDataSource)方案配置上看起来配置会稍微复杂一些,但是很好的符合了“即拿即用,即用即还”的设计原则,我们把多个数据源看成了一个池子,然后进行消费。它的缺点正如上文所暴露的那样:我们往往需要在事务的需求下做出妥协。而且由于需要切换环境上下文,在高并发量的系统上进行资源竞争时容易发生死锁等活跃性问题。我们常用它来进行数据库的“读写分离”,不需要在一段业务中同时操作多个数据源。这种动态形式并不能用spring的jta实现,而且其他实现方式(seata等)虽然可以实现,但配置复杂且实用度不高。
  3. 如果需要使用事务,一定记得使用分布式事务进行Spring自带事务管理的替换,否则将无法进行一致性控制。
  4. 写到这里本文也就结束,好久没有撰写文章很多东西考虑不是很详尽,谢谢批评指正!

项目地址

springboot2.6+mybatis
https://gitee.com/liuweiqiang12/springboot-mybatis-dynamic-datasource

springboot2.6+mybatis-plus
https://gitee.com/liuweiqiang12/springboot-mybatis-plus-dynamic-datasource

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
Spring Boot 是一个用于快速构建 Java 应用程序的框架。它可以与多种其他框架和组件进行整合,以实现更丰富的功能。在这里,我们将讨论如何使用 Spring Boot 整合 Druid、MyBatis、JTA 分布式事务以及多数据源,同时使用 AOP 注解实现动态切换。 首先,我们可以在 Spring Boot 中集成 Druid 数据源。Druid 是一个高性能的 JDBC 连接池,可以提供监控和统计功能。我们可以通过在 pom.xml 文件中添加相关的依赖,并在 application.properties 文件中配置数据源信息,来实现 Druid 的集成。 接下来,我们可以整合 MyBatis 框架,它是一种优秀的持久化解决方案。我们可以使用 MyBatis 来操作数据库,并将其与 Druid 数据源进行整合。为此,我们需要在 pom.xml 文件中添加 MyBatisMyBatis-Spring 的依赖,并配置 MyBatis 的相关配置文件。 此外,我们还可以使用 JTA(Java Transaction API)实现分布式事务。JTA 可以在分布式环境中协调多个参与者的事务操作。我们可以在 pom.xml 文件中添加 JTA 的依赖,并在 Spring Boot 的配置文件中配置 JTA 的相关属性,以实现分布式事务的支持。 在实现多数据源时,我们可以使用 Spring Boot 的 AbstractRoutingDataSource 来实现动态切换数据源。这个类可以根据当前线程或其他条件选择不同的数据源来进行数据操作。我们可以通过继承 AbstractRoutingDataSource 并实现 determineCurrentLookupKey() 方法来指定当前数据源的 key。然后,在配置文件中配置多个数据源,并将数据源注入到 AbstractRoutingDataSource 中,从而实现动态切换。 最后,我们可以使用 AOP(Aspect Oriented Programming)注解来实现动态切换AOP 是一种编程范式,可以通过在代码中插入特定的切面(Aspect)来实现横切关注点的处理。我们可以在代码中使用注解来标记需要切换数据源的方法,然后使用 AOP 技术来拦截这些方法,并根据注解中指定的数据源信息来进行数据源切换。 综上所述,通过整合 Druid、MyBatis、JTA 分布式事务以及多数据源,并使用 AOP 注解实现动态切换,我们可以在 Spring Boot 中实现强大而灵活的应用程序。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

叶孤崖

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值