Mybatis 3.x 源码分析--查询操作

环境准备

  1. mybatis-configuration.xml

最新的完整mybatis每个配置属性含义可参考http://www.mybatis.org/mybatis-3/zh/configuration.html

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <environments default="development">
        <environment id="development">
        // mybatis内置提供JDBC和MANAGED两种事务管理方式,
        // 前者主要用于简单JDBC模式,后者主要用于容器管理事务,
        //一般使用JDBC事务管理方式
        // 使用了别名
            <transactionManager type="JDBC"/>
            // mybatis内置提供JNDI、POOLED、UNPOOLED三种数据源工厂,一般情况下使用POOLED数据源。
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://127.0.0.1:3306/mytest?useUnicode=true&amp;characterEncoding=UTF-8&amp;allowMultiQueries=true&amp;useSSL=false"/>
                <property name="username" value="root"/>
                <property name="password" value="1234"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <mapper resource="ConfigMapper.xml"/>
    </mappers>
</configuration>
  1. ConfigMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xxx.springboot.demo.mapper.ConfigMapper">
  <resultMap id="BaseResultMap" type="com.xxx.springboot.demo.entity.ConfigBO">
    <id column="id" jdbcType="INTEGER" property="id" />
    <result column="ckey" jdbcType="VARCHAR" property="ckey" />
    <result column="cvalue" jdbcType="VARCHAR" property="cvalue" />
    <result column="application" jdbcType="VARCHAR" property="application" />
    <result column="profile" jdbcType="VARCHAR" property="profile" />
    <result column="label" jdbcType="VARCHAR" property="label" />
    <result column="type" jdbcType="INTEGER" property="type" />
  </resultMap>

  <sql id="Base_Column_List">
    id, ckey, cvalue, `application`, profile, `label`, `type`
  </sql>

  <select id="selectByPrimaryKey" parameterType="java.lang.Integer" resultMap="BaseResultMap">
    select 
    <include refid="Base_Column_List" />
    from config
    where id = #{id,jdbcType=INTEGER}
  </select>
</mapper>

3.java 代码

