Mybatis底层实现学习

目录

 传统JDBC和Mybatis相比的弊病

mybatis整体架构图

 mybatis功能架构分为三层:

API接口层:

数据处理层:

配置解析流程:

Configuration对象

SQL解析(sqlsource)

 SQL执行(executor)

基础支撑层:

Mybatis核心配置文件解析原理

解析的目的

XML解析流程分析 

解析入口

Mapper映射文件解析原理

核心执行器executor详解

Mybatis核心执行组件介绍 

 Executor执行器分析

JDBC的执行器

 Mybatis执行器

 Executor接口

BaseExecutor(基础执行器)

simpleExecutor(简单执行器)

 ReuseExecutor(可重用执行器)

 batchExecutor(批处理执行器)

executor执行器实例化过程

Mybatis缓存原理

一级缓存

二级缓存

 Mybatis的SQL执行过程

​编辑 查询语句的执行分析


 传统JDBC和Mybatis相比的弊病

1.数据库连接创建,释放频繁造成西戎资源的浪费,从而影响系统性能,使用数据库连接池可以解决问题。

2.sql语句在代码中硬编码,造成代码的不已维护,实际应用中sql的变化可能较大,sql代码和java代码没有分离开来维护不方便。

3.使用preparedStatement向有占位符传递参数存在硬编码问题因为sql中的where子句的条件不确定,同样是修改不方便

4.对结果集中解析存在硬编码问题,sql的变化导致解析代码的变化,系统维护不方便。

5、JDBC没有提供缓存,增加了数据库压力。

mybatis整体架构图

 mybatis功能架构分为三层:

API接口层:

提供给外部使用的接口API,开发人员通过这些本地API来操纵数据库。接口层一接收到调用请求就会调用数据处理层完成具体的数据处理。其核心是SqlSession接口。

数据处理层:

负责具体的SQL查找、SQL解析、SQL执行和执行结果映射处理等。它主要的目的是根据调用的请求完成一次工作。

配置解析流程:

 在Mybatis初始化过程中,会加载mybatis-config.xml配置文件、映射文件以及Mapper接口中的信息,解析后的配置信息会形成相应的对象并保存到Configuration对象中。利用该configuration对象创建SqlSessionFactory对象。待Mybatis初始化之后。开发人员可以通过初始化得到SqlSessionFactory创建SqlSession对象并完成数据库操作。

Configuration对象

是一个所有配置型的的容器对象。

SQL解析(sqlsource)

对应的是scripting模块。Mybatis中的scripting模块,会根据用户传入的实参,解析映射文件中定义的SQL节点,并形成数据库可执行的SQL语句。之后会处理SQL语句中的占位符,绑定用户传入的实参

负责根据用户传递的parameterObject,动态生成SQL语句,将信息封装到BoundSql对象中并返回

 SQL执行(executor)

基础支撑层:

负责最基础的功能支撑,包括连接管理、事务管理、配置加载和缓存处理,这些都是共用的东西,将他们抽取成最基础的组件。为上层的数据处理层提供最基础的支撑。

Mybatis核心配置文件解析原理

在mybatis初始化过程中,会加载mybatis-config.xml配置文件、映射配置文件以及Mapper接口中的注解信息,解析后的配置信息会形成相应的对象并保存到Configuration对象中。

解析的目的

XML解析流程分析 

解析入口

(1)入口:

Mybatis的初始化流程的入口是SqlSessionFactoryBuilder的build()方法:

public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
    try {
        //创建XMLConfigBuilder对象
      XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
        //执行XML解析
        //创建DefaultSqlSessionFactory对象
      return build(parser.parse());
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error building SqlSession.", e);
    } finally {
      ErrorContext.instance().reset();
      try {
        reader.close();
      } catch (IOException e) {
        // Intentionally ignore. Prefer previous error.
      }
    }
  }

(2)XMLConfigBuilder对象:

继承BaseBuilder抽象类,XML配置构建起,主要负责解析mybatis-config.xml配置文件

