SpringAop源码分析(基于注解)四:拦截器链

前言

通过阅读这篇文章,可以了解到以下几个问题:

  • 通知的是如何起作用的?
  • 多个通知的执行顺序是怎样的?
  • 多个切面的多个通知的执行顺序是怎样的?
  • @Transactional注解,调用本类方法为什么不起作用?

下面我们可以带着这些疑问来看

一、invoke()

本文依据JdkDynamicAopProxy来分析,对CGLIB感兴趣的同学看一看ObjenesisCglibAopProxy相关代码。 JdkDynamicAopProxy实现了InvocationHandler接口,我们来看下invoke()方法:

//JdkDynamicAopProxy.java

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
	MethodInvocation invocation;
	Object oldProxy = null;
	boolean setProxyContext = false;

	TargetSource targetSource = this.advised.targetSource;
	Object target = null;

	try {
		//如果目标对象没有定义equals()方法的话,就会直接调用而不会增强
<1>		if (!this.equalsDefined && AopUtils.isEqualsMethod(method)) {
			// The target does not implement the equals(Object) method itself.
			return equals(args[0]);
		}
		//如果目标对象没有定义hashCode()方法的话,就会直接调用而不会增强
		else if (!this.hashCodeDefined && AopUtils.isHashCodeMethod(method)) {
			// The target does not implement the hashCode() method itself.
			return hashCode();
		}
		else if (method.getDeclaringClass() == DecoratingProxy.class) {
			// There is only getDecoratedClass() declared -> dispatch to proxy config.
			return AopProxyUtils.ultimateTargetClass(this.advised);
		}
		//Advised接口或者其父接口中定义的方法,直接反射调用,不应用通知
		else if (!this.advised.opaque && method.getDeclaringClass().isInterface() &&
				method.getDeclaringClass().isAssignableFrom(Advised.class)) {
			// Service invocations on ProxyConfig with the proxy config...
			return AopUtils.invokeJoinpointUsingReflection(this.advised, method, args);
		}

		Object retVal;

		// 如果 exposeProxy 属性为 true,则暴露代理对象
		// exposeProxy 是 @EnableAspectJAutoProxy 注解的属性之一
<2>		if (this.advised.exposeProxy) {
			// Make invocation available if necessary.
			// 向 AopContext 中设置代理对象
			oldProxy = AopContext.setCurrentProxy(proxy);
			setProxyContext = true;
		}

		//获得目标对象的类
<3>		target = targetSource.getTarget();
		Class<?> targetClass = (target != null ? target.getClass() : null);

		//获取可以应用到此方法上的 Interceptor 拦截器 列表,并且排序
<4>		List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);


		//如果没有可以应用到此方法的通知(Interceptor),此直接反射调用 method.invoke(target, args)
<5>		if (chain.isEmpty()) {
			// We can skip creating a MethodInvocation: just invoke the target directly
			// Note that the final invoker must be an InvokerInterceptor so we know it does
			// nothing but a reflective operation on the target, and no hot swapping or fancy proxying.
			Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
			retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
		}
<6>		else {
			//创建 MethodInvocation,将拦截器链放入
			invocation = new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
			// 执行拦截器链
			retVal = invocation.proceed();
		}

		// 获取方法返回值类型
<7>		Class<?> returnType = method.getReturnType();
		if (retVal != null && retVal == target &&
				returnType != Object.class && returnType.isInstance(proxy) &&
				!RawTargetAccess.class.isAssignableFrom(method.getDeclaringClass())) {
			// 如果方法返回值为 this,即 return this; 则将代理对象 proxy 赋值给 retVal
			retVal = proxy;
		}
		// 如果返回值类型为基础类型,比如 int,long 等,当返回值为 null,抛出异常
		else if (retVal == null && returnType != Void.TYPE && returnType.isPrimitive()) {
			throw new AopInvocationException(
					"Null return value from advice does not match primitive return type for: " + method);
		}
		return retVal;
	}
	finally {
		if (target != null && !targetSource.isStatic()) {
			// Must have come from TargetSource.
			targetSource.releaseTarget(target);
		}
		if (setProxyContext) {
			// Restore old proxy.
			AopContext.setCurrentProxy(oldProxy);
		}
	}
	}

