Mybatis源码分析

在我们工作中一般都是spring + mybatis 组合使用,试想,如果单独使用Mybatis Api如何使用呢?

    @Test
    public void testUserFind() throws IOException {

        //1.读取mybatis全局配置文件,创建sqlSessionFactory
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        //2.创建sqlSession
        SqlSession session = sqlSessionFactory.openSession();
        try {
            //3.获取mapper 代理对象
            UserMapper mapper = session.getMapper(UserMapper.class);
            //4.执行查询
            User user = mapper.findUserById(1);
            System.out.println(user);
        } finally {
            session.close();
        }
    }

以上就是原生Mybatis的api使用过程。从这里我们可以得知几个重要的对象:

SqlSessionFactoryBuilder : 创建 SqlSessionFactory

SqlSessionFactory : 用户创建sqlSession

SqlSession: 操作数据库的api门面和获取mapper等功能。

接下来我们就一步一步来分析这几个步骤都做了什么事。全部缕清楚后就明白Mybatis的运行原理

第一步:

SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

我们主要看 build 方法:

  public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
      XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
       //调用parse()进行解析处理返回ConfigBuiler,再由build()方法创建sqlSessionFactory
      return build(parser.parse());
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error building SqlSession.", e);
    } finally {
      ErrorContext.instance().reset();
      try {
        inputStream.close();
      } catch (IOException e) {
        // Intentionally ignore. Prefer previous error.
      }
    }
  }



   //1.构建 XMLConfigBuilder 对象
  private XMLConfigBuilder(XPathParser parser, String environment, Properties props) {
    super(new Configuration());
    ErrorContext.instance().resource("SQL Mapper Configuration");
    this.configuration.setVariables(props);
    this.parsed = false;
    this.environment = environment;
    this.parser = parser;
  }


// 2. 调用parse()方法查询解析
 public Configuration parse() {
    if (parsed) {
      throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    parsed = true;
    // configuration是mybatis-config.xml顶级标签,
    parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
  }

//2.1 
 private void parseConfiguration(XNode root) {
    try {
      //issue #117 read properties first
      propertiesElement(root.evalNode("properties"));
      Properties settings = settingsAsProperties(root.evalNode("settings"));
      loadCustomVfs(settings);
      loadCustomLogImpl(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"));
      //解析我们写的mapper.xml
      mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
  }


//3.创建 SqlSessionFactory 
 public SqlSessionFactory build(Configuration config) {
    return new DefaultSqlSessionFactory(config);
  }

主要做了三件事

1.根据配置文件流创建 XMLConfigBuilder 对象

2.然后调用parse()方法对 mybatis-config.xml 文件一级标签进行解析,如:<settings>,<typeAliases>等

3.调用build方法创建 SqlSessionFactory

在解析方法中我们主要看下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);
            //每一个mapper.xml由一个XMLMapperBuilder 对象来进行解析处理
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
            //解析mapper.xml
            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.");
          }
        }
      }
    }
  }

可以看到这里又创建  XMLMapperBuilder 来负责解析我们的xxxMapper.xml。 看下是如果解析的:

  public void parse() {
    if (!configuration.isResourceLoaded(resource)) {
      //我们看下这里
      configurationElement(parser.evalNode("/mapper"));
      configuration.addLoadedResource(resource);
      bindMapperForNamespace();
    }

    parsePendingResultMaps();
    parsePendingCacheRefs();
    parsePendingStatements();
  }


  private void configurationElement(XNode context) {
    try {
      String namespace = context.getStringAttribute("namespace");
      if (namespace == null || namespace.equals("")) {
        throw new BuilderException("Mapper's namespace cannot be empty");
      }
      builderAssistant.setCurrentNamespace(namespace);
      cacheRefElement(context.evalNode("cache-ref"));
      cacheElement(context.evalNode("cache"));
      parameterMapElement(context.evalNodes("/mapper/parameterMap"));
      //定义的结果集解析
      resultMapElements(context.evalNodes("/mapper/resultMap"));
      sqlElement(context.evalNodes("/mapper/sql"));
      //解析我们定义的增、删、改、查标标签内容
      buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
    }
  }

