Spring Boot @SpringBootApplication注解原理

Spring程序启动的代码如下

@SpringBootApplication
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

启动类标上@SpringBootApplication注解就可以使用Spring的各种功能,@SpringBootApplication如下

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
    ...
}

@SpringBootApplication有三个注解:@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan。@SpringBootConfiguration等同于一个@Configuration,表明启动类是一个配置类,@EnableAutoConfiguration启用自动配置,@ComponentScan会扫描指定包下面的@Component。分别来看

@SpringBootConfiguration

@SpringBootConfiguration实际上就等同于一个@Configuration,如下

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
@Indexed
public @interface SpringBootConfiguration {

    /*
     * 注释同下,删去
     */
	@AliasFor(annotation = Configuration.class)
	boolean proxyBeanMethods() default true;
}

@Configuration里定义了两个字段

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Configuration {

	@AliasFor(annotation = Component.class)
	String value() default "";
    
	/**
	 * Specify whether {@code @Bean} methods should get proxied in order to enforce
	 * bean lifecycle behavior, e.g. to return shared singleton bean instances even
	 * in case of direct {@code @Bean} method calls in user code. This feature
	 * requires method interception, implemented through a runtime-generated CGLIB
	 * subclass which comes with limitations such as the configuration class and
	 * its methods not being allowed to declare {@code final}.
	 * <p>The default is {@code true}, allowing for 'inter-bean references' via direct
	 * method calls within the configuration class as well as for external calls to
	 * this configuration's {@code @Bean} methods, e.g. from another configuration class.
	 * If this is not needed since each of this particular configuration's {@code @Bean}
	 * methods is self-contained and designed as a plain factory method for container use,
	 * switch this flag to {@code false} in order to avoid CGLIB subclass processing.
	 * <p>Turning off bean method interception effectively processes {@code @Bean}
	 * methods individually like when declared on non-{@code @Configuration} classes,
	 * a.k.a. "@Bean Lite Mode" (see {@link Bean @Bean's javadoc}). It is therefore
	 * behaviorally equivalent to removing the {@code @Configuration} stereotype.
	 * @since 5.2
	 */
	boolean proxyBeanMethods() default true;
}

翻译一下注释重要部分

指定bean方法是否会被cglib代理,使得bean方法具有spring的生命周期,也就是:即便是在代码中直接调用标bean方法也会返回单例bean。这种特性需要通过cglib代理生成子类,拦截bean方法,这也就要求@Configuration类和bean方法不能是final的。

配置类有Lite和Full两种模式,标了@Configuration并且proxyBeanMethods=true就是Full模式,否则就是Lite模式,在Full模式下会进行上述的cglib代理。看下面这个配置类,fooService方法直接调用了fooRepository方法,而这两个方法定义的都是单例bean。如果fooService直接调用fooRepository的话,那么fooRepository这个bean将不具有bean的生命周期,spring中的fooRepository bean和fooService内部的fooRepository将不是同一个。Full模式通过cglib代理生成子类,让这种直接的方法调用就跟getBean()获取的bean一样,具备spring的生命周期

   @Configuration
   public class AppConfig {
  
       @Bean
       public FooService fooService() {
           return new FooService(fooRepository());
       }
  
       @Bean
       public FooRepository fooRepository() {
           return new JdbcFooRepository(dataSource());
       }
   }

@Bean可以定义在没有@Configuration注解的类里面,这种类就是Lite模式的配置类,不会进行cglib代理。此时对bean方法的调用就是java方法调用,不会具有spring生命周期
那么@Configuration注解是在哪里处理的,上面这种机制优势怎么支持的呢?

@Configuration处理过程

@Configuration是在ConfigurationClassPostProcessor处理的,这个类实现了BeanDefinitionRegistryPostProcessor接口,BeanDefinitionRegistryPostProcessor接口的父接口是BeanFactoryPostProcessor。BeanFactoryPostProcessor#postProcessBeanFactory接收一个beanFactory,能修改beanFactory里面的bean。BeanDefinitionRegistryPostProcessor#postProcessBeanDefinitionRegistry接受一个registry,能向registry里面注册bean

