【开源项目】AOP框架Nepxion Matrix原理拆解

项目地址

项目地址:https://toscode.gitee.com/nepxion/Matrix

原理分析

Spring AutoProxy机制

它统一封装接口(Spring)代理和类代理(CGLIB),注解无论在接口和类的头部或者方法上,都可以让业务端执行有效切面,可以轻松快速实现对接口或者类的复杂代理业务

DefaultAutoScanProxy,继承DefaultAutoScanProxy,定义包扫描路径,代理模式和扫描模式;指定拦截器和注解。

@Component("myAutoScanProxyForClass")
public class MyAutoScanProxyForClass extends DefaultAutoScanProxy {
    private static final long serialVersionUID = -5968030133395182024L;

    // 多个包路径,用“;”分隔
    private static final String SCAN_PACKAGES = "com.charles";

    @SuppressWarnings("rawtypes")
    private Class[] commonInterceptorClasses;

    @SuppressWarnings("rawtypes")
    private Class[] classAnnotations;

    public MyAutoScanProxyForClass() {
        super(SCAN_PACKAGES, ProxyMode.BY_CLASS_ANNOTATION_ONLY, ScanMode.FOR_CLASS_ANNOTATION_ONLY);
    }

    @SuppressWarnings("unchecked")
    @Override
    protected Class<? extends MethodInterceptor>[] getCommonInterceptors() {
        if (commonInterceptorClasses == null) {
            commonInterceptorClasses = new Class[]{MyInterceptor1.class};
        }
        return commonInterceptorClasses;
    }

    @SuppressWarnings("unchecked")
    @Override
    protected Class<? extends Annotation>[] getClassAnnotations() {
        if (classAnnotations == null) {
            classAnnotations = new Class[]{Annotation1.class};
        }
        return classAnnotations;
    }

    @Override
    protected void classAnnotationScanned(Class<?> targetClass, Class<? extends Annotation> classAnnotation) {
        System.out.println("Class annotation scanned, targetClass=" + targetClass + ", classAnnotation=" + classAnnotation);
    }
}

AbstractAutoProxyCreator#postProcessAfterInitialization,入口类在AbstractAutoProxyCreator。判断类是否被拦截,如果被拦截,获取被拦截后的代理类。

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

	protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
		if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) {
			return bean;
		}
		if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
			return bean;
		}
		if (isInfrastructureClass(bean.getClass()) || shouldSkip(bean.getClass(), beanName)) {
			this.advisedBeans.put(cacheKey, Boolean.FALSE);
			return bean;
		}

		// Create proxy if we have advice.
		Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
		if (specificInterceptors != DO_NOT_PROXY) {
			this.advisedBeans.put(cacheKey, Boolean.TRUE);
			Object proxy = createProxy(
					bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
			this.proxyTypes.put(cacheKey, proxy.getClass());
			return proxy;
		}

		this.advisedBeans.put(cacheKey, Boolean.FALSE);
		return bean;
	}