这个invoke()方法主要有以下几个步骤:

  • <1>处,对equals()、hashCode()等方法进行判断
  • <2>处,处理exposeProxy属性,代理的暴露方式
  • <3>处,获取目标对象的Class
  • <4>处,获取可以应用到当前方法的拦截器链
  • <5>处,拦截器链为空,直接调用当前方法,不做增强
  • <6>处,执行拦截器链
  • <7>处,获取返回值类型,并校验

我们重点关注第<4>步和第<6>步,这两个地方非常重要,第<2>步涉及比较多,最后我们再分析,先来看下第<4>步。

1.1、获取可以应用到当前方法的拦截器链

代码:

//AdvisedSupport.java

public List<Object> getInterceptorsAndDynamicInterceptionAdvice(Method method, @Nullable Class<?> targetClass) {
	MethodCacheKey cacheKey = new MethodCacheKey(method);
	//从缓存中获取
	List<Object> cached = this.methodCache.get(cacheKey);
	if (cached == null) {
		//获取所有的拦截器
		cached = this.advisorChainFactory.getInterceptorsAndDynamicInterceptionAdvice(
				this, method, targetClass);
		this.methodCache.put(cacheKey, cached);
	}
	return cached;
	}

继续深入:

//DefaultAdvisorChainFactory.java

/**
 * 从提供的配置实例config中获取advisor列表,遍历处理这些advisor.如果是IntroductionAdvisor,
 * 则判断此Advisor能否应用到目标类targetClass上.如果是PointcutAdvisor,则判断
 * 此Advisor能否应用到目标方法method上.将满足条件的Advisor通过AdvisorAdaptor转化成Interceptor列表返回.
 */
@Override
public List<Object> getInterceptorsAndDynamicInterceptionAdvice(
	Advised config, Method method, @Nullable Class<?> targetClass) {

    List<Object> interceptorList = new ArrayList<>(config.getAdvisors().length);
    Class<?> actualClass = (targetClass != null ? targetClass : method.getDeclaringClass());
    //查看是否包含IntroductionAdvisor
    boolean hasIntroductions = hasMatchingIntroductions(config, actualClass);
    //这里实际上注册一系列AdvisorAdapter,用于将 通知Advisor 转化成 方法拦截器MethodInterceptor
    AdvisorAdapterRegistry registry = GlobalAdvisorAdapterRegistry.getInstance();
    //遍历
    for (Advisor advisor : config.getAdvisors()) {
    	if (advisor instanceof PointcutAdvisor) {
    		PointcutAdvisor pointcutAdvisor = (PointcutAdvisor) advisor;
    		//通过ClassFilter对当前Bean类型进行匹配
    		if (config.isPreFiltered() || pointcutAdvisor.getPointcut().getClassFilter().matches(actualClass)) {
    			//将 通知Advisor 转化成 拦截器Interceptor
    			MethodInterceptor[] interceptors = registry.getInterceptors(advisor);
    			MethodMatcher mm = pointcutAdvisor.getPointcut().getMethodMatcher();
    			//检查当前advisor的pointcut是否可以匹配当前方法
    			if (MethodMatchers.matches(mm, method, actualClass, hasIntroductions)) {
    				if (mm.isRuntime()) {
    					// Creating a new object instance in the getInterceptors() method
    					// isn't a problem as we normally cache created chains.
    					for (MethodInterceptor interceptor : interceptors) {
    						//加入拦截器链
    						interceptorList.add(new InterceptorAndDynamicMethodMatcher(interceptor, mm));
    					}
    				}
    				else {
    					//加入拦截器链
    					interceptorList.addAll(Arrays.asList(interceptors));
    				}
    			}
    		}
    	}
    	else if (advisor instanceof IntroductionAdvisor) {
    		IntroductionAdvisor ia = (IntroductionAdvisor) advisor;
    		// IntroductionAdvisor 类型的通知器,仅进行类级别的匹配即可
    		if (config.isPreFiltered() || ia.getClassFilter().matches(actualClass)) {
    			Interceptor[] interceptors = registry.getInterceptors(advisor);
    			interceptorList.addAll(Arrays.asList(interceptors));
    		}
    	}
    	else {
    		Interceptor[] interceptors = registry.getInterceptors(advisor);
    		interceptorList.addAll(Arrays.asList(interceptors));
    	}
    }
    
    return interceptorList;
}