@FunctionalInterface
public interface BeanFactoryPostProcessor {

	void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException;
}

public interface BeanDefinitionRegistryPostProcessor extends BeanFactoryPostProcessor {

	void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException;
}

这两个接口方法在PostProcessorRegistrationDelegate#invokeBeanFactoryPostProcessors方法里被调用,这个方法会按一定优先级调用BeanDefinitionRegistryPostProcessor、BeanFactoryPostProcessor,BeanDefinitionRegistryPostProcessor先调用、BeanFactoryPostProcessor后调用。所以ConfigurationClassPostProcessor的postProcessBeanDefinitionRegistry方法先调用、postProcessBeanFactory方法后调用,看这两个方法分别做了什么

postProcessBeanDefinitionRegistry

顺着调用流程找到ConfigurationClassPostProcessor#processConfigBeanDefinitions方法,这个类做了几件事情:
1、将registry里面所有配置类找出来
2、排序
3、通过ConfigurationClassParser和ConfigurationClassBeanDefinitionReader处理每一个配置类(本文不涉及)

	public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) {
		List<BeanDefinitionHolder> configCandidates = new ArrayList<>();
		String[] candidateNames = registry.getBeanDefinitionNames();

		for (String beanName : candidateNames) {
			BeanDefinition beanDef = registry.getBeanDefinition(beanName);
			if (beanDef.getAttribute(ConfigurationClassUtils.CONFIGURATION_CLASS_ATTRIBUTE) != null) {
				if (logger.isDebugEnabled()) {
					logger.debug("Bean definition has already been processed as a configuration class: " + beanDef);
				}
			}
			else if (ConfigurationClassUtils.checkConfigurationClassCandidate(beanDef, this.metadataReaderFactory)) {
				configCandidates.add(new BeanDefinitionHolder(beanDef, beanName));
			}
		}
        
        // 排序&处理
    }

具体看下ConfigurationClassUtils#checkConfigurationClassCandidate方法是怎么确定一个类是不是配置类的

public static boolean checkConfigurationClassCandidate(
			BeanDefinition beanDef, MetadataReaderFactory metadataReaderFactory) {
        省略...
            
		Map<String, Object> config = metadata.getAnnotationAttributes(Configuration.class.getName());
		if (config != null && !Boolean.FALSE.equals(config.get("proxyBeanMethods"))) {
			beanDef.setAttribute(CONFIGURATION_CLASS_ATTRIBUTE, CONFIGURATION_CLASS_FULL);
		}
		else if (config != null || isConfigurationCandidate(metadata)) {
			beanDef.setAttribute(CONFIGURATION_CLASS_ATTRIBUTE, CONFIGURATION_CLASS_LITE);
		}
		else {
			return false;
		}

		// It's a full or lite configuration candidate... Let's determine the order value, if any.
		Integer order = getOrder(metadata);
		if (order != null) {
			beanDef.setAttribute(ORDER_ATTRIBUTE, order);
		}

		return true;
	}

if (config != null && !Boolean.FALSE.equals(config.get(“proxyBeanMethods”)))

如果这个类有@Configuration注解并且proxyBeanMethods不是false,那就是Full模式配置类

else if (config != null || isConfigurationCandidate(metadata))

如果这个类有@Configuration注解或者isConfigurationCandidate(metadata),那就是Lite模式配置了
否则不是配置类
看下isConfigurationCandidate方法

	public static boolean isConfigurationCandidate(AnnotationMetadata metadata) {
		// Do not consider an interface or an annotation...
		if (metadata.isInterface()) {
			return false;
		}

		// Any of the typical annotations found?
		for (String indicator : candidateIndicators) {
			if (metadata.isAnnotated(indicator)) {
				return true;
			}
		}

		// Finally, let's look for @Bean methods...
		return hasBeanMethods(metadata);
	}
	private static final Set<String> candidateIndicators = new HashSet<>(8);
	static {
		candidateIndicators.add(Component.class.getName());
		candidateIndicators.add(ComponentScan.class.getName());
		candidateIndicators.add(Import.class.getName());
		candidateIndicators.add(ImportResource.class.getName());
	}