Aspect获取拦截器是用的AbstractAdvisorAutoProxyCreator,而Nepxion Matrix实现的子类是是AbstractAutoScanProxy,那么分析一下AbstractAutoScanProxy#getAdvicesAndAdvisorsForBean是怎么获取拦截器类的。

  • 判断当前类是否在扫描包的目录下
  • 获取接口或者实现类的拦截器
    @Override
    protected Object[] getAdvicesAndAdvisorsForBean(Class<?> beanClass, String beanName, TargetSource targetSource) {
        boolean scanPackagesEnabled = scanPackagesEnabled();
        // scanPackagesEnabled=false,表示“只扫描指定目录”的方式未开启,则不会对扫描到的bean进行代理预先判断
        if (scanPackagesEnabled) {
            boolean scanPackagesContained = scanPackagesContained(beanClass);
            // 如果beanClass的类路径,未包含在扫描目录中,返回DO_NOT_PROXY
            if (!scanPackagesContained) {
                return DO_NOT_PROXY;
            }
        }

        // 根据Bean名称获取Bean对象
        Object bean = beanMap.get(beanName);

        // 获取最终目标类
        Class<?> targetClass = null;
        if (bean != null /* && AopUtils.isCglibProxy(bean) */) {
            targetClass = AopProxyUtils.ultimateTargetClass(bean);
        } else {
            targetClass = beanClass;
        }

        // Spring容器扫描实现类
        if (!targetClass.isInterface()) {
            // 扫描接口(从实现类找到它的所有接口)
            if (targetClass.getInterfaces() != null) {
                for (Class<?> targetInterface : targetClass.getInterfaces()) {
                    Object[] proxyInterceptors = scanAndProxyForTarget(targetInterface, beanName, false);
                    if (proxyInterceptors != DO_NOT_PROXY) {
                        return proxyInterceptors;
                    }
                }
            }

            // 扫描实现类(如果接口上没找到注解, 就找实现类的注解)
            Object[] proxyInterceptors = scanAndProxyForTarget(targetClass, beanName, true);
            if (proxyInterceptors != DO_NOT_PROXY) {
                return proxyInterceptors;
            }
        }

        return DO_NOT_PROXY;
    }

AbstractAutoScanProxy#scanAndProxyForTarget,根据代理模式,获取类上的或者方法上的注解

    protected Object[] scanAndProxyForTarget(Class<?> targetClass, String beanName, boolean proxyTargetClass) {
        String targetClassName = targetClass.getCanonicalName();
        Object[] interceptors = getInterceptors(targetClass);
        // 排除java开头的接口,例如java.io.Serializable,java.io.Closeable等,执行不被代理
        if (StringUtils.isNotEmpty(targetClassName) && !targetClassName.startsWith("java.")) {
            // 避免对同一个接口或者类扫描多次
            Boolean proxied = proxyMap.get(targetClassName);
            if (proxied != null) {
                if (proxied) {
                    return interceptors;
                }
            } else {
                Object[] proxyInterceptors = null;
                switch (proxyMode) {
                    // 只通过扫描到接口名或者类名上的注解后,来确定是否要代理
                    case BY_CLASS_ANNOTATION_ONLY:
                        proxyInterceptors = scanAndProxyForClass(targetClass, targetClassName, beanName, interceptors, proxyTargetClass);
                        break;
                    // 只通过扫描到接口或者类方法上的注解后,来确定是否要代理
                    case BY_METHOD_ANNOTATION_ONLY:
                        proxyInterceptors = scanAndProxyForMethod(targetClass, targetClassName, beanName, interceptors, proxyTargetClass);
                        break;
                    // 上述两者都可以
                    case BY_CLASS_OR_METHOD_ANNOTATION:
                        Object[] classProxyInterceptors = scanAndProxyForClass(targetClass, targetClassName, beanName, interceptors, proxyTargetClass);
                        // 没有接口或者类名上扫描到目标注解,那么扫描接口或者类的方法上的目标注解
                        Object[] methodProxyInterceptors = scanAndProxyForMethod(targetClass, targetClassName, beanName, interceptors, proxyTargetClass);
                        if (classProxyInterceptors != DO_NOT_PROXY || methodProxyInterceptors != DO_NOT_PROXY) {
                            proxyInterceptors = interceptors;
                        } else {
                            proxyInterceptors = DO_NOT_PROXY;
                        }
                        break;
                }

                // 是否需要代理
                proxyMap.put(targetClassName, Boolean.valueOf(proxyInterceptors != DO_NOT_PROXY));

                if (proxyInterceptors != DO_NOT_PROXY) {
                    // 是接口代理还是类代理
                    proxyTargetClassMap.put(beanName, proxyTargetClass);

                    /*LOG.info("------------ Matrix Proxy Information -----------");
                    Class<? extends MethodInterceptor>[] commonInterceptorClasses = getCommonInterceptors();
                    if (ArrayUtils.isNotEmpty(commonInterceptorClasses)) {
                        LOG.info("Class [{}] is proxied by common interceptor classes [{}], proxyTargetClass={}", targetClassName, ProxyUtil.toString(commonInterceptorClasses), proxyTargetClass);
                    }

                    String[] commonInterceptorNames = getCommonInterceptorNames();
                    if (ArrayUtils.isNotEmpty(commonInterceptorNames)) {
                        LOG.info("Class [{}] is proxied by common interceptor beans [{}], proxyTargetClass={}", targetClassName, ProxyUtil.toString(commonInterceptorNames), proxyTargetClass);
                    }

                    if (proxyInterceptors != PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS && ArrayUtils.isNotEmpty(proxyInterceptors)) {
                        LOG.info("Class [{}] is proxied by additional interceptors [{}], proxyTargetClass={}", targetClassName, proxyInterceptors, proxyTargetClass);
                    }
                    LOG.info("-------------------------------------------------");*/
                }

                return proxyInterceptors;
            }
        }

        return DO_NOT_PROXY;
    }

