Java简单循环依赖的解决 —— spring_imitate(Spring的模仿)

@Java简单循环依赖的解决

浅谈循环依赖

第一次接触 循环依赖 是在我上JAVA课的过程中,我们老师在模仿Spring时提出的问题,依照我个人的想法,简单的总结为:在 注入 的过程中,生成A的对象需要new 一个 B,而生成B的对象需要new 一个 A,如果不加以处理,那么久会一直等待着对方的实例化,如果再复杂一些,A需要B,B需要C,C需要A,形成的间接递归很让人头大;所以,就如何解决这个问题,采取模仿Spring的方法,我们展开了一系列讨论。

对Spring的模仿

依照Spring的模式,我们核心的分成这几个类:
BeanFactory、BeanDefinition、BeanMethodDefinition、OnReadyBeanMethodDefinition、parameterDependence;
注解类我们创建了三个做测试的注解:
Component、Bean、Autowired;
接下来主要介绍这几个类的作用以及相互之间的运作方式:
我们采取用包扫描的方法,通过给一个Java包的路径,识别出其下所有的类(类名称),然后再判断这些类中是否被我们给出的特殊注解类注解过,按照不同的注解分成不同的类别(注解类、注解方法、注解成员);区分过后再通过注入反射机制,执行所有的注解方法 ,接下来,从最进本的注解类开始记录。

三个注解类

注解类主要是我们为了区分方法类成员创建的,这里我们划分:Component属于的注解,Bean是方法的注解,Autowired是类成员的注解;因为我们只是简单的模仿,所以不做过多的测试,其内代码以及说明如下都比较简单(如果不给name(),换成其他的也可以,注解的用处不只如此,这里不详细说明):

// Component注解类
@Retention(RUNTIME)
@Target(TYPE)
public @interface Component {
	String name() default "";
}
// Bean注解类
@Retention(RUNTIME)
@Target(METHOD)
public @interface Bean {
	String name() default "";
}
// Autowired注解类
@Retention(RUNTIME)
@Target(FIELD)
public @interface Autowired {
	String name() default "";
}

BeanDefinition类

我们采取包扫描的方法实现对各个类、方法和成员的区分,当扫描出一个带有Component注解的类的时候,我们将其放入BeanDefinition中,它的成员有“自身类型”、“将来实例化后的对象”;

// BeanDefinition类
public class BeanDefinition {
	private Class<?> klass;
	private Object object;
	
	BeanDefinition() {
	}

	Class<?> getKlass() {
		return klass;
	}

	void setKlass(Class<?> klass) {
		this.klass = klass;
	}

	Object getObject() {
		return object;
	}

	void setObject(Object object) {
		this.object = object;
	}

	@Override
	public String toString() {
		return klass.getSimpleName() + " : " + object;
	}
	
}

BeanMethodDefinition类

它是扫描出的带有Bean注解的方法,它的实现很简单,每一个BeanMethodDefinition只需要表明这个 方法 是“哪一个”、“它的参数个数”(看它是否还需要等待执行)、“这个类的实例化对象”(执行invoke的必要)、以及方法“Method”本身。具体的实现代码以及说明如下如下:

// BeanMethodDefinition类
public class BeanMethodDefinition {
	private Class<?> klass;
	private Object object;
	private Method method;
	private int parameterCount;
	
	public BeanMethodDefinition() {
	}
	
	Class<?> getKlass() {
		return klass;
	}
	
	void setKlass(Class<?> klass) {
		this.klass = klass;
	}
	
	Object getObject() {
		return object;
	}
	
	void setObject(Object object) {
		this.object = object;
	}
	
	Method getMethod() {
		return method;
	}
	
	void setMethod(Method method) {
		this.method = method;
		Parameter[] parameters = method.getParameters();
		// 得到所有的参数过后,如果参数的个数为0,不作处理,直接返回
		if (parameters.length <= 0) {
			this.parameterCount = 0;
			return;
		}
		
		// 如果参数的个数大于0,那么我们用HashMap将其装起来
		// 键为方法的getType(),值为null
		// 这样一来就容易对parameterCount进行赋值(map.size()即可)
		Map<Class<?>, Object> paraTypeMap = new HashMap<Class<?>, Object>();
		for (Parameter parameter : parameters) {
			paraTypeMap.put(parameter.getType(), null);
		}
		parameterCount = paraTypeMap.size();
	}
	
