MyBatis(技术NeiMu):核心处理层(SqlNode和SqlSource)

回顾

前面我们已经看了整个MyBatis的初始化过程,其中比较关键的是对于SQL映射配置文件的解析和绑定,在里面对于sql节点,也就是insert、update、delete、select标签节点会被解析成一个个MappedStatement,里面的SQL会被解析成SqlSource,里面的一些动态标签则会变成一个个SqlNode,下面就认识一下SqlNode与SqlSource

SqlNode与SqlSource

SqlSource

首先,我们先认识一下SqlSource,前面提到过,对于Sql节点中的Sql是会被解析成一个SqlSource
在这里插入图片描述
可以看到,该接口里面就只有一个方法,返回的是绑定的Sql

下面再看看其实现类

在这里插入图片描述

  • DynamicSqlSource:动态Sql,拥有${}占位符或者动态标签节点会被视为动态SQL
  • RawSqlSource:静态Sql,没有${}占位符,也没有动态标签节点会被视为静态SQL
  • StaticSqlSource:动态Sql和静态Sql处理完Sql语句后,都会封装转化成StaticSqlSource,StaticSqlSource中的Sql可以直接交由数据库执行,其实真实的是StaticSqlSource是存储解析了#{}占位符的SQL的,而DynamicSqlSource在完成动态节点解析之后,再继续转化成StaticSqlSource,然后获取绑定SQL的;RawSqlSource则也是通过StaticSqlSource来获取绑定SQL的,只不过RawSqlSource没有去解析动态标签
  • ProviderSqlSource

在具体分析每个SqlSource前,先认识一下组合模式!

组合模式

组合模式:可以将存储对象的容器当作对象来处理,从而让调用者无需知道使用容器和单一对象的区别,也就是对于整个容器这个复杂对象,客户端可以像简单的单一对象这样去操作,可以统一去调用,组合模式其实就是将对象组合成树形结构,来表示了整体-部分的层次结构

举个栗子

在这里插入图片描述
好像上面这个图一样,容器是一个文件夹,在文件夹里面既有叶子节点(文件)和非叶子节点(文件夹),将叶子节点和非叶子节点都抽象成组件,然后组合起来,形成树状结构,那么就可以对叶子节点和非叶子节点统一处理了

组合模式包含4个部分

  • Component:抽象的组件,有组合、删除其他Component的功能,同时也有自身组件的操作功能
  • Leaf:代表叶子节点,实现了Component,拥有组件的性质,但不会去实现组合其他组件的功能,也就是没有组件给叶子节点管理
  • Composite:非叶子节点,拥有组合其他组件的功能,同时可以操控下面的组件
  • Client:客户端,通过Component接口去操纵整个树形结构

组合模式的好处

  • 用户无需知道自己操控的是一个复杂对象、还是一个简单对象,也就是操控整个树形结构跟操控单个叶子节点是一样的,将客户端与复杂对象进行了解耦,当对象变得逐渐复杂的时候,不需要去改变客户端
  • 使用了组合模式,可以通过添加新的Component对象,来添加新功能,符合了开放封闭原则

组合模式的缺点

  • 实现起来有很大困难,因为将所有业务中的对象都抽象成了组件,如果业务规则比较复杂的话,很难将其进行抽象统一,并且假如规定了在某个组合结构中只能拥有特定类型的组件,我们已经将组件都抽象成一个个Component接口了,必须要加一层类型判断,而且由于是树形结构,需要去递归,很难去定位到业务问题

什么时候使用组合模式

  • 当我们需要忽略整体和部分的差异时,可以使用组合模式
  • 当客户端不需要知道整体和部分的差异,对待整体可以和对待单个对象一一昂

在MyBatis中,在处理动态SQl标签时,就使用到了组合模式,MyBatis会将所有的动态节点都抽象成对应的SqlNode,而一个动态节点里面可能也会有嵌套的动态节点!

举个栗子

<where>
    <if></if>
    <if></if>
</where>

使用组合模式可以让上层忽略动态节点里面的复杂性

OGNL表达式

