Spring源码学习---IOC容器(解析篇)

本文结合Spring源码,介绍了使用IOC容器获取bean的不同方式,包括ClassPathXmlApplicationContext等。详细阐述了GenericApplicationContext的使用步骤,对refresh()方法进行展开说明。还通过断点调试,分析了xml加载解析bean定义的过程,帮助理解ApplicationContext的创建流程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

上一篇我们大致地看了一下 ApplicationContext 的子类所拥有的一些东西,这一篇我们结合Spring源码学习---IOC容器介绍到的知识点来运用一下

使用IOC容器来获取bean的不同方式(展开部分refresh()方法)

编写了一些测试用例,然后用一个main方法去调用这些不同的实现

① ClassPathXmlApplicationContext

// xml的方式
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");

CombatService cs = context.getBean(CombatService.class);
cs.doInit();
cs.combating();
复制代码

② FileSystemXmlApplicationContext

ApplicationContext context1 = new FileSystemXmlApplicationContext("f:/study/application.xml");
cs = context1.getBean(CombatService.class);
cs.doInit();
cs.combating();
复制代码

③ GenericXmlApplicationContext

context1 = new GenericXmlApplicationContext("file:f:/study/application.xml");
cs = context1.getBean(CombatService.class);
cs.doInit();
cs.combating();

// 注解的方式
ApplicationContext context2 = new AnnotationConfigApplicationContext(YTApplication.class);
CombatService cs2 = context2.getBean(CombatService.class);
cs2.combating();
复制代码

④ GenericApplicationContext

通用的 ApplicationContext的使用,将会把我的创建及使用的过程分步说明


1.创建一个GenericApplicationContext

这里注意,我们 new GenericApplicationContext()中并没给入任何的参数,那将如何去判断这是xml或者是注解的模式?

GenericApplicationContext context3 = new GenericApplicationContext();
复制代码

2.使用XmlBeanDefinitionReader来确认为xml模式

new XmlBeanDefinitionReader---前面在手写得时候也说过,用来读取xml配置的bean定义

把context3(注册器)给入,为什么context3是注册器上一篇已经提到了,GenericApplicationContext实现了bean定义注册接口,然后使用了loadBeanDefinitions()方法,然后指定了类目录下的application.xml

new XmlBeanDefinitionReader(context3).loadBeanDefinitions("classpath:application.xml");
复制代码

那能否加入注解形式的呢


3.使用ClassPathBeanDefinitionScanner来确认为注解模式

扫描器ClassPathBeanDefinitionScanner帮我们完成注解bean定义的扫描注册,然后把注册器放入,指定扫描这个包下的bean

scan()方法是支持多个参数的,可以注册多个的注册器---public int scan(String... basePackages)

new ClassPathBeanDefinitionScanner(context3).scan("com.study.SpringSource.service");
复制代码

4.使用refresh()方法进行刷新

一定要刷新,不然它无法正常创建bean

context3.refresh();
复制代码
5.对refresh()方法的一些简单阐述

简单地过一下refresh()方法的步骤,此处直接截取源码的片段

synchronized (this.startupShutdownMonitor) {
	// Prepare this context for refreshing.
	prepareRefresh();

	// Tell the subclass to refresh the internal bean factory.
	ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

	// Prepare the bean factory for use in this context.
	prepareBeanFactory(beanFactory);
	
        try {
		// Allows post-processing of the bean factory in context subclasses.
		postProcessBeanFactory(beanFactory);

		// Invoke factory processors registered as beans in the context.
		invokeBeanFactoryPostProcessors(beanFactory);

		// Register bean processors that intercept bean creation.
		registerBeanPostProcessors(beanFactory);

		// Initialize message source for this context.
		initMessageSource();

		// Initialize event multicaster for this context.
		initApplicationEventMulticaster();

		// Initialize other special beans in specific context subclasses.
		onRefresh();

		// Check for listener beans and register them.
		registerListeners();

		// Instantiate all remaining (non-lazy-init) singletons.
		finishBeanFactoryInitialization(beanFactory);

		// Last step: publish corresponding event.
		finishRefresh();
	}

	catch (BeansException ex) {
		if (logger.isWarnEnabled()) {
			logger.warn("Exception encountered during context initialization - " +
					"cancelling refresh attempt: " + ex);
		}

		// Destroy already created singletons to avoid dangling resources.
		destroyBeans();

		// Reset 'active' flag.
		cancelRefresh(ex);

		// Propagate exception to caller.
		throw ex;
	}

	finally {
		// Reset common introspection caches in Spring's core, since we
		// might not ever need metadata for singleton beans anymore...
		resetCommonCaches();
	}
}
复制代码
first:synchronized (this.startupShutdownMonitor)