AbstractAutoScanProxy#getInterceptors,获取拦截器。优先执行getAdditionalInterceptors获取拦截器,获取不到再执行getCommonInterceptors

    protected Object[] getInterceptors(Class<?> targetClass) {
        Object[] interceptors = getAdditionalInterceptors(targetClass);
        if (ArrayUtils.isNotEmpty(interceptors)) {
            return interceptors;
        }

        Class<? extends MethodInterceptor>[] commonInterceptorClasses = getCommonInterceptors();
        String[] commonInterceptorNames = getCommonInterceptorNames();
        if (ArrayUtils.isNotEmpty(commonInterceptorClasses) || ArrayUtils.isNotEmpty(commonInterceptorNames)) {
            return PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS;
        }

        return DO_NOT_PROXY;
    }

AbstractAutoScanProxy#scanAndProxyForClass,扫描类上的注解。

AbstractAutoScanProxy#scanAndProxyForMethod,扫描方法上的注解。

    protected Object[] scanAndProxyForClass(Class<?> targetClass, String targetClassName, String beanName, Object[] interceptors, boolean proxyTargetClass) {
        // 判断目标注解是否标注在接口名或者类名上
        boolean proxied = false;
        Class<? extends Annotation>[] classAnnotations = getClassAnnotations();
        if (ArrayUtils.isNotEmpty(classAnnotations)) {
            for (Class<? extends Annotation> classAnnotation : classAnnotations) {
                if (targetClass.isAnnotationPresent(classAnnotation)) {
                    // 是否执行“注解扫描后处理”
                    if (scanMode == ScanMode.FOR_CLASS_ANNOTATION_ONLY || scanMode == ScanMode.FOR_CLASS_OR_METHOD_ANNOTATION) {
                        classAnnotationScanned(targetClass, classAnnotation);
                    } else {
                        // 如果“注解扫描后处理”不开启,没必要再往下执行循环,直接返回
                        return interceptors;
                    }

                    // 目标注解被扫描到,proxied赋值为true,即认为该接口或者类被代理
                    if (!proxied) {
                        proxied = true;
                    }
                }
            }
        }

        return proxied ? interceptors : DO_NOT_PROXY;
    }

    protected Object[] scanAndProxyForMethod(Class<?> targetClass, String targetClassName, String beanName, Object[] interceptors, boolean proxyTargetClass) {
        // 判断目标注解是否标注在方法上
        boolean proxied = false;
        Class<? extends Annotation>[] methodAnnotations = getMethodAnnotations();
        if (ArrayUtils.isNotEmpty(methodAnnotations)) {
            for (Method method : targetClass.getDeclaredMethods()) {
                for (Class<? extends Annotation> methodAnnotation : methodAnnotations) {
                    if (method.isAnnotationPresent(methodAnnotation)) {
                        // 是否执行“注解扫描后处理”
                        if (scanMode == ScanMode.FOR_METHOD_ANNOTATION_ONLY || scanMode == ScanMode.FOR_CLASS_OR_METHOD_ANNOTATION) {
                            methodAnnotationScanned(targetClass, method, methodAnnotation);
                        } else {
                            // 如果“注解扫描后处理”不开启,没必要再往下执行循环,直接返回
                            return interceptors;
                        }

                        // 目标注解被扫描到,proxied赋值为true,即认为该接口或者类被代理
                        if (!proxied) {
                            proxied = true;
                        }
                    }
                }
            }
        }

        return proxied ? interceptors : DO_NOT_PROXY;
    }