isConfigurationCandidate检查了下这个类是否有@Component、@ComponentScan、@Import、@ImportResource任何一个注解,如果有的话那就是Lite模式配置类,没有的话进一步看有没有Bean方法,有的话也是Lite模式配置类,总结一下ConfigurationClassUtils#checkConfigurationClassCandidate方法怎么判断是不是配置类的:
1、如果有@Configuration注解并且proxyBeanMethods不是false,那就是Full模式配置类
2、有@Configuration注解,或者有@Component、@ComponentScan、@Import、@ImportResource注解,或者有@Bean方法,那就是Lite模式配置类
3、否则不是配置类

postProcessBeanFactory

postProcessBeanFactory调用ConfigurationClassPostProcessor#enhanceConfigurationClasses对Full模式配置类进行cglib代理。删去无关代码,首先for循环把Full模式的bean definition拿出来,放到configBeanDefs;然后通过ConfigurationClassEnhancer生成cglib代理类,替换掉bean definition里原来的类

public void enhanceConfigurationClasses(ConfigurableListableBeanFactory beanFactory) {
		StartupStep enhanceConfigClasses = this.applicationStartup.start("spring.context.config-classes.enhance");
		Map<String, AbstractBeanDefinition> configBeanDefs = new LinkedHashMap<>();
		for (String beanName : beanFactory.getBeanDefinitionNames()) {
			BeanDefinition beanDef = beanFactory.getBeanDefinition(beanName);
			Object configClassAttr = beanDef.getAttribute(ConfigurationClassUtils.CONFIGURATION_CLASS_ATTRIBUTE);
			
			if (ConfigurationClassUtils.CONFIGURATION_CLASS_FULL.equals(configClassAttr)) {
				configBeanDefs.put(beanName, (AbstractBeanDefinition) beanDef);
			}
		}
		if (configBeanDefs.isEmpty() || NativeDetector.inNativeImage()) {
			// nothing to enhance -> return immediately
			enhanceConfigClasses.end();
			return;
		}

		ConfigurationClassEnhancer enhancer = new ConfigurationClassEnhancer();
		for (Map.Entry<String, AbstractBeanDefinition> entry : configBeanDefs.entrySet()) {
			AbstractBeanDefinition beanDef = entry.getValue();
			// If a @Configuration class gets proxied, always proxy the target class
			beanDef.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE);
			// Set enhanced subclass of the user-specified bean class
			Class<?> configClass = beanDef.getBeanClass();
			Class<?> enhancedClass = enhancer.enhance(configClass, this.beanClassLoader);
			if (configClass != enhancedClass) {
				if (logger.isTraceEnabled()) {
					logger.trace(String.format("Replacing bean definition '%s' existing class '%s' with " +
							"enhanced class '%s'", entry.getKey(), configClass.getName(), enhancedClass.getName()));
				}
				beanDef.setBeanClass(enhancedClass);
			}
		}
		enhanceConfigClasses.tag("classCount", () -> String.valueOf(configBeanDefs.keySet().size())).end();
	}

@EnableAutoConfiguration

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {

	/**
	 * Environment property that can be used to override when auto-configuration is
	 * enabled.
	 */
	String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

	/**
	 * Exclude specific auto-configuration classes such that they will never be applied.
	 * @return the classes to exclude
	 */
	Class<?>[] exclude() default {};

	/**
	 * Exclude specific auto-configuration class names such that they will never be
	 * applied.
	 * @return the class names to exclude
	 * @since 1.3.0
	 */
	String[] excludeName() default {};

}

