Mybatis原理浅尝

Mybatis核心对象解释

  • Configuration:初始化基础配置,比如mybatis的别名、一些重要的类型对象,如,插件,映射器,ObjectFactory和typeHandler对象,MyBatis所有的配置信息都维持在Configuration对象之中。

  • SqlSessionFactory: SqlSession工厂,默认是DefaultSqlSessionFactory。

  • SqlSession:mybatis工作的主要顶级API,每一个SqlSession对象表示一次与数据库交互的回话,完成必要的增删改查功能。

  • Executor: mybatis执行器,是mybatis的调度中心,负责SQL语句的生成和查询缓存的维护

  • StatementHandler: 封装了JDBC Statement操作,作用是设置参数、将结果集转成list集合

  • ParameterHandler: 将用户传递的参数转换成JDBC Statement 所需要的参数

  • ResultSetHandler: 将JDBC返回的ResultSet结果集对象转换成List类型集合

  • TypeHandler: 负责java数据类型和jdbc数据类型之间的映射和转换

  • MappedStatement: MappedStatement维护了一条<select|update|delete|insert>节点的封装

  • SqlSource: 根据用户传递参数,动态生成SQL语句,将信息封装到BoundSql对象

  • BoundSql: 包含已生成的sql信息、及参数信息

Mybatis的Mapper对象创建过程

暂以Springboot项目为例,浅谈下Springboot项目启动后Mybatis的初始化加载过程。
我们先看一个最基础的Springboot和Mybatis的整合案例:https://gitee.com/wangkeqiang118706/poem.git。

我们知道Springboot项目特点是通过自动配置原理,通过扫描各jar包classpath/META-INF下的spring.factories文件,将application.properties或application.yml配置文件的配置信息自动注入到spring.factories文件中的各AutoConfig类中。

我们看下Mybatis的jar包META-INF/spring.factories究竟有什么内容,如下:
在这里插入图片描述
Mybatis的自动配置类MybatisAutoConfiguration,我们进到该类中会发现该配置类有声明了SqlSessionFactory、SqlSessionTemplate两个bean对象,这两个对象参考上文,我们都比较熟悉,SqlSessionFactory是会话工厂,SqlSessionTemplate就是特殊的SqlSession,它内部维护了一个特殊SqlSession(动态代理生成,auto_commit设置为true。
至于第三个AutoConfiguredMapperScannerRegistrar对象,其实我们通过代码能看到,只有当前工厂中不存在MapperFactoryBean对象时,MybatisAutoConfiguration.AutoConfiguredMapperScannerRegistrar对象才会被创建,那么我们先考虑下MapperFactoryBean究竟是什么?
在这里插入图片描述

MapperFactoryBean: 一言蔽之,MapperFactoryBean是用以创建Mybatis mapper对象的工厂bean,该类实现了FactoryBean接口,我们知道凡是实现FactoryBean接口的对象,在Spring工厂实例化对象时,是通过getObject()方法来完成的,那么Mapper的实例化与此方法有关。

断点1:在此留一个断点,我们稍后往下深究MapperFactoryBean的getObject()方法。

在这里插入图片描述
我们以debug模式启动项目时,会发现,当SqlSessionFactory、SqlSessionTemplate两个bean对象都已经加载完成时,第三个对象AutoConfiguredMapperScannerRegistrar对象却不会被创建,这说明MapperFactoryBean已经被创建过了,那么它是在什么时间被创建的呢?
原因是项目中配置了@MapperScan的注解。

@MapperScan(basePackages = “com.sclience.poem.dao”)

我们看下@MapperScan这个注解,该注解导入了一个bean对象MapperScannerRegistrar,MapperScannerRegistrar类实现了ImportBeanDefinitionRegistrar接口,并实现了registerBeanDefinitions方法,看名称我们能揣摩出大半,个人理解在spring工厂对bean实例化之前,需要先将各种bean定义为BeanDefinition结构,BeanDefinition可以理解为是对一个bean基本信息的描述。而MapperScannerRegistrar正是要将mapper对象先转换为BeanDefinition结构,为下一步的bean对象实例化做准备。
在这里插入图片描述

public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        AnnotationAttributes annoAttrs = AnnotationAttributes.fromMap(importingClassMetadata.getAnnotationAttributes(MapperScan.class.getName()));
        ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry);
        if (this.resourceLoader != null) {
            scanner.setResourceLoader(this.resourceLoader);
        }

        Class<? extends Annotation> annotationClass = annoAttrs.getClass("annotationClass");
        if (!Annotation.class.equals(annotationClass)) {
            scanner.setAnnotationClass(annotationClass);
        }

        Class<?> markerInterface = annoAttrs.getClass("markerInterface");
        if (!Class.class.equals(markerInterface)) {
            scanner.setMarkerInterface(markerInterface);
        }

        Class<? extends BeanNameGenerator> generatorClass = annoAttrs.getClass("nameGenerator");
        if (!BeanNameGenerator.class.equals(generatorClass)) {
            scanner.setBeanNameGenerator((BeanNameGenerator)BeanUtils.instantiateClass(generatorClass));
        }

        Class<? extends MapperFactoryBean> mapperFactoryBeanClass = annoAttrs.getClass("factoryBean");
        if (!MapperFactoryBean.class.equals(mapperFactoryBeanClass)) {
            scanner.setMapperFactoryBean((MapperFactoryBean)BeanUtils.instantiateClass(mapperFactoryBeanClass));
        }

        scanner.setSqlSessionTemplateBeanName(annoAttrs.getString("sqlSessionTemplateRef"));
        scanner.setSqlSessionFactoryBeanName(annoAttrs.getString("sqlSessionFactoryRef"));
        List<String> basePackages = new ArrayList();
        String[] var10 = annoAttrs.getStringArray("value");
        int var11 = var10.length;

        int var12;
        String pkg;
        for(var12 = 0; var12 < var11; ++var12) {
            pkg = var10[var12];
            if (StringUtils.hasText(pkg)) {
                basePackages.add(pkg);
            }
        }

        var10 = annoAttrs.getStringArray("basePackages");
        var11 = var10.length;

        for(var12 = 0; var12 < var11; ++var12) {
            pkg = var10[var12];
            if (StringUtils.hasText(pkg)) {
                basePackages.add(pkg);
            }
        }

        Class[] var14 = annoAttrs.getClassArray("basePackageClasses");
        var11 = var14.length;

        for(var12 = 0; var12 < var11; ++var12) {
            Class<?> clazz = var14[var12];
            basePackages.add(ClassUtils.getPackageName(clazz));
        }

        scanner.registerFilters();
        scanner.doScan(StringUtils.toStringArray(basePackages));
    }

