Spring 源码分析补充篇三 :Spring Aop 的关键类

一、前言

本系列是在重看源码分析过程中,对一些遗漏内容的补充,内容仅用于个人学习记录,写的会比较随性,难免有错漏,欢迎指正。

全集目录:Spring源码分析:全集整理


本文系列:

  1. Spring源码分析十一:@Aspect方式的AOP上篇 - @EnableAspectJAutoProxy
  2. Spring源码分析十二:@Aspect方式的AOP中篇 - getAdvicesAndAdvisorsForBean
  3. Spring源码分析十三:@Aspect方式的AOP下篇 - createProxy
  4. Spring源码分析二十四:cglib 的代理过程

本文衍生篇:

  1. Spring 源码分析衍生篇九 : AOP源码分析 - 基础篇
  2. Spring 源码分析衍生篇十二 :AOP 中的引介增强

补充篇:

  1. Spring 源码分析补充篇三 :Spring Aop 的关键类

本文,想不借助 AspectJ 框架来实现 Aop 的功能,因此简单看了一下 Spring Aop 其中的一些类的实现。

二、关键类

Spring Aop 有三个关键类 ,如下:

  • Pointcut :决定什么时候切入
  • Advice :决定切入后的具体行为
  • Advisor :包含 Advice 和 Pointcut

下面我们具体来看:

1. Pointcut 分类

public interface Pointcut {

	// 检验拦截哪个类
	ClassFilter getClassFilter();

	// 校验拦截的方法
	MethodMatcher getMethodMatcher();

	// 恒为 true 的拦截实例
	Pointcut TRUE = TruePointcut.INSTANCE;

}

从 Pointcut 的 方法我们就可以看出 Pointcut 的拦截精度在 Method 级别,即通过 ClassFilter 确定拦截哪个类,MethodMatcher 确定拦截哪个方法。


下面是 Pointcut 的一些实现类,我们来简单介绍一下:

名称功能
ControlFlowPointcut可以实现更精细的切入点。如我们对一个方法进行切入通知,但只有这个方法在一个特定方法中被调用的时候执行通知,我们可以使用ControlFlowPointCut流程切入点。但该种方式效率较低,慎重使用。
DynamicMethodMatcherPointcut该切点为抽象类, 在Pointcut 的基础上增加了接口DynamicMethodMatcher。所谓动态切入点,即每次方法执行前都会进行条件判断,满足条件才会执行。如我们在增强某些方法时指定为方法入参值某个值时才会执行增强,即可使用该类。
StaticMethodMatcherPointcut该切点为抽象类,在Pointcut 的基础上增加了接口 StaticMethodMatcher 。与 DynamicMethodMatcherPointcut 相反,StaticMethodMatcherPointcut 针对每个方法的增强判断只会执行一次,让将结果缓存起来,之后的判断依赖于缓存结果。
ComposablePointcut可组合切入点。通过 union 或者 intersection 方法来设置多个 Pointcut、ClassFilter、ClassFilter 的交并集。
ExpressionPointcutExpressionPointcut 是一个接口,存在两个实现类 AbstractExpressionPointcut,AspectJExpressionPointcut。多提供了一个 getExpression 方法用来获取表达式。可以使用表达式的方式进行匹配。
AnnotationMatchingPointcut查找被指定注解修饰的类作为切点。
TruePointcut恒为true的切点

为了更好的解释各个 PointCut 的功能,这里我们来以下面的代码具体举例:

package com.kingfish.util;

import org.aopalliance.aop.Advice;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.aop.Advisor;
import org.springframework.aop.DynamicIntroductionAdvice;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;

// 这里为了举例,所有所有类写在一起。
public class PointcutMain {

    public static class PointCutDemo {
        public void say(String msg) {
            System.out.println("Hello " + msg);
        }
    }

    public static void main(String[] args) {
        Advice advice = createAdvice();
        Pointcut pc = createPointCut();
        // 创建代理顾问
        Advisor advisor = new DefaultPointcutAdvisor(pc, advice);
        run(new PointCutDemo(), advisor);
    }

    // 运行方法
    private static void run(PointCutDemo demo, Advisor advisor){
        ProxyFactory proxyFactory = new ProxyFactory();
        proxyFactory.addAdvisor(advisor);
        proxyFactory.setTarget(demo);
        // 创建了代理对象
        final PointCutDemo proxyDemo = (PointCutDemo) proxyFactory.getProxy();
        // 直接调用 say() 方法
        proxyDemo.say("World");
    }