其实每一个JavaBean可以理解成是一棵Java对象树,节点就是成员属性或者方法,OGNL可以获取Java对象中的属性、调用Java对象树中的方法

OGNL:Object Graphic Navigation Language,对象图导航语言,可以去导航Java对象图里面的某个位置

先认识一下OGNL表达式三个重要概念

  • 表达式:OGNL规定了自身的一套语法,表达式必须要符合语法才算正确的,OGNL会根据表达式去做对应的操作,如下
    • 操作一个对象的方法:对象.方法名
    • 访问一个对象的属性:对象.属性名
    • 访问一个对象的静态方法或静态属性:@[类的全限定类名]@[静态方法或静态属性]
  • root对象(目标对象):需要OGNL进行操作的对象
  • OgnlContext(上下文对象):只是一个HashMap对象,里面存放了各种对象,可以让OGNL从该上下文进行检索出对应的对象来执行表达式对应的操作,不过要加上#前缀才能使用
  • 说白了就是直接根据root对象操作直接使用属性名即可,但如果要从上下文中进行检索,则需要加上#前缀

使用OGNL表达式需要使用ognl-3.1.jar和javassist-3.21.jar

在这里插入图片描述
在MyBatis中,使用了OgnlCache对原生的Ognl进行了封装,因为Ognl的解析是会耗时的,因此OgnlCache也如同其名字一样,提供了对应的缓存服务,缓存仅仅只是解析的结果!

OgnlCache

在这里插入图片描述
里面的expressionCache就是表达式对应的缓存!

getValue方法用于获取Root对象中,表达式对应的属性

public static Object getValue(String expression, Object root) {
    try {
        //创建OgnlContext对象
      Map context = Ognl.createDefaultContext(root, MEMBER_ACCESS, CLASS_RESOLVER, null);
        //先使用parseExpression方法,查看缓存是否有对应的expression的解析结果!
      return Ognl.getValue(parseExpression(expression), context, root);
    } catch (OgnlException e) {
      throw new BuilderException("Error evaluating expression '" + expression + "'. Cause: " + e, e);
    }
  }
//解析表达式
private static Object parseExpression(String expression) throws OgnlException {
    //从缓存中获取解析结果
    Object node = expressionCache.get(expression);
    //如果没有
    if (node == null) {
        //进行解析
      node = Ognl.parseExpression(expression);
        //放入缓存
      expressionCache.put(expression, node);
    }
    return node;
  }

DynamicContext

DynamicContext是用来记录解析动态SQL语句之后产生的SQL语句片段的,也就是用来记录动态SQL语句解析结果的容器,同时也会去记录SQL需要使用到的实参

在这里插入图片描述
核心的属性有两个

  • bindings:绑定的参数上下文
  • sqlBuilder:拼接动态SQL的
ContextMap

ContextMap用于封装了参数的上下文,里面存储着动态SQL需要使用到的参数值,是DynamicContext里面的一个静态类

在这里插入图片描述
可以看到,ContextMap继承了HashMap,从而拥有HashMap的性质,并且仅仅重写了get方法,其关键属性是parameterMetaObject,他代表的就是运行时传来的参数

看一下重写的get方法

public Object get(Object key) {
      String strKey = (String) key;
    //判断容器中是否拥有该key,有的话直接返回  
    if (super.containsKey(strKey)) {
        return super.get(strKey);
      }
	
      if (parameterMetaObject == null) {
        return null;
      }
	//如果HashMap中没有,则从运行时参数中取
      if (fallbackParameterObject && !parameterMetaObject.hasGetter(strKey)) {
        return parameterMetaObject.getOriginalObject();
      } else {
        // issue #61 do not modify the context when reading
        return parameterMetaObject.getValue(strKey);
      }
    }

可以看到,会优先从底层的HashMap容器中取,如果没有,则会从运行时参数中取

回到我们的DynamicContext

DynamicContext可以很好将访问POJO和Map对象的差异抹平,先来看看其构造方法