	int getParameterCount() {
		return parameterCount;
	}
	
	int decrease() {
		return --this.parameterCount;
	}
	
}

其中,全部给的是包权限的 Setters 和 Getters,主要原因就是不想让外部对MethodDefinition进行操作(内部实现);
另外,parameterCount的赋值不太好实现,所以我们给出了比较人性化的实现——map.size(),算是比较精妙的一环节。

OnReadyBeanMethodDefinition类

OnReadyBeanMethodDefinition(以下简称为OnReady)算是一个中转站一样的存在,它是所有可执行方法的集合,里面只有一个泛型为BeanMethodDefinition的List,凡是在List中的方法,全都是可执行方法;

// OnReadyBeanMethodDefinition类
public class OnReadyBeanMethodDefinition {
	private List<BeanMethodDefinition> beanMethodDefinitionList;
	
	public OnReadyBeanMethodDefinition() {
		this.beanMethodDefinitionList = new ArrayList<BeanMethodDefinition>();
	}
	
	void in(BeanMethodDefinition bmd) {
		beanMethodDefinitionList.add(bmd);
	}
	
	BeanMethodDefinition next() {
		return beanMethodDefinitionList.remove(0);
	}
	
	boolean hasNext() {
		return !beanMethodDefinitionList.isEmpty();
	}
	
}

这里面,为了方便以后遍历表,我们给出了常见的next()、hasNext()方法,算是“见贤思齐”。

ParameterDependence类

这个类就如同其名字一般,意在参数独立;大体的思路是将类型方法作为键值对,放入一个Map里面,如果方法可以执行,那么就直接放入OnReady表里去,本着这样的思路,本类中的主要方法分成了:addDependence()、matchDependence()、checkOnReady()三个,下来我们一步一步介绍;

parameterDependence里的成员:Map。

// ParameterDependence类
private static Map<Class<?>, List<BeanMethodDefinition>> parameterDependence;
static {
	parameterDependence = new HashMap<Class<?>, 
					List<BeanMethodDefinition>>();
}

addDependence()方法
这个方法的作用就是把传过来的beanMethodDefinition检查一遍,然后将其加入Map中去

// ParameterDependence类
boolean addDependence(BeanMethodDefinition methodDefinition) {
	// 先检查传过来的methodDefinition是否需要参数才能执行
	// 如果不需要任何参数,那么就没有必要加入到Map里去
	// 直接返回false,后直接加入OnReady里就好
	Method method = methodDefinition.getMethod();
	int paraCount = method.getParameterCount();
	if (paraCount <= 0) {
		return false;
	}
	
	// 如果需要参数执行,先获得参数类型
	// 若Map(parameterDependence)存在该类型,那么直接加入List里,等待匹配
	// 若Map(parameterDependence)不存在该类型,那么创建一个List,加入到Map(parameterDependence)中去
	Parameter[] parameters = method.getParameters();
	for (Parameter parameter : parameters) {
		Class<?> type = parameter.getType();
		if (!parameterDependence.containsKey(type)) {
			parameterDependence.put(type, new ArrayList<BeanMethodDefinition>());
		}
		List<BeanMethodDefinition> bmdList = parameterDependence.get(type);
		bmdList.add(methodDefinition);
	}
	
	return true;
}

matchDependence()方法

此方法就是为了检查方法是否可以执行,它需要两个参数——对应方法的类型,以及OnReady列表

// ParameterDependence类
void matchDependence(Class<?> klass, OnReadyBeanMethodDefinition onReady) {
		// 根据传过来的Class检查Map(parameterDependence)里是否存在对应的List
	List<BeanMethodDefinition> bmdList = parameterDependence.get(klass);
	// 不存在相对应列表,说明Map(parameterDependence)没有存入此Class,也就说明不是需要的类型
	// 直接返回
	if (bmdList == null) {
		return;
	}
	// 如果找到了对应列表,对表进行遍历,以此判断是否可以加入到OnReady里去
	for (BeanMethodDefinition bmd : bmdList) {
		// 因为参数已经能满足一个,所以此处用的是decrease();
		int paraCount = bmd.decrease();
		if (paraCount <= 0) {
			onReady.in(bmd);
		}
		
		bmdList.remove(bmd);
		if (bmdList.isEmpty()) {
			parameterDependence.remove(klass);
		}
	}
}