方法内容较简单,最终的目的就是扫描所配置的包名,我们深究下scanner.doScan(StringUtils.toStringArray(basePackages))这个方法。
scanner是一个ClassPathMapperScanner对象,该类继承了Spring的ClassPathBeanDefinitionScanner,也就是Spring原生的包扫描类,项目中常见@Controller、@Service等Spring原生的注解均是该类扫描并转换为BeanDefinition结构。

ClassPathMapperScanner的doScan方法先调用了父类ClassPathBeanDefinitionScanner的doScan方法,完成了Mapper对象转BeanDefinition结构。

大家注意,这个地方有一个小细节,及其容易忽略: BeanDefinition有一个关键属性beanClassName,bean对象实例化时与此有关。而Mapper在转BeanDefinition结构时,beanClassName对应的是mapper接口的限定名,如com.xxx.xxx.mapper.PoetMapper, 那么问题来了,我们都知道接口是不能被直接实例化的,Spring工厂是如何实例化这些mapper接口对象的呢?

断点2:mapper接口怎么实例化?

继续看ClassPathMapperScanner的doScan方法,如下图,processBeanDefinitions方法将刚生成的BeanDefinition的beanClassName设置为了MapperFactoryBean.class。
在这里插入图片描述
看到这里,断点1的问题我们就可以接着说了,各mapper bean已经注册完成,下一步就可以开始实例化了,
而mapper对象的实例化就在MapperFactoryBean的getObject()方法中实现的。我们沿着MapperFactoryBean的getObject()方法往下深究,最终到了MapperRegistry的getMapper方法上。 通俗地讲,MapperRegistry的作用是注册和获取mapper接口的代理服务站,MapperRegistry的getMapper方法如下,该方法通过MapperProxyFactory 即mapper代理工厂基于jdk动态代理生成了mapper接口的一个MapperProxy代理对象。

public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
        MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory)this.knownMappers.get(type);
        if (mapperProxyFactory == null) {
            throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
        } else {
            try {
                return mapperProxyFactory.newInstance(sqlSession);
            } catch (Exception var5) {
                throw new BindingException("Error getting mapper instance. Cause: " + var5, var5);
            }
        }
    }

//mapperProxyFactory.newInstance代码

public T newInstance(SqlSession sqlSession) {
        MapperProxy<T> mapperProxy = new MapperProxy(sqlSession, this.mapperInterface, this.methodCache);
        return this.newInstance(mapperProxy);
    }