public DynamicContext(Configuration configuration, Object parameterObject) {
    //parameterObject其实就是传进来的运行时参数
    if (parameterObject != null && !(parameterObject instanceof Map)) {
        //如果不是一个Map类型,则创建对应的MetaObject对象
      MetaObject metaObject = configuration.newMetaObject(parameterObject);
        //判断是否存在TypeHandler,类型转换!
      boolean existsTypeHandler = configuration.getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass());
      //初始化bindings,使用metaObject去初始化
      bindings = new ContextMap(metaObject, existsTypeHandler);
    } else {
       //如果是一个Map类型的,或者为null
        //则初始化底层为null
      bindings = new ContextMap(null, false);
    }
    //添加parameterObject和databaseId进容器中
    //直接将参数丢进集合中!
    //对应的key是_paramter和_databaseid
    bindings.put(PARAMETER_OBJECT_KEY, parameterObject);
    bindings.put(DATABASE_ID_KEY, configuration.getDatabaseId());
  }

对于DynamicContext最常用的就是appendSql的方法

在这里插入图片描述
可以看到,只是使用StringJoiner去添加而已

然后getSql仅仅只是使用StringJoiner去创建而已

SqlNode

认识完上面的一些组件之后,来看一下动态标签是怎么解析的

在这里插入图片描述
SqlNode里面只有一个apply接口,参数是一个DynamicContext,也就是说SqlNode的功能就是根据用户传入的实参,去将自身的SQL片段添加进参数DynamicContext中,当所有的SqlNode完成解析后,参数中的DynamicContext就是一个动态生成的完整的SQL了,并且对于一些需要根据实参去判断拼接语句的,可以从DynamicContext中取出参数上下文来判断!
在这里插入图片描述
可以看到,SqlNode有很多的实现类,每个实现类对应的就是一个动态标签,前面提到过SqlNode是组合模式中的抽象组件!

  • SqlNode:抽象组件
  • MixedSqlNode:组合模式中的树枝,也就是复杂的节点!
  • 其他SqlNode:叶子节点

下面简单分析几种SqlNode

MixedSqlNode

这个是树枝,叶子节点在其下层
在这里插入图片描述

可以看到,其apply方法就是调用所有下层节点的apply方法,从而通知到下面的每个叶子节点

StaticSqlNode

在这里插入图片描述
staticSqlNode属于叶子节点,其作用就是简单地让DynamicContext拼接上自己的静态文本

TextSqlNode

TextSqlNode则不是静态文本,而是需要解析的文本,对文本解析,看了那么久的MyBatis,之哟${…}了,这里也不例外,TextSqlNode就是会对占位符尽心解析

@Override
  public boolean apply(DynamicContext context) {
      //生成GenericTokenParser
    GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
    //将解析后的结果拼接到DynamicContext的Sql中
     context.appendSql(parser.parse(text));
    return true;
  }

解析过程前面已经看过了,这里就不再赘述

还有很多的其他动态节点,比如if、where、set这些,就不去深入了。。。

SqlSourceBuilder

认识了上面对于SqlSource还有SqlNode之后,下面就认识一下SqlSourceBuilder

在一系列的SqlNode的apply方法解析之后,完整的SQL语句就出现了,下面就交由SqlSourceBuilder来创建出SqlSource了,这里要注意与初始化MyBatis映射文件时创建的SqlSource区分,SqlSourceBuilder是在执行Mapper接口方法时会使用到!

在这里插入图片描述
SqlSourceBuilder有以下两个作用

  • 一方面是解析SQL语句中的#{}占位符中定义的属性
  • 另一方面则是将#{}替换成?,变为以前我们使用PrepareStatement时候的占位符

核心的逻辑在parse方法上

public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
    //originalSql就是一系列SqlNode解析完成的动态SQL
    //additionalParameters就是传来的实参类型
    //additionalParameters就是形参与实参的对应关系,
    //说白了就是经过sqlNode一系列处理之后的DynamicContext中的ContextMap
    //ParameterMappingTokenHandler,用来解析#{}中的属性和替换#{}为?的
    ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
    //GenericTokenParser,负责取出指定开关区间里面的token,然后使用对应handler来处理
    //这里的处理就是使用了ParameterMappingTokenHandler来对解析出来的token进行处理
    GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
    String sql;
    //使用GenericTokenParser来对SQL进行解析,并且使用对应handler去处理解析出来的token
    if (configuration.isShrinkWhitespacesInSql()) {
        //这个是削减SQL空格的
      sql = parser.parse(removeExtraWhitespaces(originalSql));
    } else {
      sql = parser.parse(originalSql);
    }
    //返回一个StaticSqlSource!
    return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
  }

