【Spring源码解析】Spring XML配置默认bean标签解析

1、前言

上文对XmlBeanFactory文件资源加载的流程做了初步的讲解,主要内容是Reousce资源的加载,XmlBeanFactory初始化,Resouce对象进行编码并转换成数据流,Xml配置文件验证,转换成Document对象,之后进行解析注册操作。

上次解析的内容是初步整理,到 registerBeanDefinitions 方法,此处继续向下解析。

2、从解析到注册

// 执行解析注册前的前置处理
preProcessXml(root);
// 注册解析
parseBeanDefinitions(root, this.delegate);
// 执行注册解析后的后置处理
postProcessXml(root);

preProcessXml,postProcessXml 都是给使用的时候扩展使用,Spring 并没有完成功能。
parseBeanDefinitions是主要的解析内容:

2.1、解析主要步骤

解析的过程主要分为默认标签的解析,就是Spring 自带标签的解析,以及扩展和自定义标签的解析。

delegate.isDefaultNamespace(root) 就是用来判断是否为默认标签。

protected void parseBeanDefinitions(Element root, BeanDefinitionParserDelegate delegate) {
   // 判断命名空间
   if (delegate.isDefaultNamespace(root)) {
      NodeList nl = root.getChildNodes();
      for (int i = 0; i < nl.getLength(); i++) {
         Node node = nl.item(i);
         if (node instanceof Element) {
            Element ele = (Element) node;
            if (delegate.isDefaultNamespace(ele)) {
                // 默认标签解析
               parseDefaultElement(ele, delegate);
            }
            else {
                // 自定义标签解析
               delegate.parseCustomElement(ele);
            }
         }
      }
   }
   else {
       // 自定义标签解析
      delegate.parseCustomElement(root);
   }
}
  • isDefaultNamespace
public boolean isDefaultNamespace(Node node) {
   return isDefaultNamespace(getNamespaceURI(node));
}

通过断点调试可以发现,这里 getNamespaceURI 判断相等 http://www.springframework.org/schema/beans 也就是 Xml 的根节点 xmlns 的属性值,也就是从根节点开始解析获取所有的子节点,如果不是根节点,那么就采用自定义的节点解析。

之后获取所有的子节点,并判断所有的子节点是否为默认的标签元素,如果是采用默认标签的解析方式,如果不是采用自定义的解析方式进行解析。

  • 默认标签解析 parseDefaultElement
  • 自定义标签的解析 delegate.parseCustomElement

2.2、默认标签解析

private void parseDefaultElement(Element ele, BeanDefinitionParserDelegate delegate) {
   if (delegate.nodeNameEquals(ele, IMPORT_ELEMENT)) {
      importBeanDefinitionResource(ele);
   }
   else if (delegate.nodeNameEquals(ele, ALIAS_ELEMENT)) {
      processAliasRegistration(ele);
   }
   else if (delegate.nodeNameEquals(ele, BEAN_ELEMENT)) {
      processBeanDefinition(ele, delegate);
   }
   else if (delegate.nodeNameEquals(ele, NESTED_BEANS_ELEMENT)) {
      // recurse
      doRegisterBeanDefinitions(ele);
   }
}

通过查看IMPORT_ELEMENT,ALIAS_ELEMENT,BEAN_ELEMENT,NESTED_BEANS_ELEMENT四个常量的值 我们可以看到分别对应标签<import>,<alias>,<bean>和<beans>。 所以spring 根节点下有这四种默认的标签。

而这四种标签中bean标签的内容是最多的,也是最复杂的,其他三种都是对bean标签功能的补充和扩展。
我们这里就解析bean标签的内容。

2.3、bean标签

processBeanDefinition 方法是对bean标签的解析,点击查看:

protected void processBeanDefinition(Element ele, BeanDefinitionParserDelegate delegate) {
   // 对标签进行解析,节点对象转换
   BeanDefinitionHolder bdHolder = delegate.parseBeanDefinitionElement(ele);
   if (bdHolder != null) {
       // 解析bean标签下的自定义标签内容
      bdHolder = delegate.decorateBeanDefinitionIfRequired(ele, bdHolder);
      try {
         // Register the final decorated instance.
         // 执行注册到缓存
         BeanDefinitionReaderUtils.registerBeanDefinition(bdHolder, getReaderContext().getRegistry());
      }
      catch (BeanDefinitionStoreException ex) {
         getReaderContext().error("Failed to register bean definition with name '" +
               bdHolder.getBeanName() + "'", ele, ex);
      }
      // Send registration event.
      // 处理注册事务给监听器
      getReaderContext().fireComponentRegistered(new BeanComponentDefinition(bdHolder));
   }
}

