MyBatis 源码分析

2 篇文章 0 订阅

一、学习之前思考

在学习mybatis之前,我是首先带着三个疑问去学习mybatis:

(1)数据库链接怎样管理:与数据库链接的线程池创建,sql执行等(个人理解再牛逼的orm框架,最后都需要转成

mysql(本次实例中是使用mysql数据)的原生的sql语句,最后执行的肯定是sql执行语句)

(2)java的dao层怎样操作sql语句:一个dao接口,为什么可以直接执行sql语句,

(3)数据结果是怎样封装的

由上述三个疑问,总结下面的mybatis的orm框架大体图:

二、结构化分析

由上述的描述,总结了下面的mybatis的需要实现的功能的结构化图:

三、DAO层容器

 

2.1、dao层注入

(1)怎样获取dao??

通过注解@MapperScan获取扫描到的包,而真正去处理的是MapperScannerRegistrar。

(2)MapperScannerRegistrar是怎样处理扫描到的dao接口的??

MapperScannerRegistrar 主要处理过程核心方法:registerBeanDefinitions

MapperScannerRegistrar.registerBeanDefinitions:

@Override
  public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
    //获取注解的属性
    AnnotationAttributes annoAttrs = AnnotationAttributes.fromMap(importingClassMetadata.getAnnotationAttributes(MapperScan.class.getName()));
    //通过spring上下文实例化一个扫描器
    ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry);
    // 非空判断,设置resourceLoader,3.1之后不需要检测
    if (resourceLoader != null) {
      scanner.setResourceLoader(resourceLoader);
    }
    //Annotation 注解类设置
    Class<? extends Annotation> annotationClass = annoAttrs.getClass("annotationClass");
    if (!Annotation.class.equals(annotationClass)) {
      scanner.setAnnotationClass(annotationClass);
    }
    //接口标记类加载
    Class<?> markerInterface = annoAttrs.getClass("markerInterface");
    if (!Class.class.equals(markerInterface)) {
      scanner.setMarkerInterface(markerInterface);
    }
    //bean name自动生成类加载
    Class<? extends BeanNameGenerator> generatorClass = annoAttrs.getClass("nameGenerator");
    if (!BeanNameGenerator.class.equals(generatorClass)) {
      scanner.setBeanNameGenerator(BeanUtils.instantiateClass(generatorClass));
    }
    //MapperFactoryBean类加载-----这个就是代理dao的BeanFactory
    Class<? extends MapperFactoryBean> mapperFactoryBeanClass = annoAttrs.getClass("factoryBean");
    if (!MapperFactoryBean.class.equals(mapperFactoryBeanClass)) {
      scanner.setMapperFactoryBean(BeanUtils.instantiateClass(mapperFactoryBeanClass));
    }
    //设置扫描器数据库管理模板和sqlSessionFactory
    scanner.setSqlSessionTemplateBeanName(annoAttrs.getString("sqlSessionTemplateRef"));
    scanner.setSqlSessionFactoryBeanName(annoAttrs.getString("sqlSessionFactoryRef"));

    List<String> basePackages = new ArrayList<String>();
    for (String pkg : annoAttrs.getStringArray("value")) {
      if (StringUtils.hasText(pkg)) {
        basePackages.add(pkg);
      }
    }
    for (String pkg : annoAttrs.getStringArray("basePackages")) {
      if (StringUtils.hasText(pkg)) {
        basePackages.add(pkg);
      }
    }
    for (Class<?> clazz : annoAttrs.getClassArray("basePackageClasses")) {
      basePackages.add(ClassUtils.getPackageName(clazz));
    }
    scanner.registerFilters();
    scanner.doScan(StringUtils.toStringArray(basePackages));//核心方法,具体扫描所做的逻辑都在此方法中
  }

ClassPathMapperScanner.doScan:

protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
		Assert.notEmpty(basePackages, "At least one base package must be specified");
		Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<BeanDefinitionHolder>();
		for (String basePackage : basePackages) {
      //扫描包,由于包扫描得到了所有的class,去掉一些非spring config的类,得到ScannedGenericBeanDefinition类型的BeanDefinition
			Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
			for (BeanDefinition candidate : candidates) {
        //获取需要注解的bean的生命周期,如:singleton
				ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate);
        //设置bean的生命周期
				candidate.setScope(scopeMetadata.getScopeName());
        //bean名字自动初始化
				String beanName = this.beanNameGenerator.generateBeanName(candidate, this.registry);
				if (candidate instanceof AbstractBeanDefinition) {
          //如果是继承抽象的AbstractBeanDefinition,在进一步初始化,此处初始化主要是为了设置默认值
					postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName);
				}
        //同上一步一样,如果该bean继承AnnotatedBeanDefinition接口,设置默认初始值
				if (candidate instanceof AnnotatedBeanDefinition) {
					AnnotationConfigUtils.processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate);
				}
				if (checkCandidate(beanName, candidate)) {
          //赋给bean定的持有者
					BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
          //判断当前的bean对应scope注解的那种形式,mybatis 没有使用任何形式故结果返回的还是definitionHolder
					definitionHolder =
							AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
					beanDefinitions.add(definitionHolder);
          //注册bean
					registerBeanDefinition(definitionHolder, this.registry);
				}
			}
		}
		return beanDefinitions;
	}

ClassPathMapperScanner.processBeanDefinitions:


  private void processBeanDefinitions(Set<BeanDefinitionHolder> beanDefinitions) {
    GenericBeanDefinition definition;//mapperFactoryBean 注册的是此bean,最终我们每个dao都转换成mapperFactoryBean,注意一个dao对应一个mapperFactoryBean
    for (BeanDefinitionHolder holder : beanDefinitions) {
      definition = (GenericBeanDefinition) holder.getBeanDefinition();
      if (logger.isDebugEnabled()) {
        logger.debug("Creating MapperFactoryBean with name '" + holder.getBeanName() 
          + "' and '" + definition.getBeanClassName() + "' mapperInterface");
      }
      //definition设置参数,即被代理的bean,查看mapperFactoryBean构造器可以看到,只有一个参数mapperInterface------代理bean构造器初始化
      definition.getConstructorArgumentValues().addGenericArgumentValue(definition.getBeanClassName()); 
      definition.setBeanClass(this.mapperFactoryBean.getClass());//设置beanClass,这个值代理对象bean
      //代理添加参数addToConfig
      definition.getPropertyValues().add("addToConfig", this.addToConfig);
      boolean explicitFactoryUsed = false;
      if (StringUtils.hasText(this.sqlSessionFactoryBeanName)) {
        //如果当前的被代理的bean所指定的sqlSessionFactory有名字,将当前sqlSessionFactory作为参数添加到代理bean中
        definition.getPropertyValues().add("sqlSessionFactory", new RuntimeBeanReference(this.sqlSessionFactoryBeanName));
        explicitFactoryUsed = true;
      } else if (this.sqlSessionFactory != null) {
        //为空直接使用当前的sqlSessionFactory
        definition.getPropertyValues().add("sqlSessionFactory", this.sqlSessionFactory);
        explicitFactoryUsed = true;
      }
      //同sqlSessionFactory一样原理
      if (StringUtils.hasText(this.sqlSessionTemplateBeanName)) {
        if (explicitFactoryUsed) {
          logger.warn("Cannot use both: sqlSessionTemplate and sqlSessionFactory together. sqlSessionFactory is ignored.");
        }
        definition.getPropertyValues().add("sqlSessionTemplate", new RuntimeBeanReference(this.sqlSessionTemplateBeanName));
        explicitFactoryUsed = true;
      } else if (this.sqlSessionTemplate != null) {
        if (explicitFactoryUsed) {
          logger.warn("Cannot use both: sqlSessionTemplate and sqlSessionFactory together. sqlSessionFactory is ignored.");
        }
        definition.getPropertyValues().add("sqlSessionTemplate", this.sqlSessionTemplate);
        explicitFactoryUsed = true;
      }

      if (!explicitFactoryUsed) {
        if (logger.isDebugEnabled()) {
          logger.debug("Enabling autowire by type for MapperFactoryBean with name '" + holder.getBeanName() + "'.");
        }
        //设置根据类型自动注入
        definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);//这行代码,,,,根据type实例化的,,,配置多数据源的时候这个地方就会出问题
      }
    }
  }