AbstractInterceptor,抽象拦截器定义了一堆封装好的方法,获取方法名称,代理类名,类上的注解和方法上的注解。

@Component("myInterceptor3")
public class MyInterceptor3 extends AbstractInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        String proxyClassName = getProxyClassName(invocation);
        Object[] arguments = getArguments(invocation);
        String proxiedClassName = getProxiedClassName(invocation);
        Class<?>[] proxiedInterfaces = getProxiedInterfaces(invocation);
        Annotation[] classAnnotations = getProxiedClassAnnotations(invocation);
        String methodName = getMethodName(invocation);
        Annotation[] methodAnnotations = getMethodAnnotations(invocation);
        String[] parameterNames = getMethodParameterNames(invocation);
    }
}

代理模式:根据类上或者方法上的注解来生成代理

扫描模式:根据类上或者方法上的注解来执行methodAnnotationScanned或者classAnnotationScanned

Spring Registrar机制

实现象@FeignClient注解那样,只有接口没有实现类,就能实现注入和动态代理。

EnableMyAnnotation注入MyRegistrar

MyRegistrar继承了AbstractRegistrarAbstractRegistrar实现了ImportBeanDefinitionRegistrar

  • 根据MyRegistrar配置的getAnnotationClass注解类
    @Override
    public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
        registerAnnotations(metadata, registry);
    }

    public void registerAnnotations(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
        ClassPathScanningCandidateComponentProvider scanner = getScanner();
        scanner.setResourceLoader(this.resourceLoader);

        AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(getAnnotationClass());
        scanner.addIncludeFilter(annotationTypeFilter);
        Set<String> basePackages = getBasePackages(metadata);

        for (String basePackage : basePackages) {
            Set<BeanDefinition> candidateComponents = scanner.findCandidateComponents(basePackage);
            for (BeanDefinition candidateComponent : candidateComponents) {
                if (candidateComponent instanceof AnnotatedBeanDefinition) {
                    // verify annotated class is an interface
                    AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
                    AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();

                    Map<String, Object> attributes = annotationMetadata.getAnnotationAttributes(getAnnotationClass().getCanonicalName());
                    registerAnnotation(registry, annotationMetadata, attributes);
                }
            }
        }
    }

AbstractRegistrar#getBasePackages,根据getEnableAnnotationClass获取扫描包的路径。获取使用EnableMyAnnotation注解的包名。

    protected Set<String> getBasePackages(AnnotationMetadata importingClassMetadata) {
        Map<String, Object> attributes = importingClassMetadata.getAnnotationAttributes(getEnableAnnotationClass().getCanonicalName());

        Set<String> basePackages = new HashSet<>();
        for (String pkg : (String[]) attributes.get("value")) {
            if (StringUtils.hasText(pkg)) {
                basePackages.add(pkg);
            }
        }
        for (String pkg : (String[]) attributes.get("basePackages")) {
            if (StringUtils.hasText(pkg)) {
                basePackages.add(pkg);
            }
        }
        for (Class<?> clazz : (Class[]) attributes.get("basePackageClasses")) {
            basePackages.add(ClassUtils.getPackageName(clazz));
        }

        if (basePackages.isEmpty()) {
            basePackages.add(ClassUtils.getPackageName(importingClassMetadata.getClassName()));
        }

        return basePackages;
    }