从这里可以看到,SqlSourceBuilder的核心parse方法的步骤如下

  • 创建对应的GenericTokenParser,该GenericTokenParser可以解析#{}的占位符,并且对于解析出来的Token使用ParameterMappingTokenHandler来进行处理!
  • 调用GenericTokenParser的parser方法来进行解析SQL中的所有#{}占位符

所以说,对于#{}里面的属性解析,核心在于ParameterMappingTokenHandler,这里也可以与之间对于${}的解析,其Handler就是从Configuration的Properties中取出对应的值,然后拼接而已,下面来看看ParameterMappingTokenHandler是如何解析#{}里面的属性的,从而从底层上可以知道两种占位符有什么区别

ParameterMappingTokenHandler

在这里插入图片描述
从图中可以看到,ParameterMappingTokenHandler是继承了BaseBuilder和实现了TokenHandler接口的

成员属性

  • parameterMappings:解析得到的ParameterMapping集合,底层是一个ArrayList
  • metaParameters:DynamicContext中的参数上下文ContextMap的metaObject对象

ParameterMapping

ParameterMapping(参数映射)中记录了#{}占位符中的参数属性,说白了就是一个占位符里面的参数就代表着一个ParameterMapping!绑定对应的Java实参

在这里插入图片描述
成员属性如下

  • property:占位符中的token名字
  • mode:是一个输入参数还是输出参数
  • javaType:解析出的属性对应的java类型
  • jdbcType:解析出的属性对应的jdbc类型
  • numericScale:浮点型的精度值
  • typeHandler:参数对应的TypeHandler,用来做类型转换
  • resultMapId:参数对应的resultMap的id
  • jdbcTypeName:参数jdbcTypeName属性
  • expression:表达式,目前还不支持

之前我们学习GenericTokenHandler会将解析的表达式获取的token交给TokenHandler去进行,对应执行的是handlerToken方法

在这里插入图片描述
而ParameterMappingTokenHandler会调用buildParameterMapping去解析token,获取到对应的ParameterMapping,然后添加到ParameterMappings集合中,然后返回一个问号,这也就完成了SqlSourceBuilder的功能。

下面来看一下其是如何创建ParameterMapping的

源码如下