(3)总结

一句话:dao层注入,dao接口->definition->BeanDefinitionHolder->MapperFactoryBean,是通过MapperFactoryBean来代理bean的

2.2、xml与dao绑定

上面我们已经注入了dao的MapperFactoryBean代理bean,每一个dao都有一个对应的MapperFactoryBean代理对象,现在就是想知道xml或者sql语句怎么和MapperFactoryBean联系在一起的。

2.2.1、xml文件扫面映射

由于扫描xml文件是在sqlSessionFactory中扫描的,所以我是先从sqlSessionFactory 的bean注入代码去了解的:

javaconfig的SqlSessionFactory:

@Bean(name="sqlSessionFactory1")
    @Primary
    public SqlSessionFactory sqlSessionFactory1() throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(testOne);
        List<Resource> resources=new ArrayList<>();
        resources.addAll(Arrays.asList(new PathMatchingResourcePatternResolver().getResources("classpath:mybatis/*/person.xml")));
        factoryBean.setMapperLocations(resources.toArray(new Resource[resources.size()]));
        return factoryBean.getObject();
    }

核心方法SqlSessionFactoryBean.buildSqlSessionFactory:

protected SqlSessionFactory buildSqlSessionFactory() throws IOException {
    Configuration configuration;//整个SqlSessionFactory的核心,没有他SqlSessionFactory就没法去往后操作的,主要是保存SqlSessionFactory的一些配置信息
    XMLConfigBuilder xmlConfigBuilder = null;
    if (this.configuration != null) {//如果不为空直接解析xml文件
      configuration = this.configuration;
      if (configuration.getVariables() == null) {
        configuration.setVariables(this.configurationProperties);//这个不知道什么参数可以在这里设置,你要是懂,可留言告知我
      } else if (this.configurationProperties != null) {
        configuration.getVariables().putAll(this.configurationProperties);
      }
    } else if (this.configLocation != null) {
      xmlConfigBuilder = new XMLConfigBuilder(this.configLocation.getInputStream(), null, this.configurationProperties);
      configuration = xmlConfigBuilder.getConfiguration();
    } else {
      if (LOGGER.isDebugEnabled()) {
        LOGGER.debug("Property 'configuration' or 'configLocation' not specified, using default MyBatis Configuration");
      }
      configuration = new Configuration();
      if (this.configurationProperties != null) {
        configuration.setVariables(this.configurationProperties);
      }
    }

    if (this.objectFactory != null) {
      configuration.setObjectFactory(this.objectFactory);
    }

    if (this.objectWrapperFactory != null) {
      configuration.setObjectWrapperFactory(this.objectWrapperFactory);
    }

    if (this.vfs != null) {
      configuration.setVfsImpl(this.vfs);
    }

    if (hasLength(this.typeAliasesPackage)) {
      String[] typeAliasPackageArray = tokenizeToStringArray(this.typeAliasesPackage,
          ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
      for (String packageToScan : typeAliasPackageArray) {
        configuration.getTypeAliasRegistry().registerAliases(packageToScan,
                typeAliasesSuperType == null ? Object.class : typeAliasesSuperType);
        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Scanned package: '" + packageToScan + "' for aliases");
        }
      }
    }

    if (!isEmpty(this.typeAliases)) {
      for (Class<?> typeAlias : this.typeAliases) {
        configuration.getTypeAliasRegistry().registerAlias(typeAlias);
        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Registered type alias: '" + typeAlias + "'");
        }
      }
    }

    if (!isEmpty(this.plugins)) {
      for (Interceptor plugin : this.plugins) {
        configuration.addInterceptor(plugin);
        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Registered plugin: '" + plugin + "'");
        }
      }
    }

    if (hasLength(this.typeHandlersPackage)) {
      String[] typeHandlersPackageArray = tokenizeToStringArray(this.typeHandlersPackage,
          ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
      for (String packageToScan : typeHandlersPackageArray) {
        configuration.getTypeHandlerRegistry().register(packageToScan);
        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Scanned package: '" + packageToScan + "' for type handlers");
        }
      }
    }

    if (!isEmpty(this.typeHandlers)) {
      for (TypeHandler<?> typeHandler : this.typeHandlers) {
        configuration.getTypeHandlerRegistry().register(typeHandler);
        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Registered type handler: '" + typeHandler + "'");
        }
      }
    }

    if (this.databaseIdProvider != null) {//fix #64 set databaseId before parse mapper xmls
      try {
        configuration.setDatabaseId(this.databaseIdProvider.getDatabaseId(this.dataSource));
      } catch (SQLException e) {
        throw new NestedIOException("Failed getting a databaseId", e);
      }
    }

    if (this.cache != null) {
      configuration.addCache(this.cache);
    }

    if (xmlConfigBuilder != null) {
      try {
        xmlConfigBuilder.parse();

        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Parsed configuration file: '" + this.configLocation + "'");
        }
      } catch (Exception ex) {
        throw new NestedIOException("Failed to parse config resource: " + this.configLocation, ex);
      } finally {
        ErrorContext.instance().reset();
      }
    }

    if (this.transactionFactory == null) {
      this.transactionFactory = new SpringManagedTransactionFactory();
    }

    configuration.setEnvironment(new Environment(this.environment, this.transactionFactory, this.dataSource));

    if (!isEmpty(this.mapperLocations)) {
      for (Resource mapperLocation : this.mapperLocations) {
        if (mapperLocation == null) {
          continue;
        }

        try {
          XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(mapperLocation.getInputStream(),
              configuration, mapperLocation.toString(), configuration.getSqlFragments());//此处是重点,解析了sql的xml文件
          xmlMapperBuilder.parse();//解析xml文件,注意,这里解析返回的会有72中mybatis已经规定好的类型别名(我们在定义别名的时候注意不要重复)
        } catch (Exception e) {
          throw new NestedIOException("Failed to parse mapping resource: '" + mapperLocation + "'", e);
        } finally {
          ErrorContext.instance().reset();
        }

        if (LOGGER.isDebugEnabled()) {
          LOGGER.debug("Parsed mapper file: '" + mapperLocation + "'");
        }
      }
    } else {
      if (LOGGER.isDebugEnabled()) {
        LOGGER.debug("Property 'mapperLocations' was not specified or no matching resources found");
      }
    }

    return this.sqlSessionFactoryBuilder.build(configuration);
  }