    // 创建 PointCut 的方法,下面我们会分别实现该方法来分析
    private static Pointcut createPointCut() {
        return null
    }


    private static Advice createAdvice() {
        // 方法拦截器 Advice
        return new MethodInterceptor() {
            @Override
            public Object invoke(MethodInvocation invocation) throws Throwable {
                try {
                    System.out.println("before");
                    return invocation.proceed();
                } finally {
                    System.out.println("after");
                }
            }
        };
    }

    public static void runSay(PointCutDemo demo) {
        demo.say("Method World");
    }
}

我们预留了一个 createPointCut 方法来创建 Pointcut ,下面我们来演示不同的 Pointcut 创建后的效果。

1.1 ControlFlowPointcut

ControlFlowPointcut 会动态判断调用场景,在合适的调用场景下才会执行增强。但该种方式效率较低,慎重使用。

我们修改上面代码中的方法,如下:

	// 运行方法
	private static void run(PointCutDemo demo, Advisor advisor){
	 	ProxyFactory proxyFactory = new ProxyFactory();
        proxyFactory.addAdvisor(advisor);
        proxyFactory.setTarget(demo);
        // 创建了代理对象
        final PointCutDemo proxyDemo = (PointCutDemo) proxyFactory.getProxy();
        // 直接调用 say() 方法
        proxyDemo.say("World");
        System.out.println("/************************/");
        // 通过 ControlFlowPointcutDemo.runSay 调用runSay 方法
        PointcutMain.runSay(proxyDemo);
	}
	
	// 创建 ControlFlowPointcut
    private static Pointcut createPointCut() {
        return new ControlFlowPointcut(PointcutMain.class, "runSay");
    }

在运行后,输出结果如下。

Hello World
/************************/
before
Method World
after

这里我们的代码调用了两次 say 方法。不同的是第二次是通过 PointcutMain#runSay 的方法调用。两次调用的结果没有区别。但是由于这里使用 ControlFlowPointcut,而 ControlFlowPointcut在构造时指定了是 PointcutMain.class"runSay",所以只有通过 PointcutMain#runSay 方法调用目标对象时才会执行增强。


下面我们简单介绍一下其原理:
ControlFlowPointcut 实现了MethodMatcher 接口,而 ControlFlowPointcut#matches方法实现如下,

	// 通过构造 保存了 目标 Class  和  methodName。
	public ControlFlowPointcut(Class<?> clazz, @Nullable String methodName) {
		Assert.notNull(clazz, "Class must not be null");
		this.clazz = clazz;
		this.methodName = methodName;
	}
	// 该方法返回ture,则每次方法调用时都会触发下面的 matches 方法。
	@Override
	public boolean isRuntime() {
		return true;
	}
	
	// 该方法返回false,则不会执行代理增强。
	@Override
	public boolean matches(Method method, Class<?> targetClass, Object... args) {
		// 统计调用此时,可以通过此属性来判断触发了多少次以方便进行优化。
		this.evaluations.incrementAndGet();
		
		for (StackTraceElement element : new Throwable().getStackTrace()) {
			// 如果 当前 Class 与目标 Class 匹配 && 当前 methodName 与目标 methodName 匹配返回true。
			if (element.getClassName().equals(this.clazz.getName()) &&
					(this.methodName == null || element.getMethodName().equals(this.methodName))) {
				return true;
			}
		}
		return false;
	}

在 ControlFlowPointcut#matches 方法中判断了调用类和方法满足匹配条件后才会返回true,进而触发增强。

1.2 DynamicMethodMatcherPointcut

DynamicMethodMatcherPointcut 为动态切入点。当使用 DynamicMethodMatcherPointcut 时,目标对象每次调用方法都会通过 DynamicMethodMatcherPointcut#matches 方法来确定是否匹配。基于此,我们甚至可以针对每次调用的参数不同来决定是否执行增强。

DynamicMethodMatcherPointcut 和 ControlFlowPointcut类似,不同的是 ControlFlowPointcut 自身实现了 matches 方法,而 DynamicMethodMatcherPointcut 交由我们来实现 matches 方法。

