一道面试题引发的spring源码阅读

前言

最近读到一道面试题,谈谈spring的实例化,比如一个类有多个构造方法的话,spring该选择哪个构造方法来进行实例化呢

正文开始

spring的实例化过程中会有一步推断构造方法的方法,但是要知道,spring是个非常复杂的框架,对于这个面试题,为了避免篇幅过长,我就不从spring容器初始化开始说起了,这里我就画一下从实例化单例对象方法开始直到推断构造方法的调用链,然后再来详细的讨论推断构造方法的源码

好了,接下来我们开始看代码,首先,我们知道,构造方法是用来实例化对象的,所以,我们就要先从createBeanInstance这个方法入手,看看这个方法都做了些什么,因为这个方法就是创建spring bean实例的方法

// Make sure bean class is actually resolved at this point.
		Class<?> beanClass = resolveBeanClass(mbd, beanName);

		/**
		 * 检测一个类的访问权限spring默认情况下对于非public的类是允许访问的。
		 */
		if (beanClass != null && !Modifier.isPublic(beanClass.getModifiers()) && !mbd.isNonPublicAccessAllowed()) {
			throw new BeanCreationException(mbd.getResourceDescription(), beanName,
					"Bean class isn't public, and non-public access not allowed: " + beanClass.getName());
		}

		Supplier<?> instanceSupplier = mbd.getInstanceSupplier();
		if (instanceSupplier != null) {
			return obtainFromSupplier(instanceSupplier, beanName);
		}

		/**
		 *
		 * 如果工厂方法不为空,则通过工厂方法构建 bean 对象
		 * 
		 */
		if (mbd.getFactoryMethodName() != null)  {
			return instantiateUsingFactoryMethod(beanName, mbd, args);
		}

		// Shortcut when re-creating the same bean...
		/**
		 * 从spring的原始注释可以知道这个是一个Shortcut,什么意思呢?
         * 可以把它理解为一个缓存
		 * 当多次构建同一个 bean 时,可以使用这个Shortcut,
		 * 比如在多次构建同一个prototype类型的 bean 时,就可以走此处的shortcut
		 * 这里的 resolved 和 mbd.constructorArgumentsResolved 将会在 bean 第一次实例
		 * 化的过程中被设置
		 */
		boolean resolved = false;
		boolean autowireNecessary = false;
		if (args == null) {
			synchronized (mbd.constructorArgumentLock) {
				if (mbd.resolvedConstructorOrFactoryMethod != null) {
					resolved = true;
					//如果已经解析了构造方法的参数,则必须要通过一个带参构造方法来实例
					autowireNecessary = mbd.constructorArgumentsResolved;
				}
			}
		}
		if (resolved) {
			if (autowireNecessary) {
				// 通过构造方法自动装配的方式构造 bean 对象
				return autowireConstructor(beanName, mbd, null, null);
			}
			else {
				//通过默认的无参构造方法进行
				return instantiateBean(beanName, mbd);
			}
		}

		// Candidate constructors for autowiring?
		//由后置处理器决定返回哪些构造方法
		Constructor<?>[] ctors = determineConstructorsFromBeanPostProcessors(beanClass, beanName);
		if (ctors != null || mbd.getResolvedAutowireMode() == AUTOWIRE_CONSTRUCTOR ||
				mbd.hasConstructorArgumentValues() || !ObjectUtils.isEmpty(args))  {
			return autowireConstructor(beanName, mbd, ctors, args);
		}

		// No special handling: simply use no-arg constructor.
		//使用默认的无参构造方法进行初始化
		return instantiateBean(beanName, mbd);
	}

首先,第一次实例化的时候还没有设置resolved 和 mbd.constructorArgumentsResolved的值,所以我们先跳过if (resolved)包裹起来的这段代码,直接来看determineConstructorsFromBeanPostProcessors方法,顾名思义,这个方法的意思是从BeanPostProcessor来推断构造方法,而我们知道BeanPostProcessor可以用来插手Bean的初始化,我们点进去看

