mybatis查询过程源码解析

本文详细介绍了MyBatis查询执行的五个步骤,包括获取MapperProxy、解析MapperMethod、执行SQL类型、缓存处理及数据库查询。在开发中应注意的一级二级缓存问题、多结果集查询的selectOne操作、mapper接口与XML配置一致性以及#与$的区别。通过对源码的解读,理解MyBatis如何执行SQL,以便更好地优化和避免常见问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

    
本文只讲解mybatis的源码 ,不涉及过多的用法讲解,用法请百度。
mybatis查询执行步骤(举SELECT分析)
第一步:获取mapperProxy
第二步:获取mapperMethod
第三步:找到对应的执行类型(SELECT\UPDATE\INSERT\DELETE)
第四步:查询前的缓存处理,判断是否需要从二级、一级缓存中取数据(先二级再一级)
第五步:真正调用用jdbc调数据库,先预编译、再注入参数
第六步:进行结果集解析填充
开发mybatis涉及的坑
一二级缓存最好都关闭掉
select多个报selectOne操作
mapper.xml与mapper.java的类型不一致导致返回类型错误
#与$区别
看源码前各个名词介绍
1 mappedStatement
在程序启动扫描加载的时候,会把所有mapper的信息存储在mappedStatements上。可通过 全限定类名+方法名 或 方法名 获取对应mappedStatement
``````
public MappedStatement getMappedStatement(String id, boolean validateIncompleteStatements) {
   ............
  // 这里 id = com.example.app.mapper.UserMapper.selectById
  return mappedStatements.get(id);
}
``````
需要注意什么?
- userMapper.java接口里面不能实现同名函数重载,不然MappedStatement会冲突
mappedStatement里面有什么数据?
查询类型、数据来源、xml位置、id、参数类型信息、结果类型信息
2 TypeHandler
处理参数和结果集
可自定义实现一个typeHandler,继承BaseTypeHandler来自定义一个typeHandler类型
其默认常见的功能是自动映射参数类型(映射枚举类:JdbcType.java),举例如下:
- select user_id,user_name from lw_user where user_name=#{userName}
- 其实会自动映射成:select user_id,user_name from lw_user where user_name=#{userName,jdbcType=VARCHAR}
下面举一个mybatis 查询demo来进行讲解:
public static void main (String[] args) throws IOException {
    String resource = "mybatis-config.xml" ;
    InputStream inputStream = Resources. getResourceAsStream (resource) ;
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream) ;
     // 通过SqlSessionFactory获取sqlSession
    try (SqlSession session = sqlSessionFactory.openSession()) {
          // 通过SqlSession对象获取UserMapper接口的代理对象,
        // 然后赋值给UserMapper接口
        UserMapper mapper = session.getMapper(UserMapper. class ) ;
          // 调用UserMapper接口的方法来执行指定的数据库操作
        System. out .println(mapper.selectById( 1 )) ;
    }
}
第一步:获取mapperProxy
触发时机UserMapper mapper = session.getMapper(UserMapper.class)
其加载过程为:
第一步: 从configuration中获取mapperProxy代理对象
第二步: 用knownMappers.get(type)获取对应的代理工厂,这里拿到的的是UserMapper代理工厂,然后用jdk动态代理生成UserMapper的MapperProxy代理对象并返回。
接下来我们一步步进行解析:
// 1 从configuration中获取mapperProxy代理对象
// - 所在类:DefaultSqlSession
public < T > T getMapper (Class< T > type) {
    return this . configuration .getMapper(type , this ) ;
}
..........
// 2 用 knownMappers.get(type)获取对应的代理工厂,这里拿到的的是UserMapper代理工厂
//      然后用jdk动态代理生成UserMapper的MapperProxy代理对象并返回。。
// - 所在类: MapperRegistry.java
public < T > T getMapper (Class< T > type , SqlSession sqlSession) {
    /**
    * 加载 mybatis-config.xml 配置的 <mapper> 配置,根据指定 type ,查找对应的 MapperProxyFactory 对象
    **/
    // eg1: 获得 UserMapper mapperProxyFactory
    final MapperProxyFactory< T > mapperProxyFactory = (MapperProxyFactory< T >) knownMappers .get(type) ;
    /**
    * 如果没配置 <mapper> ,则找不到对应的 MapperProxyFactory ,抛出 BindingException 异常
    */
    if (mapperProxyFactory == null ) {
        throw new BindingException( "Type " + type + " is not known to the MapperRegistry." ) ;
    }
    try {
        /** 使用该工厂类生成 MapperProxy 的代理对象 */
        return mapperProxyFactory. newInstance (sqlSession) ;
    } catch (Exception e) {
        throw new BindingException( "Error getting mapper instance. Cause: " + e , e) ;
    }
}
/**
* 通过动态代理,创建 mapperInterface 的代理类对象
*
* @param mapperProxy
*/
@SuppressWarnings ( "unchecked" )
protected T newInstance (MapperProxy< T > mapperProxy) { // 这里就是JDK动态代理生成的userMapper的mapperProxy代理对象
    return ( T ) Proxy. newProxyInstance ( mapperInterface .getClassLoader() , new Class[] { mapperInterface } , mapperProxy) ;
}
public T newInstance (SqlSession sqlSession) {
    /**
    * 创建 MapperProxy 对象,每次调用都会创建新的 MapperProxy 对象, MapperProxy implements InvocationHandler
    */
    final MapperProxy< T > mapperProxy = new MapperProxy< T >(sqlSession , mapperInterface , methodCache ) ;
    return newInstance(mapperProxy) ;
}
第一步核心是:获取MapperProxy。其时序图如下:


第二步: 获取 mapperMethod
触发时机List<User> users = mapper.selectByIds(1);
调用上面,就会触发到MapperProxy#invoke()方法。
为什么调用mapper.selectByIds(1)会触发到invoke()方法?
因为获取到的mapper是UserMapper的MpaerProxy代理,并且MaperProxy实现了InvocationHandler接口,实现了invoke方法,我们就可以在invoke方法自定义加载逻辑。
- public class MapperProxy<T> implements InvocationHandler, Serializable {....}
其加载过程为:
第一步:从methodCache缓存中获取,没有就创建一个put进去
第二步:创建mapperMethod过程中,会涉及到两个重要成员属性:
- 从反射的method中获取信息,然后存放到mapperMethod中。MapperMethod结构如下:
``````java
public class MapperMethod {
    // 记录了SQL语句的名称和类型
    private final SqlCommand command;
    // Mapper接口中对应方法的相关信息
    private final MethodSignature method;
    ................
}
``````
- commond:
--  name属性:MappedStatement的唯一标识id
--  type属性:SQL的命令类型UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH
- method(总体是 判断返回类型、入参、下标号、注解值):
--  returnsMany:判断返回类型是集合或者数组吗
--  returnsMap:判断返回类型是Map类型吗
--  returnsVoid:判断返回类型是集void吗
--  returnsCursor:判断返回类型是Cursor类型吗
--  Class<?> returnType:方法返回类型
--  String mapKey:获得@MapKey注解里面的value值
--  Integer resultHandlerIndex:入参为ResultHandler类型的下标号
--  Integer rowBoundsIndex:入参为RowBounds类型的下标号
--  ParamNameResolver paramNameResolver:入参名称解析器
接下来我们一步步进行解析:
// 第一步: methodCache缓存中获取,没有就创建一个put进去
@Override
public Object invoke (Object proxy , Method method , Object[] args) throws Throwable {
    try {
        /** 如果被代理的方法是 Object 类的方法,如 toString() clone() ,则不进行代理 */
        // eg1: method.getDeclaringClass()==interface mapper.UserMapper 由于被代理的方法是 UserMapper getUserById 方法,而不是 Object 的方法,所以返回 false
        if (Object. class .equals(method.getDeclaringClass())) {
            return method.invoke( this, args) ;
        }
        /** 如果是接口中的 default 方法,则调用 default 方法 */
        else if (isDefaultMethod(method)) { // eg1: 不是 default 方法,返回 false
            return invokeDefaultMethod(proxy , method , args) ;
        }
    } catch (Throwable t) {
        throw ExceptionUtil. unwrapThrowable (t) ;
    }
    // eg1: method = public abstract vo.User mapper.UserMapper.getUserById(java.lang.Long)
    /** 初始化一个 MapperMethod 并放入缓存中 或者 从缓存中取出之前的 MapperMethod */
    final MapperMethod mapperMethod = cachedMapperMethod(method) ;
    / eg1: sqlSession = DefaultSqlSession@1953 args = {2L}
    /** 调用 MapperMethod.execute() 方法执行 SQL 语句 */  // 这里是后面要说的逻辑,先不看
    return mapperMethod.execute( sqlSession , args) ;
}
//  第二步:创建mapperMethod过程中,会涉及到两个重要成员属性。 从反射的method中获取信息,然后存放到mapperMethod中
private MapperMethod cachedMapperMethod (Method method) {
    /**
    * 在缓存中查找 MapperMethod ,若没有,则创建 MapperMethod 对象,并添加到 methodCache 集合中缓存
    */
    // eg1: 因为 methodCache 为空,所以 mapperMethod 等于 null
    MapperMethod mapperMethod = methodCache .get(method) ;
    if (mapperMethod == null ) {
        // eg1: 构建 mapperMethod 对象,并维护到缓存 methodCache
        mapperMethod = new MapperMethod( mapperInterface , method , sqlSession .getConfiguration()) ;
        // eg1: method = public abstract vo.User mapper.UserMapper.getUserById(java.lang.Long)
        methodCache .put(method , mapperMethod) ;
    }
    return mapperMethod ;
}
第二步核心是:获取MapperMethod。其时序图如下:


第三步:找到对应的执行类型(SELECT\UPDATE\INSERT\DELETE)
触发时机:MapperProxy.invoke()方法里面执行 mapperMethod.execute(sqlSession, args);
``````java
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    .........其他执行逻辑
    // 前面已经获取到了MaperProxy和MapperMethod
    /** 调用MapperMethod.execute()方法执行SQL语句 */
    return mapperMethod.execute(sqlSession, args);
}
``````
其加载过程为(下面举例 SELECT, 所以下面只会讲解SELECT怎么查询,其他类型基本差不多 ):
第一步:调用MapperMethod.execute()方法执行SQL语句
第二步:在switch(command.type)中找到对应的类型(SELECT\UPDATE\DELETE\INSERT\FLUSH),并执行
接下来我们一步步进行解析:
//  第一步: 调用MapperMethod.execute()方法执行SQL语句
@Override
public Object invoke (Object proxy , Method method , Object[] args) throws Throwable {
    ........
    /** 调用 MapperMethod.execute() 方法执行 SQL 语句 */  // 真正出发选择执行对应类型的入口
    return mapperMethod.execute( sqlSession , args) ;
}
/**
  第二步:在switch(command.type)中找到对应的类型(SELECT\UPDATE\DELETE\INSERT\FLUSH),并执行
* MapperMethod 采用命令模式运行,根据上下文跳转,它可能跳转到许多方法中。实际上它最后就是通过 SqlSession 对象去运行对象的 SQL
*/
public Object execute (SqlSession sqlSession , Object[] args) {
    Object result ;
    // eg1: command.getType() = SELECT
    switch ( command .getType()) {
        case INSERT : {
            Object param = method .convertArgsToSqlCommandParam(args) ;
            result = rowCountResult(sqlSession.insert( command .getName() , param)) ;
            break;
        }
        case UPDATE : {
            Object param = method .convertArgsToSqlCommandParam(args) ;
                                    result = rowCountResult(sqlSession.update( command .getName() , param)) ;
                                    break;
        }
        case DELETE : {
                                    Object param = method .convertArgsToSqlCommandParam(args) ;
                                    result = rowCountResult(sqlSession.delete( command .getName() , param)) ;
                                    break;
        }
        case SELECT :
                                    // eg1: method.returnsVoid() = false method.hasResultHandler() = false
                                    if ( method .returnsVoid() && method .hasResultHandler()) {
                                            executeWithResultHandler(sqlSession , args) ;
                                            result = null;
            } else if ( method .returnsMany()) {  // 查询多条数据 List<User> users = mapper.selectByIds(1);
                                            result = executeForMany(sqlSession , args) ;
                                    } else if ( method .returnsMap()) {  // 查询返回 map ,一般不建议这样用: Map map = mapper.selectMapById(1);
                                            result = executeForMap(sqlSession , args) ;
                                    } else if ( method .returnsCursor()) { // 游标,一般也不用,不用管
                                            result = executeForCursor(sqlSession , args) ;
                                    } else { //  mapper.selectById(1)会快走到这一步
                                         // 查询单个数据: User user = mapper.selectById(1);
                                            /** 将参数转换为 sql 语句需要的入参 */
                                            Object param = method .convertArgsToSqlCommandParam(args) ;
                                            /** 执行 sql 查询操作 */
               result = sqlSession.selectOne( command .getName() , param) ;
                                    }
                                    break;
                         case FLUSH :
                                    result = sqlSession.flushStatements() ;
                                    break;
        default :
                                    throw new BindingException( "Unknown execution method for: " + command .getName()) ;
                         }
        if (result == null && method .getReturnType().isPrimitive() && ! method .returnsVoid()) {
            throw new BindingException( "Mapper method '" + command .getName()
                                                                        + " attempted to return null from a method with a primitive return type (" + method .getReturnType()
                                                                        + ")." ) ;
                         }
        return result ;
}
// 发现selectOne最终也会调用到selectList,不过限制了只能返回一条记录。所以如果不确定是否只有一条,查询单条数据的时候最好加上limit 1
@Override
public < T > T selectOne (String statement , Object parameter) {
     List< T > list = this.selectList(statement, parameter) ;
    if (list.size() == 1 ) {
        return list.get( 0 ) ;
    } else if (list.size() > 1 ) {
        throw new TooManyResultsException(
                "Expected one result (or null) to be returned by selectOne(), but found: " + list.size()) ;
    } else {
        return null;
    }
}
第三步核心是: 找到对应的执行类型(SELECT\UPDATE\INSERT\DELETE) 。其时序图如下:


第四步:查询前的缓存处理, 判断是否需要从二级、一级缓存中取数据(先二级再一级)
触发时机:DefaultSqlSession的方法  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds)
其加载过程为
第一步:根据mappedStatements.get(id)获取对应的mappedStatement
第二步:对参数进行处理,对Collection、Array加多一层映射。下面举两个例子:
--  selectById(@Param("id") Integer id):默认会给我们加一个 {"param1":1, "id":1}映射,其中param1是自动生成的
--  selectById(@Param("ids") List<Integer> ids):默认会给我们加一个 {"list":[1], "ids":[1]}映射,其中list是自动生成的
第三步:解析出boundSql,里面包含参数映射,待执行sql等。并获取缓存cacheKey,进入待行方法query()
--  boundSql:包含待执行sql,参数映射等
--  cacheKey:会拼接出大概这种"619109510:2666897233:com.example.app.mapper.UserMapper.selectById:0:2147483647:select id,name from userinfo where id = ? limit 1:1:development"
第四步:进行缓存处理。先处理二级,再处理一级缓存
--  二级缓存:需要xml配置了<cache/>来开启
--  一级缓存:默认开启,如果开启statement模式则会每次都刷出不进行缓存存储
接下来我们一步步进行解析:
//  第一步:根据 mappedStatements.get(id)获取对应的mappedStatement。
@Override
public < E > List< E > selectList (String statement , Object parameter , RowBounds rowBounds) {
    .........
    MappedStatement ms = configuration .getMappedStatement(statement) ;// 获取mappedStatements
    return executor .query(ms , wrapCollection(parameter) , rowBounds , Executor. NO_RESULT_HANDLER ) ;
     .........
}
//  第二步:对参数进行处理,对 Collection、Array加多一层映射
private Object wrapCollection ( final Object object) {
    if (object instanceof Collection) {
        StrictMap<Object> map = new StrictMap<Object>() ;
        map.put( "collection" , object) ;
         // 如果是Collection类型,就给他加一个list映射,所以平时我们就算不给类似selectById(List<Integer> list)加一个@Param("list")都能用默认的list访问
         if (object instanceof List) {
            map.put( "list" , object) ;
        }
        return map ;
    } else if (object != null && object.getClass().isArray()) {
         // 同上。不过这里加的是array
        StrictMap<Object> map = new StrictMap<Object>() ;
        map.put( "array" , object) ;
        return map ;
    }
    // 其他情况,举例:selectById(@Param("id") Integer id);默认会给我们加一个 {"param1":1, "id":1}映射,其中param1是自动生成的
    return object ;
}
//  第三步:解析出boundSql,里面包含参数映射,待执行sql等。并获取缓存cacheKey,进入待行方法query()
public < E > List< E > query (MappedStatement ms , Object parameterObject , RowBounds rowBounds ResultHandler resultHandler) throws SQLException {
    /** 获得 boundSql 对象,承载着 sql 和对应的参数 */
    BoundSql boundSql = ms.getBoundSql(parameterObject) ;
    /** 生成缓存 key */
    CacheKey key = createCacheKey(ms , parameterObject , rowBounds , boundSql) ;
    /** 执行查询语句 */
    return query(ms , parameterObject , rowBounds , resultHandler , key , boundSql) ;
}
// 第四步:进行缓存处理。先处理二级,再处理一级缓存
//     --  二级缓存:需要xml配置了<cache/>来开启 
//      --  一级缓存:默认开启,如果开启statement模式则会每次都刷出不进行缓存存储
public < E > List< E query (MappedStatement ms , Object parameterObject , RowBounds rowBounds ResultHandler resultHandler , CacheKey key , BoundSql boundSql) throws SQLException {
    Cache cache = ms.getCache() ;
    // eg1: cache = null
    /** 如果在 UserMapper.xml 配置了 <cache/> 开启了二级缓存,则 cache 不为 null*/
    if (cache != null ) {
        /**
         * 如果 flushCacheRequired=true 并且缓存中有数据,则先清空缓存
          *
          * <select id="save" parameterType="XXXXXEO" statementType="CALLABLE" flushCache="true" useCache="false">
          * ……
          * </select>
          * */
          flushCacheIfRequired(ms) ;
          /** 如果 useCache=true 并且 resultHandler=null*/
          if (ms.isUseCache() && resultHandler == null ) {
              ensureNoOutParams(ms , parameterObject , boundSql) ;
              @SuppressWarnings ( "unchecked" )
              List< E > list = (List< E >) tcm .getObject(cache , key) ;
              if (list == null ) {
                    /** 执行查询语句 */
                    list = delegate .< E >query(ms , parameterObject , rowBounds , resultHandler , key , boundSql) ;
                    /** cacheKey 为主键,将结果维护到缓存中 */
                    tcm .putObject(cache , key , list) ; // issue #578 and #116
              }
              return list ;
          }
    }
    return delegate .< E >query(ms , parameterObject , rowBounds , resultHandler , key , boundSql) ;
}
public < E > List< E > query (MappedStatement ms , Object parameter , RowBounds rowBounds , ResultHandler resultHandler ,
    CacheKey key , BoundSql boundSql) throws SQLException {
    ErrorContext. instance ().resource(ms.getResource()).activity( "executing a query" ).object(ms.getId()) ;
    // eg1: closed = false
    if ( closed ) {
        throw new ExecutorException( "Executor was closed." ) ;
    }
    // eg1: queryStack = 0 ms.isFlushCacheRequired() = false
    /** 如果配置了 flushCacheRequired=true 并且 queryStack=0 (没有正在执行的查询操作),则会执行清空缓存操作 */
    if ( queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache() ;
    }
    List< E > list ;
    try {
        /** 记录正在执行查询操作的任务数 */
        queryStack ++ ; // eg1: queryStack=1
        // eg1: resultHandler=null localCache.getObject(key)=null
          /** localCache 维护一级缓存,试图从一级缓存中获取结果数据,如果有数据,则返回结果;如果没有数据,再执行 queryFromDatabase */
          list = resultHandler == null ? (List< E >) localCache .getObject(key) : null;
          // eg1: list = null
          if (list != null ) {
              /** 如果是执行存储过程 */
              handleLocallyCachedOutputParameters(ms , key , parameter , boundSql) ;
          } else {
              // 终于走到真正执行数据库的地方了
              list = queryFromDatabase(ms , parameter , rowBounds , resultHandler , key , boundSql) ;
          }
    } finally {
        queryStack -- ;
    }
    if ( queryStack == 0 ) {
          /** 延迟加载处理 */
          for (DeferredLoad deferredLoad : deferredLoads ) {
              deferredLoad.load() ;
          }
        // issue #601
          deferredLoads .clear() ;
        /** 如果设置了 <setting name="localCacheScope" value="STATEMENT"/> ,则会每次执行完清空缓存。即:使得一级缓存失效 */
          if ( configuration .getLocalCacheScope() == LocalCacheScope. STATEMENT ) {
              // issue #482
              clearLocalCache() ;
          }
    }
    return list ;
}
第四步:查询前的缓存处理, 判断是否需要从二级、一级缓存中取数据(先二级再一级) 。其时序图如下:


第五步:真正调用用jdbc调数据库,先预编译、再注入参数
触发时机:list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql) 
         -> doQuery(ms, parameter, rowBounds, resultHandler, boundSql)
走到这步,就是真的查询,过程为:先预编译、再注入参数、然后调用数据库查询 
接下来我们一步步进行解析:
@Override
public < E > List< E > doQuery (MappedStatement ms , Object parameter , RowBounds rowBounds , ResultHandler resultHandler BoundSql boundSql) throws SQLException {
    Statement stmt = null;
    try {
        Configuration configuration = ms.getConfiguration() ;
                         /** 根据 Configuration 来构建 StatementHandler */
                        StatementHandler handler = configuration.newStatementHandler( wrapper , ms , parameter , rowBounds resultHandler , boundSql) ;
                       /** 然后使用 prepareStatement 方法,对 SQL 进行预编译并设置入参 */
                     stmt = prepareStatement(handler , ms.getStatementLog()) ;
                        /** 开始执行真正的查询操作。将包装好的 Statement 通过 StatementHandler 来执行,并把结果传递给 resultHandler */
                        return handler.< E >query(stmt , resultHandler) ;
    } finally {
                        closeStatement(stmt) ;
    }
}
/**
* 使用 prepareStatement 方法,对 SQL 编译并设置入参
*
* @param handler
* @param statementLog
* @return
* @throws SQLException
*/
private Statement prepareStatement (StatementHandler handler , Log statementLog) throws SQLException {
    Statement stmt ;
    /** 获得 Connection 实例 */
    Connection connection = getConnection(statementLog) ;
    /** 第一步:调用了 StatementHandler prepared 进行了【 sql 的预编译】 */
    stmt = handler.prepare(connection , transaction .getTimeout()) ;
    /** 第二步:通过 PreparedStatementHandler parameterize 来给【 sql 设置入参】 */
    handler.parameterize(stmt) ;
     return stmt ;
}
第五步:真正调用用jdbc调数据库,先预编译、再注入参数 。其时序图如下:


第六步:进行结果集解析填充
这一步就属于结果集填充。可通过实现typeHandler来实现结果集填充的自定义填充功能。
入口源码如下:
/**
* 处理结果集
*/
private void handleResultSet(ResultSetWrapper rsw , ResultMap resultMap , List<Object> multipleResults ,
    ResultMapping parentMapping) throws SQLException {
    try {
        if (parentMapping != null ) {
              handleRowValues(rsw , resultMap , null, RowBounds. DEFAULT , parentMapping) ;
         } else {
            if ( resultHandler == null ) {
                /** 初始化 ResultHandler 实例,用于解析查询结果并存储于该实例对象中 */
                   DefaultResultHandler defaultResultHandler = new DefaultResultHandler( objectFactory) ;
                    /** 解析行数据 */
                   handleRowValues(rsw , resultMap , defaultResultHandler , rowBounds , null) ;
                   multipleResults.add(defaultResultHandler.getResultList()) ;
              } else {
                   handleRowValues(rsw , resultMap , resultHandler , rowBounds , null) ;
              }
         }
    } finally {
        /** 关闭 ResultSet */
         closeResultSet(rsw.getResultSet()) ;
    }
}
时序图如下:
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值