如下:方法第一个入参是 World 时才执行增强。

    private static void run(PointCutDemo demo, Advisor advisor) {
        ProxyFactory proxyFactory = new ProxyFactory();
        proxyFactory.addAdvisor(advisor);
        proxyFactory.setTarget(demo);
        // 创建了代理对象
        final PointCutDemo proxyDemo = (PointCutDemo) proxyFactory.getProxy();
        // 直接调用 say() 方法
        proxyDemo.say("World");
        proxyDemo.say("Morld");

    }

    private static Pointcut createPointCut() {
        // 切点
        Pointcut pc = new DynamicMethodMatcherPointcut() {
            // 每次调用目标对象的方法都会执行该方法
            @Override
            public boolean matches(Method method, Class<?> targetClass, Object... args) {
                return "World".equals(args[0]);
            }
        };
        return pc;
    }

执行结果如下:

before
Hello World
after
Hello Morld

这里可以看到第二次调用 Morld 的入参并没有执行增强。


其原理简单说明一下:

  1. 当代理类调用代理方法时会通过 AdvisedSupport#getInterceptorsAndDynamicInterceptionAdvice来获取当前调用方法的方法拦截器,并将结果缓存,之后调用则直接依赖于缓存结果。

    	// AdvisedSupport#getInterceptorsAndDynamicInterceptionAdvice。
    	// 当前 this 为 ProxyFactory,所以这里的缓存是作用域是在 ProxyFactory中,如果换一个 ProxyFactory则需要重新加载一次。
    	public List<Object> getInterceptorsAndDynamicInterceptionAdvice(Method method, @Nullable Class<?> targetClass) {
    		// 生成当前调用 方法的 Key
    		MethodCacheKey cacheKey = new MethodCacheKey(method);
    		// 尝试从缓存中获取增强
    		List<Object> cached = this.methodCache.get(cacheKey);
    		if (cached == null) {
    			// 缓存没命中,调用DefaultAdvisorChainFactory#getInterceptorsAndDynamicInterceptionAdvice 方法来获取
    			cached = this.advisorChainFactory.getInterceptorsAndDynamicInterceptionAdvice(
    					this, method, targetClass);
    			// 放入缓存
    			this.methodCache.put(cacheKey, cached);
    		}
    		return cached;
    	}
    
  2. DefaultAdvisorChainFactory#getInterceptorsAndDynamicInterceptionAdvice 方法则会调用 MethodMatcher#matches(Method method, Class<?> targetClass) 来判断当前的方法拦截器是否适用于当前调用方法。而 DynamicMethodMatcherPointcut#matches(Method method, Class<?> targetClass) 返回恒为True,随后程序会判断 DynamicMethodMatcherPointcut#isRuntime 是否为 True,如果为True则认为是动态切入点,将其封装成 InterceptorAndDynamicMethodMatcher类型保存到集合中,如果为false则直接保存到集合中。
    在这里插入图片描述

  3. 随后在执行增强方法时会通过 DynamicAdvisedInterceptor#intercept 方法调用 CglibMethodInvocation#process,而在 CglibMethodInvocation#process中会判断当前增强是否为 InterceptorAndDynamicMethodMatcher 类型,如果是,则触发其 MethodMatcher#matches(Method method, Class<?> targetClass, Object... args) 方法来判断是否执行增强。如果不是 InterceptorAndDynamicMethodMatcher 类型,则执行其增强方法。
    在这里插入图片描述

1.3 StaticMethodMatcherPointcut

StaticMethodMatcherPointcut 作为静态切入点并不会在每次方法调用时都去校验,而是在第一次调用时进行校验后便将结果缓存起来,之后的调用依赖于缓存。

这里需要注意 StaticMethodMatcherPointcut 的抽象方法和 DynamicMethodMatcherPointcut 的抽象方法并非是同一个方法,而是MethodMatcher 的重载方法,两个方法调用时机的不同也造成了功能的不同,如下:

  • StaticMethodMatcherPointcut#matches(Method method, Class<?> targetClass)
  • DynamicMethodMatcherPointcut#matches(Method method, Class<?> targetClass, Object… args)