private ParameterMapping buildParameterMapping(String content) {
    //解析参数的属性,并形成一个Map
    //在#{}中,是可以去添加javaType,jdbcType这些属性的
    //比如#{xxx,javaType=int,jdbcType=NUMERIC,..}
    //这一步就是去解析这些属性出来
    //同时,xxx的key为property!
      Map<String, String> propertiesMap = parseParameterMapping(content);
    //获取property属性,也就是参数的名称
      String property = propertiesMap.get("property");
      Class<?> propertyType;
    //根据实参和property值去自动解析Java类型
    //确定对应的java类型,通过解析出的property和MetaObject确定实参的Java类型!
    //因为可能不会表明对应的javaType,可以看到是根据get方法去找的!所以一定要有get方法!
    //get方法的返回值类型!
      if (metaParameters.hasGetter(property)) { // issue #448 get type from additional params
        propertyType = metaParameters.getGetterType(property);
      } else if (typeHandlerRegistry.hasTypeHandler(parameterType)) {
        propertyType = parameterType;
      } else if (JdbcType.CURSOR.name().equals(propertiesMap.get("jdbcType"))) {
        propertyType = java.sql.ResultSet.class;
      } else if (property == null || Map.class.isAssignableFrom(parameterType)) {
        propertyType = Object.class;
      } else {
          //最终采用MetaClass去获取
        MetaClass metaClass = MetaClass.forClass(parameterType, configuration.getReflectorFactory());
        if (metaClass.hasGetter(property)) {
          propertyType = metaClass.getGetterType(property);
        } else {
          propertyType = Object.class;
        }
      }
    //使用ParameterMapping的builder来创建ParameterMapping
      ParameterMapping.Builder builder = new ParameterMapping.Builder(configuration, property, propertyType);
    //
      Class<?> javaType = propertyType;
      String typeHandlerAlias = null;
      for (Map.Entry<String, String> entry : propertiesMap.entrySet()) {
          //遍历解析的属性,使用builder对各个属性进行建造!
        String name = entry.getKey();
        String value = entry.getValue();
        if ("javaType".equals(name)) {
            //使用别名注册中心查找javaType
          javaType = resolveClass(value);
          builder.javaType(javaType);
        } else if ("jdbcType".equals(name)) {
            //
          builder.jdbcType(resolveJdbcType(value));
        } else if ("mode".equals(name)) {
          builder.mode(resolveParameterMode(value));
        } else if ("numericScale".equals(name)) {
          builder.numericScale(Integer.valueOf(value));
        } else if ("resultMap".equals(name)) {
          builder.resultMapId(value);
        } else if ("typeHandler".equals(name)) {
          typeHandlerAlias = value;
        } else if ("jdbcTypeName".equals(name)) {
          builder.jdbcTypeName(value);
        } else if ("property".equals(name)) {
          // Do Nothing
            //忽略property,因为是值!前面已经注入了!
        } else if ("expression".equals(name)) {
          throw new BuilderException("Expression based parameters are not supported yet");
        } else {
          throw new BuilderException("An invalid property '" + name + "' was found in mapping #{" + content + "}.  Valid properties are " + PARAMETER_PROPERTIES);
        }
      }
      if (typeHandlerAlias != null) {
        builder.typeHandler(resolveTypeHandler(javaType, typeHandlerAlias));
      }
      return builder.build();
    }

可以看到,解析ParameterMapping除了解析token之外,剩下的都是利用前面我们学习到的一些基础组件来解析的对应属性的,最终使用解析出的属性来创建ParameterMapping

此时ParameterMapping里面就有#{}里面的所有属性与实参的映射关系了!包括property(token里面的值)、javaType(可能没有这属性,会去自动根据token去解析Java类型)、jdbcType,此时一个#{}占位符所有信息都封装在该ParameterMapping中了

当经过SqlSourceBuilder解析之后,此时就会得到#{}占位符替换为?的SQL语句和对应ParameterMappings了,然后会将SQL语句和ParameterMappings封装成StaticSqlSource对象

在这里插入图片描述
所以,StaticSqlSource对象本质上是一个SQL语句和#{}占位符解析出的ParameterMappings结合

BoundSql

StaticSqlSource里面仅仅只有一个getBoundSql的方法

在这里插入图片描述
在这里插入图片描述
成员属性有如下

  • sql:将#{}占位符替换成?的SQL语句
  • parameterMappings:#{}占位符解析出的parameterMapping集合
  • parameterObject:传进来的实参
  • additionParameters:额外的参数,是一个HashMap
  • metaParameters:额外的参数对应的MetaObject对象

当底层执行JDBC的时候,就是使用BoundSql来处理的,BoundSql里面已经拥有了完整的SQL,还有问号占位符对应的所有实参

DynamicSqlSource

现在回过头来看我们的DynamicSqlSource

在这里插入图片描述
直接看它的getBoundSql方法

源码如下

public BoundSql getBoundSql(Object parameterObject) {
    //创建DynamicContext,也就是参数上下文
    DynamicContext context = new DynamicContext(configuration, parameterObject);
    //第一步,动态标签节点去拼接SQL
    rootSqlNode.apply(context);
    //完成动态标签节点的拼接之后,使用SqlSourceBuilder解析SQL的#{}
    SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
    //参数类型如果为Null,则为Object类型
    Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
    //解析,变成一个StaticSqlSource
    SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
    //使用StaticSqlSource生成对应的BoundSql
    BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
    context.getBindings().forEach(boundSql::setAdditionalParameter);
    //返回BoundSql
    return boundSql;
  }

现在对于insert、update、delete、select中的SQL的动态SQL的完整解析就看完了

