@Scope注解的proxyMode的作用以及如何影响IoC容器的依赖查找

首先查看下@Scope注解的定义信息,其共有三个方法,分别为value、scopeName、proxyMode,其中value和scopeName利用了Spring的显性覆盖,这两个方法的作用是一样的,只不过scopeName要比value更具有语义性。重点是proxyMode方法,其默认值为ScopedProxyMode.DEFAULT。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Scope {

	@AliasFor("scopeName")
	String value() default "";

	@AliasFor("value")
	String scopeName() default "";

	ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT;

}

ScopedProxyMode是一个枚举类,该类共定义了四个枚举值,分别为NO、DEFAULT、INTERFACE、TARGET_CLASS,其中DEFAULT和NO的作用是一样的。INTERFACES代表要使用JDK的动态代理来创建代理对象,TARGET_CLASS代表要使用CGLIB来创建代理对象。

public enum ScopedProxyMode {

	
	DEFAULT,

	NO,

	INTERFACES,

	TARGET_CLASS

}

解析@Scope注解的入口有很多,但它们的行为都是一致的,这里以扫描组件信息入口为例来进行分析。

在ClassPathBeanDefinitionScanner的doScan方法中,对于findCandidateComponents方法返回值进行遍历时,会首先调用ScopeMetadataResolver的resolveScopeMetadata方法,传入BeanDefinition对象。该方法会返回一个ScopeMetadata对象,然后将该对象设置到BeanDefinition中去,通过BeanDefinition的setScope方法。

接下来便是通过BeanNameGenerator的generatedBeanName方法来生成BeanName,判断BeanDefinition对象(以下简称为candidate)是否是AbstractBeanDefinition,如果判断成立,则调用postProcessBeanDefinition方法(该方法主要用来设置BeanDefinition的一些默认值),判断candidate是否是AnnotatedBeanDefinition,如果判断成立则调用AnnotationConfigUtils的processCommonDefinitionAn-notations方法(通过方法名也可以看出,该方法主要用来解析一些通用的注解)。

调用checkCandidate方法,如果该方法返回值为true(该方法用来判断当前(注意,不是层级查找)IoC容器中是否指定BeanName的BeanDefinition信息,如果包含,则进行兼容性比对)。

创建BeanDefinitionHolder实例,然后调用AnnotationConfigUtils的applyScopedProxyMode方法来根据前面解析好的ScopeMetadata对象来处理BeanDefinitionHolder,注意这里传了BeanDefinitionRegistry实例,最后调用registerBeanDefinition方法将AnnotationConfigUtils的applyScopedProxyMode方法返回值注册到BeanDefinition到IoC容器中。

private ScopeMetadataResolver scopeMetadataResolver = new AnnotationScopeMetadataResolver();
// ClassPathBeanDefinitionScanner#doScan
protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
		Assert.notEmpty(basePackages, "At least one base package must be specified");
		Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<>();
		for (String basePackage : basePackages) {
			// 根据指定包路径扫描Bean资源并加载
			Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
			for (BeanDefinition candidate : candidates) {
				// 使用AnnotationScopeMetadataResolver的resolveScopeMeatdata方法来根据Bean中@Scope(如果有)注解创建ScopeMeatdata对象
				ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate);
				candidate.setScope(scopeMetadata.getScopeName());
				// 调用AnnotationBeanNameGenerator的generatorBeanName方法生成beanName
				String beanName = this.beanNameGenerator.generateBeanName(candidate, this.registry);
				// 如果BeanDefinition是AbstractBeanDefinition类型的,设置BeanDefinition的默认值
				if (candidate instanceof AbstractBeanDefinition) {
					postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName);
				}
				// 如果BeanDefinition是AnnotatedBeanDefinition类型,解析通用注解
				if (candidate instanceof AnnotatedBeanDefinition) {
					AnnotationConfigUtils.processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate);
				}
				// 如果BeanDefinition可以兼容
				if (checkCandidate(beanName, candidate)) {
					BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
					// 解析Bean中的@Scope注解
					definitionHolder =
							AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
					beanDefinitions.add(definitionHolder);
					registerBeanDefinition(definitionHolder, this.registry);
				}
			}
		}
		return beanDefinitions;
	}