至此第二个断点疑问也解决了,是通过MapperProxyFactory生成了mapper接口的一个MapperProxy代理对象,完成了mapper bean的实例化。

不过我们要继续深究下MapperProxy,也就是我们要搞清楚mapper代理对象到底干了些什么:

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            if (Object.class.equals(method.getDeclaringClass())) {
                return method.invoke(this, args);
            }

            if (this.isDefaultMethod(method)) {
                return this.invokeDefaultMethod(proxy, method, args);
            }
        } catch (Throwable var5) {
            throw ExceptionUtil.unwrapThrowable(var5);
        }
		// 通过MapperMethod来执行mapper接口中方法
        MapperMethod mapperMethod = this.cachedMapperMethod(method);
        return mapperMethod.execute(this.sqlSession, args);
    }

// mapperMethod.execute方法如下
public Object execute(SqlSession sqlSession, Object[] args) {
        Object param;
        Object result;
        switch(this.command.getType()) {
        case INSERT:
            param = this.method.convertArgsToSqlCommandParam(args);
            result = this.rowCountResult(sqlSession.insert(this.command.getName(), param));
            break;
        case UPDATE:
            param = this.method.convertArgsToSqlCommandParam(args);
            result = this.rowCountResult(sqlSession.update(this.command.getName(), param));
            break;
        case DELETE:
            param = this.method.convertArgsToSqlCommandParam(args);
            result = this.rowCountResult(sqlSession.delete(this.command.getName(), param));
            break;
        case SELECT:
        	//如果有结果处理器
            if (this.method.returnsVoid() && this.method.hasResultHandler()) {
                this.executeWithResultHandler(sqlSession, args);
                result = null;
            } else if (this.method.returnsMany()) {
            	//如果结果有多条记录
                result = this.executeForMany(sqlSession, args);
            } else if (this.method.returnsMap()) {
            	//如果结果是map
                result = this.executeForMap(sqlSession, args);
            } else if (this.method.returnsCursor()) {
            	//否则就是一条记录
                result = this.executeForCursor(sqlSession, args);
            } else {
                param = this.method.convertArgsToSqlCommandParam(args);
                result = sqlSession.selectOne(this.command.getName(), param);
            }
            break;
        case FLUSH:
            result = sqlSession.flushStatements();
            break;
        default:
            throw new BindingException("Unknown execution method for: " + this.command.getName());
        }

        if (result == null && this.method.getReturnType().isPrimitive() && !this.method.returnsVoid()) {
            throw new BindingException("Mapper method '" + this.command.getName() + " attempted to return null from a method with a primitive return type (" + this.method.getReturnType() + ").");
        } else {
            return result;
        }
    }


MapperMethod 该类是一个映射器方法,封装了insert|update|delete|select四种常见,分别调用SqlSession的4大类方法。每一个MapperMethod对应了一个mapper文件中配置的一个sql语句或FLUSH配置,对应的sql语句通过mapper对应的class文件名+方法名从Configuration对象中获得。

看到这里是不是有种恍然大明白的感觉,原来所有的mapper接口操作,都转化为了操作SqlSession的方法了,这样一来,我们也不用再手动维护SqlSession对象了,全部交由spring工厂管理,代码量大大减少了,更利于维护,国安民乐,岂不美哉?!

补充一下,如果不使用@MapperScan注解,也可以在每个mapper接口上增加@Mapper注解,这样的话会走MybatisAutoConfiguration的
AutoConfiguredMapperScannerRegistrar类中的registerBeanDefinitions方法,扫描@Mapper注解,与上述过程无异。

Mybatis的加载过程

Mybatis四大组件

1. Executor

​ Executor是mybatis执行器,是mybatis的调度中心,负责SQL语句的生成和查询缓存的维护。SqlSession都会拥有一个Executor对象,这个对象负责增删改查的具体操作,我们可以简单的将Executor理解为JDBC中Statement的封装版。Executor继承结构如下图所示:

​ [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rvpertUj-1598490828892)(C:\Users\viruser.v-desktop\AppData\Roaming\Typora\typora-user-images\1597293403345.png)]

  1. BaseExecutor是一个抽象类,有三个子类:
    • SimpleExecutor: 简单执行器,是MyBatis中默认使用的执行器,每执行一次update或select,就开启一个Statement对象,用完就直接关闭Statement对象(可以是Statement或者是PreparedStatment对象)
    • ReuseExecutor: 可重用执行器,这里的重用指的是重复使用Statement,它会在内部使用一个Map把创建的Statement都缓存起来,每次执行SQL命令的时候,都会去判断是否存在基于该SQL的Statement对象,如果存在Statement对象并且对应的connection还没有关闭的情况下就继续使用之前的Statement对象,并将其缓存起来。因为每一个SqlSession都有一个新的Executor对象,所以我们缓存在ReuseExecutor上的Statement作用域是同一个SqlSession。
    • BatchExecutor: 批处理执行器,用于将多个SQL一次性输出到数据库
  2. CachingExecutor:缓存执行器,,先从缓存中查询结果,如果存在,就返回;如果不存在,再委托给Executor delegate 去数据库中取,delegate可以是上面任何一个执行器