总结一下动态SQL的解析

  • 对于MyBatis来说,拥有动态SQL标签节点和拥有${}占位符就视为动态SQL,使用DynamicSqlSource来表示
  • 整体的解析过程
    • 创建DynamicContext,来封装参数的上下文和动态SQL的片段
    • 前面MyBatis已经使用组合模式来将全部的动态节点标签组合成一个复杂的对象,使用该对象来将所有动态节点的SQL片段给拼接上DynamicContext的SQL片段中
    • 将拼接完成的SQL交由SqlSourceBuilder进行解析,处理#{}占位符,每个#{}占位符对应的就是一个ParameterMapping,ParameterMapping里面存储了#{}占位符的信息,比如映射的属性名、JavaType、JdbcType等,SqlSourceBuilder使用PatameterMappingTokenHandler去解析每个#{}占位符生成对应的ParameterMapping对象,然后存放进一个ArrayList集合中,并且使用问号替换了#{}占位符
    • 最后使用完成替换的SQL和存储ParameterMapping的ArrayList集合生成BoundSql
    • 返回BoundSql,此时BoundSql就拥有完整的信息了,映射关系和完整SQL

拓展:${}和#{}的区别

面试对于MyBatis框架内容有时会问这个问题,下面提出自己的几点见解

  • ${}我们一般是用来获取配置文件上的property标签里面映射的真实值,解析的时候会用真实值来替换占位符的
  • #{}则是出现在SQL节点里面的SQL中,用来生成预处理SQL需要使用到的实参的,当解析完该占位符之后,会使用?去替换这个占位符,个人认为这是用来抹平原生JDBC使用prepareStatement执行SQL时与对应参数的耦合性的,在我们使用原生JDBC时,一般为了防止SQL注入,都是采用PrepareStatement去执行SQL,SQL中要使用的参数不是拼接上去的,而是采用?占位符,对应该占位符的顺序位置去注入对应参数,当我们需要去改动了SQL时,或者注入另外一种参数,此时就会需要去改动源代码,而MyBatis使用了#{}和XML配置文件,让我们仅仅只需改动配置文件即可,不需要去动源代码
  • #{}对应一个ParameterMapping对象,MyBatis使用SqlSourceBuilder去解析SQL的#{}占位符,每解析一个占位符就生成一个ParameterMapping对象添加进ParameterMappings集合中,然后使用?占位符去替换该占位符,ParameterMappings其实是一个ArrayList集合,这样ParameterMappings集合的索引与?占位符产生对应的顺序映射关系了,可以对应获取到?占位符对应的实参,不需要去改动源代码去完成SQL变化的需求了
  • 如果在SQL中使用${}和#{},其主要区别是,一个对于参数是直接替换上去,中间不会经过预处理;而另外一个是采用PrepareStatement的方式,会进行预处理,防止SQL注入等问题
  • 当然,${}占位符不仅可以使用在SQL上,还可以使用在其他标签上,比如对于数据库信息的配置,或者include标签和sql标签

RawSqlSource

前面我们解析SQL节点标签时,如果是动态SQL则是使用DynamicSqlSource,而静态SQL则是使用RawSqlSource,所谓静态就是没有使用${}占位符和其他动态标签的SQL

回到我们的XMLScriptBuilder中的parseScriptNode方法中

在这里插入图片描述
可以看到,如果不是动态的SQL,是使用RawSqlSource的

下面就来看看这个RawSqlSource吧

在这里插入图片描述
成员属性只有一个

  • sqlSource:也就是StaticSqlSource对象

可以看到其构造方法直接使用SqlSourceBuilder进行了解析,并没有去使用组合的复杂SqlNode进行拼接SQL,因为没有动态标签,所以也就没有SqlNode

在这里插入图片描述
getBoundSql,则是直接使用SqlSourceBuilder解析#{}占位符生成的StaticSqlSource来获取绑定的SQL的。

至此,MyBatis的SqlSource与SqlNode的具体运用,并且认识了MyBatis使用的组合模式场景,也了解了当执行SQL时,MyBatis是如何根据传进来的参数来进行映射的,也清楚了#{}占位符与${}占位符的作用与区别,至此MySQL对于SQL映射配置文件的解析

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值