首先分析下ScopeMetadataResolver这个接口。该接口存在两个实现类,分别为AnnotationScopeM-etadataResolver和Jsr330ScopeMetadataResolver。

AnnotationScopeMetadataResolver用来处理Spring的@Scope注解,而Jsr330ScopeMetadataResolver则用来处理Jsr-330规范中提出的@Scope注解。ClassPathBeanDefinitionScanner默认使用的是AnnotationScopeMetadataResolver。
在这里插入图片描述
在AnnotationScopeMetadataResolver的resolveScopeMetadata方法中,首先创建ScopeMetadata实例,然后判断传入的BeanDefinition是否是AnnotatedBeanDefinition类型的。这里需要说明下通过Cl-assPathBeanDefinitionScanner扫描的类信息并创建的BeanDefinition都是ScannedGenericBeanDefin-ition类型的,该类型实现了AnnotatedBeanDefinition接口,因此这里的判断成立。

判断成立后首先将BeanDefinition强制转型为AnnotatedBeanDefinition,调用AnnotationConfigUtils的attributesFor方法,传入从注解元数据(AnnotationMetadata)以及@Scope注解的类型,返回AnnotationAttributes对象(以下简称attributes),如果返回的对象不为空,则设置ScopeMetadata的scopeName(通过调用atributes的getString方法),调用attributes的getEnum方法来获取@Scope注解中proxyMode方法的返回值,如果返回的proxyMode等等于ScopeProxyMode的DEFAULT,则将proxyMode重置为ScopedProxyMode.NO(这也是前面讲到的DEFAULT和NO的作用是一样的),将proxyMode设置到metadata中。

最后返回设置好的metadata。

public AnnotationScopeMetadataResolver() {
		this.defaultProxyMode = ScopedProxyMode.NO;
}
// AnnotationScopeMetadataResolver#resolveScopeMetadata
public ScopeMetadata resolveScopeMetadata(BeanDefinition definition) {
   ScopeMetadata metadata = new ScopeMetadata();
   if (definition instanceof AnnotatedBeanDefinition) {
      AnnotatedBeanDefinition annDef = (AnnotatedBeanDefinition) definition;
      AnnotationAttributes attributes = AnnotationConfigUtils.attributesFor(
            annDef.getMetadata(), this.scopeAnnotationType);
      if (attributes != null) {
         metadata.setScopeName(attributes.getString("value"));
         ScopedProxyMode proxyMode = attributes.getEnum("proxyMode");
         if (proxyMode == ScopedProxyMode.DEFAULT) {
            proxyMode = this.defaultProxyMode;
         }
         metadata.setScopedProxyMode(proxyMode);
      }
   }
   return metadata;
}

在这里插入图片描述

在AnnotationConfigUtils的applyScopedProxyMode方法中,通过传入的ScopeMetadata实例的getScopedProxyMode方法来获取ScopedProxyMode,如果获取到的ScopedProxyMode等于ScopedProxyMode.NO,则直接原样返回。

接下来则是判断获取到的scopedProxyMode是否等于ScopedProxyMode.TARGET_CLASS,并将比较结果赋值给proxyTargetClass,调用ScopedProxyCreator的createScopeProxy方法。

// AnnotationConfigUtils#applyScopedProxyMode
static BeanDefinitionHolder applyScopedProxyMode(
      ScopeMetadata metadata, BeanDefinitionHolder definition, BeanDefinitionRegistry registry) {

   ScopedProxyMode scopedProxyMode = metadata.getScopedProxyMode();
   if (scopedProxyMode.equals(ScopedProxyMode.NO)) {
      return definition;
   }
   boolean proxyTargetClass = scopedProxyMode.equals(ScopedProxyMode.TARGET_CLASS);
   return ScopedProxyCreator.createScopedProxy(definition, registry, proxyTargetClass);
}