我们可以得到注册的四个步骤:

  1. 通过bean标签的解析并转换成节点对象
  2. 对bean标签自定义标签的解析
  3. 把解析后的节点对象注册到缓存中
  4. 把注册完成的事务发送给监听器

2.4、标签转换成节点对象

@Nullable
public BeanDefinitionHolder parseBeanDefinitionElement(Element ele) {
   return parseBeanDefinitionElement(ele, null);
}

@Nullable
public BeanDefinitionHolder parseBeanDefinitionElement(Element ele, @Nullable BeanDefinition containingBean) {
    // 获取id属性
   String id = ele.getAttribute(ID_ATTRIBUTE);
   // 获取name属性
   String nameAttr = ele.getAttribute(NAME_ATTRIBUTE);
   // 把name属性解析内容放入别名
   List<String> aliases = new ArrayList<>();
   if (StringUtils.hasLength(nameAttr)) {
      String[] nameArr = StringUtils.tokenizeToStringArray(nameAttr, MULTI_VALUE_ATTRIBUTE_DELIMITERS);
      aliases.addAll(Arrays.asList(nameArr));
   }
    // 假如name属性和id属性相同,那么移除相同的别名
   String beanName = id;
   if (!StringUtils.hasText(beanName) && !aliases.isEmpty()) {
      beanName = aliases.remove(0);
      if (logger.isTraceEnabled()) {
         logger.trace("No XML 'id' specified - using '" + beanName +
               "' as bean name and " + aliases + " as aliases");
      }
   }
   // 检查id 和别名,并放入Set集合 usedNames
   if (containingBean == null) {
      checkNameUniqueness(beanName, aliases, ele);
   }
   // 创建BeanDefination ,进一步解析其他标签
   AbstractBeanDefinition beanDefinition = parseBeanDefinitionElement(ele, beanName, containingBean);
   if (beanDefinition != null) {
      if (!StringUtils.hasText(beanName)) {
         try {
             // 如果beanName不存在那么就创建一个默认的
            if (containingBean != null) {
               beanName = BeanDefinitionReaderUtils.generateBeanName(
                     beanDefinition, this.readerContext.getRegistry(), true);
            }
            else {
               beanName = this.readerContext.generateBeanName(beanDefinition);
               // Register an alias for the plain bean class name, if still possible,
               // if the generator returned the class name plus a suffix.
               // This is expected for Spring 1.2/2.0 backwards compatibility.
               String beanClassName = beanDefinition.getBeanClassName();
               if (beanClassName != null &&
                     beanName.startsWith(beanClassName) && beanName.length() > beanClassName.length() &&
                     !this.readerContext.getRegistry().isBeanNameInUse(beanClassName)) {
                  aliases.add(beanClassName);
               }
            }
            if (logger.isTraceEnabled()) {
               logger.trace("Neither XML 'id' nor 'name' specified - " +
                     "using generated bean name [" + beanName + "]");
            }
         }
         catch (Exception ex) {
            error(ex.getMessage(), ele);
            return null;
         }
      }
      String[] aliasesArray = StringUtils.toStringArray(aliases);
      // 返回bean持有对象
      return new BeanDefinitionHolder(beanDefinition, beanName, aliasesArray);
   }

   return null;
}

通过以上注释,我们可以看到bean标签的解析共有几个步骤:

  1. 获取id和name属性的值,并把name属性作为别名,并处理id和name属性相同的情况
  2. 检查id和name属性值是否以及被其他bean使用
  3. 创建bean标签对应的对象BeanDefination,并进一步解析其他标签
  4. 如果察觉beanName也就是id属性不存在,那么就根据默认规则创建
  5. 把BeanDefination,beanName,aliasArray封装成对象返回