@Nullable
	protected Constructor<?>[] determineConstructorsFromBeanPostProcessors(@Nullable Class<?> beanClass, String beanName)
			throws BeansException {
		//如果有实例化感知BeanPostProcessor
		if (beanClass != null && hasInstantiationAwareBeanPostProcessors()) {
			//遍历所有的beanPostProcessor
			for (BeanPostProcessor bp : getBeanPostProcessors()) {
				//如果beanPostProcessor是SmartInstantiationAwareBeanPostProcessor
				if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
					SmartInstantiationAwareBeanPostProcessor ibp = (SmartInstantiationAwareBeanPostProcessor) bp;
					//让这个beanPostProcessor去推测合适的构造方法,返回一个构造方法数组
					Constructor<?>[] ctors = ibp.determineCandidateConstructors(beanClass, beanName);
					if (ctors != null) {
						return ctors;
					}
				}
			}
		}
		return null;
	}

我们找到determineCandidateConstructors的实现类org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor#determineCandidateConstructors

这个方法我们只看这一段

//经过之前一系列的判断,候选构造方法已经被找出放到了一个数组中
for (Constructor<?> candidate : rawCandidates) {
	if (!candidate.isSynthetic()) {
		nonSyntheticConstructors++;
	} else if (primaryConstructor != null) {
		continue;
	}
	//找一下是否有Autowired注解
	AnnotationAttributes ann = findAutowiredAnnotation(candidate);
	if (ann == null) {
		Class<?> userClass = ClassUtils.getUserClass(beanClass);
		if (userClass != beanClass) {
			try {
				Constructor<?> superCtor =
						userClass.getDeclaredConstructor(candidate.getParameterTypes());
				//再找有没有Autowired注解
				ann = findAutowiredAnnotation(superCtor);
			} catch (NoSuchMethodException ex) {
				// Simply proceed, no equivalent superclass constructor found...
			}
		}
	}
	if (ann != null) {
		if (requiredConstructor != null) {
			throw new BeanCreationException(beanName,
					"Invalid autowire-marked constructor: " + candidate +
							". Found constructor with 'required' Autowired annotation already: " +
							requiredConstructor);
		}
		//推断是否required
		boolean required = determineRequiredStatus(ann);
		if (required) {
			if (!candidates.isEmpty()) {
				throw new BeanCreationException(beanName,
						"Invalid autowire-marked constructors: " + candidates +
								". Found constructor with 'required' Autowired annotation: " +
								candidate);
			}
			requiredConstructor = candidate;
		}
		candidates.add(candidate);
	} else if (candidate.getParameterCount() == 0) {
		defaultConstructor = candidate;
	}
}
if (!candidates.isEmpty()) {
	// Add default constructor to list of optional constructors, as fallback.
	if (requiredConstructor == null) {
		if (defaultConstructor != null) {
			candidates.add(defaultConstructor);
		} else if (candidates.size() == 1 && logger.isWarnEnabled()) {
			logger.warn("Inconsistent constructor declaration on bean with name '" + beanName +
					"': single autowire-marked constructor flagged as optional - " +
					"this constructor is effectively required since there is no " +
					"default constructor to fall back to: " + candidates.get(0));
		}
	}
	candidateConstructors = candidates.toArray(new Constructor<?>[0]);
} else if (rawCandidates.length == 1 && rawCandidates[0].getParameterCount() > 0) {
	candidateConstructors = new Constructor<?>[]{rawCandidates[0]};
} else if (nonSyntheticConstructors == 2 && primaryConstructor != null &&
		defaultConstructor != null && !primaryConstructor.equals(defaultConstructor)) {
	candidateConstructors = new Constructor<?>[]{primaryConstructor, defaultConstructor};
} else if (nonSyntheticConstructors == 1 && primaryConstructor != null) {
	candidateConstructors = new Constructor<?>[]{primaryConstructor};
} else {
	candidateConstructors = new Constructor<?>[0];
}
//把推断出来的构造放到一个构造方法map中去
this.candidateConstructorsCache.put(beanClass, candidateConstructors);

然后我们再来看autowireConstructor这个方法,这个方法顾名思义就是自动注入构造方法,注意这句源码注释@return a BeanWrapper for the new instance,说明这个方法最终会返回一个BeanWrapper,BeanWrapper可以操作Bean的实例,这个方法要传入四个参数,分别是bean的名字,RootBeanDefination,推断出来的构造方法数组,方法参数