因为执行refresh方法是需要一定时间的,在容器执行刷新时避免启动或销毁容器的操作

next: prepareRefresh()
protected void prepareRefresh() {
	this.startupDate = System.currentTimeMillis();
	this.closed.set(false);
	this.active.set(true);

	if (logger.isDebugEnabled()) {
		if (logger.isTraceEnabled()) {
			logger.trace("Refreshing " + this);
		}
		else {
			logger.debug("Refreshing " + getDisplayName());
		}
	}

	// Initialize any placeholder property sources in the context environment
	initPropertySources();

	// Validate that all properties marked as required are resolvable
	// see ConfigurablePropertyResolver#setRequiredProperties
	getEnvironment().validateRequiredProperties();

	// Allow for the collection of early ApplicationEvents,
	// to be published once the multicaster is available...
	this.earlyApplicationEvents = new LinkedHashSet<>();
}
复制代码

刷新前的准备,一句话大概说明就其实是系统属性以及环境变量的初始化和验证,记录下容器的启动时间、标记 两个属性 closed 为 false 和 active 为 true,也就是已启动的一个标识而已,后面三行可直译一下,然后进行日志打印,initPropertySources()是处理了配置文件的占位符,getEnvironment().validateRequiredProperties()是校验了 xml 配置 , 最后把这个集合初始化了一下

then:obtainFreshBeanFactory()

其次通过obtainFreshBeanFactory()获取到刷新后的beanFactory和为每个bean生成BeanDefinition等,也就是经过这个方法后ApplicationContext就已经拥有了BeanFactory的全部功能

protected ConfigurableListableBeanFactory obtainFreshBeanFactory() {
	refreshBeanFactory();
	return getBeanFactory();
}
复制代码

此时我们再点开 refreshBeanFactory() 的方法里面

protected abstract void refreshBeanFactory() throws BeansException, IllegalStateException;
复制代码

这里会发现此方法为一个父类提供的模板方法,交由子类来进行实现,此时我们再转到其实现中会有两个方案,如果看通用 GenericApplicationContext 的实现,就是以下

protected final void refreshBeanFactory() throws IllegalStateException {
	if (!this.refreshed.compareAndSet(false, true)) {
		throw new IllegalStateException(
				"GenericApplicationContext does not support multiple refresh attempts: just call 'refresh' once");
	}
	this.beanFactory.setSerializationId(getId());
}
复制代码

简单说明一下,就是会判断当前是否被刷新,如果没有,就报出一个异常,然后给了一个序列化后的id,如果是 AbstractRefreshableApplicationContext 的话就是以下代码

protected final void refreshBeanFactory() throws BeansException {
	if (hasBeanFactory()) {
		destroyBeans();
		closeBeanFactory();
	}
	try {
		DefaultListableBeanFactory beanFactory = createBeanFactory();
		beanFactory.setSerializationId(getId());
		customizeBeanFactory(beanFactory);
		loadBeanDefinitions(beanFactory);
		synchronized (this.beanFactoryMonitor) {
			this.beanFactory = beanFactory;
		}
	}
	catch (IOException ex) {
		throw new ApplicationContextException("I/O error parsing bean definition source for " + getDisplayName(), ex);
	}
}
复制代码

简单说下,如果 ApplicationContext 中已经加载过 BeanFactory 了,销毁所有 Bean,关闭 BeanFactory,注意这里的 ApplicationContext 是指现在的这个this,因为应用中有多个 BeanFactory 再也正常不过了,然后我们可以看到使用了 DefaultListableBeanFactory 来创建工厂

为什么使用此类,那是因为这是 ConfigurableListableBeanFactory 可配置bean工厂的唯一实现,而 ConfigurableListableBeanFactory 是继承自分层bean工厂 HierarchicalBeanFactory 接口的,如下图