其中除了3之外其他的都比较简单,这里继续查看parseBeanDefinitionElement方法。

2.5、解析其他标签

parseBeanDefinitionElement 方法进入,这里面的内容就比较多:

@Nullable
public AbstractBeanDefinition parseBeanDefinitionElement(
      Element ele, String beanName, @Nullable BeanDefinition containingBean) {

   this.parseState.push(new BeanEntry(beanName));

   String className = null;
   if (ele.hasAttribute(CLASS_ATTRIBUTE)) {
      className = ele.getAttribute(CLASS_ATTRIBUTE).trim();
   }
   String parent = null;
   if (ele.hasAttribute(PARENT_ATTRIBUTE)) {
      parent = ele.getAttribute(PARENT_ATTRIBUTE);
   }

   try {
       // 创建对象 AbstractBeanDefinition 承载数GenericBeanDefinition
      AbstractBeanDefinition bd = createBeanDefinition(className, parent);
       // 硬编码解析bean的各个属性
      parseBeanDefinitionAttributes(ele, beanName, containingBean, bd);
      // 解析 Description
      bd.setDescription(DomUtils.getChildElementValueByTagName(ele, DESCRIPTION_ELEMENT));
      // 解析元数据meta
      parseMetaElements(ele, bd);
      // 解析lookup-method标签
      parseLookupOverrideSubElements(ele, bd.getMethodOverrides());
      // 解析replace-method
      parseReplacedMethodSubElements(ele, bd.getMethodOverrides());
      // 解析构造方法
      parseConstructorArgElements(ele, bd);
      // 解析property 
      parsePropertyElements(ele, bd);
      // 解析qualifier
      parseQualifierElements(ele, bd);

      bd.setResource(this.readerContext.getResource());
      bd.setSource(extractSource(ele));

      return bd;
   }
   catch (ClassNotFoundException ex) {
      error("Bean class [" + className + "] not found", ele, ex);
   }
   catch (NoClassDefFoundError err) {
      error("Class that bean class [" + className + "] depends on not found", ele, err);
   }
   catch (Throwable ex) {
      error("Unexpected failure during bean definition parsing", ele, ex);
   }
   finally {
      this.parseState.pop();
   }

   return null;
}

从这里我们可以看到许多常用的标签的解析情况。
解析的整个步骤为:

1. 先放入parseState 的双端队列
2. 解析class,parent 标签
3. 创建对象 AbstractBeanDefinition 承载数GenericBeanDefinition
4. 解析bean的各个属性,这里比较多下面介绍
5. 解析子标签description
6. 解析子标签meta
7. 解析子标签lookup-method
8. 解析子标签replace-method
9. 解析子标签constructor-arg
10. 解析子标签property
11. 解析子标签qualifier
12. 设置Resouce,source
13. 从双端队列弹出
  • createBeanDefinition
protected AbstractBeanDefinition createBeanDefinition(@Nullable String className, @Nullable String parentName) throws ClassNotFoundException {
    return BeanDefinitionReaderUtils.createBeanDefinition(parentName, className, this.readerContext.getBeanClassLoader());
}

进入BeanDefinitionReaderUtils 查看,可以得知真正创建的是AbstractBeanDefinition的实现类GenericBeanDefinition,并setParentName,根据className来判断setBeanClass或者setBeanClassName。

public static AbstractBeanDefinition createBeanDefinition(@Nullable String parentName, @Nullable String className, @Nullable ClassLoader classLoader) throws ClassNotFoundException {
    GenericBeanDefinition bd = new GenericBeanDefinition();
    bd.setParentName(parentName);
    if (className != null) {
        if (classLoader != null) {
            bd.setBeanClass(ClassUtils.forName(className, classLoader));
        } else {
            bd.setBeanClassName(className);
        }
    }

    return bd;
}
  • 解析bean的各个属性parseBeanDefinitionAttributes
this.parseBeanDefinitionAttributes(ele, beanName, containingBean, bd)

这里可以看到解析了 singleton,scope,abstract,lazy-init,autowire,depends-on,autowire-candidate,primary,init-method,destroy-method,factory-method,factory-bean。

以上就是bean标签的所有属性。解析的时候大多是简单处理放入AbstractBeanDefinition 对象,这里只处理主题流程,稍后单独写进行解析。