如下:

    private static void run(PointCutDemo demo, Advisor advisor) {
        ProxyFactory proxyFactory = new ProxyFactory();
        proxyFactory.addAdvisor(advisor);
        proxyFactory.setTarget(demo);
        // 创建了代理对象
        final PointCutDemo proxyDemo = (PointCutDemo) proxyFactory.getProxy();
        // 直接调用 say() 方法
        proxyDemo.say("World");
        proxyDemo.say("World");
    }

    private static Pointcut createPointCut() {
        Pointcut pc = new StaticMethodMatcherPointcut() {
            @Override
            public boolean matches(Method method, Class<?> targetClass) {
                return"say".equals(method.getName());
            }

        };
        return pc;
    }

输出如下:

before
Hello World
after
before
Hello World
after

1.3 ComposablePointcut

ComposablePointcut 可以组合多个 PointCut 、ClassFilter 等,我们把上面两个 PointCut 都使用,并取并集,如下

    private static Pointcut createPointCut() {
        // 切点
        Pointcut controlFlowPointcut = new ControlFlowPointcut(PointcutMain.class, "runSay");
        Pointcut dynamicMethodMatcherPointcut = new DynamicMethodMatcherPointcut() {
            @Override
            public boolean matches(Method method, Class<?> targetClass, Object... args) {
                return "World".equals(args[0]);
            }
        };
        // 取并集
        return new ComposablePointcut(controlFlowPointcut)
                .union(dynamicMethodMatcherPointcut);
    }

输出如下,两种情况全部满足:

before
Hello World
after
/************************/
before
Hello Method World
after

1.4 ExpressionPointcut

用于表达式匹配的切点,AspectJ 就是创建了此接口 AspectJExpressionPointcut 的实现类 AspectJExpressionPointcut 。在 AspectJExpressionPointcut 中,针对 expression进行了解析完成了expression 匹配的功能。

如有需要详参: Spring源码分析十三:@Aspect方式的AOP中篇 - getAdvicesAndAdvisorsForBean三、筛选合适的Advisors - findAdvisorsThatCanApply 章节中提到了 AspectJExpressionPointcut 。

1.5 AnnotationMatchingPointcut

AnnotationMatchingPointcut 即可以使用注解匹配的 PointCut。被指定注解修饰的 类或方法才会满足 要求。如下:

    private static Pointcut createPointCut() {
    	// 这代表着:满足下面三个条件的类才会被增强 (构造函数有很多重载,这里选择最复杂的一个)
    	// 1. 被 ClassComponent注解修饰的类
    	// 2. 被 MethodComponent 注解修饰的方法,
    	// 3. false 代表不检查超类和接口,即需要当前类满足前两个条件,而不能是父类或接口。
        return new AnnotationMatchingPointcut(ClassComponent.class, MethodComponent.class, false);
    }

1.7 TruePointcut

恒为 True 的切点,作为某些场景的默认切点。

2. Advice 分类

名称功能
org.aopalliance.intercept.Interceptor代表一个通用的拦截器,一般不直接使用。 而使用 两个子接口 MethodInterceptor、ConstructorInterceptor 拦截特定事件。其中 MethodInterceptor 非常重要,是实现 Aop 功能的核心拦截器。
org.springframework.aop.BeforeAdvice前置通知的通用标记接口,实现该接口的类会在方法调用前执行增强方法
org.springframework.aop.DynamicIntroductionAdviceDynamicIntroductionAdvice 和 IntroductionAdvisor 一起实现了 Spring Aop 的引介增强功能
org.springframework.aop.aspectj.AbstractAspectJAdvice抽象类,用于AspectJ 实现 Aop 功能
org.springframework.aop.AfterAdvice后置通知的通用标记接口,实现该接口的类会在方法调用后执行增强方法

2.1 Interceptor

我们一般不直接使用 Interceptor,而是使用其子接口 MethodInterceptor。其子接口提供了 invoke 方法,我们可以在方法执行前后调用。

如下:

    private static Advice createAdvice() {
        // 方法拦截器 Advice
        return new MethodInterceptor() {
            @Override
            public Object invoke(MethodInvocation invocation) throws Throwable {
                try {
                    System.out.println("before");
                    return invocation.proceed();
                } finally {
                    System.out.println("after");
                }
            }
        };
    }

2.2 BeforeAdvice