且实现了BeanDefinitionRegistry,功能已经十分强大,再然后序列化之后是两个非常重要的方法 customizeBeanFactory() 和 loadBeanDefinitions()

protected void customizeBeanFactory(DefaultListableBeanFactory beanFactory) {
	if (this.allowBeanDefinitionOverriding != null) {
		beanFactory.setAllowBeanDefinitionOverriding(this.allowBeanDefinitionOverriding);
	}
	if (this.allowCircularReferences != null) {
		beanFactory.setAllowCircularReferences(this.allowCircularReferences);
	}
}
复制代码

这个方法进行了两个判断,“是否允许 Bean 定义覆盖” 和 “是否允许 Bean 间的循环依赖” ,bean定义覆盖是指比如定义 bean 时使用了相同的 id 或 name,如果在同一份配置中是会报错的,但是如果不同,那就会进行覆盖处理,循环依赖很简单就不用说明了。

再往下,就要往我们新new出来的bean工厂里面增加bean定义,如何加,点进去loadBeanDefinitions(),又是我们耳熟能详的交由子类实现的例子

protected abstract void loadBeanDefinitions(DefaultListableBeanFactory beanFactory)
		throws BeansException, IOException;
复制代码

这里进行一个只突出重点的展开(全展开篇幅过长了···)

XmlBeanDefinitionReader beanDefinitionReader = new XmlBeanDefinitionReader(beanFactory);
复制代码

再往下走会看到一个xml方式的bean定义reader,把DefaultListableBeanFactory类型的beanFactory参数放入,这个reader需要的是一个注册器参数,那到底DefaultListableBeanFactory有没有实现注册器的接口呢

不难发现它实现了bean定义注册器接口

    beanDefinitionReader.setEnvironment(this.getEnvironment());
	beanDefinitionReader.setResourceLoader(this);
	beanDefinitionReader.setEntityResolver(new ResourceEntityResolver(this));
复制代码

这三行setEnvironment()给入了一些环境参数(比如properties文件中的值和profile),ResourceLoader是负责加载xml文件,比如给入的是字符串如何转化为resource,setEntityResolver是xml的解析用的

initBeanDefinitionReader(beanDefinitionReader);
复制代码

初始化这个reader,在子类的覆写实现中也是什么都没干···姑且鸡肋一波,继续往下又是一个loadBeanDefinitions

protected void loadBeanDefinitions(XmlBeanDefinitionReader reader) throws BeansException, IOException {
	Resource[] configResources = getConfigResources();
	if (configResources != null) {
		reader.loadBeanDefinitions(configResources);
	}
	String[] configLocations = getConfigLocations();
	if (configLocations != null) {
		reader.loadBeanDefinitions(configLocations);
	}
}
复制代码

这里getConfigResources()返回的是null,它的子类实现中是返回了它所持有的环境参数信息

protected Resource[] getConfigResources() {
	return this.configResources;
}
复制代码

之后两个if分别是判断环境参数configResources是否为空,加载进去Bean定义,然后判断我们所提供的字符串信息configLocations--->也就是我们的application.xml是否为空,有就又加载到bean定义,从根本上就是一个顺序执行下来,我们再点进第二个loadBeanDefinitions(configLocations)中去

@Override
public int loadBeanDefinitions(String... locations) throws BeanDefinitionStoreException {
	Assert.notNull(locations, "Location array must not be null");
	int count = 0;
	for (String location : locations) {
		count += loadBeanDefinitions(location);
	}
	return count;
}
复制代码

这里就是一个计数,统计我们给到的xml有多少个,每有一个都加载到bean定义中,再点进去这个loadBeanDefinitions,