public BeanWrapper autowireConstructor(String beanName, RootBeanDefinition mbd,
			@Nullable Constructor<?>[] chosenCtors, @Nullable Object[] explicitArgs) {
		//实例一个BeanWrapperImpl 对象很好理解
		//前面外部返回的BeanWrapper 其实就是这个BeanWrapperImpl
		//因为BeanWrapper是个接口
		BeanWrapperImpl bw = new BeanWrapperImpl();
		this.beanFactory.initBeanWrapper(bw);

		Constructor<?> constructorToUse = null;
		ArgumentsHolder argsHolderToUse = null;
		Object[] argsToUse = null;
		//确定参数值列表
		//argsToUse可以有两种办法设置
		//第一种通过beanDefinition设置
		//第二种通过xml设置
		if (explicitArgs != null) {
			argsToUse = explicitArgs;
		}
		else {
			Object[] argsToResolve = null;
			synchronized (mbd.constructorArgumentLock) {
				//获取已解析的构造方法
				//一般不会有,因为构造方法一般会提供一个
				//除非有多个。那么才会存在已经解析完成的构造方法
				constructorToUse = (Constructor<?>) mbd.resolvedConstructorOrFactoryMethod;
				if (constructorToUse != null && mbd.constructorArgumentsResolved) {
					// Found a cached constructor...
					argsToUse = mbd.resolvedConstructorArguments;
					if (argsToUse == null) {
						argsToResolve = mbd.preparedConstructorArguments;
					}
				}
			}
			if (argsToResolve != null) {
				argsToUse = resolvePreparedArguments(beanName, mbd, bw, constructorToUse, argsToResolve);
			}
		}

		if (constructorToUse == null) {
			//如果没有已经解析的构造方法
			//则需要去解析构造方法
			// Need to resolve the constructor.
			//判断构造方法是否为空,判断是否根据构造方法自动注入
			boolean autowiring = (chosenCtors != null ||
					mbd.getResolvedAutowireMode() == AutowireCapableBeanFactory.AUTOWIRE_CONSTRUCTOR);
			ConstructorArgumentValues resolvedValues = null;

			//定义了最小参数个数
			//如果你给构造方法的参数列表给定了具体的值
			//那么这些值的个数就是构造方法参数的个数
			int minNrOfArgs;
			if (explicitArgs != null) {
				minNrOfArgs = explicitArgs.length;
			}
			else {
				//实例一个对象,用来存放构造方法的参数值
				//当中主要存放了参数值和参数值所对应的下标
				//
				ConstructorArgumentValues cargs = mbd.getConstructorArgumentValues();
				resolvedValues = new ConstructorArgumentValues();
				/**
				 * 确定构造方法参数数量,假设有如下配置:
				 *     <bean id="luban" class="com.luban.Luban">
				 *         <constructor-arg index="0" value="str1"/>
				 *         <constructor-arg index="1" value="1"/>
				 *         <constructor-arg index="2" value="str2"/>
				 *     </bean>
				 *
				 *     在通过spring内部给了一个值的情况那么表示你的构造方法的最小参数个数一定
				 *
				 * minNrOfArgs = 3
				 */
				minNrOfArgs = resolveConstructorArguments(beanName, mbd, bw, cargs, resolvedValues);
			}

			// Take specified constructors, if any.
			Constructor<?>[] candidates = chosenCtors;
			if (candidates == null) {
				Class<?> beanClass = mbd.getBeanClass();
				try {
					candidates = (mbd.isNonPublicAccessAllowed() ?
							beanClass.getDeclaredConstructors() : beanClass.getConstructors());
				}
				catch (Throwable ex) {
					throw new BeanCreationException(mbd.getResourceDescription(), beanName,
							"Resolution of declared constructors on bean Class [" + beanClass.getName() +
							"] from ClassLoader [" + beanClass.getClassLoader() + "] failed", ex);
				}
			}
			//根据构造方法的访问权限级别和参数数量进行排序
			//怎么排序的呢?
			/**
			 *  先访问权限,然后参数个数
			 * 1. public Test(Object o1, Object o2, Object o3)
			 * 2. public Test(Object o1, Object o2)
			 * 3. public Test(Object o1)
			 * 4. protected Test(Integer i, Object o1, Object o2, Object o3)
			 * 5. protected Test(Integer i, Object o1, Object o2)
			 * 6. protected Test(Integer i, Object o1)
			 */
			AutowireUtils.sortConstructors(candidates);
			//定义了一个差异变量
			int minTypeDiffWeight = Integer.MAX_VALUE;
			//不明确的构造函数,一个set,也就是不重复
			Set<Constructor<?>> ambiguousConstructors = null;
			LinkedList<UnsatisfiedDependencyException> causes = null;

			//循环所有的构造方法
			for (Constructor<?> candidate : candidates) {
				Class<?>[] paramTypes = candidate.getParameterTypes();
				/**
				 * constructorToUse != null这个很好理解,
				 * constructorToUse主要是用来装已经解析过了并且在使用的构造方法
				 * 只有在他等于空的情况下,才有继续的意义,因为下面如果解析到了一个符合的构造方法
				 * 就会赋值给这个变量。如果这个变量不等于null就不需要再进行解析了,说明spring已经
				 * 找到一个合适的构造方法,直接使用便可以
				 * argsToUse.length > paramTypes.length这个应该怎么理解
				 * 首先假设 argsToUse = [1,"aaa",obj]
				 * 那么回去匹配到上面的构造方法的1和5
				 * 由于构造方法1有更高的访问权限,所有选择1,尽管5看起来更加匹配
				 * 但是我们看2,直接参数个数就不对所以直接忽略
				 *
				 *
				 */
				if (constructorToUse != null && argsToUse.length > paramTypes.length) {
					// Already found greedy constructor that can be satisfied ->
					// do not look any further, there are only less greedy constructors left.
					break;
				}
				if (paramTypes.length < minNrOfArgs) {
					continue;
				}

				ArgumentsHolder argsHolder;
				if (resolvedValues != null) {
					try {
						//判断是否加了ConstructorProperties注解如果加了则把值取出来
						String[] paramNames = ConstructorPropertiesChecker.evaluate(candidate, paramTypes.length);
						if (paramNames == null) {
							ParameterNameDiscoverer pnd = this.beanFactory.getParameterNameDiscoverer();
							if (pnd != null) {
								//获取构造方法参数名称列表
								/**
								 * 假设你有一个(String aaa,Object vvv)
								 * 则paramNames=[aaa,vvv]
								 */
								paramNames = pnd.getParameterNames(candidate);
							}
						}

						//获取构造方法参数值列表
						/**
						 * 这个方法比较复杂
						 * 因为spring只能提供字符串的参数值
						 * 故而需要进行转换
						 * argsHolder所包含的值就是转换之后的
						 */
						argsHolder = createArgumentArray(beanName, mbd, resolvedValues, bw, paramTypes, paramNames,
								getUserDeclaredConstructor(candidate), autowiring);
					}
					catch (UnsatisfiedDependencyException ex) {
						if (logger.isTraceEnabled()) {
							logger.trace("Ignoring constructor [" + candidate + "] of bean '" + beanName + "': " + ex);
						}
						// Swallow and try next constructor.
						if (causes == null) {
							causes = new LinkedList<>();
						}
						causes.add(ex);
						continue;
					}
				}
				else {
					// Explicit arguments given -> arguments length must match exactly.
					if (paramTypes.length != explicitArgs.length) {
						continue;
					}
					argsHolder = new ArgumentsHolder(explicitArgs);
				}

				/**
				 * 最终寻找合适的构造方法的精髓就在这行代码
				 * typeDiffWeight 差异量,何谓差异量呢?
				 * argsHolder.arguments和paramTypes之间的差异
				 * 每个参数值的类型与构造方法参数列表的类型之间的差异
				 * 通过这个差异量来衡量或者确定一个合适的构造方法
				 *
				 * 值得注意的是constructorToUse=candidate
				 *
				 * 第一次循环一定会typeDiffWeight < minTypeDiffWeight,因为minTypeDiffWeight的值非常大
				 * 然后每次循环会把typeDiffWeight赋值给minTypeDiffWeight(minTypeDiffWeight = typeDiffWeight)
				 * else if (constructorToUse != null && typeDiffWeight == minTypeDiffWeight)
				 * 第一次循环肯定不会进入这个
				 * 第二次如果进入了这个分支代表什么?
				 * 代表有两个构造方法都符合我们要求?那么spring迷茫了
				 * spring迷茫了怎么办?
				 * ambiguousConstructors.add(candidate);把candidate加到这个叫做模棱两可的构造方法的数据中去
				 * ambiguousConstructors=null 非常重要
				 * 为什么重要,因为需要清空
				 * 这也解释了为什么他找到两个符合要求的方法不直接抛异常的原因
				 * 如果这个ambiguousConstructors一直存在,spring会在循环外面去抛异常
				 */
				int typeDiffWeight = (mbd.isLenientConstructorResolution() ?
						argsHolder.getTypeDifferenceWeight(paramTypes) : argsHolder.getAssignabilityWeight(paramTypes));
				// Choose this constructor if it represents the closest match.
				if (typeDiffWeight < minTypeDiffWeight) {
					constructorToUse = candidate;
					argsHolderToUse = argsHolder;
					argsToUse = argsHolder.arguments;
					minTypeDiffWeight = typeDiffWeight;
					ambiguousConstructors = null;
				}
				else if (constructorToUse != null && typeDiffWeight == minTypeDiffWeight) {
					if (ambiguousConstructors == null) {
						ambiguousConstructors = new LinkedHashSet<>();
						ambiguousConstructors.add(constructorToUse);
					}
					ambiguousConstructors.add(candidate);
				}
			}
			//循环结束
			//没有找到合适的构造方法
			if (constructorToUse == null) {
				if (causes != null) {
					UnsatisfiedDependencyException ex = causes.removeLast();
					for (Exception cause : causes) {
						this.beanFactory.onSuppressedException(cause);
					}
					throw ex;
				}
				//异常中说明了找不到匹配的构造方法
				throw new BeanCreationException(mbd.getResourceDescription(), beanName,
						"Could not resolve matching constructor " +
						"(hint: specify index/type/name arguments for simple parameters to avoid type ambiguities)");
			}

			//如果ambiguousConstructors还存在则异常
			//上面注释当中有说明
			else if (ambiguousConstructors != null && !mbd.isLenientConstructorResolution()) {
				throw new BeanCreationException(mbd.getResourceDescription(), beanName,
						"Ambiguous constructor matches found in bean '" + beanName + "' " +
						"(hint: specify index/type/name arguments for simple parameters to avoid type ambiguities): " +
						ambiguousConstructors);
			}

			if (explicitArgs == null) {
				/*
				 * 缓存相关信息,比如:
				 *   1. 已解析出的构造方法对象 resolvedConstructorOrFactoryMethod
				 *   2. 构造方法参数列表是否已解析标志 constructorArgumentsResolved
				 *   3. 参数值列表 resolvedConstructorArguments 或 preparedConstructorArguments
				 *   这些信息可用在其他地方,用于进行快捷判断
				 */
				argsHolderToUse.storeCache(mbd, constructorToUse);
			}
		}

		try {
			/*
			 * 策略模式通过构造函数创建bean
             */

			final InstantiationStrategy strategy = beanFactory.getInstantiationStrategy();
			Object beanInstance;

			if (System.getSecurityManager() != null) {
				final Constructor<?> ctorToUse = constructorToUse;
				final Object[] argumentsToUse = argsToUse;
				beanInstance = AccessController.doPrivileged((PrivilegedAction<Object>) () ->
						strategy.instantiate(mbd, beanName, beanFactory, ctorToUse, argumentsToUse),
						beanFactory.getAccessControlContext());
			}
			else {
				beanInstance = strategy.instantiate(mbd, beanName, this.beanFactory, constructorToUse, argsToUse);
			}

			bw.setBeanInstance(beanInstance);
			return bw;
		}
		catch (Throwable ex) {
			throw new BeanCreationException(mbd.getResourceDescription(), beanName,
					"Bean instantiation via constructor failed", ex);
		}
	}