在ScopedProxyCreator的createScopedProxy方法中直接委派给ScopedProxyUtils的createdScopedProxy方法实现。

// ScopedProxyCreator#createScopedProxy
public static BeanDefinitionHolder createScopedProxy(
      BeanDefinitionHolder definitionHolder, BeanDefinitionRegistry registry, boolean proxyTargetClass) {

   return ScopedProxyUtils.createScopedProxy(definitionHolder, registry, proxyTargetClass);
}

在ScopedProxyUtils的createScopedProxy方法中,调用传入的BeanDefinitionHolder的getBeanName方法获取beanName并赋值给originalBeanName,调用传入的BeanDefinitionHolder的getBeanDefinition方法来获取BeanDefinition并赋值给targetBeanDefinition变量,调用getTargetBeanName方法来处理originalBeanName并赋值给targetBeanName变量(该方法的处理逻辑就是在传入的beanName前拼接上“scopedTarget.”)。

重点是接下来创建的RootBeanDefinition-proxyDefinition,传入的beanClass为ScopedProxyFactoryBean的Class,根据targetBeanDefinition以及targetBeanName来创建BeanDefinitionHolder并设置到proxyDefinition的decoratedDefinition属性中,设置targetDefinition到proxyDefinition的originatingBeanDefinition属性中,获取proxyDefinition的属性元数据(getPropertyValues方法),将其targetBea-nName的属性值设置为targetBeanName。设置…。将targetBeanDefinition的autowireCandidate以及primary设置为false(设置这两个属性的意义在后面会分析到)。

通过调用传入的BeanDefinitionRegistry的registerBeanDefinition方法,来注册targetDefinition,需重点关注的是,在注册targetDefinition时,传递的beanName为targetBeanName(即拼接上“scopedTarget.”前缀的beanName)。

最后创建BeanDefinitionHolder,指定的beanName却为originalBeanName(即未拼接上“scopedTarget.”前缀的beanName)。返回该实例。

// ScopedProxyUtils#createScopedProxy
public static BeanDefinitionHolder createScopedProxy(BeanDefinitionHolder definition,
      BeanDefinitionRegistry registry, boolean proxyTargetClass) {

   String originalBeanName = definition.getBeanName();
   BeanDefinition targetDefinition = definition.getBeanDefinition();
   String targetBeanName = getTargetBeanName(originalBeanName);

   // Create a scoped proxy definition for the original bean name,
   // "hiding" the target bean in an internal target definition.
   RootBeanDefinition proxyDefinition = new RootBeanDefinition(ScopedProxyFactoryBean.class);
   proxyDefinition.setDecoratedDefinition(new BeanDefinitionHolder(targetDefinition, targetBeanName));
   proxyDefinition.setOriginatingBeanDefinition(targetDefinition);
   proxyDefinition.setSource(definition.getSource());
   proxyDefinition.setRole(targetDefinition.getRole());

   proxyDefinition.getPropertyValues().add("targetBeanName", targetBeanName);
   if (proxyTargetClass) {
      targetDefinition.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE);
      // ScopedProxyFactoryBean's "proxyTargetClass" default is TRUE, so we don't need to set it explicitly here.
   } else {
      proxyDefinition.getPropertyValues().add("proxyTargetClass", Boolean.FALSE);
   }

   // Copy autowire settings from original bean definition.
   proxyDefinition.setAutowireCandidate(targetDefinition.isAutowireCandidate());
   proxyDefinition.setPrimary(targetDefinition.isPrimary());
   if (targetDefinition instanceof AbstractBeanDefinition) {
      proxyDefinition.copyQualifiersFrom((AbstractBeanDefinition) targetDefinition);
   }

   // The target bean should be ignored in favor of the scoped proxy.
   targetDefinition.setAutowireCandidate(false);
   targetDefinition.setPrimary(false);

   // Register the target bean as separate bean in the factory.
   registry.registerBeanDefinition(targetBeanName, targetDefinition);

   // Return the scoped proxy definition as primary bean definition
   // (potentially an inner bean).
   return new BeanDefinitionHolder(proxyDefinition, originalBeanName, definition.getAliases());
}