public interface ConfigMapper {
    ConfigBO selectByPrimaryKey(Integer id);
}
    public static void main(String[] args) {
        String resource = "Configuration.xml";
        Reader reader;
        try {
            reader = Resources.getResourceAsReader(resource);
            SqlSessionFactory sqlSession = new SqlSessionFactoryBuilder().build(reader);
           // SqlSession就是jdbc连接的代表,openSession()就是获取jdbc连接
            SqlSession session = sqlSession.openSession();
            try {
                // 调试入口
                // 在session上可执行各种CRUD操作
                ConfigBO config = (ConfigBO) session.selectOne("com.xxx.springboot.demo.mapper.ConfigMapper.selectByPrimaryKey", 1);
            } finally {
                session.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

开始

1.session.selectOne

此处session的实际类型为:org.apache.ibatis.session.defaults.DefaultSqlSession

  public <T> T selectOne(String statement, Object parameter) {
    // selectOne方法的底层是通过selectList实现的
    List<T> list = this.<T>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;
    }
  }

2. DefaultSqlSession#selectList

  public <E> List<E> selectList(String statement, Object parameter) {
    // 初始化分页参数
    return this.selectList(statement, parameter, RowBounds.DEFAULT);
  }

 // selectList的重载方法
  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
    try {
      // 从Mybatis的configuration中获取MappedStatement
      MappedStatement ms = configuration.getMappedStatement(statement);
      return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error querying database.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }

(1)selectList引入RowBounds类,作用:分页功能的封装
(2)什么是MappedStatement?
它在ConfigMapper.xml长这样:

  <select id="selectByPrimaryKey" parameterType="java.lang.Integer" resultMap="BaseResultMap">
    select 
    <include refid="Base_Column_List" />
    from config
    where id = #{id,jdbcType=INTEGER}
  </select>

也就是它代表了sql语句的一个映射关系,从sql配置文件到java实体类的映射。它保存在一个Map中,那么什么是它的key呢?跟踪源码我们可以看到定义:

protected final Map<String, MappedStatement> mappedStatements = new StrictMap<MappedStatement>("Mapped Statements collection");

它的key 就是参数statement的值,即:(key = 全类名+方法名)
com.xxx.springboot.demo.mapper.ConfigMapper.selectByPrimaryKey

思考下列问题:
2.1 MappedStatement对象是怎么实例化的?
2.2 上述selectByPrimaryKey方法可以有重载方法吗?

(3)wrapCollection(parameter) 作用是什么?如果参数是集合类,就包装成Map对象。

  private Object wrapCollection(final Object object) {
    if (object instanceof Collection) {
      StrictMap<Object> map = new StrictMap<Object>();
      map.put("collection", object);
      if (object instanceof List) {
        map.put("list", object);
      }
      return map;
    } else if (object != null && object.getClass().isArray()) {
      StrictMap<Object> map = new StrictMap<Object>();
      map.put("array", object);
      return map;
    }
    return object;
  }

很自然,我们要思考为什么这么做?如果大家使用过<foreach>标签,应该知道:
<foreach collection=“list” item="" open="" close="" separator="" >
上面的list就是这里map的key,当然也可以指定为“collection”

StrictMap对HashMap的装饰在于增加了put时防重复的处理,get时取不到值时候的异常处理,这样核心应用层就不需要额外关心各种对象异常处理,简化应用层逻辑。

(4)引入了参数Executor.NO_RESULT_HANDLER
Executor类中定义了一个空的结果处理器:
ResultHandler NO_RESULT_HANDLER = null;

(5)executor.query方法中executor具体类型是什么?
它是在DefaultSqlSession实例化时赋值的,这里实际类型为:CachingExecutor

3.CachingExecutor#query

  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

  // 上面query的一个重载方法,增加了MappedStatement级别的缓存功能
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    Cache cache = ms.getCache();
    if (cache != null) {
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, 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);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

前面我们知道MappedStatement 是一个sql语句的映射,它包括了sql语句、参数类型、结果集类型等,那么sql语句在MappedStatement 是怎样保存的呢?答案就是SqlSource类,它有2个实现类DynamicSqlSource和RawSqlSource,在解析mapper文件时会根据当前sql语句是动态与否实例化成相应的类。在本例中它被实例成为RawSqlSource。
(1)BoundSql类:封装真实sql字符串和相关sql参数
从sqlSource获得的实际SQL字符串,SQL可能有SQL占位符“?”和一个参数映射的列表(有序的),其中包含每个参数的附加信息。
(2)实例化CacheKey对象,用于Mybatis提供的缓存机制
(3)CachingExecutor#query在MappedStatement级别的实现了缓存功能
Cache cache = ms.getCache();
思考下列问题:
3.1 怎么设置MappedStatement级别的缓存?
在mapper.xml中使用<cache>标签开启
(4)CachingExecutor将执行sql查询的操作委托给SimpleExecutor,就是这里的delegate对象,而CachingExecutor只实现缓存功能。

4.BaseExecutor#query

SimpleExecutor的父类BaseExecutor的query实现了sqlSession级别的缓存,具体实现类为PerpetualCache

  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());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      // sqlSession级别的缓存
      // resultHandler赋值,前面有提到
      list = resultHandler == null ? (List<E>) localCache.getObject(key) : 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();
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
  }

  // 设置sqlSession级别的缓存
  private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
      list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
      localCache.removeObject(key);
    }
    localCache.putObject(key, list);
    if (ms.getStatementType() == StatementType.CALLABLE) {
      localOutputParameterCache.putObject(key, parameter);
    }
    return list;
  }

5. SimpleExecutor#doQuery

  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();
      StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
      stmt = prepareStatement(handler, ms.getStatementLog());
      return handler.<E>query(stmt, resultHandler);
    } finally {
      closeStatement(stmt);
    }
  }

(1) 实例化语句处理器StatementHandler
相关代码如下:

  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) interceptorChain.pluginAll(statementHandler);
    return statementHandler;
  }

  public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
    // StatementType 可在mapper.xml中指定,默认为PREPARED
    switch (ms.getStatementType()) {
      case STATEMENT:
        delegate = new SimpleStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
        break;
      case PREPARED:
        delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
        break;
      case CALLABLE:
        delegate = new CallableStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
        break;
      default:
        throw new ExecutorException("Unknown statement type: " + ms.getStatementType());
    }

  }

RoutingStatementHandler 是对上面3种语句处理器的一个封装。这里真正实例化的语句处理器是PreparedStatementHandler

(2)分析SimpleExecutor#prepareStatement方法

  private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
    Statement stmt;
    // 这里从transaction中获取connection,然后返回代理过的connection
    // connection的具体类型为 org.apache.ibatis.logging.jdbc.ConnectionLogger
    Connection connection = getConnection(statementLog);
    stmt = handler.prepare(connection, transaction.getTimeout());
    handler.parameterize(stmt);
    return stmt;
  }

流程1 BaseStatementHandler#prepare