这里的主逻辑不是很复杂:

  • 遍历所有通知器
  • 对于 PointcutAdvisor 类型的通知器,这里要调用通知器所持有的切点(Pointcut)对类和方法进行匹配,匹配成功说明应向当前方法织入通知逻辑
  • 将通知Advisor 转化成 拦截器Interceptor
  • 返回拦截器数链

这里返回的拦截器链是有序的,按照afterReturn、after、around、before排好序,(具体排序是在获取所有通知的时候sortAdvisors(eligibleAdvisors)),方便后面执行。

1.2、执行拦截器链

现在有了拦截器链,接下来再看下怎么执行的:

//ReflectiveMethodInvocation.java

//当前拦截器下标
private int currentInterceptorIndex = -1;
//拦截器链集合
protected final List<?> interceptorsAndDynamicMethodMatchers;

public Object proceed() throws Throwable {
	//	We start with an index of -1 and increment early.
	// 拦截器链中的最后一个拦截器执行完
	if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) {
		//执行目标方法
		return invokeJoinpoint();
	}

	//每次执行新的拦截器,下标+1
	Object interceptorOrInterceptionAdvice =
			this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex);

	//如果要动态匹配切点
	if (interceptorOrInterceptionAdvice instanceof InterceptorAndDynamicMethodMatcher) {
		// Evaluate dynamic method matcher here: static part will already have
		// been evaluated and found to match.
		InterceptorAndDynamicMethodMatcher dm =
				(InterceptorAndDynamicMethodMatcher) interceptorOrInterceptionAdvice;
		// 如果是动态切点,需要进行参数匹配,以确定是否需要执行该动态横切逻辑
		if (dm.methodMatcher.matches(this.method, this.targetClass, this.arguments)) {
			return dm.interceptor.invoke(this);
		}
		else {
			//动态切点匹配失败,略过当前Intercetpor,调用下一个Interceptor
			return proceed();
		}
	}
	else {
		//执行当前Intercetpor
		return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
	}
	}

proceed 根据currentInterceptorIndex按照倒序执行拦截器链,每次执行完+1,当所有拦截器执行完后,再执行目标方法。 这里我们可以提出几个疑问:

  • 当一方法匹配了多个通知时,不同通知之间是按照什么顺序来执行的?
  • 从代码上看,所有通知都执行完毕后才会执行目标方法,那后置通知又是如何实现的?

我们带着这2个疑问往下看,

proceed中的invoke(this)方法根据通知的类型,有不同的实现类,如:

  • @Before 对应 MethodBeforeAdviceInterceptor
  • @After 对应 AspectJAfterAdvice
  • @AfterReturning 对应 AfterReturningAdviceInterceptor
  • @AfterThrowing 对应 AspectJAfterThrowingAdvice
  • @Around 对应 AspectJAroundAdvice

1.2.1、后置通知

由于拦截器链是按照倒序排列的,我们先来看下 后置通知 的代码:

//AspectJAfterAdvice.java

public class AspectJAfterAdvice extends AbstractAspectJAdvice
		implements MethodInterceptor, AfterAdvice, Serializable {

	public AspectJAfterAdvice(
			Method aspectJBeforeAdviceMethod, AspectJExpressionPointcut pointcut, AspectInstanceFactory aif) {
		super(aspectJBeforeAdviceMethod, pointcut, aif);
	}


	@Override
	public Object invoke(MethodInvocation mi) throws Throwable {
		try {
			//执行下一个拦截器链
			return mi.proceed();
		}
		finally {
			//执行后置逻辑
			invokeAdviceMethod(getJoinPointMatch(), null, null);
		}
	}

	@Override
	public boolean isBeforeAdvice() {
		return false;
	}

	@Override
	public boolean isAfterAdvice() {
		return true;
	}
}

可以看到,后置拦截器是会先继续执行下一个拦截器,当拦截器链执行完毕之后,proceed()再执行目标方法,最后执行后置逻辑。

1.2.2、环绕通知

我们再来看下环绕拦截器

//AspectJAroundAdvice.java