真正解析类:XMLMapperBuilder.parse()

 public void parse() {
    if (!configuration.isResourceLoaded(resource)) {//资源如果未加载,开始解析
      configurationElement(parser.evalNode("/mapper"));//解析mapper节点---对应xml文件<mapper></mapper>
      configuration.addLoadedResource(resource);
      bindMapperForNamespace();//命名空间绑定:namespace="bootdemo.dao.db1.PersonDaoOne",此处会生成一个对应的MapperProxyFactory 来代理MapperProxyFactoryBean
    }
    parsePendingResultMaps();
    parsePendingCacheRefs();
    parsePendingStatements();
  }

xml解析方法实现:XMLMapperBuilder.configurationElement

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);//设置命名空间名称---留个疑问:为什么命名空间必须是dao类路径
      cacheRefElement(context.evalNode("cache-ref"));//mapper缓存设置
      cacheElement(context.evalNode("cache"));
      parameterMapElement(context.evalNodes("/mapper/parameterMap"));//xml文件定的参数map,对应xml标签:<parameterMap></parameterMap>
      resultMapElements(context.evalNodes("/mapper/resultMap"));//xml文件定的结果DTO对应map,对应xml标签:<resultMap></resultMap>
      sqlElement(context.evalNodes("/mapper/sql"));//xml文件定的结果sql标签,对应xml标签:<sql></sql>
      //增删改语句解析,增删改对应的映射类为:XMLStatementBuilder,解析成的XMLStatementBuilder放在configuration中
      buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing Mapper XML. Cause: " + e, e);
    }
  }