主要工作就是把我们在mapper.xml中定义的属性转换为JDBC标准的调用

这里handler.prepare方法的具体逻辑委托给BaseStatementHandler#prepare实现:

  public Statement prepare(Connection connection, Integer transactionTimeout) throws SQLException {
    ErrorContext.instance().sql(boundSql.getSql());
    Statement statement = null;
    try {
      // instantiateStatement由子类实现
      statement = instantiateStatement(connection);
      // 设置查询超时时间
      setStatementTimeout(statement, transactionTimeout);
      // 设置每次查询抓取的最大size
      setFetchSize(statement);
      return statement;
    } catch (SQLException e) {
      closeStatement(statement);
      throw e;
    } catch (Exception e) {
      closeStatement(statement);
      throw new ExecutorException("Error preparing statement.  Cause: " + e, e);
    }
  }

PreparedStatementHandler#instantiateStatement

  // 返回值类型为java.sql.PreparedStatement
  protected Statement instantiateStatement(Connection connection) throws SQLException {
    String sql = boundSql.getSql();
    // 处理Jdbc3KeyGenerator,因为它代表的是自增,另外一个是SelectKeyGenerator用于不支持自增的情况
    if (mappedStatement.getKeyGenerator() instanceof Jdbc3KeyGenerator) {
      String[] keyColumnNames = mappedStatement.getKeyColumns();
      if (keyColumnNames == null) {
        return connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS);
      } else {
        return connection.prepareStatement(sql, keyColumnNames);
      }
    } else if (mappedStatement.getResultSetType() != null) {
      return connection.prepareStatement(sql, mappedStatement.getResultSetType().getValue(), ResultSet.CONCUR_READ_ONLY);
    } else {
      return connection.prepareStatement(sql);
    }
  }

connection的类型为ConnectionLogger,它代理了JDBC4Connection对象
ConnectionLogger#invoke代码:

  public Object invoke(Object proxy, Method method, Object[] params)
      throws Throwable {
    try {
      if (Object.class.equals(method.getDeclaringClass())) {
        return method.invoke(this, params);
      }    
      if ("prepareStatement".equals(method.getName())) {
        if (isDebugEnabled()) {
          debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
        }
        // stmt 返回类型为 JDBC42PreparedStatement         
        PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
        // PreparedStatementLogger代理了JDBC42PreparedStatement并返回
        stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
        return stmt;
      } else if ("prepareCall".equals(method.getName())) {
        if (isDebugEnabled()) {
          debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
        }        
        PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
        stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
        return stmt;
      } else if ("createStatement".equals(method.getName())) {
        Statement stmt = (Statement) method.invoke(connection, params);
        stmt = StatementLogger.newInstance(stmt, statementLog, queryStack);
        return stmt;
      } else {
        return method.invoke(connection, params);
      }
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
  }

实例化PreparedStatementLogger#newInstance对象时,给它的属性
private final PreparedStatement statement;
赋值为 statement = JDBC42PreparedStatement对象。

  public static PreparedStatement newInstance(PreparedStatement stmt, Log statementLog, int queryStack) {
    InvocationHandler handler = new PreparedStatementLogger(stmt, statementLog, queryStack);
    ClassLoader cl = PreparedStatement.class.getClassLoader();
    return (PreparedStatement) Proxy.newProxyInstance(cl, new Class[]{PreparedStatement.class, CallableStatement.class}, handler);
  }

  private PreparedStatementLogger(PreparedStatement stmt, Log statementLog, int queryStack) {
    super(statementLog, queryStack);
    this.statement = stmt;
  }

流程2 PreparedStatementHandler#parameterize

主要功能:将mapper.xml中定义的参数转换为JDBC参数

语句处理器持有参数处理器ResultSetHandler和结果集处理器ParameterHandler
这里parameterHandler的实现类为DefaultParameterHandler,PreparedStatementHandler.parameterize将具体实现委托给了DefaultParameterHandler.setParameters()方法

  public void parameterize(Statement statement) throws SQLException {
    parameterHandler.setParameters((PreparedStatement) statement);
  }
DefaultParameterHandler#setParameters

我们在mapper中定义的所有ParameterType、ParameterMap、内嵌参数映射等在最后都在这里被作为ParameterMapping转换为JDBC参数

  public void setParameters(PreparedStatement ps) {
    ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    if (parameterMappings != null) {
      for (int i = 0; i < parameterMappings.size(); i++) {
        ParameterMapping parameterMapping = parameterMappings.get(i);
        // 只处理入参类型的
        if (parameterMapping.getMode() != ParameterMode.OUT) {
          Object value;
          String propertyName = parameterMapping.getProperty();
          // 1.先判断是不是属于语句的AdditionalParameter
          if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
            value = boundSql.getAdditionalParameter(propertyName);
          // 2.参数是不是null
          } else if (parameterObject == null) {
            value = null;
          // 3.判断是不是属于注册类型
          } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
            value = parameterObject;
          } else {
          // 4. 估计参数是object或者map,借助于MetaObject获取属性值
            MetaObject metaObject = configuration.newMetaObject(parameterObject);
            value = metaObject.getValue(propertyName);
          }
          TypeHandler typeHandler = parameterMapping.getTypeHandler();
          JdbcType jdbcType = parameterMapping.getJdbcType();
          if (value == null && jdbcType == null) {
            jdbcType = configuration.getJdbcTypeForNull();
          }
          try {
          // jdbc下标从1开始,由具体的类型处理器进行参数的设置, 对于每个jdbcType, mybatis都提供了一个对应的Handler,
          //具体可参考TypeHandlerRegistry详解, 其内部调用的是PrepareStatement.setXXX进行设置。
            typeHandler.setParameter(ps, i + 1, value, jdbcType);
          } catch (TypeException e) {
            throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
          } catch (SQLException e) {
            throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
          }
        }
      }
    }
  }