public AbstractBeanDefinition parseBeanDefinitionAttributes(Element ele, String beanName, @Nullable BeanDefinition containingBean, AbstractBeanDefinition bd) {
    // singleton 
    if (ele.hasAttribute("singleton")) {
        this.error("Old 1.x 'singleton' attribute in use - upgrade to 'scope' declaration", ele);
    // scope
    } else if (ele.hasAttribute("scope")) {
        bd.setScope(ele.getAttribute("scope"));
    } else if (containingBean != null) {
        bd.setScope(containingBean.getScope());
    }
    // abstract
    if (ele.hasAttribute("abstract")) {
        bd.setAbstract("true".equals(ele.getAttribute("abstract")));
    }
    // lazy-init
    String lazyInit = ele.getAttribute("lazy-init");
    if (this.isDefaultValue(lazyInit)) {
        lazyInit = this.defaults.getLazyInit();
    }

    bd.setLazyInit("true".equals(lazyInit));
    // autowire
    String autowire = ele.getAttribute("autowire");
    bd.setAutowireMode(this.getAutowireMode(autowire));
    String autowireCandidate;
    // depends-on
    if (ele.hasAttribute("depends-on")) {
        autowireCandidate = ele.getAttribute("depends-on");
        bd.setDependsOn(StringUtils.tokenizeToStringArray(autowireCandidate, ",; "));
    }
    // autowire-candidate
    autowireCandidate = ele.getAttribute("autowire-candidate");
    String destroyMethodName;
    if (this.isDefaultValue(autowireCandidate)) {
        destroyMethodName = this.defaults.getAutowireCandidates();
        if (destroyMethodName != null) {
            String[] patterns = StringUtils.commaDelimitedListToStringArray(destroyMethodName);
            bd.setAutowireCandidate(PatternMatchUtils.simpleMatch(patterns, beanName));
        }
    } else {
        bd.setAutowireCandidate("true".equals(autowireCandidate));
    }
    // primary
    if (ele.hasAttribute("primary")) {
        bd.setPrimary("true".equals(ele.getAttribute("primary")));
    }
    // init-method
    if (ele.hasAttribute("init-method")) {
        destroyMethodName = ele.getAttribute("init-method");
        bd.setInitMethodName(destroyMethodName);
    } else if (this.defaults.getInitMethod() != null) {
        bd.setInitMethodName(this.defaults.getInitMethod());
        bd.setEnforceInitMethod(false);
    }
    // destroy-method
    if (ele.hasAttribute("destroy-method")) {
        destroyMethodName = ele.getAttribute("destroy-method");
        bd.setDestroyMethodName(destroyMethodName);
    } else if (this.defaults.getDestroyMethod() != null) {
        bd.setDestroyMethodName(this.defaults.getDestroyMethod());
        bd.setEnforceDestroyMethod(false);
    }
    // factory-method
    if (ele.hasAttribute("factory-method")) {
        bd.setFactoryMethodName(ele.getAttribute("factory-method"));
    }
    // factory-bean
    if (ele.hasAttribute("factory-bean")) {
        bd.setFactoryBeanName(ele.getAttribute("factory-bean"));
    }

    return bd;
}
  • 子元素解析

此处为IDEA提示所有子元素:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GefBRsI8-1657724065720)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/de5333a68c8b4f8cb030d6bbfda27178~tplv-k3u1fbpfcp-watermark.image?)]

完成属性和子元素解析后就对bean所有的内容解析完毕,之后再设置Resouce 和 source,最后弹出
parseState 完成默认标签的解析。

2.6、自定义属性解析

默认的属性和子标签解析完成,可能还会存在自定义的标签,这里对这类标签的解析内容。

例如:<bean id = “stu” class = “com.fans.Student”><my:age>12<my:age></bean>
其中my:age就是自定义标签。

delegate.decorateBeanDefinitionIfRequired(ele, bdHolder)

进入方法内部查看:

public BeanDefinitionHolder decorateBeanDefinitionIfRequired(Element ele, BeanDefinitionHolder definitionHolder) {
    return this.decorateBeanDefinitionIfRequired(ele, definitionHolder, (BeanDefinition)null);
}