进入到instantiate方法

public Object instantiate(RootBeanDefinition bd, @Nullable String beanName, BeanFactory owner,
			final Constructor<?> ctor, @Nullable Object... args) {
		//如果没有加lookup-override或者replace-override属性,直接通过构造方法和参数进行实例化
		if (!bd.hasMethodOverrides()) {
			if (System.getSecurityManager() != null) {
				// use own privileged to change accessibility (when security is on)
				AccessController.doPrivileged((PrivilegedAction<Object>) () -> {
					ReflectionUtils.makeAccessible(ctor);
					return null;
				});
			}
			return (args != null ? BeanUtils.instantiateClass(ctor, args) : BeanUtils.instantiateClass(ctor));
		}
		//lookup功能,需要使用spring默认的cglib动态代理的策略进行实例化
		else {
			return instantiateWithMethodInjection(bd, beanName, owner, ctor, args);
		}
	}

BeanUtils.instantiateClass方法反射创建Bean

public static <T> T instantiateClass(Constructor<T> ctor, Object... args) throws BeanInstantiationException {
		Assert.notNull(ctor, "Constructor must not be null");
		try {
			// 设置构造方法为可访问
			ReflectionUtils.makeAccessible(ctor);
			//反射创建对象
			return (KotlinDetector.isKotlinType(ctor.getDeclaringClass()) ?
					KotlinDelegate.instantiateClass(ctor, args) : ctor.newInstance(args));
		}
		catch (InstantiationException ex) {
			throw new BeanInstantiationException(ctor, "Is it an abstract class?", ex);
		}
		catch (IllegalAccessException ex) {
			throw new BeanInstantiationException(ctor, "Is the constructor accessible?", ex);
		}
		catch (IllegalArgumentException ex) {
			throw new BeanInstantiationException(ctor, "Illegal arguments for constructor", ex);
		}
		catch (InvocationTargetException ex) {
			throw new BeanInstantiationException(ctor, "Constructor threw exception", ex.getTargetException());
		}
	}