BeforeAdvice 作为一个标识接口,代表实现该接口的类会在方法调用前执行。

  • MethodBeforeAdvice :BeforeAdvice 的子接口,可以实现该接口完成前置调用。
    public interface MethodBeforeAdvice extends BeforeAdvice {
    	void before(Method method, Object[] args, @Nullable Object target) throws Throwable;
    }
    
  • MethodBeforeAdviceInterceptor :BeforeAdvice 的实现类,MethodBeforeAdvice 会被封装成MethodBeforeAdviceInterceptor,MethodBeforeAdvice 的前置调用基于此类完成。
    public class MethodBeforeAdviceInterceptor implements MethodInterceptor, BeforeAdvice, Serializable {
    
    	private final MethodBeforeAdvice advice;
    
    	public MethodBeforeAdviceInterceptor(MethodBeforeAdvice advice) {
    		Assert.notNull(advice, "Advice must not be null");
    		this.advice = advice;
    	}
    
    	
    	@Override
    	public Object invoke(MethodInvocation mi) throws Throwable {
    		// 前置执行advice.before 方法。
    		this.advice.before(mi.getMethod(), mi.getArguments(), mi.getThis());
    		return mi.proceed();
    	}
    
    }
    
    

如下:

    private static Advice createAdvice() {
        // 方法拦截器 Advice
        return new MethodBeforeAdvice() {
            @Override
            public void before(Method method, Object[] args, Object target) throws Throwable {
                System.out.println("MethodBeforeAdvice");
            }
        };
    }

2.3 DynamicIntroductionAdvice

DynamicIntroductionAdvice 的功能很强大,引介切面。
引入增强(Introduction Advice)的概念:一个Java类,没有实现A接口,在不修改Java类的情况下,使其具备A接口的功能。

详参 Spring 源码分析衍生篇十二 :AOP 中的引介增强

2.4 AbstractAspectJAdvice

AspectJ 功能实现基于该抽象类。这里不再赘述。

2.5 AfterAdvice

AfterAdvice 与 BeforeAdvice 类似,作为一个后置调用标志,实现该接口的类会在方法调用后调用。不同于 BeforeAdvice 的是,后置调用分为正常执行结束的后置调用和执行异常的后置调用。

我们这里不再介绍 AspectJ 的相关实现类,

  • AfterReturningAdvice :AfterAdvice 的子接口,可以实现该接口完成正常后置调用。
  • AfterReturningAdviceInterceptor :AfterAdvice 的实现类,AfterReturningAdvice 会被封装成AfterReturningAdviceInterceptor ,AfterReturningAdvice 的前置调用基于此类完成。
  • ThrowsAdvice :AfterAdvice 的子接口,可以实现该接口完成异常后置调用。需要注意该接口并没有提供额外方法供实现,而是在 ThrowsAdviceInterceptor 中指定了方法名为 afterThrowing。
  • ThrowsAdviceInterceptor :AfterAdvice 的实现类,ThrowsAdvice 会被封装成ThrowsAdviceInterceptor ,ThrowsAdvice 的前置调用基于此类完成。

这里我们看一下 ThrowsAdviceInterceptor 的实现来了解一下异常时是如何执行增强流程的:

public class ThrowsAdviceInterceptor implements MethodInterceptor, AfterAdvice {

	private static final String AFTER_THROWING = "afterThrowing";

	private static final Log logger = LogFactory.getLog(ThrowsAdviceInterceptor.class);


	private final Object throwsAdvice;

	/** Methods on throws advice, keyed by exception class. */
	private final Map<Class<?>, Method> exceptionHandlerMap = new HashMap<>();

	public ThrowsAdviceInterceptor(Object throwsAdvice) {
		Assert.notNull(throwsAdvice, "Advice must not be null");
		this.throwsAdvice = throwsAdvice;
		
		Method[] methods = throwsAdvice.getClass().getMethods();
		for (Method method : methods) {
			// 获取 throwsAdvice 中方法名为 afterThrowing && 入参个数为 1或者4的方法
			if (method.getName().equals(AFTER_THROWING) &&
					(method.getParameterCount() == 1 || method.getParameterCount() == 4)) {
				Class<?> throwableParam = method.getParameterTypes()[method.getParameterCount() - 1];
				// 判断最后一个入参是否是异常类
				if (Throwable.class.isAssignableFrom(throwableParam)) {
					// An exception handler to register...
					// 如果是则将将当前方法和对应的异常类保存起来
					this.exceptionHandlerMap.put(throwableParam, method);

				}
			}
		}

		if (this.exceptionHandlerMap.isEmpty()) {
			throw new IllegalArgumentException(
					"At least one handler method must be found in class [" + throwsAdvice.getClass() + "]");
		}
	}