@EnableAutoConfiguration注解Import了AutoConfigurationImportSelector

public class AutoConfigurationImportSelector implements DeferredImportSelector, BeanClassLoaderAware,
		ResourceLoaderAware, BeanFactoryAware, EnvironmentAware, Ordered 

AutoConfigurationImportSelector实现了DeferredImportSelector,所以@EnableAutoConfiguration什么时候处理已经清楚了:
ConfigurationClassParser解析启动类这个配置类过程中处理了该注解,由于AutoConfigurationImportSelector是一个DeferredImportSelector,所以会将AutoConfigurationImportSelector暂存在DeferredImportSelectorHandler#deferredImportSelectors一个List中,等当前一轮配置类都解析完了才会处理AutoConfigurationImportSelector

DeferredImportSelector

public interface DeferredImportSelector extends ImportSelector {

	@Nullable
	default Class<? extends Group> getImportGroup() {
		return null;
	}

	interface Group {
		void process(AnnotationMetadata metadata, DeferredImportSelector selector);

		Iterable<Entry> selectImports();

		class Entry {

			private final AnnotationMetadata metadata;
			private final String importClassName;
            
            // constructor, getters and setters
		}
	}
}

ImportSelector比较简单,就是一个selectImports方法,就是基于当前配置类的AnnotationMetadata返回一些需要import的类名。DeferredImportSelector扩展了ImportSelector,增加了getImportGroup方法,作用是什么?
由于DeferredImportSelector是先暂存,延迟到一批配置类处理完后会统一处理这些DeferredImportSelector,此时这些不同的DeferredImportSelector导入哪些类可以放在一起做一定的逻辑处理。每个DeferredImportSelector的会属于一个Group,属于同一个Group的DeferredImportSelector会统一处理,Group为null则默认为DefaultDeferredImportSelectorGroup。Entry保存了需要import的类名和配置类的AnnotationMetadata的映射关系,因为多个DeferredImportSelector可能是由多个配置类导入的,最后返回的时候要知道import进来的类是由哪个配置类import进来的
DeferredImportSelector调用上与ImportSelector不同,对于ImportSelector,会直接调用selectImports;而对于DeferredImportSelector,外部先按照Group对所有DeferredImportSelector进行分组,属于同一组的DeferredImportSelector,调用这个组的Group#process方法处理每个DeferredImportSelector,Group#process里面一般会调用DeferredImportSelector#selectImports将导入进来的类暂存一下,然后调用一次Group#selectImports方法,返回这一组DeferredImportSelector最重要import的类

DefaultDeferredImportSelectorGroup

先看一下默认的DefaultDeferredImportSelectorGroup实现细节

	private static class DefaultDeferredImportSelectorGroup implements Group {

		private final List<Entry> imports = new ArrayList<>();

		@Override
		public void process(AnnotationMetadata metadata, DeferredImportSelector selector) {
			for (String importClassName : selector.selectImports(metadata)) {
				this.imports.add(new Entry(metadata, importClassName));
			}
		}

		@Override
		public Iterable<Entry> selectImports() {
			return this.imports;
		}
	}

imports保存了导入类的Entry。process方法将DeferredImportSelector导入了的类缓存一下;然后selectImports返回每个DeferredImportSelector导入的类

既然多个DeferredImportSelector属于一个Group,那么哪些DeferredImportSelector属于AutoConfigurationGroup?
AutoConfigurationImportSelector和ImportAutoConfigurationImportSelector都属于AutoConfigurationGroup,分别对应@EnableAutoConfiguration注解和@ImportAutoConfiguration注解