public int loadBeanDefinitions(String location, @Nullable Set<Resource> actualResources) throws BeanDefinitionStoreException {
	ResourceLoader resourceLoader = getResourceLoader();
	if (resourceLoader == null) {
		throw new BeanDefinitionStoreException(
				"Cannot load bean definitions from location [" + location + "]: no ResourceLoader available");
	}

	if (resourceLoader instanceof ResourcePatternResolver) {
		// Resource pattern matching available.
		try {
			Resource[] resources = ((ResourcePatternResolver) resourceLoader).getResources(location);
			int count = loadBeanDefinitions(resources);
			if (actualResources != null) {
				Collections.addAll(actualResources, resources);
			}
			if (logger.isTraceEnabled()) {
				logger.trace("Loaded " + count + " bean definitions from location pattern [" + location + "]");
			}
			return count;
		}
		catch (IOException ex) {
			throw new BeanDefinitionStoreException(
					"Could not resolve bean definition resource pattern [" + location + "]", ex);
		}
	}
	else {
		// Can only load single resources by absolute URL.
		Resource resource = resourceLoader.getResource(location);
		int count = loadBeanDefinitions(resource);
		if (actualResources != null) {
			actualResources.add(resource);
		}
		if (logger.isTraceEnabled()) {
			logger.trace("Loaded " + count + " bean definitions from location [" + location + "]");
		}
		return count;
	}
}
复制代码

第一行ResourceLoader resourceLoader = getResourceLoader()这里的resourceLoader返回的是ApplicationContext,此时如果resource为空,那就抛出异常,之后try代码块中int count = loadBeanDefinitions(resources)又是一个统计加载bean定义数目的,再往下点进去loadBeanDefinitions我们能进入到XmlBeanDefinitionReader的loadBeanDefinitions

ps:为了方便观看,从这里开始,方法内的源码没用上的我直接删除不粘贴出来