2. StatementHandler

StatementHandler: 封装了JDBC Statement操作,作用是设置参数、将结果集转成list集合

  1. BaseStatementHandler是一个抽象类,有三个子类:
    • SimpleStatementHandler,这个很简单了,就是对应我们JDBC中常用的Statement接口,用于简单SQL的处理;
    • PreparedStatementHandler,这个对应JDBC中的PreparedStatement,预编译SQL的接口
    • CallableStatementHandler,这个对应JDBC中CallableStatement,用于执行存储过程相关的接口
  2. RoutingStatementHandler,这个接口是以上三个接口的路由,没有实际操作,只是负责根据MappedStatement中的StatementType来上面三个StatementHandler的创建及调用。

3. ParameterHandler

​ ParameterHandler: 将用户传递的参数转换成JDBC Statement 所需要的参数 。ParameterHandler只有一个实现类DefaultParameterHandler,较其他组件简单,负责为PreparedStatement 的 sql 语句参数动态赋值。它实现了两个方法。

  • getParameterObject: 用于读取参数

  • setParameters: 用于对 PreparedStatement 的参数赋值

参数处理器对象是在创建 StatementHandler 对象的同时被创建的,由 Configuration 对象负责创建,具体参照BaseStatementHandler类的构造方法:

protected BaseStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
  this.configuration = mappedStatement.getConfiguration();
  this.executor = executor;
  this.mappedStatement = mappedStatement;
  this.rowBounds = rowBounds;

  this.typeHandlerRegistry = configuration.getTypeHandlerRegistry();
  this.objectFactory = configuration.getObjectFactory();

  if (boundSql == null) { // issue #435, get the key before calculating the statement
    generateKeys(parameterObject);
    boundSql = mappedStatement.getBoundSql(parameterObject);
  }

  this.boundSql = boundSql;

  // 创建参数处理器
  this.parameterHandler = configuration.newParameterHandler(mappedStatement, parameterObject, boundSql);
  // 创建结果映射器
  this.resultSetHandler = configuration.newResultSetHandler(executor, mappedStatement, rowBounds, parameterHandler, resultHandler, boundSql);
}

我们可以细致梳理下ParameterHandler的创建过程,对应的Configuration 逻辑代码如下:

public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
        ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
        parameterHandler = (ParameterHandler)this.interceptorChain.pluginAll(parameterHandler);
        return parameterHandler;
    }

它实际上是交由 LanguageDriver 来创建具体的参数处理器,LanguageDriver 默认的实现类是 XMLLanguageDriver,由它调用 DefaultParameterHandler 中的构造方法完成 ParameterHandler 的创建工作。

此时可能会存在一个疑问,参数是如何而来的呢?我们知道Parameter中的参数是由Mapper接口和xml中的sql对应而来的,那这个参数是如何映射的呢? 我们继续往下深究:

我们知道Mybatis的sql主程序是MapperProxy的invoke方法,该类是基于jdk动态代理实现,通过处理sql及参数并封装成MappedStatement的对象,该类继承了HashMap。最终MapperProxy将执行交给了Executor 、StatementHandler进行对应的参数解析和执行,因为是带参数的sql语句,最终会创建PreparedStatementHandler对象并创建ParameterHandler参数解析器进行参数解析。最终在生成jdbc Statement对象方法中,会调用ParameterHandler的setParameters方法。

4. ResultSetHandler

ResultSetHandler: 将JDBC返回的ResultSet结果集对象转换成List类型集合,该接口只有一个实现类DefaultResultSetHandler.

该接口方法解释如下:

public interface ResultSetHandler {
    // 将Statement执行后产生的结果集(可能有多个结果集)映射为结果列表
  <E> List<E> handleResultSets(Statement stmt) throws SQLException;

  <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;
    // 处理存储过程执行后的输出参数
  void handleOutputParameters(CallableStatement cs) throws SQLException;

}

这个就不多解释了,主要功能就是:

  1. 获取结果集ResultSet
  2. 获取rersultMap,即结果列和实体类成员变量的对应关系
  3. 将结果集处理为对应的ResultMap对象
  4. 将RresultMap对象添加至集合
  5. 返回list集合