生成MapperProxyFactory:XMLMapperBuilder.bindMapperForNamespace----实际上xml文件是由MapperRegissry来映射的

 private void bindMapperForNamespace() {
    String namespace = builderAssistant.getCurrentNamespace();
    if (namespace != null) {
      Class<?> boundType = null;
      try {
        boundType = Resources.classForName(namespace);
      } catch (ClassNotFoundException e) {
      }
      if (boundType != null) {
        if (!configuration.hasMapper(boundType)) {
          configuration.addLoadedResource("namespace:" + namespace);//空间命名添加到容器中
          configuration.addMapper(boundType);//将MapperProxyFactory 代理类添加到容器中
        }
      }
    }
  }

//添加MapperProxyFactory代理的核心代码
 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 {
        knownMappers.put(type, new MapperProxyFactory<T>(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.
        MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
        parser.parse();
        loadCompleted = true;
      } finally {
        if (!loadCompleted) {
          knownMappers.remove(type);
        }
      }
    }
  }

总结:基于上述的代码,我们已经将整个的xml配置文件解析到configuration,并赋给SqlSessionFactory属中的属性:xml->configuration->SqlSessionFactory

四、调用方怎样调用

由第二部分介绍了,一个dao层怎么变成一个bean实例的(MapperFactoryBean),第三部分介绍了怎样xml文件怎样

生成java实例(MapperRegissry),接下来就是怎么将两者联系在一起和怎样去执行方法的。

4.1、xml和注入的dao bean关联

java的类dao生成了MapperFactoryBean实例,从MapperFactoryBean源码可以看到MapperFactoryBean继承了FactoryBean,所以他是一个Bean工厂类,我们可以看到他的bean获取实例方法getObject()。

MapperFactoryBean.getObject():获取实例对象

@Override
  public T getObject() throws Exception {
    return getSqlSession().getMapper(this.mapperInterface);
  }

对于采用SqlSessionTemplate模板管理的getMapper方法:

public <T> T getMapper(Class<T> type) {
    return getConfiguration().getMapper(type, this);
  }

MapperRegistry.getMapper():

public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
    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);
    }
  }

protected T newInstance(MapperProxy<T> mapperProxy) {
    //MapperProxy 又是一个代理对象,最终我们生成的bean是由MapperProxy来代理的
    return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy);
  }

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

总结:MapperFactoryBean.getObject()->SqlSession.getMapper()->MapperRegistry.getMapper()->MapperProxyFactory.newInstance()->MapperProxy代理,最终获取到实例

4.2、MapperProxy怎样去执行dao中的方法

首先看MapperProxy代理类的invoke是怎样执行的方法的。

MapperProxy.invoke():

@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 (isDefaultMethod(method)) {
        //如果直接实现代理类,直接运行方法----据说是JDK 1.8属性
        return invokeDefaultMethod(proxy, method, args);
      }
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
    //获取需要执行的方法mapper,从Configuration容器中获取到对应的MapperMethod:
    //MapperMethod.SqlCommand 静态类,其构造器有个方法resolveMappedStatement为后去mapper 接口
    final MapperMethod mapperMethod = cachedMapperMethod(method);
    return mapperMethod.execute(sqlSession, args);//真正执行的接口
  }

sql执行接口MapperMethod.execute():

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);
        }
        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;
  }

以查询为例,真正查询的接sql:BaseExecutor.query()

@Override
  public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
    PreparedStatement ps = (PreparedStatement) statement;
    ps.execute();//执行查询
    return resultSetHandler.<E> handleResultSets(ps);//结果封装到dto中
  }

有啥疑问请指出,联系方式qq:158479841

拒绝转载!!!!!!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值