private XMLConfigBuilder(XPathParser parser, String environment, Properties props) {
    //创建Configuration对象
    super(new Configuration());
    ErrorContext.instance().resource("SQL Mapper Configuration");
    //设置Configuration的variables属性,把props绑定到configuration的props属性上
    this.configuration.setVariables(props);
    this.parsed = false;
    this.environment = environment;
    this.parser = parser;
  }
  //parse判断是否解析过
  public Configuration parse() {
    //若已经解析过了 就抛出异常
    if (parsed) {
      throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    //标志已解析
    parsed = true;
    //解析XML configuration节点
    parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
  }
  //方法实现说明:解析我们mybatis-config.xml的 configuration节点
   
  private void parseConfiguration(XNode root) {
    try {
      /**
       * 解析 properties节点
       *     <properties resource="mybatis/db.properties" />
       */
      propertiesElement(root.evalNode("properties"));
      /**
       * 解析我们的mybatis-config.xml中的settings节点
       */
      Properties settings = settingsAsProperties(root.evalNode("settings"));
      loadCustomVfs(settings);
      // 指定 MyBatis 所用日志的具体实现,未指定时将自动查找。
      loadCustomLogImpl(settings);
      ///解析我们的别名
      typeAliasesElement(root.evalNode("typeAliases"));
      //解析我们的插件(比如分页插件)
      pluginElement(root.evalNode("plugins"));
      objectFactoryElement(root.evalNode("objectFactory"));
      objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
      reflectorFactoryElement(root.evalNode("reflectorFactory"));
      // 设置settings 和默认值
      settingsElement(settings);
      // read it after objectFactory and objectWrapperFactory issue #631

      // 解析我们的mybatis环境
      environmentsElement(root.evalNode("environments"));
      // 解析数据库厂商
      databaseIdProviderElement(root.evalNode("databaseIdProvider"));
      // 解析我们的类型处理器节点
      typeHandlerElement(root.evalNode("typeHandlers"));
      //最最最最最重要的就是解析我们的mapper
      mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
  }

xpath会读取dtd文件中定义好的标签规则,从而对xml文件进行解析 .

Mapper映射文件解析原理

再mybatis的核心配置文件解析的过程中,解析到mappers节点时,会进一步解析mapper映射文件。

当扫描到mappers节点后会去执行mapperElement方法,根据不同的mapper配置方式获取不同的输入流(例如XML文件配置的mapper就获取XMLMapperBuilder)。

private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
      /**
       * 获取我们mappers节点下的一个一个的mapper节点
       */
      for (XNode child : parent.getChildren()) {
        /**
         * 判断我们mapper是不是通过批量注册的
         * <package name="com.tuling.mapper"></package>
         */
        if ("package".equals(child.getName())) {
          String mapperPackage = child.getStringAttribute("name");
          configuration.addMappers(mapperPackage);
        } else {
          /**
           * 判断从classpath下读取我们的mapper
           * <mapper resource="mybatis/mapper/EmployeeMapper.xml"/>
           */
          String resource = child.getStringAttribute("resource");
          /**
           * 判断是不是从我们的网络资源读取(或者本地磁盘得)
           * <mapper url="D:/mapper/EmployeeMapper.xml"/>
           */
          String url = child.getStringAttribute("url");
          /**
           * 解析这种类型(要求接口和xml在同一个包下)
           * <mapper class="com.tuling.mapper.DeptMapper"></mapper>
           *
           */
          String mapperClass = child.getStringAttribute("class");

          /**
           * 我们得mappers节点只配置了
           * <mapper resource="mybatis/mapper/EmployeeMapper.xml"/>
           */
          if (resource != null && url == null && mapperClass == null) {
            ErrorContext.instance().resource(resource);
            /**
             * 把我们的文件读取出一个流
             */
            InputStream inputStream = Resources.getResourceAsStream(resource);
            /**
             * 创建读取XmlMapper构建器对象,用于来解析我们的mapper.xml文件
             */
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
            /**
             * 真正的解析我们的mapper.xml配置文件(说白了就是来解析我们的sql)
             */
            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对mapper.xml配置文件的解析

/**
   * 方法实现说明:真正的去解析我们的Mapper.xml(EmployeeMapper.xml)
   * @author:xsls
   * @return:
   * @exception:
   * @date:2019/8/30 16:43
   */
 public void parse() {
    /**
     * 判断当前的Mapper是否被加载过
     */
    if (!configuration.isResourceLoaded(resource)) {
      /**
       * 真正的解析我们的 <mapper namespace="com.tuling.mapper.EmployeeMapper">
       *
       */
      configurationElement(parser.evalNode("/mapper"));
      /**
       * 把资源保存到我们Configuration中
       */
      configuration.addLoadedResource(resource);

      bindMapperForNamespace();
    }

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


private void configurationElement(XNode context) {
    try {
      /**
       * 解析我们的namespace属性
       * <mapper namespace="com.tuling.mapper.EmployeeMapper">
       */
      String namespace = context.getStringAttribute("namespace");
      if (namespace == null || namespace.equals("")) {
        throw new BuilderException("Mapper's namespace cannot be empty");
      }
      /**
       * 保存我们当前的namespace  并且判断接口完全类名==namespace
       */
      builderAssistant.setCurrentNamespace(namespace);
      /**
       * 解析我们的缓存引用
       * 说明我当前的缓存引用和DeptMapper的缓存引用一致
       * <cache-ref namespace="com.tuling.mapper.DeptMapper"></cache-ref>
            解析到org.apache.ibatis.session.Configuration#cacheRefMap<当前namespace,ref-namespace>
            异常下(引用缓存未使用缓存):org.apache.ibatis.session.Configuration#incompleteCacheRefs
       */
      cacheRefElement(context.evalNode("cache-ref"));
      /**
       * 解析我们的cache节点
       * <cache type="org.mybatis.caches.ehcache.EhcacheCache"></cache>
          解析到:org.apache.ibatis.session.Configuration#caches
                 org.apache.ibatis.builder.MapperBuilderAssistant#currentCache
       */
      cacheElement(context.evalNode("cache"));
      /**
       * 解析paramterMap节点(该节点mybaits3.5貌似不推荐使用了)
       */
      parameterMapElement(context.evalNodes("/mapper/parameterMap"));
      /**
       * 解析我们的resultMap节点
       * 解析到:org.apache.ibatis.session.Configuration#resultMaps
       *    异常 org.apache.ibatis.session.Configuration#incompleteResultMaps
       *
       */
      resultMapElements(context.evalNodes("/mapper/resultMap"));
      /**
       * 解析我们通过sql节点
       *  解析到org.apache.ibatis.builder.xml.XMLMapperBuilder#sqlFragments
       *   其实等于 org.apache.ibatis.session.Configuration#sqlFragments
       *   因为他们是同一引用,在构建XMLMapperBuilder 时把Configuration.getSqlFragments传进去了
       */
      sqlElement(context.evalNodes("/mapper/sql"));
      /**
       * 解析我们的select | insert |update |delete节点
       * 解析到org.apache.ibatis.session.Configuration#mappedStatements
       */
      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);
    }
  }

XMLStatementBuilder-解析insert、update、delete等节点生成SQL语句:

//方法实现说明:用于解析我们的的inset|select|update|delte节点的
  public XMLStatementBuilder(Configuration configuration, MapperBuilderAssistant builderAssistant, XNode context, String databaseId) {
    super(configuration);
    this.builderAssistant = builderAssistant;
    this.context = context;
    this.requiredDatabaseId = databaseId;
  }

  public void parseStatementNode() {
    // 我们的insert|delte|update|select 语句的sqlId
    String id = context.getStringAttribute("id");
    //判断我们的insert|delte|update|select  节点是否配置了 数据库厂商标注
    String databaseId = context.getStringAttribute("databaseId");

    // 匹配当前的数据库厂商id是否匹配当前数据源的厂商id
    if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
      return;
    }

    // 获得节点名称:select|insert|update|delete
    String nodeName = context.getNode().getNodeName();
    // 根据nodeName 获得 SqlCommandType枚举
    SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
    //判断是不是select语句节点
    boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
    // 获取flushCache属性 默认值为isSelect的反值:查询:默认flushCache=false   增删改:默认flushCache=true
    boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
    /**
     * 获取useCache属性
     * 默认值为isSelect:查询:默认useCache=true   增删改:默认useCache=false
     */
    boolean useCache = context.getBooleanAttribute("useCache", isSelect);

    /**
     * resultOrdered:  是否需要处理嵌套查询结果 group by (使用极少)
     * 可以将比如 30条数据的三组数据  组成一个嵌套的查询结果
     */
    boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);

    /**
     * 解析我们的sql公用片段
     *     <select id="qryEmployeeById" resultType="Employee" parameterType="int">
              <include refid="selectInfo"></include>
              employee where id=#{id}
          </select>
        将 <include refid="selectInfo"></include> 解析成sql语句 放在<select>Node的子节点中
     */
    // Include Fragments before parsing
    XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
    includeParser.applyIncludes(context.getNode());

    /**
     * 解析我们sql节点的参数类型
     */
    String parameterType = context.getStringAttribute("parameterType");
    // 把参数类型字符串转化为class
    Class<?> parameterTypeClass = resolveClass(parameterType);

    /**
     * 查看sql是否支撑自定义语言
     * <delete id="delEmployeeById" parameterType="int" lang="tulingLang">
     <settings>
          <setting name="defaultScriptingLanguage" value="tulingLang"/>
     </settings>
     */
    String lang = context.getStringAttribute("lang");
    /**
     * 获取自定义sql脚本语言驱动 默认:class org.apache.ibatis.scripting.xmltags.XMLLanguageDriver
     */
    LanguageDriver langDriver = getLanguageDriver(lang);

    // Parse selectKey after includes and remove them.
    /**
     * 解析我们<insert 语句的的selectKey节点, 还记得吧,一般在oracle里面设置自增id
     */
    processSelectKeyNodes(id, parameterTypeClass, langDriver);

    // Parse the SQL (pre: <selectKey> and <include> were parsed and removed)
    /**
     * 我们insert语句 用于主键生成组件
     */
    KeyGenerator keyGenerator;
    /**
     * selectById!selectKey
     * id+!selectKey
     */
    String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;
    /**
     * 把我们的命名空间拼接到keyStatementId中
     * com.tuling.mapper.Employee.saveEmployee!selectKey
     */
    keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);
    /**
     *<insert id="saveEmployee" parameterType="com.tuling.entity.Employee" useGeneratedKeys="true" keyProperty="id">
     *判断我们全局的配置类configuration中是否包含以及解析过的组件生成器对象
     */
    if (configuration.hasKeyGenerator(keyStatementId)) {
      keyGenerator = configuration.getKeyGenerator(keyStatementId);
    } else {

      /**
       * 若我们配置了useGeneratedKeys 那么就去除useGeneratedKeys的配置值,
       * 否者就看我们的mybatis-config.xml配置文件中是配置了
       * <setting name="useGeneratedKeys" value="true"></setting> 默认是false
       * 并且判断sql操作类型是否为insert
       * 若是的话,那么使用的生成策略就是Jdbc3KeyGenerator.INSTANCE
       * 否则就是NoKeyGenerator.INSTANCE
       */
      keyGenerator = context.getBooleanAttribute("useGeneratedKeys",
          configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))
          ? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;
    }

    /**
     * 通过class org.apache.ibatis.scripting.xmltags.XMLLanguageDriver来解析我们的
     * sql脚本对象  .  解析SqlNode. 注意, 只是解析成一个个的SqlNode, 并不会完全解析sql,因为这个时候参数都没确定,动态sql无法解析
     */
    SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
    /**
     * STATEMENT,PREPARED 或 CALLABLE 中的一个。这会让 MyBatis 分别使用 Statement,PreparedStatement 或 CallableStatement,默认值:PREPARED
     */
    StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
    /**
     * 这是一个给驱动的提示,尝试让驱动程序每次批量返回的结果行数和这个设置值相等。 默认值为未设置(unset)(依赖驱动)
     */
    Integer fetchSize = context.getIntAttribute("fetchSize");
    /**
     * 这个设置是在抛出异常之前,驱动程序等待数据库返回请求结果的秒数。默认值为未设置(unset)(依赖驱动)。
     */
    Integer timeout = context.getIntAttribute("timeout");
    /**
     * 将会传入这条语句的参数类的完全限定名或别名。这个属性是可选的,因为 MyBatis 可以通过类型处理器(TypeHandler) 推断出具体传入语句的参数,默认值为未设置
     */
    String parameterMap = context.getStringAttribute("parameterMap");
    // 从这条语句中返回的期望类型的类的完全限定名或别名。 注意如果返回的是集合,那应该设置为集合包含的类型,而不是集合本身。
     //可以使用 resultType 或 resultMap,但不能同时使用
    String resultType = context.getStringAttribute("resultType");
    /**解析我们查询结果集返回的类型     */
    Class<?> resultTypeClass = resolveClass(resultType);
    //外部 resultMap 的命名引用。结果集的映射是 MyBatis 最强大的特性,如果你对其理解透彻,许多复杂映射的情形都能迎刃而解。
     //可以使用 resultMap 或 resultType,但不能同时使用。
    String resultMap = context.getStringAttribute("resultMap");

    String resultSetType = context.getStringAttribute("resultSetType");
    ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);
    if (resultSetTypeEnum == null) {
      resultSetTypeEnum = configuration.getDefaultResultSetType();
    }

    //解析 keyProperty  keyColumn 仅适用于 insert 和 update
    String keyProperty = context.getStringAttribute("keyProperty");
    String keyColumn = context.getStringAttribute("keyColumn");
    String resultSets = context.getStringAttribute("resultSets");

    //为我们的insert|delete|update|select节点构建成我们的mappedStatment对象
    builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
        fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
        resultSetTypeEnum, flushCache, useCache, resultOrdered,
        keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
  }