AutoConfigurationGroup

	private static class AutoConfigurationGroup
			implements DeferredImportSelector.Group, BeanClassLoaderAware, BeanFactoryAware, ResourceLoaderAware {

		private final Map<String, AnnotationMetadata> entries = new LinkedHashMap<>();

		private final List<AutoConfigurationEntry> autoConfigurationEntries = new ArrayList<>();

		@Override
		public void process(AnnotationMetadata annotationMetadata, DeferredImportSelector deferredImportSelector) {
			Assert.state(deferredImportSelector instanceof AutoConfigurationImportSelector,
					() -> String.format("Only %s implementations are supported, got %s",
							AutoConfigurationImportSelector.class.getSimpleName(),
							deferredImportSelector.getClass().getName()));
			AutoConfigurationEntry autoConfigurationEntry = ((AutoConfigurationImportSelector) deferredImportSelector)
					.getAutoConfigurationEntry(annotationMetadata);
			this.autoConfigurationEntries.add(autoConfigurationEntry);
			for (String importClassName : autoConfigurationEntry.getConfigurations()) {
				this.entries.putIfAbsent(importClassName, annotationMetadata);
			}
		}
    }
AutoConfigurationGroup#process过程

AutoConfigurationGroup#process方法调用了AutoConfigurationImportSelector或者ImportAutoConfigurationImportSelector的selectImports方法,拿到AutoConfigurationEntry,暂存在autoConfigurationEntries里,并且每个导入进来的类都暂存在entries里
两个ImportSelector的selectImports做了什么?
AutoConfigurationImportSelector#selectImports

	public String[] selectImports(AnnotationMetadata annotationMetadata) {
		if (!isEnabled(annotationMetadata)) {
			return NO_IMPORTS;
		}
		AutoConfigurationEntry autoConfigurationEntry = getAutoConfigurationEntry(annotationMetadata);
		return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());
	}

	protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
		if (!isEnabled(annotationMetadata)) {
			return EMPTY_ENTRY;
		}
		AnnotationAttributes attributes = getAttributes(annotationMetadata);
		List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
		configurations = removeDuplicates(configurations);
		Set<String> exclusions = getExclusions(annotationMetadata, attributes);
		checkExcludedClasses(configurations, exclusions);
		configurations.removeAll(exclusions);
		configurations = getConfigurationClassFilter().filter(configurations);
		fireAutoConfigurationImportEvents(configurations, exclusions);
		return new AutoConfigurationEntry(configurations, exclusions);
	}

	protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
		List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
				getBeanClassLoader());
		Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you "
				+ "are using a custom packaging, make sure that file is correct.");
		return configurations;
	}

	protected Class<?> getSpringFactoriesLoaderFactoryClass() {
		return EnableAutoConfiguration.class;
	}

AutoConfigurationImportSelector#selectImports实际上就是调用了SpringFactoriesLoader从spring.factories里面去读取配置的好的AutoConfiguration类

ImportAutoConfigurationImportSelector#selectImports
ImportAutoConfigurationImportSelector里面覆盖了getCandidateConfigurations方法。

	protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
		List<String> candidates = new ArrayList<>();
		Map<Class<?>, List<Annotation>> annotations = getAnnotations(metadata);
		annotations.forEach(
				(source, sourceAnnotations) -> collectCandidateConfigurations(source, sourceAnnotations, candidates));
		return candidates;
	}

	private void collectCandidateConfigurations(Class<?> source, List<Annotation> annotations,
			List<String> candidates) {
		for (Annotation annotation : annotations) {
			candidates.addAll(getConfigurationsForAnnotation(source, annotation));
		}
	}

	private Collection<String> getConfigurationsForAnnotation(Class<?> source, Annotation annotation) {
		String[] classes = (String[]) AnnotationUtils.getAnnotationAttributes(annotation, true).get("classes");
		if (classes.length > 0) {
			return Arrays.asList(classes);
		}
		return loadFactoryNames(source);
	}

	protected Collection<String> loadFactoryNames(Class<?> source) {
		return SpringFactoriesLoader.loadFactoryNames(source, getBeanClassLoader());
	}