(1)ParameterMapping 对象:
ParameterMapping{property=‘id’, mode=IN, javaType=class java.lang.Integer, jdbcType=INTEGER, numericScale=null, resultMapId=‘null’, jdbcTypeName=‘null’, expression=‘null’}
(2)TypeHandlerRegistry对象
提供了常见的Jdbc对象的java处理器类型,参考源码

6 PreparedStatementHandler#query

上面第5步中SimpleExecutor#doQuery,将查询操作委托给RoutingStatementHandler#query执行。而RoutingStatementHandler又委托给PreparedStatementHandler#query执行

  public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
    PreparedStatement ps = (PreparedStatement) statement;
    // 查询语句的真正执行动作
    ps.execute();
    // 查询结果集的封装
    return resultSetHandler.<E> handleResultSets(ps);
  }

7 configuration文件解析核心代码

org.apache.ibatis.builder.xml.XMLConfigBuilder#parseConfiguration

  private void parseConfiguration(XNode root) {
    try {
      //issue #117 read properties first
      propertiesElement(root.evalNode("properties"));
      Properties settings = settingsAsProperties(root.evalNode("settings"));
      loadCustomVfs(settings);
      typeAliasesElement(root.evalNode("typeAliases"));
      pluginElement(root.evalNode("plugins"));
      objectFactoryElement(root.evalNode("objectFactory"));
      objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
      reflectorFactoryElement(root.evalNode("reflectorFactory"));
      settingsElement(settings);
      // read it after objectFactory and objectWrapperFactory issue #631
      environmentsElement(root.evalNode("environments"));
      databaseIdProviderElement(root.evalNode("databaseIdProvider"));
      typeHandlerElement(root.evalNode("typeHandlers"));
      mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
  }

(1)入参XNode root对象信息如下:

<property name="driver" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&useSSL=false"/>
<property name="username" value="root"/>
<property name="password" value="1234"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="ConfigMapper.xml"/>
</mappers>
</configuration>

(2) <mappers>标签的解析代码

  private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
      for (XNode child : parent.getChildren()) {
        if ("package".equals(child.getName())) {
          String mapperPackage = child.getStringAttribute("name");
          configuration.addMappers(mapperPackage);
        } else {
          String resource = child.getStringAttribute("resource");
          String url = child.getStringAttribute("url");
          String mapperClass = child.getStringAttribute("class");
          if (resource != null && url == null && mapperClass == null) {
            ErrorContext.instance().resource(resource);
            InputStream inputStream = Resources.getResourceAsStream(resource);
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
            mapperParser.parse();
          } else if (resource == null && url != null && mapperClass == null) {
            ErrorContext.instance().resource(url);
            InputStream inputStream = Resources.getUrlAsStream(url);
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
            mapperParser.parse();
          } else if (resource == null && url == null && mapperClass != null) {
            Class<?> mapperInterface = Resources.classForName(mapperClass);
            configuration.addMapper(mapperInterface);
          } else {
            throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
          }
        }
      }
    }
  }

8 资料

参考资料:
[1] https://www.cnblogs.com/zhjh256/p/8512392.html
[2] https://blog.csdn.net/isea533/article/details/44002219
[3] https://blog.csdn.net/qq_35525955/article/details/80890926

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值