在 configurationElement() 方法中可以看到不同标签交给了不同方法去处理解析,我们主要看下 select | insert | update | delete 的解析:

  private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
    for (XNode context : list) {
       // mapper.xml中定义的每一个 增、册、改、查 statement 交给每个XMLStatementBuilder 对象来处理
      final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
      try {
         //进行解析处理
        statementParser.parseStatementNode();
      } catch (IncompleteElementException e) {
        configuration.addIncompleteStatement(statementParser);
      }
    }
  }

这里又创建一个 XMLStatementBuilder 对象来专门处理,每上select | update 等标签称为 Statement 。具体解析xml的代码就不在这里贴出来了,要我们要关注的是解析完mapper.xml做了什么事儿:

public void parseStatementNode() {
    
 //获取标签里定义的各属性值 
    String id = context.getStringAttribute("id");
    String databaseId = context.getStringAttribute("databaseId");
     .....
    String keyProperty = context.getStringAttribute("keyProperty");
    String keyColumn = context.getStringAttribute("keyColumn");
    String resultSets = context.getStringAttribute("resultSets");

   //将结果增加到MappedStatement容器中
    builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
        fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
        resultSetTypeEnum, flushCache, useCache, resultOrdered,
        keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
  }




  public MappedStatement addMappedStatement(String id,SqlSource sqlSource,StatementType statementType,SqlCommandType sqlCommandType,Integer fetchSize,Integer timeout, String parameterMap,Class<?> parameterType,String resultMap,Class<?> resultType,ResultSetType resultSetType,boolean flushCache,boolean useCache,boolean resultOrdered,KeyGenerator keyGenerator,String keyProperty,String keyColumn,String databaseId,LanguageDriver lang,String resultSets) {

    .....
    
    // 构建一个mappedStatement
    MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType)
        .resource(resource)
        .fetchSize(fetchSize)
        .timeout(timeout)
        .statementType(statementType)
        .keyGenerator(keyGenerator)
        .keyProperty(keyProperty)
        .keyColumn(keyColumn)
        .databaseId(databaseId)
        .lang(lang)
        .resultOrdered(resultOrdered)
        .resultSets(resultSets)
        .resultMaps(getStatementResultMaps(resultMap, resultType, id))
        .resultSetType(resultSetType)
        .flushCacheRequired(valueOrDefault(flushCache, !isSelect))
        .useCache(valueOrDefault(useCache, isSelect))
        .cache(currentCache);

    ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id);
    if (statementParameterMap != null) {
      statementBuilder.parameterMap(statementParameterMap);
    }

    MappedStatement statement = statementBuilder.build();
    //添加到Configuration 的map结合中,id为key,对象为value
    configuration.addMappedStatement(statement);
    return statement;
  }

把解析出来的属性值封装成 MappedStatement 对象,然后注册到Configuration的MappedStatement集合容器中(Map<statementId, MappedStatement>)。

其他的解析也是类似,解析-封装-注册到Configuration对象中.

我们再回到上面的一段代码:

XMLMapperBuilder.parse()方法:

  public void parse() {
    if (!configuration.isResourceLoaded(resource)) {
      configurationElement(parser.evalNode("/mapper"));
      configuration.addLoadedResource(resource);
   
      //看这里
      bindMapperForNamespace();
    }

    parsePendingResultMaps();
    parsePendingCacheRefs();
    parsePendingStatements();
  }

我们看下bindMapperForNamespace()方法:

// MapperRegistry 类
private void bindMapperForNamespace() {
    String namespace = builderAssistant.getCurrentNamespace();
    if (namespace != null) {
      Class<?> boundType = null;
      try {
         //根据namespace获取到我们定义mapper.java 的字节码
        boundType = Resources.classForName(namespace);
      } catch (ClassNotFoundException e) {
        //ignore, bound type is not required
      }
      if (boundType != null) {
        if (!configuration.hasMapper(boundType)) {
          // Spring may not know the real resource name so we set a flag
          // to prevent loading again this resource from the mapper interface
          // look at MapperAnnotationBuilder#loadXmlResource
          
         
          configuration.addLoadedResource("namespace:" + namespace);
           //注册mapper
          configuration.addMapper(boundType);
        }
      }
    }
  }