private static final String TARGET_NAME_PREFIX = "scopedTarget.";

public static String getTargetBeanName(String originalBeanName) {
		return TARGET_NAME_PREFIX + originalBeanName;
}

把目光回到调用入口处-ClassPathBeanDefinitionScanner的doScan方法中,在该方法的最后将Annot-ationConfigUtils的applyScopedProxyMode方法返回的BeanDefinitionHolder注册到BeanDefinitionR-egistry中。

这意味着如果某个Bean添加了@Scope注解,并且将proxyMode设置为非DEFAULT、NO时,IoC容器中将会存在该Bean的两个实例,一个名为“scopedTarget.beanName”其对应的是真正的Bean实例,另一个为“beanName”其对应的是ScopedProxyFactoryBean创建出来的目标Bean的代理对象。

BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
definitionHolder =
		AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
beanDefinitions.add(definitionHolder);
registerBeanDefinition(definitionHolder, this.registry);

代码验证

使用启动类来进行@Scope的proxyMode属性测试。

@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ScopedBeanDemo {

    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
        context.register(ScopedBeanDemo.class);
        context.refresh();
        String[] beanDefinitionNames = context.getBeanDefinitionNames();
        Stream.of(beanDefinitionNames)
                .forEach(
                        beanName -> {
                            Class<?> beanType = context.getType(beanName);
                            System.out.printf("beanName : %s -----> beanType:%s \n",beanName,beanType.getName());
                        });
    }
}

运行结果如下:
在这里插入图片描述

可以发现,IoC容器中的确存在ScopedBeanDemo的两个BeanDefinition数据,一个beanName为“scopedTarget.scopedBeanName”,另一个为“scopeBeanDemo”。

相同类型的Bean,谁生效?

如上面的分析结果,IoC容器中存在两个相同类型的Bean,那么当我们通过BeanFactory的getBean(Class)方法来查找时,是会抛出异常呢?还是正常返回呢?如果正常返回,那么该返回那个呢?

下面我们先来编写代码测试下:

import org.springframework.aop.scope.ScopedProxyUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;

import java.beans.Introspector;
import java.lang.reflect.Field;

@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ScopedBeanDemo {

    public static void main(String[] args) throws Exception {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
        context.register(ScopedBeanDemo.class);
        context.refresh();
        // 根据 ScopedBeanDemo 类型来查找
        ScopedBeanDemo byType = context.getBean(ScopedBeanDemo.class);
        // 根据 ScopedBeanDemo 在IoC容器中的BeanName来进行查找 -> 其底层也是通过 Java Beans 中的
        // Introspector#decapitalize方法来生成BeanName
        ScopedBeanDemo byName =
                (ScopedBeanDemo) context.getBean(Introspector.decapitalize("ScopedBeanDemo"));
        // 在 ScopedBeanDemo 在IoC容器中的BeanName 前面拼接上 ScopedProxyUtils#TARGET_NAME_PREFIX 字段的值
        Field field = ScopedProxyUtils.class.getDeclaredField("TARGET_NAME_PREFIX");
        field.setAccessible(true);
        Object value = field.get(null);
        ScopedBeanDemo byScopedName =
                (ScopedBeanDemo)
                        context.getBean(value + Introspector.decapitalize("ScopedBeanDemo"));
        System.out.println("根据ScopedBeanDemo类型查找到的:" + byType.getClass());
        System.out.println("根据ScopedBeanDemo名称查找到的:" + byName.getClass());
        System.out.println("根据scopedTarget.ScopedBeanDemo名称查找到的:" + byScopedName.getClass());
        // 关闭Spring 应用上下文
        context.close();
    }
}

运行结果:
在这里插入图片描述
可以发现无论是根据类型还是根据beanName来进行IoC容器返回的始终是是代理后的对象。只有按其拼接的规则来拼接beanName后(在beanName前拼接上“scopedTarget.”前缀),再使用BeanFactory的getBean(String)方法来查找才会返回原始对象。