核心执行器executor详解

Mybatis核心执行组件介绍 

在mybatis中,sqlsession对数据库的操作,将委托给执行其Executor来完成;mybatis执行过程中,主要的执行模块是:sqlsession -> Executor -> statementHandler -> 数据库。

四个核心组件:

 

 

 Executor执行器分析

JDBC的执行器

 Mybatis执行器

 

 Executor接口

public interface Executor {

  //ResultHandler 对象的枚举
  ResultHandler NO_RESULT_HANDLER = null;

  //更新 or 插入 or 删除,由传入的 MappedStatement 的 SQL 所决定

  int update(MappedStatement ms, Object parameter) throws SQLException;

  // 查询带缓存key查询
  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey cacheKey, BoundSql boundSql) throws SQLException;

  // 不走缓存查询

  <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException;

  // 调用存过查询返回游标对象
  <E> Cursor<E> queryCursor(MappedStatement ms, Object parameter, RowBounds rowBounds) throws SQLException;

  // 刷入批处理语句
  List<BatchResult> flushStatements() throws SQLException;

  //提交事务
  void commit(boolean required) throws SQLException;

  //回滚事务
  void rollback(boolean required) throws SQLException;

  //创建缓存key
  CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql);

  // 判断是否缓存
  boolean isCached(MappedStatement ms, CacheKey key);
  // 清除本地缓存
  void clearLocalCache();

  // 延迟加载
  void deferLoad(MappedStatement ms, MetaObject resultObject, String property, CacheKey key, Class<?> targetType);

  //获取一个事务
  Transaction getTransaction();
  // 关闭事务
  void close(boolean forceRollback);

  //判断是否关闭
  boolean isClosed();

  // 设置包装的 Executor 对象
  void setExecutorWrapper(Executor executor);

}

