• Spring源码分析之AOP使用和分析


    前言

    AOP(Aspect Oriented Programming)面向切面编程,通过在运行期对切入点(如指定类的指定方法)创建代理对象,来完成对业务功能的增强,适用于日志监听,事务处理等场景。SpringAOP是在IOC容器的基础上实现的。

    AOP的各种概念

    • 通知(Advice):
      定义在连接点处的行为,围绕方法调用而注入,如打印日志行为
    • 切入点(Pointcut):
      确定在哪些连接点处应用通知,如包含指定注解的方法
    • 通知器(Advisor):
      组合通知和切入点,在什么地方注入什么行为
    • 连接点(JoinPoint):
      被拦截的方法
    • 切面(Aspect):
      在Spring中可以看做一系列Advisor的封装。

    AspectJ介绍

    AspectJ是AOP编程的完美解决方案,可以使用特定的编译器实现在编译期向代码中植入相应的Advice行为。Spring的AOP实现使用了AspectJ的2点功能:

    1. 其中一些注解如@Aspect,@Pointcut等
    2. 通过AspectJ来解析Pointcut表达式,如@Pointcut("execution(public * abc.sayHi())"),判断一个业务类的业务方法是否会被拦截。

    这里我们使用一个例子来更好的了解AspectJ。首先引入依赖

    <dependency>
      <groupId>org.aspectj</groupId>
      <artifactId>aspectjrt</artifactId>
      <version>1.8.13</version>
    </dependency>
    <dependency>
      <groupId>org.aspectj</groupId>
      <artifactId>aspectjweaver</artifactId>
      <version>1.8.13</version>
    </dependency>
    

    代码如下

    import java.lang.reflect.Method;
    import java.util.HashSet;
    import java.util.Set;
    import org.aspectj.weaver.tools.PointcutExpression;
    import org.aspectj.weaver.tools.PointcutParameter;
    import org.aspectj.weaver.tools.PointcutParser;
    import org.aspectj.weaver.tools.PointcutPrimitive;
    
    public class TestAspectJ {
    
      private static final Set<PointcutPrimitive> SUPPORTED_PRIMITIVES = new HashSet<>();
    
      static {
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.EXECUTION);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.ARGS);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.REFERENCE);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.THIS);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.TARGET);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.WITHIN);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_ANNOTATION);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_WITHIN);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_ARGS);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_TARGET);
      }
    
      public static void main(String[] args) throws NoSuchMethodException {
        ClassLoader classLoader = TestAspectJ.class.getClassLoader();
        //创建一个解析器
        PointcutParser parser = PointcutParser
            .getPointcutParserSupportingSpecifiedPrimitivesAndUsingSpecifiedClassLoaderForResolution(
                SUPPORTED_PRIMITIVES, classLoader);
        PointcutParameter[] parameters = {};
        //解析出切入点表达式,这里的方法路径替换为当前SmsService的sayHi()的实际路径
        String expression = "execution(public * com.imooc.sourcecode.java.spring.aop.test1.TestAspectJ.SmsService.sayHi())";
        PointcutExpression pointcutExpression = parser
            .parsePointcutExpression(expression, null, parameters);
        //判断SmsService类是否会被拦截
        boolean match = pointcutExpression.couldMatchJoinPointsInType(SmsService.class);
        System.out.println(match);//true
        Method smsServiceToStringMethod = SmsService.class.getMethod("toString");
        //判断SmsService类的toString()方法是否会被拦截
        match = pointcutExpression.matchesMethodExecution(smsServiceToStringMethod).alwaysMatches();
        System.out.println(match);//false
        Method smsServiceSayHiMethod = SmsService.class.getMethod("sayHi");
        //判断SmsService类的sayHi()方法是否会被拦截
        match = pointcutExpression.matchesMethodExecution(smsServiceSayHiMethod).alwaysMatches();
        System.out.println(match);//true
      }
    
      public static class SmsService {
        public void sayHi() {
          System.out.println("SmsService.sayHi()");
        }
      }
    
    }
    

    我们也可以使用@Pointcut注解来定义表达式

    import java.lang.reflect.Method;
    import java.util.HashSet;
    import java.util.Set;
    import org.aspectj.lang.annotation.Pointcut;
    import org.aspectj.weaver.tools.PointcutExpression;
    import org.aspectj.weaver.tools.PointcutParameter;
    import org.aspectj.weaver.tools.PointcutParser;
    import org.aspectj.weaver.tools.PointcutPrimitive;
    
    public class TestAspectJ2 {
    
      private static final Set<PointcutPrimitive> SUPPORTED_PRIMITIVES = new HashSet<>();
    
      static {
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.EXECUTION);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.ARGS);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.REFERENCE);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.THIS);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.TARGET);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.WITHIN);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_ANNOTATION);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_WITHIN);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_ARGS);
        SUPPORTED_PRIMITIVES.add(PointcutPrimitive.AT_TARGET);
      }
    
      public static void main(String[] args) throws NoSuchMethodException {
        ClassLoader classLoader = TestAspectJ2.class.getClassLoader();
        //创建一个解析器
        PointcutParser parser = PointcutParser
            .getPointcutParserSupportingSpecifiedPrimitivesAndUsingSpecifiedClassLoaderForResolution(
                SUPPORTED_PRIMITIVES, classLoader);
        PointcutParameter[] parameters = {};
        //解析出切入点表达式
        PointcutExpression pointcutExpression = parser
            .parsePointcutExpression("webLog()", LogAspect.class, parameters);
        //判断SmsService类是否会被拦截
        boolean match = pointcutExpression.couldMatchJoinPointsInType(SmsService.class);
        System.out.println(match);//true
        Method smsServiceToStringMethod = SmsService.class.getMethod("toString");
        //判断SmsService类的toString()方法是否会被拦截
        match = pointcutExpression.matchesMethodExecution(smsServiceToStringMethod).alwaysMatches();
        System.out.println(match);//false
        Method smsServiceSayHiMethod = SmsService.class.getMethod("sayHi");
        //判断SmsService类的sayHi()方法是否会被拦截
        match = pointcutExpression.matchesMethodExecution(smsServiceSayHiMethod).alwaysMatches();
        System.out.println(match);//true
      }
    
      public class LogAspect {
        //这里的方法路径替换为当前SmsService的sayHi()的实际路径
        @Pointcut("execution(public * com.imooc.sourcecode.java.spring.aop.test1.TestAspectJ2.SmsService.sayHi())")
        public void webLog() {
        }
      }
      public static class SmsService {
        public void sayHi() {
          System.out.println("SmsService.sayHi()");
        }
      }
    
    }
    

    我们解析表达式时传入了LogAspect的Class,所以AspectJ会在Class中寻找包含@Pointcut注解的方法,查找方法名称和我们需要的一致的具体表达式。
    PointcutExpression对象提供了couldMatchJoinPointsInType()方法来判断当前切入点是否匹配给定Class,
    提供了matchesMethodExecution()方法来判断当前切入点是否匹配给定的Method。
    Spring的AOP实现就是使用这两个方法来判断是否能够对指定业务类创建动态代理的,具体可以看AspectJExpressionPointcut,它就是@Pointcut注解的具体实现类。
    更多关于Pointcut表达式的介绍,可以查看spring aop中pointcut表达式完整版
    更多关于AspectJ的介绍,可以查看AspectJ 使用介绍

    SpringAOP使用

    import org.aopalliance.aop.Advice;
    import org.aopalliance.intercept.MethodInterceptor;
    import org.aopalliance.intercept.MethodInvocation;
    import org.springframework.aop.Pointcut;
    import org.springframework.aop.PointcutAdvisor;
    import org.springframework.aop.aspectj.AspectJExpressionPointcut;
    import org.springframework.aop.framework.ProxyFactory;
    
    public class TestProxyFactoryAOP {
      public static void main(String[] args) {
        //创建代理工厂
        ProxyFactory proxyFactory = new ProxyFactory();
        //目标对象(被代理对象)
        proxyFactory.setTarget(new SmsService());
        //实现接口,可以有多个
        proxyFactory.addInterface(IService.class);
        //通知器,包含通知和切入点,可以有多个
        proxyFactory.addAdvisor(new MyAdvisor(createPointcut()));
        //创建代理对象
        IService proxy = (IService) proxyFactory
            .getProxy(TestProxyFactoryAOP.class.getClassLoader());
        System.out.println(proxy.getClass());
        proxy.hi();//被拦截
        System.out.println("============");
        proxy.hello();//没有被拦截
      }
      private static Pointcut createPointcut() {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        //这里替换为SmsService的hi()方法具体路径
        String expression = "execution(public * com.imooc.sourcecode.java.spring.aop.test2.TestProxyFactoryAOP.SmsService.hi())";
        pointcut.setExpression(expression);
        return pointcut;
      }
      public static class MyAdvisor implements PointcutAdvisor {
        private Pointcut pointcut;
        public MyAdvisor(Pointcut pointcut) {
          this.pointcut = pointcut;
        }
        @Override
        public Pointcut getPointcut() {
          return pointcut;
        }
        @Override
        public Advice getAdvice() {
          return new MyMethodInterceptor();
        }
        @Override
        public boolean isPerInstance() {
          return true;
        }
      }
      public static class MyMethodInterceptor implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
          System.out.println("before invoke");
          Object result = invocation.proceed();
          System.out.println("after invoke");
          return result;
        }
      }
      public static class SmsService implements IService {
        @Override
        public void hi() {
          System.out.println("SmsService.hi()");
        }
        @Override
        public void hello() {
          System.out.println("SmsService.hello()");
        }
      }
      public interface IService {
        void hi();
        void hello();
      }
    }
    

    定义一个接口IService和它的实现类SmsService,创建一个通知(Advice)对象和切入点(Pointcut)对象,组合成一个通知器(Advisor)对象,
    通过ProxyFactory这个类根据目标对象和要实现的接口列表,以及最重要的通知器列表来创建一个代理对象。底层是使用JDK动态代理和CGLIB来创建代理对象。
    关于JDK动态代理,可以查看jdk实现动态代理
    关于CGLIB,可以查看CGLIB实现动态代理

    源码分析

    核心逻辑有两个,一个是创建代理对象,一个是代理方法的执行。先分析创建代理对象,进入ProxyFactory的getProxy()方法。

    创建代理对象

    public Object getProxy(@Nullable ClassLoader classLoader) {
    		return createAopProxy().getProxy(classLoader);
    	}
    protected final synchronized AopProxy createAopProxy() {
    		if (!this.active) {
    			activate();
    		}
                    //根据代理工厂来选择是使用JDK还是CGLIB
    		return getAopProxyFactory().createAopProxy(this);
    	}
    

    默认的AopProxyFactory实现类为DefaultAopProxyFactory。

    @Override
    public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
                    //如果设置了optimize标识或者设置了proxyTargetClass标识或者没有要实现的接口
    		if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) {
    			Class<?> targetClass = config.getTargetClass();
    			if (targetClass == null) {
    				throw new AopConfigException("TargetSource cannot determine target class: " +
    						"Either an interface or a target is required for proxy creation.");
    			}
                            //如果目标类型为接口,使用JDK动态代理
    			if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
    				return new JdkDynamicAopProxy(config);
    			}
                            //使用CGLIB
    			return new ObjenesisCglibAopProxy(config);
    		}
    		else {
                            //使用JDK动态代理
    			return new JdkDynamicAopProxy(config);
    		}
    	}
    

    总结起来就是

    • 如果设置了optimize标识或者设置了proxyTargetClass标识或者没有要实现的接口且目标对象Class不是接口,就使用CGLIB。
    • 其他情况都是JDK动态代理。

    因为我们添加了IService接口,所以使用JDK动态代理。进入JdkDynamicAopProxy的getProxy()方法。

    @Override
    public Object getProxy(@Nullable ClassLoader classLoader) {
    	        //获取被代理对象的接口列表,这里不仅包含我们自己的IService接口,Spring还增加了3个接口(SpringProxy,Advised,DecoratingProxy)
    		Class<?>[] proxiedInterfaces = AopProxyUtils.completeProxiedInterfaces(this.advised, true);
                    //判断我们的接口有没有包含equals()和hashCode()方法,如果有,那我们的目标对象一定实现了这两个方法,就不需要交给代理对象来处理了
    		findDefinedEqualsAndHashCodeMethods(proxiedInterfaces);
                    //创建代理对象,InvocationHandler参数传的是this
    		return Proxy.newProxyInstance(classLoader, proxiedInterfaces, this);
    	}
    

    代理方法执行

    创建代理对象的InvocationHandler就是JdkDynamicAopProxy自身,所以进入它的invoke()方法

    @Override
    @Nullable
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    		Object oldProxy = null;
    		boolean setProxyContext = false;
    
    		TargetSource targetSource = this.advised.targetSource;
    		Object target = null;
    
    		try {
                            //处理equals()方法
    			if (!this.equalsDefined && AopUtils.isEqualsMethod(method)) {
    				return equals(args[0]);
    			}
                            //处理hashCode()方法
    			else if (!this.hashCodeDefined && AopUtils.isHashCodeMethod(method)) {
    				return hashCode();
    			}
                            //处理Spring增加的DecoratingProxy接口的实现
    			else if (method.getDeclaringClass() == DecoratingProxy.class) {
    				return AopProxyUtils.ultimateTargetClass(this.advised);
    			}
                            //处理Spring增加的Advised接口的实现,实际委托给AdvisedSupport(这里是ProxyFactory)来处理
    			else if (!this.advised.opaque && method.getDeclaringClass().isInterface() &&
    					method.getDeclaringClass().isAssignableFrom(Advised.class)) {
    				return AopUtils.invokeJoinpointUsingReflection(this.advised, method, args);
    			}
    			Object retVal;
                            //如果设置了exposeProxy标识(可以通过@EnableAspectJAutoProxy注解的exposeProxy属性来设置),将创建的代理对象暴露出去
                            //我们在业务方法中可以通过AopContext的currentProxy()方法获取代理对象
    			if (this.advised.exposeProxy) {
    				oldProxy = AopContext.setCurrentProxy(proxy);
    				setProxyContext = true;
    			}
    			target = targetSource.getTarget();
    			Class<?> targetClass = (target != null ? target.getClass() : null);
    
    			//获取拦截器链,就是从所有的Advisor中过滤出匹配目标类和目标方法的Advisor
    			List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);
    
    			//如果没有匹配的Advisor,直接通过反射执行业务方法
    			if (chain.isEmpty()) {
                                    //如果没有匹配的Advisor,直接通过反射执行业务方法
    				Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
    				retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
    			}
    			else {
    				//将拦截器链和被拦截的方法封装到一个对象中,由它来具体执行
    				MethodInvocation invocation =
    						new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
    				//开始执行
    				retVal = invocation.proceed();
    			}
    			return retVal;
    		}
    	}
    

    核心为获取拦截器链和执行拦截器链,先分析获取,进入AdvisedSupport的getInterceptorsAndDynamicInterceptionAdvice()方法。

    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;
    	}
    

    继续跟进去,这里AdvisorChainFactory的具体实现类为DefaultAdvisorChainFactory

    @Override
    public List<Object> getInterceptorsAndDynamicInterceptionAdvice(
    			Advised config, Method method, @Nullable Class<?> targetClass) {
    
    		AdvisorAdapterRegistry registry = GlobalAdvisorAdapterRegistry.getInstance();
                    //这里就是我们自己定义MyAdvisor对象
    		Advisor[] advisors = config.getAdvisors();
    		List<Object> interceptorList = new ArrayList<>(advisors.length);
    		Class<?> actualClass = (targetClass != null ? targetClass : method.getDeclaringClass());
    		Boolean hasIntroductions = null;
    
    		for (Advisor advisor : advisors) {
                            //包含切入点的通知器
    			if (advisor instanceof PointcutAdvisor) {
    				// Add it conditionally.
    				PointcutAdvisor pointcutAdvisor = (PointcutAdvisor) advisor;
                                    //判断实际业务类Class是否匹配切入点,就是SmsService类是否匹配切入点表达式execution(public * xxx())
    				if (config.isPreFiltered() || pointcutAdvisor.getPointcut().getClassFilter().matches(actualClass)) {
    					MethodMatcher mm = pointcutAdvisor.getPointcut().getMethodMatcher();
    					boolean match;
                                            //判断给定的方法是否匹配切入点,这里的方法就是SmsService类的hi()或hello()方法
    					match = mm.matches(method, actualClass);
    					if (match) {
                                                    //这里的拦截器就是我们自己定义的MyMethodInterceptor对象
    						MethodInterceptor[] interceptors = registry.getInterceptors(advisor);
    						interceptorList.addAll(Arrays.asList(interceptors));
    					}
    				}
    			}
    		}
    
    		return interceptorList;
    	}
    

    关于Pointcut是如何判断给定类和给定方法是否匹配的,我们的例子中使用的是AspectJExpressionPointcut类,内部也是调用AspectJ的PointcutParser解析器来处理的,
    就像上面AspectJ介绍中的示例代码一样。
    至此我们已经获取到了一个匹配指定类指定方法的拦截器链,下面就开始执行拦截器链了。进入ReflectiveMethodInvocation的proceed()方法。

    @Override
    @Nullable
    public Object proceed() throws Throwable {
    		//内部使用一个索引,记录当前执行的拦截器,如果拦截器全部执行完了,就执行业务方法
    		if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) {
    			return invokeJoinpoint();
    		}
    
    		Object interceptorOrInterceptionAdvice =
    				this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex);
    		if (interceptorOrInterceptionAdvice instanceof InterceptorAndDynamicMethodMatcher) {
    			//暂时先不管
    		}
    		else {
    			//执行拦截器,这里就是我们配置的MyMethodInterceptor类
    			return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
    		}
    	}
    

    再看一下我们定义的MyMethodInterceptor类

    public static class MyMethodInterceptor implements MethodInterceptor {
    
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
          System.out.println("before invoke");
          Object result = invocation.proceed();
          System.out.println("after invoke");
          return result;
        }
      }
    

    我们在拦截器中又调用了MethodInvocation的proceed()方法,所有就又开始了其他拦截器的执行,这是一个递归的调用,类似于Servlet的过滤器链的执行过程。

    项目中使用

    定义切面

    import org.aspectj.lang.JoinPoint;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Before;
    import org.aspectj.lang.annotation.Pointcut;
    import org.springframework.stereotype.Component;
    
    @Component
    @Aspect
    public class LogAspect {
    
      @Pointcut("execution(public * com.imooc.sourcecode.java.spring.aop.SmsService.sayHi())")
      public void webLog() {
      }
    
      /**
       * 进入连接点之前.
       *
       * @param joinPoint JoinPoint
       */
      @Before("webLog()")
      public void doBefore(JoinPoint joinPoint) {
        // 记录下请求内容
        System.out.println("doBefore: " + joinPoint.getTarget());
      }
    }
    

    配置启用AOP

    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.EnableAspectJAutoProxy;
    
    @Configuration
    @EnableAspectJAutoProxy
    @ComponentScan
    public class BeanConfig {
    }
    

    我们在项目中可以使用注解的方式来定义Pointcut和Advice,Spring会帮我们创建对应的Pointcut对象和MethodInterceptor对象,然后组合成Advisor,
    核心为@EnableAspectJAutoProxy注解,它会向IOC容器注入AnnotationAwareAspectJAutoProxyCreator,这是一个BeanPostProcessor,
    AnnotationAwareAspectJAutoProxyCreator实现了postProcessAfterInitialization()方法,在Bean初始化之后,来判断是否需要创建代理对象。

    @Override
    public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
    		if (bean != null) {
    			Object cacheKey = getCacheKey(bean.getClass(), beanName);
    			if (this.earlyProxyReferences.remove(cacheKey) != bean) {
                                    //核心,是否需要创建代理对象
    				return wrapIfNecessary(bean, beanName, cacheKey);
    			}
    		}
    		return bean;
    	}
    

    会查找IOC容器中所有包含@Aspect注解的Bean对象,解析@Pointcut及@Before等注解,封装成Advisor对象列表。
    解析出的Advisor列表存储在BeanFactoryAspectJAdvisorsBuilder类中。

    public class BeanFactoryAspectJAdvisorsBuilder {
            //存储所有包含@Aspect注解的Bean名称的缓存
    	@Nullable
    	private volatile List<String> aspectBeanNames;
            //存储Bean名称和Advisor列表的对应关系的缓存
    	private final Map<String, List<Advisor>> advisorsCache = new ConcurrentHashMap<>();
    }
    

    只会解析一次,下次直接从缓存中取。

    分析总结

    SpringAOP的实现原理:

    1. 根据@Aspect注解解析出所有Advisor(通知器),包含Pointcut(切入点)和Advice(通知)。
    2. 在Bean初始化(属性装配已经完成,初始化方法也已经调用)之后从所有的Advisor中过滤出可以匹配的Advisor。
    3. 如果有匹配的Advisor,就使用JDK或CGLIB创建代理对象。
    4. 执行业务方法,被代理对象所拦截
    5. 代理对象会从所有的Advisor中过滤出可以匹配的Advisor,封装成过滤器链,依次执行。

    参考

    AspectJ 使用介绍
    Spring AOP 使用介绍,从前世到今生
    spring aop中pointcut表达式完整版

  • 相关阅读:
    Java 常见异常种类
    Spring3.2+mybatis3.2+Struts2.3整合配置文件大全
    Java中的基本类型和引用类型变量的区别
    【git】Git 提示fatal: remote origin already exists 错误解决办法
    【Java集合】Java中集合(List,Set,Map)
    POJ3322-经典的游戏搜索问题
    程序人生[流程图]
    不使用中间变量交换两个数
    做人要低调,别把自己太当回事
    【转】嵌套子控件设计时支持
  • 原文地址:https://www.cnblogs.com/strongmore/p/16246932.html
Copyright © 2020-2023  润新知