mybatis源码分析之文件解析

mybatis中的配置文件

mybatis的配置文件有两种:
(1) mybatis的全局配置文件:全局配置文件包含了mybatis的全局化的配置,比如mybatis的环境配置、别名配置、插件(拦截器)配置、类型处理器配置、数据库标识配置、映射器配置等。
(2) mybatis的mapper接口的映射文件:映射文件必须与映射器接口(mapper接口)联合使用,映射器接口声明了方法,映射文件指明的方法对应的具体的sql语句。映射文件包含了sql语句配置、sql代码段配置、结果集映射配置等。每个映射文件都有一个命名空间,命名空间必须和映射器接口的类全名相同,否则在解析时就会抛出异常。

mybatis配置文件解析

首先,我们来看一个简单的mybatis使用的例子:

// 获取mybatis全局配置文件的流
InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
// 根据流构建SqlSessionFactory对象
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
// 根据sqlSessionFactory获取SqlSession对象
SqlSession sqlSession = sqlSessionFactory.openSession();
// 通过sqlSession执行查询方法。主要有两个参数:第一个参数是要执行的接口方法的全限定名,第二个是要执行方法需要的参数
List<StudentInfo> studentInfos = sqlSession.selectList("org.apache.ibatis.test.dao.StudentInfoDao.listStudentInfo", "123");
System.out.println(studentInfos);

从上面的例子,可以知道mybatis使用的几个关键步骤:
(1) 获取mybatis全局配置文件的流对象。
(2) 创建SqlSessionFactoryBuilder对象。
(3) 根据SqlSessionFactoryBuilder对象构建SqlSessionFactory对象。
(4) 根据SqlSessionFactory创建SqlSession。
(5) 通过sqlSession执行接口方法。
首先,我们来看从源码层面看第 (3) 步:

 // 进来之后的方法
 public SqlSessionFactory build(InputStream inputStream) {
    return build(inputStream, null, null);
  }

  // 实际调用的方法,注意一下:此方法还可以传入evironment和properties对象
  public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
      // 创建一个XMLConfigBuilder对象,并传入我们前面获取的配置文件流对象
      // XMLConfigBuilder是专门用于解析mybatis的全局配置文件的类
      XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
      // 调用parser的parse方法进行解析,解析之后会返回一个Configuration对象
      // 之后再根据Configuration对象创建一个DefaultSqlSessionFactory对象并返回
      // DefaultSqlSessionFactory对象是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.
      }
    }
  }
  
  // 实际返回SqlSessionFactory实现类的方法
  public SqlSessionFactory build(Configuration config) {
  	// 根据Configuration对象创建一个DefaultSqlSessionFactory对象
    return new DefaultSqlSessionFactory(config);
  }