按照beanName来进行查找,IoC容器会返回代理对象,这点可以理解,因为在ScopedProxyUtils的createScopedProxy方法偷梁换柱,将原始的beanName对应的BeanDefinition替换为代理BeanDefinition,所以查找根据原始beanName查找出来的bean为代理Bean就不奇怪了,那么为什么根据类型来查找返回的依然是代理Bean呢?

这里先说下结论:是因为前面在ScopedProxyUtils的createScopedProxy方法中将原始的BeanDefinit-ion(targetDefinition)的autowireCandidate设置为false导致的。

// The target bean should be ignored in favor of the scoped proxy.
targetDefinition.setAutowireCandidate(false);
targetDefinition.setPrimary(false);

下面我们来分析下BeanFactory的getBean(Class)方法。AsbtractApplicationContext实现了该方法,在该方法中首先来对BeanFactory实现类实例的存活状态进行校验。之后就是调用BeanFactory实现类实例的getBean方法,传入要获取的Class。

// AbstractApplicationContext#getBean(java.lang.Class<T>)
public <T> T getBean(Class<T> requiredType) throws BeansException {
   assertBeanFactoryActive();
   return getBeanFactory().getBean(requiredType);
}

在DefaultListableBeanFactory实现的getBean方法中,调用resolveBean方法来根据类型获取,如果该方法的返回值为null,抛出NoSuchBeanDefinitionException异常。可以看出的是resolveBean方法并不会主动抛出异常,而是getBean方法抛出的异常,这一点很重要,因为包括BeanFactory提供的安全查找Bean的getBeanProvider方法底层也是基于该方法进行实现,这里就不再展开分析了。

// DefaultListableBeanFactory#getBean(java.lang.Class<T>, java.lang.Object...)
public <T> T getBean(Class<T> requiredType, @Nullable Object... args) throws BeansException {
   Assert.notNull(requiredType, "Required type must not be null");
   Object resolved = resolveBean(ResolvableType.forRawClass(requiredType), args, false);
   if (resolved == null) {
      throw new NoSuchBeanDefinitionException(requiredType);
   }
   return (T) resolved;
}

在resolveBean方法中,调用resolveNamedBean方法来进行查找,如果该方法返回值不为null,则直接返回,否则获取当前IoC容器的父容器(如果有),层层查找。

// DefaultListableBeanFactory#resolveBean
private <T> T resolveBean(ResolvableType requiredType, @Nullable Object[] args, boolean nonUniqueAsNull) {
   NamedBeanHolder<T> namedBean = resolveNamedBean(requiredType, args, nonUniqueAsNull);
   if (namedBean != null) {
      return namedBean.getBeanInstance();
   }
   BeanFactory parent = getParentBeanFactory();
   if (parent instanceof DefaultListableBeanFactory) {
      return ((DefaultListableBeanFactory) parent).resolveBean(requiredType, args, nonUniqueAsNull);
   }
   else if (parent != null) {
      ObjectProvider<T> parentProvider = parent.getBeanProvider(requiredType);
      if (args != null) {
         return parentProvider.getObject(args);
      }
      else {
         return (nonUniqueAsNull ? parentProvider.getIfUnique() : parentProvider.getIfAvailable());
      }
   }
   return null;
}

在resolveNamedBean方法中,首先根据getNamesForType方法来获取指定类型的所有beanName,该方法的返回值是一个数组。结合前面的代码可以得出这里获取到的candidateNames有两个,分别为:scopedTarget.scopedBeanDemo和scopedBeanDemo。

因此会进入第一个判断即candidateNames的长度大于1,遍历candidateNames集合,对于遍历到的每一个beanName,通过containsBeanDefinition方法来判断当前IoC容器中是否包含指定beanName的BeanDefinition数据(注意这里是对结果进行取反,因此判断失败),第二个判断是根据beanName获取到对应的BeanDefinition实例后,然后调用其isAutowireCandidate方法,注意前面我们已经分析过在ScopedProxyUtils的createScopedProxy方法将targetDefinition的autowireCandidate属性设置为false,因此真正的BeanDefinition是不会被作为候选的BeanDefinition,反而是代理BeanDefinition会作为候选的BeanDefinition。