Mybatis拦截器

基础概念:

mybatis可以在执行语句的过程中对特定对象进行拦截调用,由上可得知主要有四个组件的方法可做拦截处理:

  1. Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed) 处理增删改查
  2. ParameterHandler (getParameterObject, setParameters) 设置预编译参数
  3. ResultSetHandler (handleResultSets, handleOutputParameters) 处理结果
  4. StatementHandler (prepare, parameterize, batch, update, query) 处理sql预编译,设置参数。

我们需要了解如下概念:

mybatis拦截器需要实现Interceptor接口,并实现其方法:

​ 1. intercept: 拦截器核心逻辑,通过Invocation可获取到具体的拦截对象

​ 2. setProperties:用于初始化

  1. plugin:包装目标对象供拦截器处理,基于动态代理实现,一般方法返回Plugin.wrap(target, this); this代指 拦截器对象。

    ​ 我们需要着重了解下plugin.wrap方法,该方法代码如下,主要逻辑是获取拦截器@Intercepts注解中包含了哪些@Signature,, 一个Signature包含了需要拦截的类及方法名和参数类型。

    ​ Plugin类是动态代理类,对实现Interceptor接口的类进行处理,而实现的拦截器会被加入到 拦截器链进行处理。

    public static Object wrap(Object target, Interceptor interceptor) {
            Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
            Class<?> type = target.getClass();
            Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
            return interfaces.length > 0 ? Proxy.newProxyInstance(type.getClassLoader(), interfaces, new Plugin(target, interceptor, signatureMap)) : target;
        }
    

上面有说到Configuration类是mybatis的核心配置类,其中ParameterHandler、ResultSetHandler、StatementHandler、Executor都有对应的创建方法,换句话讲,该四大组件对象的创建必过Configuration类来完成,大致如下:

public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
        ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement, parameterObject, boundSql);
        parameterHandler = (ParameterHandler)this.interceptorChain.pluginAll(parameterHandler);
        return parameterHandler;
    }

    public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler, ResultHandler resultHandler, BoundSql boundSql) {
        ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
        ResultSetHandler resultSetHandler = (ResultSetHandler)this.interceptorChain.pluginAll(resultSetHandler);
        return resultSetHandler;
    }

    public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
        StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
        StatementHandler statementHandler = (StatementHandler)this.interceptorChain.pluginAll(statementHandler);
        return statementHandler;
    }

    public Executor newExecutor(Transaction transaction) {
        return this.newExecutor(transaction, this.defaultExecutorType);
    }

    public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
        executorType = executorType == null ? this.defaultExecutorType : executorType;
        executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
        Object executor;
        if (ExecutorType.BATCH == executorType) {
            executor = new BatchExecutor(this, transaction);
        } else if (ExecutorType.REUSE == executorType) {
            executor = new ReuseExecutor(this, transaction);
        } else {
            executor = new SimpleExecutor(this, transaction);
        }

        if (this.cacheEnabled) {
            executor = new CachingExecutor((Executor)executor);
        }

        Executor executor = (Executor)this.interceptorChain.pluginAll(executor);
        return executor;
    }

​ 我们可以看到每个组件对象在创建时都使用了this.interceptorChain.pluginAll() , 该句柄含义是返回一个经过代理链处理的对象。

使用场景

场景1:对某列关键数据进行特殊处理

拦截ResultSetHandler的handleResultSets方法, 对执行结果进行遍历,处理需要处理的列。

@Intercepts({        
    @Signature(                
        type = ResultSetHandler.class,                
        method = "handleResultSets",                
        args = {Statement.class}        
)})
场景2: 计算sql执行时间

多种方法可实现,只展示一种:

@Intercepts({
        @Signature(
                method = "query",
                type = Executor.class,
                args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
        ),
        @Signature(
                type = Executor.class,
                method = "update",
                args = {MappedStatement.class, Object.class}
        )
})

在原方法执行前后加时间判断即可。

场景3:实现分页插件

多种方法可实现,只展示一种:

@Intercepts({
        @Signature
                (
                        type = Executor.class,
                        method = "query",
                        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
                ),
        @Signature
                (
                        type = Executor.class,
                        method = "query",
                        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
                ),
})

该功能已有成熟方案,我们可以拿来复用

场景4:数据分表
场景5: 对带参数执行的sql进行拼接参数处理
@Intercepts(
        @Signature(
                type = ParameterHandler.class,
                method = "setParameters",
                args = {PreparedStatement.class}
        )
)
场景6:动态查询
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值