在matchDependence()方法中,有人或许对bmdList.remove(bmd);有疑问,“为什么要删除?你怎么知道该方法已经能执行了?这个参数满足了别的参数不一定能满足啊!不是应该在onReady.in(bmd);之后删吗?为什么要在外边删?”,当时在这个地方,我们也讨论了很久,下面简单的用一张表来解释:

Type( Class<?> )List < BeanMethodDefinition >
ClassOneMethodOne,MethodTwo;
ClassTwoMethodOne;

我们假设我们扫描到了ClassOne,那么MethodTwo已经满足了参数要求可以执行了,MethodOne不满足参数要求,但如果我们不删除ClassOne里的MethodOne,那么它将一直不能满足要求,一直等待,显然不是我们所期待的那样,因此应该删掉。

checkOnReady()方法

此方法简单的用for循环遍历整个beanFactory,查找是否有符合参数条件可以执行的方法,用matchDependence方法加入到OnReady队列里去。

// ParameterDependence类
void checkOnReady(OnReadyBeanMethodDefinition onReady) {
	BeanFactory beanFactory = new BeanFactory();
	
	for (Class<?> klass : parameterDependence.keySet()) {
		if (beanFactory.getBeanDefinition(klass) != null)  {
			matchDependence(klass, onReady);
		}
	}
}

除了以上的几个主要方法外,还有个方法需要提及:
判断Map(parameterDependence)是否空的isEmpty()方法

	// ParameterDependence类
	boolean isEmpty() {
		return parameterDependence.isEmpty();
	}

最后,在介绍核心中的核心——BeanFactory类 之前,我们先写一个Exception类给自己,防止出现异常情况

HasNoBeanException类

这个类是一个简单的 异常处理类,与普通的异常类相同,它的实现很简单,它的给出主要是为了以后在给出循环依赖报错的时候方便给出提示(描述循环依赖),并且为了停止程序的运行,我们不抛出异常,也不用try catch{},而是自己给异常处理类处理:

// HasNoBeanException类
public class HasNoBeanException extends RuntimeException {
	// 申请专有的错误序列号,每个人的电脑都不一样
	private static final long serialVersionUID = -8760349479646485327L;
	
	public HasNoBeanException() {
	}

	public HasNoBeanException(String message) {
		super(message);
	}

	public HasNoBeanException(Throwable cause) {
		super(cause);
	}

	public HasNoBeanException(String message, Throwable cause) {
		super(message, cause);
	}

	public HasNoBeanException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
		super(message, cause, enableSuppression, writableStackTrace);
	}

}

因为这个异常出现过后,之后的程序我们想让它不执行,直接停止,所以我们这里extends RunTimeException;

BeanFactory类

和Spring相同,BeanFactory是工厂模式的一个实现,它将应用配置和依赖说明从实际的应用代码以及说明如下中分离出来;
它的实现说起来麻烦实现起来更麻烦,可以说是核心中的核心,前面的一系列类,都是为了给它做铺垫;

首先我们先来看看需要的成员:

// BeanFactory类 
private static final Map<String, BeanDefinition> beanPool;
static {
	beanPool = new HashMap<String, BeanDefinition>();
}

beanPool这个成员是一个比较基本的操作;它的作用就是将扫描到的类采用键值对的方法存起来,是算是 “工厂” 中的 “原料”,我们只能先把所有扫描到的 特殊的类(带有特殊注解的类),生成BeanDefinition存进beanPool里之后才能进行其他操作;
而firstGetBean是为了判断是否是第一次注入(后会在getBean方法中给出具体说明);