AbstractRegistrar#registerAnnotation,根据配置的getBeanClassgetInterceptor(beanDefinition.getPropertyValues()),生成MyRegistrarFactoryBean实例,配置拦截器和接口的属性,customize将注解的属性数据配置在实体类,以及类名。

    private void registerAnnotation(BeanDefinitionRegistry registry, AnnotationMetadata annotationMetadata, Map<String, Object> attributes) {
        String className = annotationMetadata.getClassName();

        LOG.info("Found annotation [{}] in {} ", getAnnotationClass().getSimpleName(), className);

        BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(getBeanClass());
        AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();

        customize(registry, annotationMetadata, attributes, definition);

        try {
            definition.addPropertyValue("interfaze", Class.forName(className));
        } catch (ClassNotFoundException e) {
            LOG.error("Get interface for name error", e);
        }
        definition.addPropertyValue("interceptor", getInterceptor(beanDefinition.getPropertyValues()));

        definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);

        String alias = className;

        BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, new String[] { alias });
        BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
    }

RegistrarFactoryBean实现了FactoryBean,Spring容器获取到的就是proxy;实现了InitializingBean,调用代理类proxyFactory.getProxy(classLoader)获取到代理类。

public class RegistrarFactoryBean implements ApplicationContextAware, FactoryBean<Object>, InitializingBean, BeanClassLoaderAware {
    private ApplicationContext applicationContext;
    private Class<?> interfaze;
    private MethodInterceptor interceptor;
    private Object proxy;
    private ClassLoader classLoader;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    public ApplicationContext getApplicationContext() {
        return applicationContext;
    }

    @Override
    public Object getObject() throws Exception {
        return proxy;
    }

    @Override
    public Class<?> getObjectType() {
        return interfaze;
    }

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

    @Override
    public void afterPropertiesSet() throws Exception {
        ProxyFactory proxyFactory = new ProxyFactory();
        proxyFactory.addInterface(interfaze);
        proxyFactory.addAdvice(interceptor);
        proxyFactory.setOptimize(false);

        proxy = proxyFactory.getProxy(classLoader);
    }
}

总结一下:拦截注解,生成对应接口的代理类。所以如果标注的接口有实现,需要标注@Primary

Spring Import Selector机制

实现象@EnableCircuitBreaker注解那样,入口加上@EnableMyAnnotation,自动初始化对应的Configuration。

EnableMyAnnotation注入了EnableMyAnnotationImportSelector

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(EnableMyAnnotationImportSelector.class)
public @interface EnableMyAnnotation {
    boolean extension() default true;
}

EnableMyAnnotationImportSelector,调用父类的selectImports。判断注解上extension属性值,如果为true,加载MyConfigurationExtension,否则环境变更存入extension.enabled=false

@Order(Ordered.LOWEST_PRECEDENCE - 100)
public class EnableMyAnnotationImportSelector extends AbstractImportSelector<EnableMyAnnotation> {
    // 如下方法适合EnableXXX注解上带有参数的情形,一般用不到
    @Override
    public String[] selectImports(AnnotationMetadata metadata) {
        // 获取父类的Configuration列表
        String[] imports = super.selectImports(metadata);

        // 从注解上获取参数
        AnnotationAttributes attributes = AnnotationAttributes.fromMap(metadata.getAnnotationAttributes(getAnnotationClass().getName(), true));
        boolean extension = attributes.getBoolean("extension");

        if (extension) {
            // 如果EnableMyAnnotation注解上的extension为true,那么去装载MyConfigurationExtension,即初始化里面的MyBeanExtension
            List<String> importsList = new ArrayList<>(Arrays.asList(imports));
            importsList.add(MyConfigurationExtension.class.getCanonicalName());
            imports = importsList.toArray(new String[0]);
        } else {
            // 如果EnableMyAnnotation注解上的extension为false,那么你可以把该参数动态放到属性列表里
            Environment environment = getEnvironment();
            if (ConfigurableEnvironment.class.isInstance(environment)) {
                ConfigurableEnvironment configurableEnvironment = (ConfigurableEnvironment) environment;
                LinkedHashMap<String, Object> map = new LinkedHashMap<>();
                map.put("extension.enabled", false);
                MapPropertySource propertySource = new MapPropertySource("nepxion", map);
                configurableEnvironment.getPropertySources().addLast(propertySource);
            }
        }

        return imports;
    }
}