//注册每一个mapper对应代理工厂
  public <T> void addMapper(Class<T> type) {
    if (type.isInterface()) {
      if (hasMapper(type)) {
        throw new BindingException("Type " + type + " is already known to the MapperRegistry.");
      }
      boolean loadCompleted = false;
      try {
         //MapperProxyFactory 就是用来创建mapper接口的代理对象. 
        knownMappers.put(type, new MapperProxyFactory<>(type));

        // It's important that the type is added before the parser is run
        // otherwise the binding may automatically be attempted by the
        // mapper parser. If the type is already known, it won't try.

        //使用反射解析mapper.java上定义的注解信息
        MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
        parser.parse();
        loadCompleted = true;
      } finally {
        if (!loadCompleted) {
          knownMappers.remove(type);
        }
      }
    }
  }

根据xxxMapper.xml中的namespace 来获取xxxMapper.java的字解码,然后也注册 到Configuration的集合容器中。

至此我们的 SqlSessionFactory 对象就创建完成了,里面持有Configuration 对象。

小结:

不管是对xml解析还是xxxMapper.java绑定,最终都把结果注册到Configuration对应的容器集合中。主要目的就是初始化基础信息,为后续操作便使用。

第二步:

SqlSession session = sqlSessionFactory.openSession();

根据sqlSeseesion工厂(DefaultSqlSessionFactory)创建一个sqlSession对象:

  private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
    Transaction tx = null;
    try {
      final Environment environment = configuration.getEnvironment();
      //创建事物工厂
      final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
      tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
      //创建Executor
      final Executor executor = configuration.newExecutor(tx, execType);
      //创建sqlSession对象
      return new DefaultSqlSession(configuration, executor, autoCommit);
    } catch (Exception e) {
      closeTransaction(tx); // may have fetched a connection so lets call close()
      throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }

根据 mybatis-config.xml 配置的环境创建事物,然后根据事物对象和executor类型去创建一个Executor对象。我们看下executor的有哪几种类型

 我们看 Executor 接口定义就能明白,其实 Executor 才是真正去和数据库打交道的,他被SqlSeesion包装代理。

CachingExecutor:当我们在配置文件中开启二级缓存时会使用CachingExecutor对象,他是典型的装饰模式,里面包装了BaseExecutor对象。在操作前后进行缓存的相关操作。

SimpleExecutor 、ReuseExecutor、BatchExecutor的主要区别就是Jdbc的Statement使用上不同,ReuseExecutor 类中使用map集合维护了每一个Statement,可重用对象,SimpleExecutor则是每次执行sql都使用Jdbc的api创建一个新的 Statement 对象 。 BatchExecutor 是批量去执行update 操作的Statement。

最后根据configuration 、executor 去创建一sqlSession对象(DefaultSqlSession)返回。

第三步

UserMapper mapper = session.getMapper(UserMapper.class);

根据类型获取 mapper对象。这里获取到的肯定是一个动态代理生成的对象,因为我们只定义UserMapper接口,并没有实现类。

  //DefaultSqlSession 类
  @Override
  public <T> T getMapper(Class<T> type) {
     //从configuration获取Mapper
    return configuration.getMapper(type, this);
  }

  //Configuration 类
  public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
    //从mapperRegistry类中获取。还记得我们第一步的注册嘛,就是mapper代理工厂注册这个类容器中
    return mapperRegistry.getMapper(type, sqlSession);
  }

 //MapperRegistry 类
  public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
    //根据字节码类型获取mapper代理工厂,来创建代理对象
    final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
    if (mapperProxyFactory == null) {
      throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
    }
    try {
        //创建代理对象
      return mapperProxyFactory.newInstance(sqlSession);
    } catch (Exception e) {
      throw new BindingException("Error getting mapper instance. Cause: " + e, e);
    }
  }

根据mapper.class获取对象的mapperProxyFactory 对象 ,来创建mapper的代理对象。

我们看是他是如果创建代理对象的:

// MapperProxyFactory 类  
public T newInstance(SqlSession sqlSession) {
    final MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
    return newInstance(mapperProxy);
  }