next,判断candidateNames数组的长度是否等等于1,如果判断成立,则调用getBean方法来根据beanName获取,并将方法返回结果构建为NamedBeanHolder返回。

// DefaultListableBeanFactory#resolveNamedBean
private <T> NamedBeanHolder<T> resolveNamedBean(
      ResolvableType requiredType, @Nullable Object[] args, boolean nonUniqueAsNull) throws BeansException {

   Assert.notNull(requiredType, "Required type must not be null");
	// getBean(ScopedBeanDemo.class) -> candidateNames 中存在两个beanName,分别为
	// “scopedTarget.scopedBeanDemo”以及“scopedBeanDemo”。
   String[] candidateNames = getBeanNamesForType(requiredType);

   if (candidateNames.length > 1) {
      List<String> autowireCandidates = new ArrayList<>(candidateNames.length);
      for (String beanName : candidateNames) {
				// 由于真正的ScopedBeanDemo的BeanDefinition的autowireCandidate属性被设置为false,
				// 因此这里被保存到autowireCandidates集合中的是代理Bean的BeanDefinition 
         if (!containsBeanDefinition(beanName) || getBeanDefinition(beanName).isAutowireCandidate()) {
            autowireCandidates.add(beanName);
         }
      }
      if (!autowireCandidates.isEmpty()) {
         candidateNames = StringUtils.toStringArray(autowireCandidates);
      }
   }
		// 如果candidateNames的长度为1,通过getBean方法来触发初始化或者从缓存中获取并构建为
		// NamedBeanHolder 对象返回。
   if (candidateNames.length == 1) {
      String beanName = candidateNames[0];
      return new NamedBeanHolder<>(beanName, (T) getBean(beanName, requiredType.toClass(), args));
   } else if (candidateNames.length > 1) {
      Map<String, Object> candidates = new LinkedHashMap<>(candidateNames.length);
      for (String beanName : candidateNames) {
         if (containsSingleton(beanName) && args == null) {
            Object beanInstance = getBean(beanName);
            candidates.put(beanName, (beanInstance instanceof NullBean ? null : beanInstance));
         } else {
            candidates.put(beanName, getType(beanName));
         }
      }
      String candidateName = determinePrimaryCandidate(candidates, requiredType.toClass());
      if (candidateName == null) {
         candidateName = determineHighestPriorityCandidate(candidates, requiredType.toClass());
      }
      if (candidateName != null) {
         Object beanInstance = candidates.get(candidateName);
         if (beanInstance == null || beanInstance instanceof Class) {
            beanInstance = getBean(candidateName, requiredType.toClass(), args);
         }
         return new NamedBeanHolder<>(candidateName, (T) beanInstance);
      }
      if (!nonUniqueAsNull) {
         throw new NoUniqueBeanDefinitionException(requiredType, candidates.keySet());
      }
   }

   return null;
}

总结

@Scope注解中的proxyMode方法值指示了IoC容器要不要为Bean创建代理,如何创建代理,是使用JDK的动态代理还是使用CGLIB?

我们通过源码也了解到ScopedProxyMode的DEFAULT和NO作用是一样的,如果配置为INTERFACES或TARGET_CLASS,在ScopedProxyUtils的createScopedProxy方法中将会为目标Bean创建一个ScopedProxyFactoryBean的BeanDefinition,并使用目标Bean的beanName来注册这个BeanDefinition,将目标Bean的beanName拼接上“SscopedTarget.”前缀来注册目标Bean的BeanDefinition。

同时将目标BeanDefinition的autowireCandidate属性设置为false,以此来确保IoC容器在查找该类型的单个Bean时(getBean方法)不会返回原始Bean实例,而是返回经过代理后的Bean实例。

  • 12
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值