public int loadBeanDefinitions(EncodedResource encodedResource) throws BeanDefinitionStoreException {

	try {
		InputStream inputStream = encodedResource.getResource().getInputStream();
		try {
			InputSource inputSource = new InputSource(inputStream);
			if (encodedResource.getEncoding() != null) {
				inputSource.setEncoding(encodedResource.getEncoding());
			}
			return doLoadBeanDefinitions(inputSource, encodedResource.getResource());
		}
        }
复制代码

简单分析一下

InputStream inputStream = encodedResource.getResource().getInputStream();
复制代码

此处的try代码块就开始对字符集进行包装处理了,之后往下

doLoadBeanDefinitions(inputSource, encodedResource.getResource());
复制代码

此时开始读取xml,解析xml里面的标签取到bean定义的作用,点进去这个方法

protected int doLoadBeanDefinitions(InputSource inputSource, Resource resource)
		throws BeanDefinitionStoreException {

	try {
		Document doc = doLoadDocument(inputSource, resource);
		int count = registerBeanDefinitions(doc, resource);
		if (logger.isDebugEnabled()) {
			logger.debug("Loaded " + count + " bean definitions from " + resource);
		}
		return count;
	}

}
复制代码

此处的Document doc = doLoadDocument(inputSource, resource)就是把xml文件解析成一个Document对象,之后registerBeanDefinitions()方法就是重点了

public int registerBeanDefinitions(Document doc, Resource resource) throws BeanDefinitionStoreException {
	BeanDefinitionDocumentReader documentReader = createBeanDefinitionDocumentReader();
	int countBefore = getRegistry().getBeanDefinitionCount();
	documentReader.registerBeanDefinitions(doc, createReaderContext(resource));
	return getRegistry().getBeanDefinitionCount() - countBefore;
}
复制代码

创建DocumentReader,取到之前我们已经统计好的bean定义数countBefore,其实这个计数并不是什么重点,我们重点是它的registerBeanDefinitions--->注册bean定义的这个方法

@Override
public void registerBeanDefinitions(Document doc, XmlReaderContext readerContext) {
	this.readerContext = readerContext;
	doRegisterBeanDefinitions(doc.getDocumentElement());
}
复制代码

这里doRegisterBeanDefinitions方法会从 xml 根节点开始解析文件

再深入点进去看一眼

@SuppressWarnings("deprecation")  // for Environment.acceptsProfiles(String...)
protected void doRegisterBeanDefinitions(Element root) {

	BeanDefinitionParserDelegate parent = this.delegate;
	this.delegate = createDelegate(getReaderContext(), root, parent);

	if (this.delegate.isDefaultNamespace(root)) {
		String profileSpec = root.getAttribute(PROFILE_ATTRIBUTE);
		if (StringUtils.hasText(profileSpec)) {
			String[] specifiedProfiles = StringUtils.tokenizeToStringArray(
					profileSpec, BeanDefinitionParserDelegate.MULTI_VALUE_ATTRIBUTE_DELIMITERS);
			// We cannot use Profiles.of(...) since profile expressions are not supported
			// in XML config. See SPR-12458 for details.
			if (!getReaderContext().getEnvironment().acceptsProfiles(specifiedProfiles)) {
				if (logger.isDebugEnabled()) {
					logger.debug("Skipped XML bean definition file due to specified profiles [" + profileSpec +
							"] not matching: " + getReaderContext().getResource());
				}
				return;
			}
		}
	}

	preProcessXml(root);
	parseBeanDefinitions(root, this.delegate);
	postProcessXml(root);

	this.delegate = parent;
}
复制代码

这里面我们只要知道BeanDefinitionParserDelegate是负责解析bean定义的一个类,主要看最后面的三个方法,preProcessXml(root)和postProcessXml(root)是两个提供给子类的钩子方法,而parseBeanDefinitions(root, this.delegate)是核心解析bean定义的方法,好的,看来这就是我们的最后了

这样看下来好像基本没看懂个啥,但是我们发现了最关键的两个分支

if (delegate.isDefaultNamespace(ele)) {
	parseDefaultElement(ele, delegate);
}
else {
	delegate.parseCustomElement(ele);
}
复制代码

点开在parseDefaultElement(ele, delegate) 中我们可以看到定义了4个字符串常量

public static final String IMPORT_ELEMENT = "import";
public static final String ALIAS_ATTRIBUTE = "alias";
public static final String NESTED_BEANS_ELEMENT = "beans";
public static final String BEAN_ELEMENT = BeanDefinitionParserDelegate.BEAN_ELEMENT;
其中
public static final String BEAN_ELEMENT = "bean";
复制代码

它们之所以是属于默认的,是因为它们是处于这个 namespace 下定义的:www.springframework.org/schema/bean…

more then :prepareBeanFactory(beanFactory);

再通过 prepareBeanFactory(beanFactory) 去实现诸如以前提到的对bean的创建销毁等一系列过程进行处理增强的这些功能,尝试点开此方法的源码也可看到诸如 addBeanPostProcessor等在 手写Spring---AOP面向切面编程(4)中提及的定义一个监听接口BeanPostProcessor来监听Bean初始化前后过程的有关事项

beanFactory的准备工作,代码量也是非常大

protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) {
	// Tell the internal bean factory to use the context's class loader etc.
	beanFactory.setBeanClassLoader(getClassLoader());
	beanFactory.setBeanExpressionResolver(new StandardBeanExpressionResolver(beanFactory.getBeanClassLoader()));
	beanFactory.addPropertyEditorRegistrar(new ResourceEditorRegistrar(this, getEnvironment()));

	// Configure the bean factory with context callbacks.
	beanFactory.addBeanPostProcessor(new ApplicationContextAwareProcessor(this));
	beanFactory.ignoreDependencyInterface(EnvironmentAware.class);
	beanFactory.ignoreDependencyInterface(EmbeddedValueResolverAware.class);
	beanFactory.ignoreDependencyInterface(ResourceLoaderAware.class);
	beanFactory.ignoreDependencyInterface(ApplicationEventPublisherAware.class);
	beanFactory.ignoreDependencyInterface(MessageSourceAware.class);
	beanFactory.ignoreDependencyInterface(ApplicationContextAware.class);

	// BeanFactory interface not registered as resolvable type in a plain factory.
	// MessageSource registered (and found for autowiring) as a bean.
	beanFactory.registerResolvableDependency(BeanFactory.class, beanFactory);
	beanFactory.registerResolvableDependency(ResourceLoader.class, this);
	beanFactory.registerResolvableDependency(ApplicationEventPublisher.class, this);
	beanFactory.registerResolvableDependency(ApplicationContext.class, this);

	// Register early post-processor for detecting inner beans as ApplicationListeners.
	beanFactory.addBeanPostProcessor(new ApplicationListenerDetector(this));

	// Detect a LoadTimeWeaver and prepare for weaving, if found.
	if (beanFactory.containsBean(LOAD_TIME_WEAVER_BEAN_NAME)) {
		beanFactory.addBeanPostProcessor(new LoadTimeWeaverAwareProcessor(beanFactory));
		// Set a temporary ClassLoader for type matching.
		beanFactory.setTempClassLoader(new ContextTypeMatchClassLoader(beanFactory.getBeanClassLoader()));
	}

	// Register default environment beans.
	if (!beanFactory.containsLocalBean(ENVIRONMENT_BEAN_NAME)) {
		beanFactory.registerSingleton(ENVIRONMENT_BEAN_NAME, getEnvironment());
	}
	if (!beanFactory.containsLocalBean(SYSTEM_PROPERTIES_BEAN_NAME)) {
		beanFactory.registerSingleton(SYSTEM_PROPERTIES_BEAN_NAME, getEnvironment().getSystemProperties());
	}
	if (!beanFactory.containsLocalBean(SYSTEM_ENVIRONMENT_BEAN_NAME)) {
		beanFactory.registerSingleton(SYSTEM_ENVIRONMENT_BEAN_NAME, getEnvironment().getSystemEnvironment());
	}
}
复制代码