public Object invoke(MethodInvocation mi) throws Throwable {
	if (!(mi instanceof ProxyMethodInvocation)) {
		throw new IllegalStateException("MethodInvocation is not a Spring ProxyMethodInvocation: " + mi);
	}
	ProxyMethodInvocation pmi = (ProxyMethodInvocation) mi;
	ProceedingJoinPoint pjp = lazyGetProceedingJoinPoint(pmi);
	JoinPointMatch jpm = getJoinPointMatch(pmi);
	//执行环绕逻辑
	return invokeAdviceMethod(pjp, jpm, null, null);
	}

环绕拦截器会直接执行环绕逻辑,而 由于我们之前在LogAspect中配置了如下代码:

@Aspect
@Component
@EnableAspectJAutoProxy
public class LogAspect {

	@Pointcut("execution(* com.mydemo.work.StudentController.getName(..))")
	private void log(){}


	@Before("log()")
	public void doBefore() {
		System.out.println("===before");
	}

	@After("execution(* com.mydemo.work.StudentController.getName(..))")
	public void doAfter() {
		System.out.println("===after");
	}

	@AfterReturning("execution(* com.mydemo.work.StudentController.getName(..))")
	public void doAfterReturn() {
		System.out.println("===afterReturn");
	}

	@Around("execution(* com.mydemo.work.StudentController.getName(..))")
	public void doAround(ProceedingJoinPoint pjp) throws Throwable {

		System.out.println("===around before");
		pjp.proceed();
		System.out.println("===around after");
	}
}

所以该拦截器会先执行========around before,然后再执行下一个拦截器。

1.2.3、前置通知

我们再来看下前置拦截器

//MethodBeforeAdviceInterceptor.java

public class MethodBeforeAdviceInterceptor implements MethodInterceptor, Serializable {

	private MethodBeforeAdvice advice;

	/**
	 * Create a new MethodBeforeAdviceInterceptor for the given advice.
	 * @param advice the MethodBeforeAdvice to wrap
	 */
	public MethodBeforeAdviceInterceptor(MethodBeforeAdvice advice) {
		Assert.notNull(advice, "Advice must not be null");
		this.advice = advice;
	}

	@Override
	public Object invoke(MethodInvocation mi) throws Throwable {
		//执行前置逻辑
		this.advice.before(mi.getMethod(), mi.getArguments(), mi.getThis() );
		//执行下一个拦截器
		return mi.proceed();
	}
}

可以看到前置拦截器在执行完 前置方法 之后,又调用MethodInvocation#proceed()方法,继续去执行下一个拦截器。

二、通知的执行顺序

2.1、同一Aspect下

到这里大家可能有点绕,我们来捋一下通知的执行顺序,也就是拦截器的执行顺序。 首先,前面说过拦截器链是按照倒序排序的,如下:

可以看到这里的通知是按照:afterReturn、after、around、before排序的。

画一下执行流程图:

再用单元测试跑一遍LogAspect的相关代码,打印结果如下:

===around before
===before
do getName
===around after
===after
===afterReturn

和我们猜测的一样。现在我们可以回答前面的问题了,就是一个方法匹配多个通知时,按照什么顺序执行。

2.2、多个Aspect下

但是,在实际中,通常一个方法可能被多个Aspect拦截,比如我们想让业务方法先被 日志Aspect 拦截,然后被 异常Aspect拦截。

Spring框架已经替我们想到了这个问题,如果我们有多个Aspect,实际上它们会随机执行,没有明确的顺序。但是Spring提供了@Order注解,可以让我们指定Aspect的执行顺序。 比如我们新加一个处理异常的Aspect:

@Aspect
@Component
@EnableAspectJAutoProxy
@Order(2)
public class ErrorAspect {

	@Pointcut("execution(* com.mydemo.work.StudentController.getName(..))")
	private void log(){}


	@Before("log()")
	public void doBeforeError() {
		System.out.println("=== error before");
	}

	@After("execution(* com.mydemo.work.StudentController.getName(..))")
	public void doAfterError() {
		System.out.println("=== error after");
	}

	@AfterReturning("execution(* com.mydemo.work.StudentController.getName(..))")
	public void doAfterReturnError() {
		System.out.println("=== error afterReturn");
	}

	@Around("execution(* com.mydemo.work.StudentController.getName(..))")
	public void doAroundError(ProceedingJoinPoint pjp) throws Throwable {

		System.out.println("=== error around before");
		pjp.proceed();
		System.out.println("=== error around after");
	}
}