ImportAutoConfigurationImportSelector#selectImports就是从注解上去获取导入的类,getAnnotations不是获取所有注解,而是限制了ImportAutoConfiguration注解。getConfigurationsForAnnotation判断ImportAutoConfiguration,如果classes字段有值,那导入的就是这些类,否则还是通过SpringFactoriesLoader从spring.factories里读取要导入的类,只不过此时key就是标了ImportAutoConfiguration的类

AutoConfigurationGroup#selectImports过程

AutoConfigurationGroup#selectImports方法主要做了两个事情
1、导进来的类排掉exclusion
2、排序,返回

	private static class AutoConfigurationGroup
			implements DeferredImportSelector.Group, BeanClassLoaderAware, BeanFactoryAware, ResourceLoaderAware {

		private final Map<String, AnnotationMetadata> entries = new LinkedHashMap<>();

		private final List<AutoConfigurationEntry> autoConfigurationEntries = new ArrayList<>();
		
        @Override
		public Iterable<Entry> selectImports() {
			if (this.autoConfigurationEntries.isEmpty()) {
				return Collections.emptyList();
			}
			Set<String> allExclusions = this.autoConfigurationEntries.stream()
					.map(AutoConfigurationEntry::getExclusions).flatMap(Collection::stream).collect(Collectors.toSet());
			Set<String> processedConfigurations = this.autoConfigurationEntries.stream()
					.map(AutoConfigurationEntry::getConfigurations).flatMap(Collection::stream)
					.collect(Collectors.toCollection(LinkedHashSet::new));
			processedConfigurations.removeAll(allExclusions);

			return sortAutoConfigurations(processedConfigurations, getAutoConfigurationMetadata()).stream()
					.map((importClassName) -> new Entry(this.entries.get(importClassName), importClassName))
					.collect(Collectors.toList());
		}
    }

主要看一下配置类的排序逻辑

		private List<String> sortAutoConfigurations(Set<String> configurations,
				AutoConfigurationMetadata autoConfigurationMetadata) {
			return new AutoConfigurationSorter(getMetadataReaderFactory(), autoConfigurationMetadata)
					.getInPriorityOrder(configurations);
		}

其实还是通过AutoConfigurationSorter来排序的,看一下AutoConfigurationSorter#getInPriorityOrder方法

	List<String> getInPriorityOrder(Collection<String> classNames) {
		AutoConfigurationClasses classes = new AutoConfigurationClasses(this.metadataReaderFactory,
				this.autoConfigurationMetadata, classNames);
		List<String> orderedClassNames = new ArrayList<>(classNames);
		// Initially sort alphabetically
		Collections.sort(orderedClassNames);
		// Then sort by order
		orderedClassNames.sort((o1, o2) -> {
			int i1 = classes.get(o1).getOrder();
			int i2 = classes.get(o2).getOrder();
			return Integer.compare(i1, i2);
		});
		// Then respect @AutoConfigureBefore @AutoConfigureAfter
		orderedClassNames = sortByAnnotation(classes, orderedClassNames);
		return orderedClassNames;
	}

注释上:先按字母顺序排序,再按order排序,最后按@AutoConfigureBefore @AutoConfigureAfter两个注解排序,由于排序是稳定排序,所有三种排序方法中:注解定义的顺序优先;注解没定义顺序则按order来;order相同则按字母顺序

order排序怎么排?
classes.get(o1)获取到了AutoConfigurationClass,AutoConfigurationClass#getOrder实际上获取的是AutoConfigureOrder定义的order,跟ordered接口、@Order注解都没关系,主要是为了不影响bean注册的顺序

		private int getOrder() {
			if (wasProcessed()) {
				return this.autoConfigurationMetadata.getInteger(this.className, "AutoConfigureOrder",
						AutoConfigureOrder.DEFAULT_ORDER);
			}
			Map<String, Object> attributes = getAnnotationMetadata()
					.getAnnotationAttributes(AutoConfigureOrder.class.getName());
			return (attributes != null) ? (Integer) attributes.get("value") : AutoConfigureOrder.DEFAULT_ORDER;
		}

@ComponentScan

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值