我们简单地分步进行说明

这里分别是为此beanFactory设置一个类加载器,2,3句是表达式的解析器,第4句为添加一个 BeanPostProcessor ,这我想已经见怪莫怪了,因为这个明显又是对节点处进行了处理增强


这里的代码被分成了两串,第一串是是几个接口的实现,如果 bean 依赖于以下接口的实现类,在自动装配时将会忽略它们, 因为Spring 会通过其他方式来处理这些依赖。第二串是为特殊的几个 bean 赋值


在 bean 完成实例化后,如果是 ApplicationListener 这个类的子类,就将其添加到 listener 列表中


一个特殊的bean,可不展开讨论


Register default environment beans 这句注释说明了,Spring是会帮我们主动去注册一些有用的bean的,当然这些bean我们都是可以通过手动覆盖的


final:通过注释也可以直接大概理解的步骤(先不展开)
try {
	// Allows post-processing of the bean factory in context subclasses.
	postProcessBeanFactory(beanFactory);

	// Invoke factory processors registered as beans in the context.
	invokeBeanFactoryPostProcessors(beanFactory);

	// Register bean processors that intercept bean creation.
	registerBeanPostProcessors(beanFactory);

	// Initialize message source for this context.
	initMessageSource();

	// Initialize event multicaster for this context.
	initApplicationEventMulticaster();

	// Initialize other special beans in specific context subclasses.
	onRefresh();

	// Check for listener beans and register them.
	registerListeners();

	// Instantiate all remaining (non-lazy-init) singletons.
	finishBeanFactoryInitialization(beanFactory);

	// Last step: publish corresponding event.
	finishRefresh();
}
复制代码
5.获取并使用bean
//刷新完之后就可以进行获取了
cs2 = context3.getBean(CombatService.class);
cs2.combating();
Abean ab = context3.getBean(Abean.class);
ab.doSomething();
复制代码

现在我们再来回答以下问题

如何加载解析bean定义(对于xml的解析过程)

在这篇文章开始之前我们进行xml创建使用bean的时候编写了一个main方法来进行测试,我们就使用断点调试的形式去跟着类的执行流程跑一遍,来看看这个 ApplicationContext 的创建流程

此时我们点击下一步,将会跳转到这个构造方法中

public ClassPathXmlApplicationContext(String configLocation) throws BeansException {
	this(new String[] {configLocation}, true, null);
}
复制代码

再往下我们发现它执行了另外的一个构造方法

public ClassPathXmlApplicationContext(
		String[] configLocations, boolean refresh, @Nullable ApplicationContext parent)
		throws BeansException {

	super(parent);
	setConfigLocations(configLocations);
	if (refresh) {
		refresh();
	}
}
复制代码

此时我们可以再次查看传过来的三个参数,第一个参数是我们提供的字符串类型的"application.xml",第二个是是否要刷新为true,parent父容器为null ,setConfigLocations()方法就是把configLocations字符串放入一个String类型的数组而已,之后执行了refresh()方法,关于refresh()我们在上面已经阐述过了,所以整套连起来就是关于xml的解析的简单过程

尾声

可以说是篇幅很长,也是源码篇的第一个有进行部分展开的解读,少吃多餐的原则在这又忘得一干二净了,只想去尽量解释清楚一点又容易看懂一点,道阻且长,望多多总结,互相进步··

转载于:https://juejin.im/post/5cfa800651882524156c9f8f

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值