同时给LogAspect加上注解@Order(1),也就意味着LogAspect的通知要比ErrorAspect先执行。 先来单元测试跑一下看看结果:

===around before
===before
=== error around before
=== error before
do getName
=== error around after
=== error after
=== error afterReturn
===around after
===after
===afterReturn

可以看到,确实是LogAspect的通知先执行了,这是为什么呢?我们来debug源码看一下,主要看拦截器链,如图:

可以看到,拦截器链已经按照顺序排列好了,所以代码只要按照这个拦截器链顺序执行,就能保证2个切面有序拦截。 那么它为什么要这样排序呢?我们来画个图:

如上图所示,这2个Aspect就像2个圆圈在外面拦截,中间是目标方法。 当一个请求进来要执行目标方法:

  • 首先会被外圈的@Order(1)拦截器拦截
  • 然后被内圈@Order(2)拦截器拦截
  • 执行完目标方法后,先经过@Order(2)的后置拦截器
  • 最后再通过@Order(1)的后置拦截器

到这里我们多个通知的执行顺序就分析完了,这里面还是要去从源码层面理解,不能死记硬背,这样才能记得牢固。

三、exposeProxy

上面我们分析invoke()方法的时候,有下面这段代码:

        // 如果 exposeProxy 属性为 true,则暴露代理对象
	// exposeProxy 是 @EnableAspectJAutoProxy 注解的属性之一
	if (this.advised.exposeProxy) {
		// Make invocation available if necessary.
		// 向 AopContext 中设置代理对象
		oldProxy = AopContext.setCurrentProxy(proxy);
		setProxyContext = true;
	}

本小节我们就来详细分析一下这里是什么意思。 首先 exposeProxy 是注解@EnableAspectJAutoProxy中的一个属性,它可以被设置为true或false,默认是false。 这个属性是为了解决目标方法调用同对象中其他方法时,其他方法的切面逻辑无法执行的问题。 啥意思呢?我们来举个例子:

public class Student implements Person {

    @Override
    public void haveFun() {
        System.out.println("篮球");
        this.hello("足球");
    }

    @Override
    public void haveFun(String action) {
        System.out.println("haveFun " +  action);
    }
}

在同一个类Student.class中,一个haveFun()方法调用了本类中的另一个haveFun(String action)方法,此时haveFun(String action)上的切面逻辑就无法执行了。

3.1、为什么本类调用无效?

那为什么会这样呢?

我们知道AOP的本质就是给目标对象生成一个代理对象,对原本的方法进行增强,之后执行的方法都是我们代理类中的方法。 而haveFun()方法中使用的是this.hello("足球"),这里的this不是代理对象,而是原始对象,因此通过原始对象调用haveFun(String action)是不会被增强的,所以切面就不会起作用。

那么问题又来了,为什么这里的this不是代理对象,而是原始对象呢?

3.2、为什么this不是代理对象

上面代码中有一个ReflectiveMethodInvocation#proceed()方法如下:

public Object proceed() throws Throwable {
	//	We start with an index of -1 and increment early.
	// 拦截器链中的最后一个拦截器执行完
	if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) {
		//执行目标方法
		return invokeJoinpoint();
	}

	//...省略
		if (dm.methodMatcher.matches(this.method, this.targetClass, this.arguments)) {
			return dm.interceptor.invoke(this);
		}
        //...省略
	}
	else {
		//执行当前Intercetpor
		return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
	}
	}

在所有拦截器执行完之后,会去执行目标方法,我们跟踪这个方法invokeJoinpoint()

//ReflectiveMethodInvocation.java

protected Object invokeJoinpoint() throws Throwable {
        //重点 this.target
	return AopUtils.invokeJoinpointUsingReflection(this.target, this.method, this.arguments);
	}
//ReflectiveMethodInvocation.java

public static Object invokeJoinpointUsingReflection(@Nullable Object target, Method method, Object[] args)
		throws Throwable {

	// Use reflection to invoke the method.
	try {
		ReflectionUtils.makeAccessible(method);
		//重点 target
		return method.invoke(target, args);
	}
    //...省略
	}

