Spring Aop之Cglib实现原理详解

原创
2018/08/25 09:01
阅读数 2W
AI总结

       Spring Aop实现对目标对象的代理,主要有两种方式:Jdk代理和Cglib代理。这两种代理的区别在于,Jdk代理与目标类都会实现同一个接口,并且在代理类中会调用目标类中被代理的方法,调用者实际调用的则是代理类的方法,通过这种方式我们就可以在代理类中织入切面逻辑;Jdk代理存在的问题在于目标类被代理的方法必须实现某个接口,Cglib代理则是为了解决这个问题而存在的,其实现代理的方式是通过为目标类动态生成一个子类,通过在子类中织入相应逻辑来达到织入代理逻辑的目的。

       关于Jdk代理和Cglib代理,其优缺点主要在于:

  • Jdk代理生成的代理类只有一个,因而其编译速度是非常快的;而由于被代理的目标类是动态传入代理类中的,Jdk代理的执行效率相对来说低一点,这也是Jdk代理被称为动态代理的原因;
  • Cglib代理需要为每个目标类生成相应的子类,因而在实际运行过程中,其可能会生成非常多的子类,过多的子类始终不是太好的,因为这影响了虚拟机编译类的效率;但由于在调用过程中,代理类的方法是已经静态编译生成了的,因而Cglib代理的执行效率相对来说高一些。

       本文主要讲解Spring Aop是如何通过Cglib代理实现将切面逻辑织入目标类的。

1. AopProxy织入对象生成

       前面我们讲过,Spring Aop织入切面逻辑的入口方法是AbstractAutoProxyCreator.createProxy()方法,如下是该方法的源码:

protected Object createProxy(Class<?> beanClass, @Nullable String beanName,
        @Nullable Object[] specificInterceptors, TargetSource targetSource) {
    
    // 如果当前beanFactory实现了ConfigurableListableBeanFactory接口,则将需要被代理的
    // 对象暴露出来
    if (this.beanFactory instanceof ConfigurableListableBeanFactory) {
        AutoProxyUtils.exposeTargetClass((ConfigurableListableBeanFactory) 
            this.beanFactory, beanName, beanClass);
    }

    // 创建代理工厂
    ProxyFactory proxyFactory = new ProxyFactory();
    // 复制proxyTargetClass,exposeProxy等属性
    proxyFactory.copyFrom(this);

    // 如果当前设置了不使用Cglib代理目标类,则判断目标类是否设置了preserveTargetClass属性,
    // 如果设置了,则还是强制使用Cglib代理目标类;如果没有设置,则判断目标类是否实现了相关接口,
    // 没有设置,则还是使用Cglib代理。需要注意的是Spring默认使用的是Jdk代理来织入切面逻辑。
    if (!proxyFactory.isProxyTargetClass()) {
        // 判断目标类是否设置了preserveTargetClass属性
        if (shouldProxyTargetClass(beanClass, beanName)) {
            proxyFactory.setProxyTargetClass(true);
        } else {
            // 判断目标类是否实现了相关接口
            evaluateProxyInterfaces(beanClass, proxyFactory);
        }
    }

    // 将需要织入的切面逻辑都转换为Advisor对象
    Advisor[] advisors = buildAdvisors(beanName, specificInterceptors);
    proxyFactory.addAdvisors(advisors);
    proxyFactory.setTargetSource(targetSource);
    // 提供的hook方法,供子类实现以实现对代理工厂的定制
    customizeProxyFactory(proxyFactory);

    proxyFactory.setFrozen(this.freezeProxy);
    // 当前判断逻辑默认返回false,子类可进行重写,对于AnnotationAwareAspectJAutoProxyCreator,
    // 其重写了该方法返回true,因为其已经对获取到的Advisor进行了过滤,后面不需要在对目标类进行重新
    // 匹配了
    if (advisorsPreFiltered()) {
        proxyFactory.setPreFiltered(true);
    }

    // 生成代理类
    return proxyFactory.getProxy(getProxyClassLoader());
}

       可以看到,在生成代理类之前,主要做了两件事:①判断使用Jdk代理还是Cglib代理;②设置相关的属性。这里我们继续看最后的ProxyFactory.getProxy()方法:

public Object getProxy(@Nullable ClassLoader classLoader) {
    // 首先获取AopProxy对象,其主要有两个实现:JdkDynamicAopProxy和ObjenesisCglibAopProxy,
    // 分别用于Jdk和Cglib代理类的生成,其getProxy()方法则用于获取具体的代理对象
    return createAopProxy().getProxy(classLoader);
}

       上面的createAopProxy()方法可以理解为一个工厂方法,返回值是一个AopProxy类型的对象,其内部根据具体的条件生成相应的子类对象,即JdkDynamicAopProxy和ObjenesisCglibAopProxy。后面则通过调用AopProxy.getProxy()方法获取代理过的对象。如下是createAopProxy()方法的实现逻辑:

public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
    // 判断当前类是否需要进行运行时优化,或者是指定了使用Cglib代理的方式,再或者是目标类没有用户提供的
    // 相关接口,则使用Cglib代理实现代理逻辑的织入
    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代理生成的类,此时还是使用Jdk代理
        if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
            return new JdkDynamicAopProxy(config);
        }
        
        // 返回Cglib代理织入类对象
        return new ObjenesisCglibAopProxy(config);
    } else {
        // 返回Jdk代理织入类对象
        return new JdkDynamicAopProxy(config);
    }
}

       这里可以看到,本文需要讲解的Cglib代理逻辑的织入就在ObjenesisCglibAopProxy.getProxy()方法中。

2. 代理逻辑的织入

       关于代理逻辑的织入,其实现主体还是通过Enhancer来实现,即通过需要织入的Advisor列表,生成Callback对象,并将其设置到Enhancer对象中,最后通过Enhancer生成目标对象。如下是AopProxy.getProxy()方法的源码:

public Object getProxy(@Nullable ClassLoader classLoader) {
    if (logger.isDebugEnabled()) {
        logger.debug("Creating CGLIB proxy: target source is " 
            + this.advised.getTargetSource());
    }

    try {
        Class<?> rootClass = this.advised.getTargetClass();
        Assert.state(rootClass != null, 
                     "Target class must be available for creating a CGLIB proxy");

        // 判断当前类是否是已经通过Cglib代理生成的类,如果是的,则获取其原始父类,
        // 并将其接口设置到需要代理的接口中
        Class<?> proxySuperClass = rootClass;
        if (ClassUtils.isCglibProxyClass(rootClass)) {
            // 获取父类
            proxySuperClass = rootClass.getSuperclass();
            // 获取父类实现的接口,并将其设置到需要代理的接口中
            Class<?>[] additionalInterfaces = rootClass.getInterfaces();
            for (Class<?> additionalInterface : additionalInterfaces) {
                this.advised.addInterface(additionalInterface);
            }
        }

        // 对目标类进行检查,主要检查点有三个:
        // 1. 目标方法不能使用final修饰;
        // 2. 目标方法不能是private类型的;
        // 3. 目标方法不能是包访问权限的;
        // 这三个点满足任何一个,当前方法就不能被代理,此时该方法就会被略过
        validateClassIfNecessary(proxySuperClass, classLoader);

        // 创建Enhancer对象,并且设置ClassLoader
        Enhancer enhancer = createEnhancer();
        if (classLoader != null) {
            enhancer.setClassLoader(classLoader);
            if (classLoader instanceof SmartClassLoader &&
                ((SmartClassLoader) classLoader).isClassReloadable(proxySuperClass)) {
                enhancer.setUseCache(false);
            }
        }
        enhancer.setSuperclass(proxySuperClass);
        // 这里AopProxyUtils.completeProxiedInterfaces()方法的主要目的是为要生成的代理类
        // 增加SpringProxy,Advised,DecoratingProxy三个需要实现的接口。这里三个接口的作用如下:
        // 1. SpringProxy:是一个空接口,用于标记当前生成的代理类是Spring生成的代理类;
        // 2. Advised:Spring生成代理类所使用的属性都保存在该接口中,
        //    包括Advisor,Advice和其他相关属性;
        // 3. DecoratingProxy:该接口用于获取当前代理对象所代理的目标对象的Class类型。
        enhancer.setInterfaces(AopProxyUtils.completeProxiedInterfaces(this.advised));
        enhancer.setNamingPolicy(SpringNamingPolicy.INSTANCE);
        enhancer.setStrategy(new 
            ClassLoaderAwareUndeclaredThrowableStrategy(classLoader));

        // 获取当前需要织入到代理类中的逻辑
        Callback[] callbacks = getCallbacks(rootClass);
        Class<?>[] types = new Class<?>[callbacks.length];
        for (int x = 0; x < types.length; x++) {
            types[x] = callbacks[x].getClass();
        }

        // 设置代理类中各个方法将要使用的切面逻辑,这里ProxyCallbackFilter.accept()方法返回
        // 的整型值正好一一对应上面Callback数组中各个切面逻辑的下标,也就是说这里的CallbackFilter
        // 的作用正好指定了代理类中各个方法将要使用Callback数组中的哪个或哪几个切面逻辑
        enhancer.setCallbackFilter(new ProxyCallbackFilter(
            this.advised.getConfigurationOnlyCopy(), this.fixedInterceptorMap, 
            this.fixedInterceptorOffset));
        enhancer.setCallbackTypes(types);

        // 生成代理对象
        return createProxyClassAndInstance(enhancer, callbacks);
    } catch (CodeGenerationException | IllegalArgumentException ex) {
        throw new AopConfigException("Could not generate CGLIB subclass of class [" 
            + this.advised.getTargetClass() + "]: Common causes of this problem "  
            + "include using a final class or a non-visible class", ex);
    } catch (Throwable ex) {
        throw new AopConfigException("Unexpected AOP exception", ex);
    }
}

       可以看到,这里的AopProxy.getProxy()方法就是生成代理对象的主干逻辑。上面的逻辑中主要有两个部分需要重点讲解:①如果获取Callback数组;②CallbackFilter的作用。关于第一点,我们后面会进行重点讲解,至于第二点,这里我们需要理解的就是CallbackFilter.accept()方法接收一个Method类型的参数,该参数也即当前要生成的代理逻辑的方法,这里的accept()方法将返回目标当前要织入代理逻辑的方法所需要使用的切面逻辑,也即Callback对象在Callback数组中的下标。关于CallbackFilter的使用原理,读者可以阅读实战CGLib系列之proxy篇(二):回调过滤CallbackFilter这篇文章。下面我们继续阅读getCallbacks()的源码:

private Callback[] getCallbacks(Class<?> rootClass) throws Exception {
    boolean exposeProxy = this.advised.isExposeProxy();
    boolean isFrozen = this.advised.isFrozen();
    boolean isStatic = this.advised.getTargetSource().isStatic();

    // 用户自定义的代理逻辑的主要织入类
    Callback aopInterceptor = new DynamicAdvisedInterceptor(this.advised);

    Callback targetInterceptor;
    // 判断如果要暴露代理对象,如果是,则使用AopContext设置将代理对象设置到ThreadLocal中
    // 用户则可以通过AopContext获取目标对象
    if (exposeProxy) {
        // 判断被代理的对象是否是静态的,如果是静态的,则将目标对象缓存起来,每次都使用该对象即可,
        // 如果目标对象是动态的,则在DynamicUnadvisedExposedInterceptor中每次都生成一个新的
        // 目标对象,以织入后面的代理逻辑
        targetInterceptor = isStatic ?
          new StaticUnadvisedExposedInterceptor(
            this.advised.getTargetSource().getTarget()) :
          new DynamicUnadvisedExposedInterceptor(this.advised.getTargetSource());
    } else {
        // 下面两个类与上面两个的唯一区别就在于是否使用AopContext暴露生成的代理对象
        targetInterceptor = isStatic ?
            new StaticUnadvisedInterceptor(this.advised.getTargetSource().getTarget()) :
        new DynamicUnadvisedInterceptor(this.advised.getTargetSource());
    }

    // 当前Callback用于一般的不用背代理的方法,这些方法
    Callback targetDispatcher = isStatic ?
        new StaticDispatcher(this.advised.getTargetSource().getTarget()) 
        : new SerializableNoOp();

    // 将获取到的callback组装为一个数组
    Callback[] mainCallbacks = new Callback[] {
        aopInterceptor,  // 用户自己定义的拦截器
        targetInterceptor,  // 根据条件是否暴露代理对象的拦截器
        new SerializableNoOp(),  // 不做任何操作的拦截器
        targetDispatcher, this.advisedDispatcher,  // 用于存储Advised对象的分发器
        new EqualsInterceptor(this.advised),  // 针对equals方法调用的拦截器
        new HashCodeInterceptor(this.advised)  // 针对hashcode方法调用的拦截器
    };

    Callback[] callbacks;
    // 如果目标对象是静态的,也即可以缓存的,并且切面逻辑的调用链是固定的,
    // 则对目标对象和整个调用链进行缓存
    if (isStatic && isFrozen) {
        Method[] methods = rootClass.getMethods();
        Callback[] fixedCallbacks = new Callback[methods.length];
        this.fixedInterceptorMap = new HashMap<>(methods.length);

        for (int x = 0; x < methods.length; x++) {
            // 获取目标对象的切面逻辑
            List<Object> chain = 
                this.advised.getInterceptorsAndDynamicInterceptionAdvice(
                methods[x], rootClass);
            fixedCallbacks[x] = new FixedChainStaticTargetInterceptor(
                chain, this.advised.getTargetSource().getTarget(), 
                this.advised.getTargetClass());
            // 对调用链进行缓存
            this.fixedInterceptorMap.put(methods[x].toString(), x);
        }

        // 将生成的静态调用链存入Callback数组中
        callbacks = new Callback[mainCallbacks.length + fixedCallbacks.length];
        System.arraycopy(mainCallbacks, 0, callbacks, 0, mainCallbacks.length);
        System.arraycopy(fixedCallbacks, 0, callbacks, mainCallbacks.length, 
            fixedCallbacks.length);
        // 这里fixedInterceptorOffset记录了当前静态的调用链的切面逻辑的起始位置,
        // 这里记录的用处在于后面使用CallbackFilter的时候,如果发现是静态的调用链,
        // 则直接通过该参数获取相应的调用链,而直接略过了前面的动态调用链
        this.fixedInterceptorOffset = mainCallbacks.length;
    } else {
        callbacks = mainCallbacks;
    }
    return callbacks;
}

       这里的getCallbacks()方法主要做了三件事:①获取目标对象的动态调用链;②判断是否设置了exposeProxy属性,如果设置了,则生成一个可以暴露代理对象的Callback对象,否则生成一个不做任何处理直接调用目标对象的Callback对象;③判断目标对象是否是静态的,并且当前的切面逻辑是否是固定的,如果是,则将目标对象和调用链进行缓存,以便后续直接调用。这里需要说明的一个点在于第三点,因为在判断目标对象为静态对象,并且调用链是固定的时候,会将目标对象和调用链进行缓存,并且封装到指定的Callback对象中。这里读者可能会疑问为什么动态调用链和静态调用链都进行了缓存,这和前面讲解的CallbackFilter是息息相关的,因为上述代码最后使用fixedInterceptorOffset记录了当前静态调用链在数组中存储的位置,我们前面也讲了,Enhancer可以通过CallbackFilter返回的整数值来动态的指定从当前对象Callback数组中的第几个环绕逻辑开始织入,这里就会使用到fixedInterceptorOffset。从上述代码中可以看出,用户自定义的调用链是在DynamicAdvisedInterceptor中生成的(关于静态调用链的生成实际上是同样的逻辑,只不过静态调用链会被缓存),这里我们看看DynamicAdvisedInterceptor的实现源码:

public Object intercept(Object proxy, Method method, Object[] args, 
        MethodProxy methodProxy) throws Throwable {
    Object oldProxy = null;
    boolean setProxyContext = false;
    Object target = null;
    // 通过TargetSource获取目标对象
    TargetSource targetSource = this.advised.getTargetSource();
    try {
        // 判断如果需要暴露代理对象,则将当前代理对象设置到ThreadLocal中
        if (this.advised.exposeProxy) {
            oldProxy = AopContext.setCurrentProxy(proxy);
            setProxyContext = true;
        }

        target = targetSource.getTarget();
        Class<?> targetClass = (target != null ? target.getClass() : null);
        // 获取目标对象切面逻辑的环绕链
        List<Object> chain = this.advised
            .getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);
        Object retVal;
        if (chain.isEmpty() && Modifier.isPublic(method.getModifiers())) {
            // 对参数进行处理,以使其与目标方法的参数类型一致,尤其对于数组类型,
            // 会单独处理其数据类型与实际类型一致
            Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
            // 因为没有切面逻辑需要织入,这里直接调用目标方法
            retVal = methodProxy.invoke(target, argsToUse);
        } else {
            // 通过生成的调用链,对目标方法进行环绕调用
            retVal = new CglibMethodInvocation(proxy, target, method, 
                args, targetClass, chain, methodProxy).proceed();
        }
        
        // 对返回值进行处理,如果返回值就是当前目标对象,那么将代理生成的代理对象返回;
        // 如果返回值为空,并且返回值类型是非void的基本数据类型,则抛出异常;
        // 如果上述两个条件都不符合,则直接将生成的返回值返回
        retVal = processReturnType(proxy, target, method, retVal);
        return retVal;
    } finally {
        // 如果目标对象不是静态的,则调用TargetSource.releaseTarget()方法释放目标对象
        if (target != null && !targetSource.isStatic()) {
            targetSource.releaseTarget(target);
        }
        
        // 将代理对象设置为前面(外层逻辑)调用设置的对象,以防止暴露出来的代理对象不一致
        if (setProxyContext) {
            AopContext.setCurrentProxy(oldProxy);
        }
    }
}

       这里intercept()方法里主要逻辑有两点:①为目标对象生成切面逻辑调用链;②通过切面逻辑对目标对象进行环绕,并且进行调用。关于这两点,我们都会进行讲解,这里我们首先看看Cglib是如何生成调用链的,如下是getInterceptorsAndDynamicInterceptionAdvice()方法最终调用的源码,中间略过了部分比较简单的调用:

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类型的Advisor
    boolean hasIntroductions = hasMatchingIntroductions(config, actualClass);
    AdvisorAdapterRegistry registry = GlobalAdvisorAdapterRegistry.getInstance();

    for (Advisor advisor : config.getAdvisors()) {
        if (advisor instanceof PointcutAdvisor) {
            PointcutAdvisor pointcutAdvisor = (PointcutAdvisor) advisor;
            // 这里判断切面逻辑的调用链是否提前进行过过滤,如果进行过,则不再进行目标方法的匹配,
            // 如果没有,则再进行一次匹配。这里我们使用的AnnotationAwareAspectJAutoProxyCreator
            // 在生成切面逻辑的时候就已经进行了过滤,因而这里返回的是true,本文最开始也对这里进行了讲解
            if (config.isPreFiltered() || pointcutAdvisor.getPointcut()
                .getClassFilter().matches(actualClass)) {
                // 将Advisor对象转换为MethodInterceptor数组
                MethodInterceptor[] interceptors = registry.getInterceptors(advisor);
                MethodMatcher mm = pointcutAdvisor.getPointcut().getMethodMatcher();
                // 这里进行匹配的时候,首先会检查是否为IntroductionAwareMethodMatcher类型的
                // Matcher,如果是,则调用其定义的matches()方法进行匹配,如果不是,则直接调用
                // 当前切面的matches()方法进行匹配。这里由于前面进行匹配时可能存在部分在静态匹配时
                // 无法确认的方法匹配结果,因而这里调用是必要的,而对于能够确认的匹配逻辑,这里调用
                // 也是非常迅速的,因为前面已经对匹配结果进行了缓存
                if (MethodMatchers.matches(mm, method, actualClass, hasIntroductions)) {
                    // 判断如果是动态匹配,则使用InterceptorAndDynamicMethodMatcher对其进行封装
                    if (mm.isRuntime()) {
                        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类型的Advisor,则将调用链封装为Interceptor数组
            if (config.isPreFiltered() || ia.getClassFilter().matches(actualClass)) {
                Interceptor[] interceptors = registry.getInterceptors(advisor);
                interceptorList.addAll(Arrays.asList(interceptors));
            }
        } else {
            // 这里是提供的使用自定义的转换器对Advisor进行转换的逻辑,因为getInterceptors()方法中
            // 会使用相应的Adapter对目标Advisor进行匹配,如果能匹配上,通过其getInterceptor()方法
            // 将自定义的Advice转换为MethodInterceptor对象
            Interceptor[] interceptors = registry.getInterceptors(advisor);
            interceptorList.addAll(Arrays.asList(interceptors));
        }
    }

    return interceptorList;
}

       这里获取调用链的逻辑其实比较简单,其最终的目的就是将Advisor数组一个一个的封装为Interceptor对象。在进行Advisor封装的时候,这里分为了三种类型:

  • 如果目标切面逻辑是一般的切面逻辑,即PointcutAdvisor,则会在运行时对目标方法进行动态匹配,因为前面可能存在还不能确认的是否应该应用切面逻辑的方法;
  • 如果切面逻辑是IntroductionAdvisor的,则将其封装为Interceptor类型的数组;
  • 如果以上两个都不是,说明切面逻辑可能是用户自定义的切面逻辑,这里就通过注册的AdvisorAdapter进行匹配,如果某个Adapter能够支持当前Advisor的转换,则调用其getInterceptor()方法将Advisor转换为MethodInterceptor返回。

       下面我们看看Cglib是如何通过生成的切面调用链将目标对象进行环绕的。前面我们讲了,将切面逻辑进行织入的逻辑在CglibMethodInvocation中,实际上其调用逻辑在其proceed()方法中,这里我们直接看该方法的源码:

public Object proceed() throws Throwable {
    // 这里currentInterceptorIndex记录了当前调用链中正在调用的Intercepor的下标,该数值初始为-1
    if (this.currentInterceptorIndex == 
        this.interceptorsAndDynamicMethodMatchers.size() - 1) {
        // 如果调用链为空,则直接调用目标方法
        return invokeJoinpoint();
    }

    // 获取下一个需要织入的Interceptor逻辑
    Object interceptorOrInterceptionAdvice =
        this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex);
    if (interceptorOrInterceptionAdvice instanceof InterceptorAndDynamicMethodMatcher) {
        InterceptorAndDynamicMethodMatcher dm =
            (InterceptorAndDynamicMethodMatcher) interceptorOrInterceptionAdvice;
        // 对动态的方法进行匹配,如果匹配成功,才进行调用,否则直接进行下一个Interceptor的调用
        if (dm.methodMatcher.matches(this.method, this.targetClass, this.arguments)) {
            return dm.interceptor.invoke(this);
        } else {
            return proceed();
        }
    } else {
        // 如果不需要进行动态匹配,则直接进行下一步的调用
        return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
    }
}

       这里proceed()方法的逻辑比较简单,其使用一个索引记录了当前正在调用的Interceptor在调用链中的位置,并且依次对调用链进行调用,从而实现将切面逻辑织入目标对象的目的。这里最终对目标对象的调用的逻辑在invokeJoinpoint()方法中。

3. 小结

       本文首先讲解Spring是如何通过配置的参数来选择使用哪种代理方式的,然后重点讲解了Spring Aop是如何使用Cglib代理实现代理逻辑的织入的。

4. 广告

       读者朋友如果觉得本文还不错,可以点击下面的广告链接,这可以为作者带来一定的收入,从而激励作者创作更好的文章,非常感谢!

在项目开发过程中,企业会有很多的任务、需求、缺陷等需要进行管理,CORNERSTONE 提供敏捷、任务、需求、缺陷、测试管理、WIKI、共享文件和日历等功能模块,帮助企业完成团队协作和敏捷开发中的项目管理需求;更有甘特图、看板、思维导图、燃尽图等多维度视图,帮助企业全面把控项目情况。

展开阅读全文
加载中
点击加入讨论🔥(1) 发布并加入讨论🔥
1 评论
70 收藏
7
分享
AI总结
返回顶部
顶部