我们用的是注解的方式区分所有的类,因此,我们可以说BeanFactory的用处就是采用包扫描的方法把所有带有特殊注解的类与普通类区分开,扫描到带有特殊注解的类就生成一个相对应的BeanDefinition 放进 所谓的池子(beanPool)里,然后收集所有的带有Bean注解的方法,当每次扫描完了之后,我们可以检查是否有存在满足参数关系的方法(调用checkOnReady方法),然后 注入执行 所有满足参数条件的方法。

	// BeanFactory类 
	public void scanBeanByPackage(String packageName) {
		OnReadyBeanMethodDefinition orbmd = new OnReadyBeanMethodDefinition();
		parameterDependence parameterDependence = new parameterDependence();
		
		new PackageScanner() {
			@Override
			public void dealClass(Class<?> klass) {
				// 排除八大基本类型,数组,枚举类型,接口类型,以及没有Compoent注解的类型
				if (klass.isPrimitive()
						|| klass.isAnnotation()
						|| klass.isArray()
						|| klass.isEnum()
						|| klass.isInterface()
						|| !klass.isAnnotationPresent(Component.class)) {
					return;
				}
				
				// 用扫描到的类名称,创建一个新的BeanDefinition放进beanPool里
				Object object = null;
				try {
					object = klass.newInstance();
					BeanDefinition bd = new BeanDefinition();
					bd.setKlass(klass);
					bd.setObject(object);

					beanPool.put(klass.getName(), bd);
				} catch (Exception e) {
					e.printStackTrace();
				}
				// 查找所有的Bean方法
				// 只能先收集起来
				collectBeanMethod(klass, object, orbmd);
			}
		}.packageScanner(packageName);
		// 每次扫描完后检查OnReady列表中是否存在可以执行的方法
		// 可以执行的方法,直接执行即可
		parameterDependence.checkOnReady(orbmd);
		processBeanMethod(parameterDependence, orbmd);
	}

在我们得到所有的带有Component注解类之后,就开始着手找带有Bean注解的方法了,collectBeanMethod()方法的具体实现如下:

// BeanFactory类 

// 收集所有的带有Bean注解的方法
private static void collectBeanMethod(Class<?> klass, Object object, 
			OnReadyBeanMethodDefinition orbmd) {
	// 新生成一个parameterDependence对象
	// 但实质上用的是同一个空间(static)
	parameterDependence pd = new parameterDependence();
	
	// 得到所有的方法
	Method[] methods = klass.getDeclaredMethods();
	for (Method method : methods) {
	// 没有Bean注解的方法,直接不处理
		if (!method.isAnnotationPresent(Bean.class)) {
			continue;
		}
	// 将带有Bean注解的方法生成对应的BeanMethodDefinition放入parameterDenpendance中
		BeanMethodDefinition bmd = new BeanMethodDefinition();
		bmd.setKlass(klass);
		bmd.setMethod(method);
		bmd.setObject(object);
		// 如果是不需要参数的方法(意味着直接可以执行),那么直接放入OnReady中
		if (pd.addDependence(bmd) == false) {
			orbmd.in(bmd);
		}
	}
}