cglib动态代理的方式创建bean实例

public Object instantiate(@Nullable Constructor<?> ctor, @Nullable Object... args) {
			Class<?> subclass = createEnhancedSubclass(this.beanDefinition);
			Object instance;
			if (ctor == null) {
				instance = BeanUtils.instantiateClass(subclass);
			}
			else {
				try {
					Constructor<?> enhancedSubclassConstructor = subclass.getConstructor(ctor.getParameterTypes());
					instance = enhancedSubclassConstructor.newInstance(args);
				}
				catch (Exception ex) {
					throw new BeanInstantiationException(this.beanDefinition.getBeanClass(),
							"Failed to invoke constructor for CGLIB enhanced subclass [" + subclass.getName() + "]", ex);
				}
			}
			// SPR-10785: set callbacks directly on the instance instead of in the
			// enhanced class (via the Enhancer) in order to avoid memory leaks.
			Factory factory = (Factory) instance;
			factory.setCallbacks(new Callback[] {NoOp.INSTANCE,
					new LookupOverrideMethodInterceptor(this.beanDefinition, this.owner),
					new ReplaceOverrideMethodInterceptor(this.beanDefinition, this.owner)});
			return instance;
		}

到目前为止,我们知道了在带参数的情况下,spring是怎么来选择构造方法以及实例化bean了,那如果我们没有提供指定的构造方法的情况下,spring默认会进行无参构造函数的实例化bean,我们也要看一下,这个方法就简单很多了