public BeanDefinitionHolder decorateBeanDefinitionIfRequired(Element ele, BeanDefinitionHolder definitionHolder, @Nullable BeanDefinition containingBd) {
    BeanDefinitionHolder finalDefinition = definitionHolder;
   
    NamedNodeMap attributes = ele.getAttributes();

    for(int i = 0; i < attributes.getLength(); ++i) {
        Node node = attributes.item(i);
        finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);
    }

    NodeList children = ele.getChildNodes();

    for(int i = 0; i < children.getLength(); ++i) {
        Node node = children.item(i);
        if (node.getNodeType() == 1) {
            finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);
        }
    }

    return finalDefinition;
}

通过以上代码,我们可以了解到:

  1. 首先获取所有的属性: NamedNodeMap attributes = ele.getAttributes();
  2. 装饰所有的属性:finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);
  3. 然后获取所有的子标签: NodeList children = ele.getChildNodes();
  4. 装饰所有的子标签:finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);

这里的处理顺序其实是和默认标签相同。

  • decorateIfRequired 查看装饰的过程:
public BeanDefinitionHolder decorateIfRequired(Node node, BeanDefinitionHolder originalDef, @Nullable BeanDefinition containingBd) {
    String namespaceUri = this.getNamespaceURI(node);
    if (namespaceUri != null && !this.isDefaultNamespace(namespaceUri)) {
        NamespaceHandler handler = this.readerContext.getNamespaceHandlerResolver().resolve(namespaceUri);
        if (handler != null) {
            BeanDefinitionHolder decorated = handler.decorate(node, originalDef, new ParserContext(this.readerContext, this, containingBd));
            if (decorated != null) {
                return decorated;
            }
        } else if (namespaceUri.startsWith("http://www.springframework.org/")) {
            this.error("Unable to locate Spring NamespaceHandler for XML schema namespace [" + namespaceUri + "]", node);
        } else if (this.logger.isDebugEnabled()) {
            this.logger.debug("No Spring NamespaceHandler found for XML schema namespace [" + namespaceUri + "]");
        }
    }

    return originalDef;
}

装饰的过程其实也是和默认标签相同:

  1. 获取命名空间URI,String namespaceUri = this.getNamespaceURI(node);
  2. 根据命名空间找到相应的处理器,NamespaceHandler handler = this.readerContext.getNamespaceHandlerResolver().resolve(namespaceUri)
  3. 解析自定义内容进行修饰,BeanDefinitionHolder decorated = handler.decorate(node, originalDef, new ParserContext(this.readerContext, this, containingBd))

2.7、对bean进行注册

进行注册的过程实质上是放入缓存。

BeanDefinitionReaderUtils.registerBeanDefinition(bdHolder, this.getReaderContext().getRegistry());

进入方法内部:

public static void registerBeanDefinition(BeanDefinitionHolder definitionHolder, BeanDefinitionRegistry registry) throws BeanDefinitionStoreException {
    String beanName = definitionHolder.getBeanName();
    registry.registerBeanDefinition(beanName, definitionHolder.getBeanDefinition());
    String[] aliases = definitionHolder.getAliases();
    if (aliases != null) {
        String[] var4 = aliases;
        int var5 = aliases.length;

        for(int var6 = 0; var6 < var5; ++var6) {
            String alias = var4[var6];
            registry.registerAlias(beanName, alias);
        }
    }

}

解析上述代码:

  1. 先获取beanName
  2. 执行beanName注册放入缓存
  3. 处理别名注册
  • 执行beanName注册

再具体一点,执行beanName注册,进入registerBeanDefinition方法,这里接口有多种实现,通过断点可知是DefaultListableBeanFactory。