	@Override
	public Object invoke(MethodInvocation mi) throws Throwable {
		try {
			return mi.proceed();
		}
		catch (Throwable ex) {
			// 查找当前异常对应的处理方法
			Method handlerMethod = getExceptionHandler(ex);
			if (handlerMethod != null) {
				// 当方法调用出现时调用 invokeHandlerMethod 方法 类执行对应异常的增强。
				invokeHandlerMethod(mi, ex, handlerMethod);
			}
			throw ex;
		}
	}

	private void invokeHandlerMethod(MethodInvocation mi, Throwable ex, Method method) throws Throwable {
		Object[] handlerArgs;
		if (method.getParameterCount() == 1) {
			handlerArgs = new Object[] {ex};
		}
		else {
			// 四个方法入参,依次为 方法实例、参数数组、调用类、异常内容
			handlerArgs = new Object[] {mi.getMethod(), mi.getArguments(), mi.getThis(), ex};
		}
		try {
			method.invoke(this.throwsAdvice, handlerArgs);
		}
		catch (InvocationTargetException targetEx) {
			throw targetEx.getTargetException();
		}
	}

}

如下:
定义一个 CustomThrowsAdvice 类来进行异常增强

    private static Advice createAdvice() {
        // 方法拦截器 Advice
        return new CustomThrowsAdvice();
    }

    public static class CustomThrowsAdvice implements ThrowsAdvice  {
//       也可以直接写这样一个异常参数    
//        public void afterThrowing(Throwable throwable){
//            System.out.println("CustomThrowsAdvice.afterThrowing");
//        }

    	// 参数依次是 调用方法, 调用方法入参, 代理的原始对象,抛出的异常
        public void afterThrowing(Method method, Object[] params, Object object, Throwable throwable){
            System.out.println("CustomThrowsAdvice.afterThrowing");
        }
    }

运行结果:

Hello World
CustomThrowsAdvice.afterThrowing
...抛出的异常日志

3. Advisor 分类

类名功能
IntroductionAdvisor只能应用于类级别的拦截,只能使用Introduction型的Advice 。用于引入增强
PointcutAdvisor可以使用任何类型的Pointcut,以及几乎任何类型的Advice。用于普通的增强

关于 IntroductionAdvisor ,我们开设了衍生篇内容专门解释,详参: Spring 源码分析衍生篇十二 :AOP 中的引介增强

三、Spring Aop Demo

本来想模拟一下事务的,懒得连库就写个极简Demo:被 @CustomAnnotation 注解修饰 类会被代理。算抛砖引玉吧。

// 自定义注解
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface CustomAnnotation {
}

// 配置类,由于是 Demo ,所以一切从简
@Configuration
public class AopConfig {
    @Bean
    public Advisor advisor() {
        final DefaultPointcutAdvisor defaultPointcutAdvisor = new DefaultPointcutAdvisor();
        // 被 @CustomAnnotation 注解修饰的类满足切点要求
        defaultPointcutAdvisor.setPointcut(new AnnotationMatchingPointcut(CustomAnnotation.class));
        // 设置增强方法
        defaultPointcutAdvisor.setAdvice((MethodInterceptor) invocation -> {
            System.out.println("before");
            final Object proceed = invocation.proceed();
            System.out.println("after");
            return proceed;
        });
        return defaultPointcutAdvisor;
    }

}

// 被代理类
@CustomAnnotation
@RestController
@RequestMapping("common")
public class CommonContorller {

    @RequestMapping("common")
    public String common(String msg){
        System.out.println("CommonContorller.common");
        return msg;
    }
}

调用输出

before
CommonContorller.common
after

以上:内容部分参考
https://blog.csdn.net/daryl715/article/details/1743311
https://blog.csdn.net/daryl715/article/details/1732978
https://blog.csdn.net/f641385712/article/details/89303088
如有侵扰,联系删除。 内容仅用于自我记录学习使用。如有错误,欢迎指正

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猫吻鱼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值