protected BeanWrapper instantiateBean(final String beanName, final RootBeanDefinition mbd) {
    try {
	    Object beanInstance;
	    final BeanFactory parent = this;
	    if (System.getSecurityManager() != null) {
	    	beanInstance = AccessController.doPrivileged((PrivilegedAction<Object>) () ->
	    			getInstantiationStrategy().instantiate(mbd, beanName, parent),
	    			getAccessControlContext());
	    }
	    else {
	    	//getInstantiationStrategy()得到类的实例化策略
	    	//默认情况下是得到一个反射的实例化策略
	    	beanInstance = getInstantiationStrategy().instantiate(mbd, beanName, parent);
	    }
	    BeanWrapper bw = new BeanWrapperImpl(beanInstance);
	    initBeanWrapper(bw);
	    return bw;
	}
	catch (Throwable ex) {
		throw new BeanCreationException(
		mbd.getResourceDescription(), beanName, "Instantiation of bean failed", ex);
	}
}

总结

总结一下,spring实例化bean在碰到多个构造方法的情况下会先让后置处理器判断那些是可以选择的构造方法,然后返回,调用autowireConstructor方法,先确定参数,然后根据确定的参数来判断到底要使用哪个构造方法,然后把构造方法等相关信息放到缓存,最后使用策略模式通过构造方法实例化bean,当然,默认的无参构造方法我也讲到了,大家可以自己看,代码也比较简单。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值