从上面的源码我们可看出,mybatis的配置文件是通过XMLConfigBuilder类进行解析的,解析之后会返回一个Configuration对象(Configuration对象是mybatis的配置对象,贯穿mybatis的使用的全过程),然后会再根据Configuration对象创建一个DefaultSqlSessionFactory对象并返回(DefaultSqlSessionFactory是SqlSessionFactory接口的默认实现类)。也就是说实际上SqlSessionFactoryBuilder将文件解析的工作委托给了XMLConfigBuilder来做。
那么XMLConfigBuilder是如何完成这个工作的呢?

 // 解析mybatis全局配置文件的方法
 public Configuration parse() {
 	// 如果文件已经被解析过了,则抛出异常,避免文件重复被解析
    if (parsed) {
      throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    // 设置文件解析状态为已解析
    parsed = true;
    // 获取configuration节点,并以此节点为根进行解析
    parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
  }

  private void parseConfiguration(XNode root) {
    try {
      // 解析配置文件中的properties节点
      propertiesElement(root.evalNode("properties"));
      // 解析配置文件中的settings节点
      Properties settings = settingsAsProperties(root.evalNode("settings"));
      loadCustomVfs(settings);
      loadCustomLogImpl(settings);
      // 解析配置文件中的typeAliases节点
      typeAliasesElement(root.evalNode("typeAliases"));
      // 解析配置文件中的plugins节点
      pluginElement(root.evalNode("plugins"));
      // 解析配置文件中的objectFactory节点
      objectFactoryElement(root.evalNode("objectFactory"));
      // 解析配置文件中的objectWrapperFactory节点
      objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
      // 解析配置文件中的reflectorFactory节点
      reflectorFactoryElement(root.evalNode("reflectorFactory"));
      settingsElement(settings);
      // 解析配置文件中的evironments节点
      environmentsElement(root.evalNode("environments"));
      // 解析配置文件中的databaseIdProvider节点
      databaseIdProviderElement(root.evalNode("databaseIdProvider"));
      // 解析配置文件中的typeHandlers节点
      typeHandlerElement(root.evalNode("typeHandlers"));
      // 解析配置文件中的mappers节点(mappers节点配置了映射器文件的位置信息)
      mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
  }

从上面我们可以看出来,mybatis全局配置文件的所有节点都在这里得到了解析,现在,为了保证整个解析流程的清晰,先不管其他节点的解析细节,只看mappers节点是如何解析的:

private void mapperElement(XNode parent) throws Exception {
	// 如果mappers节点存在,则进行下面的操作
    if (parent != null) {
      // 获取mappers节点下配置的所有的子节点,并进行遍历
      // mappers节点下都可以配置多个映射器类全名、映射文件路径、映射器包路径的信息
      for (XNode child : parent.getChildren()) {
        // 使用包路径
        if ("package".equals(child.getName())) {
          String mapperPackage = child.getStringAttribute("name");
          configuration.addMappers(mapperPackage);
        } else {
          // 相对于类路径的资源引用,比如: dao/UserDao.xml
          String resource = child.getStringAttribute("resource");
          // 完全限定资源定位符,比如:file://var/dao/UserDao.xml
          String url = child.getStringAttribute("url");
          // 类的完全限定名,比如:com.lhy.dao.UserDao
          String mapperClass = child.getStringAttribute("class");
          if (resource != null && url == null && mapperClass == null) {
            ErrorContext.instance().resource(resource);
            // Resources.getResourceAsStream方法是通过相对于类路径的资源引用获取输入流
            InputStream inputStream = Resources.getResourceAsStream(resource);
            // 创建XMLMapperBuilder对象
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
            // 调用XMLMapperBuilder对象的parse方法解析映射文件
            mapperParser.parse();
          } else if (resource == null && url != null && mapperClass == null) {
            ErrorContext.instance().resource(url);
            // Resources.getUrlAsStream方法是通过完全限定资源定位符获取输入流
            InputStream inputStream = Resources.getUrlAsStream(url);
            XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
            mapperParser.parse();
          } else if (resource == null && url == null && mapperClass != null) {
            // Resources.classForName方法是通过类全名获取Class对象
            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.");
          }
        }
      }
    }
  }

上面可以看出针对映射器的配置有四种方式:
(1) package: 指定映射器接口所在的包路径。这种方式解析的时候是从映射器接口的类全名去寻找映射文件,也就要求映射文件要和映射器接口在同一个目录下(编译后),有两种方式可以实现:
a. 将映射文件与映射器接口放在同一个包下。
b. 在resources文件夹下建立一个与映射器接口所在包相同的层级结构,并将映射文件放入其 中。
(2) resource: 相对于类路径的资源引用。这种方式解析的时候是从映射文件寻找映射器接口,也就要求映射文件的命名空间必须是映射器接口的类全名。
(3) url: 完全限定资源定位符。这种方式解析的时候也是从映射文件寻找映射器接口。
(4) class: 类的完全限定名。这种方式解析的时候也是从映射器接口的类全名去寻找映射文件。

package配置方式解析

关于package配置方式的解析是这样的:

// 获取包路径
String mapperPackage = child.getStringAttribute("name");
// 将包路径添加到configuration中
configuration.addMappers(mapperPackage);

从上面可以看出关于package配置方式的解析仅仅只有简单的两行代码,所以我们可以推测实际的解析操作一定是在configuration的addMappers方法中实现的。接下来我们再看看addMappers方法的源码:

public void addMappers(String packageName) {
    mapperRegistry.addMappers(packageName);
}

addMappers方法又调用了MapperRegistry对象的addMappers方法,也就是说configuration将解析工作又委托给了MapperRegistry对象来实现,那么MapperRegistry这个类是干啥的呢?这里我先卖个关子,我们进一步往下看:

// 进来之后的方法
public void addMappers(String packageName) {
    addMappers(packageName, Object.class);
}
// 实际调用的方法
public void addMappers(String packageName, Class<?> superType) {
    ResolverUtil<Class<?>> resolverUtil = new ResolverUtil<>();
    resolverUtil.find(new ResolverUtil.IsA(superType), packageName);
    // 获取包路径下的映射器接口的Class对象集合
    Set<Class<? extends Class<?>>> mapperSet = resolverUtil.getClasses();
    for (Class<?> mapperClass : mapperSet) {
      // 添加映射器接口的Class对象
      addMapper(mapperClass);
    }
}
// 添加映射器类对象的方法
public <T> void addMapper(Class<T> type) {
  // 判断映射器类是否是一个接口,只有当其是接口时才继续向下走
  if (type.isInterface()) {
  	// 如果映射器接口被解析过,则直接抛出异常
    if (hasMapper(type)) {
      throw new BindingException("Type " + type + " is already known to the MapperRegistry.");
    }
    // 设置解析完成标识为false
    boolean loadCompleted = false;
    try {
      // 根据映射器Class对象创建对应的MapperProxyFactory,并放入到knownMappers中
      // MapperProxyFactory是代理类MapperProxy的工厂类,是工厂模式的体现
      // knowMappers是MapperRegistry中维护的一个HashMap,键是映射器接口的Class对象,值是MapperProxy的工厂类MapperProxyFactory
      knownMappers.put(type, new MapperProxyFactory<>(type));
      // 根据映射器接口创建MapperAnnotationBuilder对象
      MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
      // 调用对象的parse方法对接口中配置的注解进行解析
      parser.parse();
      // 设置解析完成的标识为true
      loadCompleted = true;
    } finally {
      // 如果解析过程中抛出了异常,则解析完成标识为false
      if (!loadCompleted) {
      	// 如果解析没有完成,则移除解析记录
        knownMappers.remove(type);
      }
    }
  }
}

我们对上述代码中的解析过程进行一下梳理,解析过程分为以下几步:
(1) 通过工具类获取包路径下映射器类对象的集合,并进行遍历,获取每一个映射器的类对象。
(2) 判断映射器是否是一个接口,只有当映射器是接口的时候才继续向下执行。
(3) 判断映射器是否被解析过,如果已经被解析过,则直接抛出异常,否则继续向下执行。
(4) 创建MapperProxy(映射器接口的代理类)的工厂类MapperProxyFactory并添加到MapperRegistry中维护的HashMap集合knowMappers中,以映射器类对象为键,MapperProxyFactory对象为值。
(5) 创建MapperAnntationBuilder对象,并调用其parse方法对映射器接口中配置的注解进行解析。

从源码可以看出MapperRegistry主要作用是为一个映射器接口注册代理类工厂(MapperProxyFactory),实际上,sqlSession.getMapper方法获取的代理对象其实就是MapperProxy对象,而MapperProxy对象是由MapperProxyFactory创建出来的。

MapperRegistry又将实际的解析动作委托给MapperAnnationBuilder对象来实现,并且通过名称可以推测出来MapperAnnationBuilder的主要工作就是解析在映射器接口中配置的注解,具体的细节如何,我们进一步向下看:

public void parse() {
  // 格式是"class|interface 类全限定名",比如,可以是:interface org.apache.ibatis.test.dao.StudentInfoDao
  String resource = type.toString();
  // 如果资源还没有被加载过,则向下执行
  if (!configuration.isResourceLoaded(resource)) {
    // 加载映射器对应的映射文件
    loadXmlResource();
    // 将资源添加到已加载列表中
    configuration.addLoadedResource(resource);
    // assistant是MapperBuilderAssistant的对象,是一个助手对象,主要的作用是记录当前的命名空间、当前的资源路径、当前的二级缓存、是否存在未解决的缓存引用等。
    assistant.setCurrentNamespace(type.getName());
    // 解析映射器接口中配置的二级缓存(@CacheNamespace)注解
    parseCache();
    // 解析映射器接口中配置二级缓存引用(@CacheNamespaceRef)注解
    parseCacheRef();
    for (Method method : type.getMethods()) {
      if (!canHaveStatement(method)) {
        // 如果方法是桥接方法或者接口的默认方法(jdk1.8),那么就不进行解析
        // 桥接方法由虚拟机自己生成,不能与sql语句绑定;默认方法有自己的默认实现,不能执行绑定的SQL语句
        continue;
      }
      if (getAnnotationWrapper(method, false, Select.class, SelectProvider.class).isPresent()
          && method.getAnnotation(ResultMap.class) == null) {
        // 解析通过注解配置的属性映射
        parseResultMap(method);
      }
      try {
        // 解析通过注解配置的SQL语句
        parseStatement(method);
      } catch (IncompleteElementException e) {
        // 如果方法解析失败,则将其添加到解析失败的列表中,后面再解析
        configuration.addIncompleteMethod(new MethodResolver(this, method));
      }
    }
  }
  // 尝试对之前解析失败的方法进行解析
  parsePendingMethods();
}
// 解析映射文件
private void loadXmlResource() {
  // Spring may not know the real resource name so we check a flag
  // to prevent loading again a resource twice
  // this flag is set at XMLMapperBuilder#bindMapperForNamespace
  // 首先判断此类对应的xml文件是否加载过
  if (!configuration.isResourceLoaded("namespace:" + type.getName())) {
    // 加载与映射器类在同一个包的xml文件
    String xmlResource = type.getName().replace('.', '/') + ".xml";
    // #1347
    // 这里为什么前面要拼接一个 / 呢?
    // 因为Class.getResourceAsStream(String path)有这样的特点:
    // 1) path不以'/'开头时默认是从此类所在的包下获取资源
    // 2) path以'/'开头时,则是从根路径下获取资源
    InputStream inputStream = type.getResourceAsStream("/" + xmlResource);
    if (inputStream == null) {
      // Search XML mapper that is not in the module but in the classpath.
      try {
        inputStream = Resources.getResourceAsStream(type.getClassLoader(), xmlResource);
      } catch (IOException e2) {
        // ignore, resource is not required
      }
    }
    if (inputStream != null) {
      // 创建XMLMapperBuilder对象,XMapperBuilder是专门用于解析映射文件的
      XMLMapperBuilder xmlParser = new XMLMapperBuilder(inputStream, assistant.getConfiguration(), xmlResource, configuration.getSqlFragments(), type.getName());
      // 调用parse方法对映射文件进行解析
      xmlParser.parse();
    }
  }
}

上面的过程可以用一个流程图展示:

加载映射文件
yes
no
yes
no
yes
no
yes
no
结束
判断映射文件是否已经被加载过
根据映射器的类全名获取映射文件的位置
获取映射文件的流对象
创建XMLMapperBuilder对象并调用其方法解析映射文件
获取映射器接口字符串表示
映射器接口是否已经被加载
退出
加载映射文件
将资源添加到已加载列表中
设置助手类的当前命名空间
解析映射器接口中配置的二级缓存
解析映射器接口中配置的二级缓存引用
获取映射器接口中的所有方法对象并进行遍历
方法是否是桥接方法或者默认方法
结束本次循环
方法上是否没有Select/SelectProvider/ResultMap注解
解析映射器接口通过注解配置的属性映射
解析映射器接口中通过注解配置的SQL语句

从上面我们可以得知一个信息,就是当解析映射器的时候会提示尝试解析映射器对应的映射文件,这个时候是根据映射器的类全名获取映射文件的位置的,所以如果在全局配置文件中配置mappers标签时是通过class或者package方式进行配置的,那么此时映射文件在项目的层级结构必须和对应的映射器是一样的。比如说:映射器的包路径是cn.lhy.dao,那么映射文件必须与映射器在同一个包下或则在resource文件夹下的/cn/lhy/dao文件夹下。

resource配置方式解析

resource配置方式解析源码如下:

// 获取映射文件的流对象
InputStream inputStream = Resources.getResourceAsStream(resource);
// 创建XMLMapperBuilder对象
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
// 调用对象的parse方法对映射文件进行解析
mapperParser.parse();

对比package方式解析的过程,那么肯定可以发现这两者都是通过XMLMapperBuilder来解析映射文件的。可见,XMLMapperBuilder就是专门用来解析mybatis的映射文件的。

接下来我们看看XMLMapperBuilder是如何解析映射文件的:

public void parse() {
// 判断configuration中是否加载过这个资源(resource是一个文件相对于类路径的文件路径,比如:dao/StudentInfoDao.xml)
  if (!configuration.isResourceLoaded(resource)) {
    configurationElement(parser.evalNode("/mapper"));
    configuration.addLoadedResource(resource);
    // 根据映射文件的命名空间解析映射器
    bindMapperForNamespace();
  }
  // 处理之前没有处理完成的resultMap节点(没有处理完成可能是因为在处理过程中出错了)
  parsePendingResultMaps();
  // 处理之前没有处理完成的cache-ref节点(如果cache-ref先于其指向的cache节点时就会出现异常)
  parsePendingCacheRefs();
  // 处理之前没有处理完成功的sql语句节点(select|update|delete|insert)
  parsePendingStatements();
}

private void bindMapperForNamespace() {
 // 获取命名空间,这里的命名空间会当做映射器的类全名来使用
 String namespace = builderAssistant.getCurrentNamespace();
  if (namespace != null) {
    Class<?> boundType = null;
    try {
      // 通过反射获取类对象,这里将映射文件的命名空间当做映射器的类全名来使用
      boundType = Resources.classForName(namespace);
    } catch (ClassNotFoundException e) {
      // ignore, bound type is not required
    }
    if (boundType != null && !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);
      // 解析映射器
      configuration.addMapper(boundType);
    }
  }
}

configuration的addMapper方法实现如下:

public <T> void addMapper(Class<T> type) {
	mapperRegistry.addMapper(type);
}

在解析package配置方式中包路径下的映射器接口时,首先,获取了这些映射器接口的类对象,然后再遍历这些对象,并调用MapperRegistry的addMapper方法进行处理。然而,在这里也用到了MapperRegistry的addMapper方法,看到这里,可以发现整个解析流程进行形成一个闭环了,用一个流程图表示如下:
在这里插入图片描述

结合以上源码与流程图中可以知道:
(1) MapperProxyFactory对象由MapperRegistry创建并保存,每个映射器接口都会对应一个MapperFactoryProxy对象。
(2) 无论从映射文件还是从映射器接口开始进行解析,最终都会将映射器接口与映射文件都解析完成。并且解析之前都会进行判断,避免重复解析。

url配置方式解析

与resource配置方式的解析一样。

class配置方式解析

与package配置方式的解析基本相同。不同的地方是package配置方式可以解析多个映射器,而class配置方式只能解析一个映射器。

总结

(1) XMLConfigBuilder是解析mybatis全局配置文件的类,解析完成后,返回一个Configuration对象,Configuration是mybatis的配置类,解析出来的所有的信息,都会放在这个配置类里。
(2) XMLConfigBuilder会将映射器接口的解析工作委托给Configuration,Configuration会将其委托给MapperRegistry,MapperRegistry在创建并保存MapperProxyFactory之后,会再将此工作委托给MapperAnnotationBuilder,MapperAnnotationBuilder在将映射器接口的注解解析完成之后,会将映射文件的解析工作委托给XMLMapperBuilder
(3) XMLConfigBuilder会将映射文件的解析工作直接委托给XMLMapperBuilder,XMLMapperBuilder在将映射文件解析完成之后,会将映射器接口的解析工作委托给Configuration。整个解析过程是一个闭环,无论是从映射文件开始解析,还是从映射器接口开始解析,最终会将映射文件与映射器接口都解析完成。
(4) MapperRegistry的主要作用是为映射器接口注册代理类工厂(MapperProxyFactory),sqlSession.getMapper获取的代理对象就是MapperProxy,而MapperProxy是有MapperProxyFactory创建的。
(5) 全局文件中配置mappers标签时,有四种方式:

  • package:指定映射器接口所在的包路径。这种方式解析的时候是从映射器接口的类全名去寻找映射文件,也就要求映射文件要和映射器接口在同一个目录下(编译后)。
  • resource:相对于类路径的资源引用。这种方式解析的时候是从映射文件寻找映射器接口,也就要求映射文件的命名空间必须是映射器接口的类全名。
  • url:完全限定资源定位符。这种方式解析的时候也是从映射文件寻找映射器接口。
  • class:类的完全限定名。这种方式解析的时候也是从映射器接口的类全名去寻找映射文件。

(6) 当映射文件的命名空间和映射器接口的类全名不一致时,为什么会抛出异常呢?
由上面的分析我们可以知道:

  • 如果是从映射文件开始解析,那么是将映文件的命名空间作为映射器接口的类全名的,因此,此时映射文件的命名空间必须是映射器接口的类全名,否则就无法解析映射器接口。
  • 如果是从映射器接口开始解析,那么是根据映射器接口的类全名去寻找映射文件的,此时的要求是映射文件必须要与映射器接口在同一个目录下(编译后)。此时映射器接口和映射文件都能得到解析,为什么还会抛出异常呢?我们从源码的角度出发看一下:
    MapperAnnotationBuilder中解析映射器接口的部分源码:
    // 设置助手类的当前命名空间为映射器接口的类全名
    assistant.setCurrentNamespace(type.getName());
    
    XMLMapperBuilder中解析映射文件的部分源码:
    // 获取映射文件配置的命名空间
      String namespace = context.getStringAttribute("namespace");
      if (namespace == null || namespace.isEmpty()) {
        throw new BuilderException("Mapper's namespace cannot be empty");
      }
      // 在助手类中应用当前获取的命名空间
      builderAssistant.setCurrentNamespace(namespace);
    
    在解析映射文件时助手类设置当前的命名空间是映射文件配置的命名空间,在解析映射器接口时助手类设置的当前命名空间为映射器接口的类全名,并且这两个解析过程使用的都是同一个助手类,也就是说如果映射文件的命名空间不是映射器接口的类全名,那么在助手类设置当前命名空间时两个值是不一样的,助手类设置当前命名空间的逻辑如下:
    public void setCurrentNamespace(String currentNamespace) {
       if (currentNamespace == null) {
         throw new BuilderException("The mapper element requires a namespace attribute to be specified.");
       }
       // 如果当前命名空间已经被设置过,并且新设置的命名空间和原先的不一致,那么就会抛出异常
       if (this.currentNamespace != null && !this.currentNamespace.equals(currentNamespace)) {
         throw new BuilderException("Wrong namespace. Expected '"
             + this.currentNamespace + "' but found '" + currentNamespace + "'.");
       }
    
       this.currentNamespace = currentNamespace;
     }
    
    可以看出,当命名空间重复设置,并且两个值不一致时,就会抛出异常。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值