BaseExecutor(基础执行器)

维护一级缓存,是simple、reuse、batch这三个执行器的父类,主要逻辑是维护缓存,其他实现交给之类。

simpleExecutor(简单执行器)

 ReuseExecutor(可重用执行器)

 batchExecutor(批处理执行器)

executor执行器实例化过程

默认是创建simpleExecutor,并开启二级缓存

Mybatis缓存原理

Mybatis提供了缓存策略,通过缓存策略来减少数据库的查询次数,从而提高性能,分为以及缓存和二级缓存。

一级缓存

当我们使用Mybatis开启一次和数据库的会话,Mybatis会创建出一个SqlSession对象表示一次数据库会话,建立一个简单的缓存,将每次查询的结果缓存起来,当下次查询的时候,如果判断先前有个完全一样的查询,会直接从缓存中直接将结果取出,返回给用户,不需要再进行一次数据库查询了。

对于会话(session)级别的数据缓存我们称之为一级缓存。

一级缓存默认是开启的,一级缓存是基于SqlSession生命周期的。

查询的时候想去localcache查,查到了直接返回,没有查到就去查数据库,把结果写入localcache并把结果返回。如果在第一次查询之后对数据进行了修改,在executor中执行update操作时会执行一次clearlocalcache方法,清楚了缓存,所以第二次去查询也无法命中缓存,需要再去数据库中查 

二级缓存

一级缓存中,最大的共享范围就是一个SqlSession内部,如果多个SqlSession之间需要共享缓存,就需要用到二级缓存。开启二级缓存之后,会用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先用CachingExecutor进行二级缓存

 CachingExecutor会先于一级缓存进行缓存查询,如果没有再执行正常的查询。

 Mybatis的SQL执行过程

SQL执行的入口分析

 

 

 

 查询语句的执行分析

 

 

 

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值