AbstractImportSelector#selectImports,用Spring SPI加载指定的注解类。而注解类的获取是获取子类上面的泛型。

    protected AbstractImportSelector() {
        this.annotationClass = (Class<T>) GenericTypeResolver.resolveTypeArgument(this.getClass(), AbstractImportSelector.class);
    }

	@Override
    public String[] selectImports(AnnotationMetadata metadata) {
        if (!isEnabled()) {
            return new String[0];
        }

        AnnotationAttributes attributes = AnnotationAttributes.fromMap(metadata.getAnnotationAttributes(this.annotationClass.getName(), true));

        Assert.notNull(attributes, "No " + getSimpleName() + " attributes found. Is " + metadata.getClassName() + " annotated with @" + getSimpleName() + "?");

        // Find all possible auto configuration classes, filtering duplicates
        List<String> factories = new ArrayList<>(new LinkedHashSet<>(SpringFactoriesLoader.loadFactoryNames(this.annotationClass, this.beanClassLoader)));

        if (factories.isEmpty() && !hasDefaultFactory()) {
            throw new IllegalStateException("Annotation @" + getSimpleName() + " found, but there are no implementations. Did you forget to include a starter?");
        }

        if (factories.size() > 1) {
            // there should only ever be one DiscoveryClient, but there might be more than one factory
            LOG.warn("More than one implementation " + "of @" + getSimpleName() + " (now relying on @Conditionals to pick one): " + factories);
        }

        return factories.toArray(new String[factories.size()]);
    }

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Nepxion Discovery【探索】使用指南,基于Spring Cloud Greenwich版、Finchley版和Hoxton版而 制作,对于Edgware版,使用者需要自行修改。使用指南主要涉及的功能包括: 基于Header传递的全链路灰度路由,网关为路由触发点。采用配置中心配置路由规则映射在网 关过滤器中植入Header信息而实现,路由规则传递到全链路服务中。路由方式主要包括版本和 区域的匹配路由、版本和区域的权重路由、基于机器IP地址和端口的路由 基于规则订阅的全链路灰度发布。采用配置中心配置灰度规则映射在全链路服务而实现,所有 服务都订阅某个共享配置。发布方式主要包括版本和区域的匹配发布、版本和区域的权重发布 全链路服务隔离。包括注册隔离、消费端隔离和提供端服务隔离,示例仅提供基于Group隔 离。除此之外,不在本文介绍内的,还包括: 注册隔离:黑/白名单的IP地址的注册隔离、最大注册数限制的注册隔离 消费端隔离:黑/白名单的IP地址的消费端隔离 全链路服务限流熔断降级权限,集成阿里巴巴Sentinel,有机整合灰度路由,扩展LimitApp的 机制,通过动态的Http Header方式实现组合式防护机制,包括基于服务名、基于灰度组、基于 灰度版本、基于灰度区域、基于机器地址和端口等防护机制,支持自定义任意的业务参数组合 实现该功能。支持原生的流控规则、降级规则、授权规则、系统规则、热点参数流控规则 全链路灰度调用链。包括Header方式和日志方式,Header方式框架内部集成,日志方式通过 MDC输出(需使用者自行集成) 同城双活多机房切换支持。它包含在“基于Header传递的全链路灰度路由”里 数据库灰度发布。内置简单的数据库灰度发布策略,它不在本文的介绍范围内 灰度路由和发布的自动化测试 license Apache 2.0 maven central v5.4.0 javadoc 5.4.0 build passing Docker容器化和Kubernetes平台的无缝支持部署
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值