收集过后我们用parameterDependence.checkOnReady(orbmd);遍历一遍表,检查是否有满足参数关系的方法,然后就该执行了;
执行之前,我们考虑:采取反射机制执行该方法时,我们要得到所有的参数值,还需要OnReady列表中的BeanMethodDefinition中的实例化对象Object,执行完过后,若该方法还有返回值,我们还需要检查此返回值类型是不是parameterDependence中被需要的类型,下面给出具体的实现代码以及说明如下:

	// BeanFactory类 
	private void processBeanMethod(ParameterDependence parameterDependence, 
		OnReadyBeanMethodDefinition orbmd) {
		// 当OnReady列表中还有BeanMethodDefinition的时候
		// 说明还有可执行的方法
		while (orbmd.hasNext()) {
			BeanMethodDefinition bmd = orbmd.next();
			Object object = bmd.getObject();
			Method method = bmd.getMethod();
			// 由getParameterValue得到所有的参数值
			Object[] parameterValues = getParameterValue(method);
			
			try {
				// 用反射机制执行该方法
				Object result = method.invoke(object, parameterValues);
				
				// 将执行过后生成的对应类生成beanDefinition放入beanPool里去
				// 这是防止万一生成的 对象的类型 是别的方法 所需要的 参数类型
				Class<?> resultClass = result.getClass();
				BeanDefinition beanDefinition = new BeanDefinition();
				beanDefinition.setInject(true);
				beanDefinition.setKlass(resultClass);
				beanDefinition.setObject(result);
				beanPool.put(resultClass.getName(), beanDefinition);
				// 用matchDependance判断是否需要该类型
				parameterDependence.matchDependance(resultClass, orbmd);				
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}

其中由方法类型得到所有的参数值的getParameterValue()方法如下:

	// BeanFactory类 
	private Object[] getParameterValue(Method method) {
		Parameter[] parameters = method.getParameters();
		int paraCount = parameters.length;
		// 如果不需要参数,直接返回空数组
		if (paraCount <= 0) {
			return new Object[] {};
		}
		
		Object[] parameterValues = new Object[paraCount];
		int index = 0;
		// 从beanPool中得到
		for (Parameter parameter : parameters) {
			Class<?> type = parameter.getType();
			parameterValues[index++] = getBeanDefinition(type).getObject();
		}
		
		return parameterValues;
	}

解决完这些问题过后,我们就开始考虑 注入 的过程了,注入的时候,我们从beanPool池子中得到beanDefinition,如果不是第一次注入,还要检查是否存在 循环依赖
为了简便我们的操作,更清晰的发现并防止循环依赖关系,我们准备了两个方法来解决问题:getBean()、injectProperties();
getBean()方法:用来得到有意义的实例化对象,且我们允许用两种方式得到对应的实例化对象;
injectProperties():给beanDefinition中的对象(Object object)注入真正的值;
为了解决循环依赖,我们给BeanDefinition类中新增了成员,并更改了构造方法:

	// 更改过后的BeanDefinition类 
	
	// BeanDefinition中新增成员及方法
	private boolean inject;
	
	BeanDefinition() {
		this.inject = false;
	}

	boolean isInject() {
		return inject;
	}

	void setInject(boolean inject) {
		this.inject = inject;
	}

此时,回归到基本的问题中:A需要B,B需要A,以此构成了依赖关系,但是,如果我们在一开始用inject区分哪些已经注入过了,在每一次的注入之前我们先检查inject的值,如果已经注入过,直接返回或停止循环,我们就可以防止继续进一步的调用,以免构成间接递归;

以下是具体代码以及说明如下以及说明如下:

getBean()方法:

	// BeanFactory类 
	
	// 从对应的类名称中得到类对应的对象
	public <T> T getBean(String className) throws RuntimeException {
		// 由beanPool中得到beanDefinition
		// 如果没有得到,返回null
		BeanDefinition bd = getBeanDefinition(className);
		if (bd == null) {
			return null;
		}
		// 得到相对应的beanDefinition后,直接开始注入过程
		Object result = bd.getObject();
		if (!bd.isInject()) {
			bd.setInject(true);
			injectProperties(bd);
		}
		return (T) result;
	}
	// BeanFactory类 
	
	// 实质上还是调用了上一个getBean()方法
	public <T> T getBean(Class<?> klass) throws RuntimeException {
		return  getBean(klass.getName());
	}

injectProperties()方法:

	// BeanFactory类 
	
	private void injectProperties(BeanDefinition beanDefinition)  {
		// 开始注入
		Class<?> klass = beanDefinition.getKlass();
		Object object = beanDefinition.getObject();
		
		// 得到所有的成员
		Field[] fields = klass.getDeclaredFields();
		for (Field field : fields) {
			// 没有Autowired注解,直接打断
			if (!field.isAnnotationPresent(Autowired.class)) {
				continue;
			}
			// 设置成员可访问
			field.setAccessible(true);
			Object value = getBean(field.getType());
			// 如果发现从getBean没有得到相关的实例化对象,直接抛异常。
			if (value == null) {
				throw new HasNoBeanException("类[" + klass.getName()
								+ "]的[" + field.getName()
								+ "]成员没有对应的Bean!");
			}
			try {
				// 注入对应的成员以及值
				field.set(object, value);
			} catch (IllegalAccessException e) {
				e.printStackTrace();
			}
		}
		// 设置isInject为true,为防止循环依赖
		beanDefinition.setInject(true);
	}

但这样是显然是不够的,Spring还可以发现基本的依赖关系给出提示啊,我们就做不到吗?答案是否定的。
我们创建ParameterDependence类的初衷就是为了解决依赖关系!

因此,在ParameterDependence类中再给出一个方法,并在getBean()方法执行的时候调用。

用来 说明依赖关系getDependenceMessage()方法(由我们以前写好的异常处理类相结合给出警告提示):

	// ParameterDependence类新增方法
	String getDependenceMessage() {
		StringBuffer res = new StringBuffer();
		
		for (Class<?> klass : parameterDependence.keySet()) {
			List<BeanMethodDefinition> bmdList = parameterDependence.get(klass);
			for (BeanMethodDefinition bmd : bmdList) {
				res.append('\n').append(bmd.getMethod())
				.append("缺少对应参数")
				.append(klass.getName());
			}
			res.append('\n');
		}
		
		return res.toString();
	}

新改进过后的getBean()方法:
此外,还在本类中新增了成员配合getBean()使用:

// BeanFactory类

// BeanFactory新增成员
private static boolean firstGetBean = true; 
	// BeanFactory类
	
	// 更改getBean()方法
	public <T> T getBean(String className) throws RuntimeException {
		if (firstGetBean) {
			// 如果是第一次getBean,那么需要初始化parameterDependence
			// 并且如果parameterDependence不为空,证明有未处理的依赖关系
			// 直接抛出异常
			ParameterDependence parameterDependence = new ParameterDependence();
			if (!parameterDependence.isEmpty()) {
				throw new RuntimeException(
						parameterDependence.getDependanceMessage());
			}
		}
		// 由beanPool中得到beanDefinition
		// 如果没有得到,返回null
		BeanDefinition bd = getBeanDefinition(className);
		if (bd == null) {
			return null;
		}
		// 得到相对应的beanDefinition后,直接开始注入过程
		Object result = bd.getObject();
		if (!bd.isInject()) {
			bd.setInject(true);
			injectProperties(bd);
		}
		return (T) result;
	}

终于,经过一系列的改进,我们可以给出几个类来测试测试了。
测试类1:

// 测试类1
public class ClassTwo {
	private ClassThree three;
	
	public ClassTwo(ClassThree three) {
		this.three = three;
	}

	public ClassThree getThree() {
		return three;
	}

	public void setThree(ClassThree three) {
		this.three = three;
	}

	@Override
	public String toString() {
		return "ClassTwo [three=" + three + "]";
	}

}

测试类2:

// 测试类2
public class ClassThree {
	private ClassTwo two;

	public ClassThree(ClassTwo two) {
		this.two = two;
	}

	public ClassTwo gettwo() {
		return two;
	}

	public void settwo(ClassTwo two) {
		this.two = two;
	}

	@Override
	public String toString() {
		return "ClassThree [two=" + two + "]";
	}

}

两个类创建好之后你可能会发现,这两个类并没有写注解啊,这怎么可能被扫描到呢?不要急,真正的类在这里:

@Component
public class Configuration {

	public Configuration() {
	}
	
	@Bean
	public ClassThree getClassThree(ClassTwo two) {
		return new ClassThree(two);
	}
	
	@Bean
	public ClassTwo getClassThree(ClassThree three) {
		return new ClassTwo(three);
	}
	
}

因为我们写的是带有Bean注解方法的注入,所以自然要在方法中进行测试,从这个类扫描到后会生成BeanDefinition类的对象,而这个类的Bean方法会生成BeanMethodDefinition类的对象。
好了,接下来就是测试主函数Demo类了:

public class Demo {
	public static void main(String[] args) {
		BeanFactory beanFactory = new BeanFactory();
		beanFactory.scanBeanByPackage("com.mec.k.spring_imitate.some_classes");
		beanFactory.getBean("");
	}
}

执行结果:

Exception in thread "main" java.lang.RuntimeException: 

public com.mec.k.spring_imitate.some_classes.ClassThree com.mec.k.spring_imitate.some_classes.Configuration.getClassThree(com.mec.k.spring_imitate.some_classes.ClassTwo)缺少对应参数com.mec.k.spring_imitate.some_classes.ClassTwo

public com.mec.k.spring_imitate.some_classes.ClassTwo com.mec.k.spring_imitate.some_classes.Configuration.getClassThree(com.mec.k.spring_imitate.some_classes.ClassThree)缺少对应参数com.mec.k.spring_imitate.some_classes.ClassThree

at com.mec.k.spring_imitate.core.BeanFactory.getBean(BeanFactory.java:203)
at com.mec.k.spring_imitate.test.Demo.main(Demo.java:9)

可以清晰的看出,描述出来的依赖关系很清晰。

最后的最后我是不是忘了一件事?哦,对了,包扫描,那就是另一方面的工具了,详情参见:https://blog.csdn.net/qq_43594241/article/details/102165697

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值