// MapperProxyFactory 类 
  protected T newInstance(MapperProxy<T> mapperProxy) {
    //使用 jdk的动态代理来创建的
    return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy);
  }

根据mapper接口创建代理mapper对象,那么在我们调用mapper接口方法时就会执行invoker方法,所以我们看下 InvocationHandler的 invoke() 方法逻辑:

// MapperProxy 类
  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      if (Object.class.equals(method.getDeclaringClass())) {
        return method.invoke(this, args);
      } else if (method.isDefault()) {
        if (privateLookupInMethod == null) {
          return invokeDefaultMethodJava8(proxy, method, args);
        } else {
          return invokeDefaultMethodJava9(proxy, method, args);
        }
      }
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
    //获取到方法对应的处理器
    final MapperMethod mapperMethod = cachedMapperMethod(method);
    //只想方法逻辑
    return mapperMethod.execute(sqlSession, args);
  }


 // MapperMethod 类
  public Object execute(SqlSession sqlSession, Object[] args) {
    Object result;
    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:
        if (method.returnsVoid() && method.hasResultHandler()) {
          executeWithResultHandler(sqlSession, args);
          result = null;
        } else if (method.returnsMany()) {
          result = executeForMany(sqlSession, args);
        } else if (method.returnsMap()) {
          result = executeForMap(sqlSession, args);
        } else if (method.returnsCursor()) {
          result = executeForCursor(sqlSession, args);
        } else {
          Object param = method.convertArgsToSqlCommandParam(args);
          result = sqlSession.selectOne(command.getName(), param);
          if (method.returnsOptional()
              && (result == null || !method.getReturnType().equals(result.getClass()))) {
            result = Optional.ofNullable(result);
          }
        }
        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;
  }

可以看到 MapperMethod.execute()方法获取到参数然后又回到sqlSession api的调用。

第四步

User user = mapper.findUserById(1);

由第三步可以知道,我们调用mapper的方法时会进入到InvocationHandler的 invoke() 方法,也就是MapperMethod.execute()里面,在这里会调用sqlSession对应的api。因为我们这里是查询操作且是seleceOne,所以最终会执行sqlSession.selectOne()方法:

//command.getName获取到就是当前方法的StatementId --> 类全名.findUserById
result = sqlSession.selectOne(command.getName(), param);
 // DefaultSqlSession 类 
 public <T> T selectOne(String statement, Object parameter) {
    // Popular vote was to return null on 0 results and throw exception on too many.
    //调用selectList方法,取第一条记录
    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;
    }
  }


// DefaultSqlSession 类 
  @Override
  public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
    try {
      //根据statement签名获取到对应的对象(第步一解析好的对象)
      MappedStatement ms = configuration.getMappedStatement(statement);
      //调用executor.query 执行查询
      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();
    }
  }

MappedStatement对应就是我们第一步解 <select id="findUserById">..</select> 的结果对象。

然后调用Executor.query 方法执行sql的查询处理:

// BaseExecutor 类,如果开启二级缓存会先进入到CachingExecutor 类的query方法 
 @Override
  public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    //获取到sql
    BoundSql boundSql = ms.getBoundSql(parameter);
   //一级缓存的key
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    //查询
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
  }






// BaseExecutor 类
  @Override
  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++;
       //先尝试一级缓存获取
      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;
  }


// BaseExecutor 类
 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;
  }


//SimpleExecutor 类
 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();
      //这里会创建参数处理器和结果处理器,用于处理我方法参数到sql参数的处理,返回记录到对象的映射处理
      StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);  
      stmt = prepareStatement(handler, ms.getStatementLog());
       //查询
      return handler.query(stmt, resultHandler);
    } finally {
      closeStatement(stmt);
    }
  }



// PreparedStatementHandler 类
  @Override
  public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
    PreparedStatement ps = (PreparedStatement) statement;
   //最终调用jdbc  ps.execute()执行查询
    ps.execute();
    //使用反射完成行记录到对象的映射处理
    return resultSetHandler.handleResultSets(ps);
  }



到这里就完成了整个selectOne的查询,将结果交给ResultHandler 进行处理,返回结果数据的集合。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值