public void registerBeanDefinition(String beanName, BeanDefinition beanDefinition) throws BeanDefinitionStoreException {
    Assert.hasText(beanName, "Bean name must not be empty");
    Assert.notNull(beanDefinition, "BeanDefinition must not be null");
    if (beanDefinition instanceof AbstractBeanDefinition) {
        try {
            // 1.对beanDefination 进行校验,校验methodOverrides的方法是否在工厂中存在
            ((AbstractBeanDefinition)beanDefinition).validate();
        } catch (BeanDefinitionValidationException var8) {
            throw new BeanDefinitionStoreException(beanDefinition.getResourceDescription(), beanName, "Validation of bean definition failed", var8);
        }
    }
    // 2.判断beanName已注册完成
    BeanDefinition existingDefinition = (BeanDefinition)this.beanDefinitionMap.get(beanName);
    if (existingDefinition != null) {
        // 3. 判断是否可以重写
        if (!this.isAllowBeanDefinitionOverriding()) {
            throw new BeanDefinitionOverrideException(beanName, beanDefinition, existingDefinition);
        }
         // ... 省略部分日志代码
        // 4. 执行写入beanDefinitionMap缓存
        this.beanDefinitionMap.put(beanName, beanDefinition);
    } else {
        if (this.hasBeanCreationStarted()) {
            synchronized(this.beanDefinitionMap) {
                // 5. bean 放入缓存
                this.beanDefinitionMap.put(beanName, beanDefinition);
                List<String> updatedDefinitions = new ArrayList(this.beanDefinitionNames.size() + 1);
                updatedDefinitions.addAll(this.beanDefinitionNames);
                updatedDefinitions.add(beanName);
                // 6. beanName 放入缓存
                this.beanDefinitionNames = updatedDefinitions;
                // 7. 清理缓存
                this.removeManualSingletonName(beanName);
            }
        } else {
            // 5. bean 放入缓存
            this.beanDefinitionMap.put(beanName, beanDefinition);
            // 6. beanName 放入缓存
            this.beanDefinitionNames.add(beanName);
            // 7. 清理缓存
            this.removeManualSingletonName(beanName);
        }

        this.frozenBeanDefinitionNames = null;
    }

    if (existingDefinition != null || this.containsSingleton(beanName)) {
        this.resetBeanDefinition(beanName);
    }

}

以上注册分为上述7步骤:

  1. 对beanDefination 进行校验,校验methodOverrides的方法是否在工厂中存在,这里涉及lookup-method 和replace-method 属性,有兴趣的可以查看下。
  2. 判断beanName已注册完成,从缓存中获取
  3. 如果已经存在,判断是否可以重写,如果可以执行重写,如果不可以抛出异常
  4. 执行写入beanDefinitionMap缓存,也就是执行重写的步骤
  5. 针对是否已存在创建对象,对缓存写入进行锁住,作用是相同的,先执行放入缓存
  6. 完成之后放入beanName
  7. 最后执行清理manualSingletonNames中的beanName缓存
  • 执行别名注册

这里别名注册就比较简单,如果存在同名的就移除这个别名,不同名的话,找到真实注册名,判断是否与传承name相等,并处理别名循环依赖的问题。
最后执行放入别名缓存。

public void registerAlias(String name, String alias) {
    Assert.hasText(name, "'name' must not be empty");
    Assert.hasText(alias, "'alias' must not be empty");
    synchronized(this.aliasMap) {
        if (alias.equals(name)) {
            this.aliasMap.remove(alias);
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Alias definition '" + alias + "' ignored since it points to same name");
            }
        } else {
            String registeredName = (String)this.aliasMap.get(alias);
            if (registeredName != null) {
                if (registeredName.equals(name)) {
                    return;
                }

                if (!this.allowAliasOverriding()) {
                    throw new IllegalStateException("Cannot define alias '" + alias + "' for name '" + name + "': It is already registered for name '" + registeredName + "'.");
                }

                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Overriding alias '" + alias + "' definition for registered name '" + registeredName + "' with new target name '" + name + "'");
                }
            }

            this.checkForAliasCircle(name, alias);
            this.aliasMap.put(alias, name);
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Alias definition '" + alias + "' registered for name '" + name + "'");
            }
        }

    }
}

以上就完成了注册的所有操作,最后就是通知监听,spring 没有具体的监听处理内容,如果需要可以完成监听内容。

 getReaderContext().fireComponentRegistered(new BeanComponentDefinition(bdHolder));

3、总结

上面就完成了默认bean标签解析的所有内容。下篇会说一下其他三种标签的解析过程,并和bean标签进行对比。

再次梳理的过程又有收获。希望你也是,加油,共勉!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值