可以看到,这里调用的是原始目标对象target来执行我们的目标方法,所以此时haveFun()方法中的this.hello("足球")的这个this其实是原始目标对象。

3.3、exposeProxy是如何起作用的?

所以exposeProxy这个属性就是来解决这个问题的,那么它是如何起作用的呢? 我们回过头来看代码:

     // 如果 exposeProxy 属性为 true,则暴露代理对象
	// exposeProxy 是 @EnableAspectJAutoProxy 注解的属性之一
	if (this.advised.exposeProxy) {
		// Make invocation available if necessary.
		// 向 AopContext 中设置代理对象
		oldProxy = AopContext.setCurrentProxy(proxy);
		setProxyContext = true;
	}

继续跟踪:

//AopContext.java
//存储代理对象 ThreadLocal
private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal<>("Current AOP proxy");

static Object setCurrentProxy(@Nullable Object proxy) {
	Object old = currentProxy.get();
	if (proxy != null) {
	        //存储代理对象到 ThreadLocal
		currentProxy.set(proxy);
	}
	else {
		currentProxy.remove();
	}
	return old;
}

如果exposeProxy被设置为了true,会把代理对象存到ThreadLocal中,而在本类中调用的时候,会从ThreadLocal中获取代理类来调用目标方法,就可以解决本来调用的问题。

最后再来看下面这种情况:

public class Student implements Person {

    @Override
    @Transactional
    public void haveFun() {
        System.out.println("篮球");
        this.hello("足球");
    }

    @Override
    @Transactional
    public void haveFun(String action) {
        System.out.println("haveFun " +  action);
    }
}

这种情况下,haveFun(String action)的事务不会生效,原因就是我们刚才分析的。实际上@Transactional本质上也是AOP实现的,所以也会有这个问题。 解决方案就是给@EnableAspectJAutoProxy注解设置exposeProxy=true

总结

时序图:

到这里SpringAOP的源码分析就告一段落了,由于本人经验和技术水平有限,所以只能先了解这么多,如有错误,欢迎提出意见。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏科技分享

GIC , SPI , PPI (窝窝科技的文章题目改了下)

转载于: http://www.wowotech.net/irq_subsystem/gic-irq-chip-driver.html

11520
来自专栏逸繁

利用朴素贝叶斯实现简单的留言过滤

  贝叶斯分类是一类分类算法的总称,这类算法均以贝叶斯定理为基础,故统称为贝叶斯分类。而朴素朴素贝叶斯分类是贝叶斯分类中最简单,也是常见的一种分类方法。而我们所...

6610
来自专栏小麦苗的DB宝专栏

【DB笔试面试477】NULL有哪些注意事项?

在运算时,NULL(空)值不参与运算。判断是否为NULL值只能用IS NULL或IS NOT NULL,不能用=NULL或<>NULL,有关NULL值有如下几点...

6910
来自专栏科技分享

GIC , SPI , PPI (窝窝科技的文章题目改了下)【转】

转自:https://www.cnblogs.com/tureno/articles/6403408.html

11850
来自专栏c#Winform自定义控件系列

(七十七)c#Winform自定义控件-采样控件

GitHub:https://github.com/kwwwvagaa/NetWinformControl

9430
来自专栏c#Winform自定义控件系列

(七十八)c#Winform自定义控件-倒影组件

GitHub:https://github.com/kwwwvagaa/NetWinformControl

7440
来自专栏c#Winform自定义控件系列

(七十六)c#Winform自定义控件-表单验证组件

GitHub:https://github.com/kwwwvagaa/NetWinformControl

8740
来自专栏CV学习史

图像局部特征提取

图像特征可以包括颜色特征、纹理特征、形状特征以及局部特征点等。其中局部特点具有很好的稳定性,不容易受外界环境的干扰。图像特征提取是图像分析与图像识别的前提,它是...

18020
来自专栏科技分享

va_list 、va_start、 va_arg、 va_end 使用说明【转】

转自:https://blog.csdn.net/f110300641/article/details/83822290

10730
来自专栏程序通事

不知道如何实现服务的动态发现?快来看看 Dubbo 是如何做到的

上篇文章如果有人问你 Dubbo 中注册中心工作原理,就把这篇文章给他大致了解了注册中心作用以及 Dubbo Registry 模块源码,这篇文章将深入 Dub...

10210

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励