百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 编程网 > 正文

SpringMvc项目仿 springcloud openfeign 实现微服务下接口调用

yuyutoo 2024-12-06 20:39 3 浏览 0 评论

SpringMvc项目集成nacos、openfeign、Ribbon,仿 springcloud openfeign 实现微服务下接口调用

背景

近几年,公司新开发项目转为微服务架构,但有很多基于 SpringMvc 老系统,若都进行系统重构会消耗很大的人力、时间成本。故尝试在 SpringMvc 系统中通过集成 nacosfeign 的方式让老系统焕发第二春。

已知

1、nacos官方已提供SpringMvc集成示例
2、openfeign基于feign的微服务架构下服务之间调用解决方案,官方只提供了Spring Cloud版本

问题

1、公司当前SpringMvc项目基于Spring 4.x版本,尝试对Spring版本升级发现存在大量问题,本人能力有限故放弃。
2、SpringMvc项目为独立单体项目,存在独立的用户权限配置体系。

分析

1、nacos官方已提供了SpringMvc集成示例
2、openfeign虽没有SpringMvc版本,但好在作为开源项目,有项目源码可以参考

实现

SpringMvc集成nacos

添加依赖

<dependency>
   <groupId>com.alibaba.nacos</groupId>
   <artifactId>nacos-spring-context</artifactId>
   <version>{nacos.version}</version>
   <exclusions>
      <exclusion>
         <groupId>org.springframework</groupId>
         <artifactId>spring-context</artifactId>
      </exclusion>
   </exclusions>
</dependency>

spring-context与项目中引用的有冲突,故排除。 通过添加 @EnableNacosDiscovery 注解开启 Nacos Spring 的服务发现功能:

@Configuration
@EnableNacosDiscovery(globalProperties = @NacosProperties(serverAddr = "127.0.0.1:8848"))
public class NacosConfiguration {

}

注意:按照 nacos 官方集成到 spring 的例子配置后会发现 nacos 管理端可以查看到服务,但是一会就消失了,怀疑是 spring 服务未定时发送心跳链接导致。 查看nacos源代码中发送心跳链接部分:

# BeatReactor.java
private final ScheduledExecutorService executorService;

public BeatReactor(NamingProxy serverProxy, int threadCount) {
    this.serverProxy = serverProxy;
    this.executorService = new ScheduledThreadPoolExecutor(threadCount, new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r);
            thread.setDaemon(true);
            thread.setName("com.alibaba.nacos.naming.beat.sender");
            return thread;
        }
    });
}

/**
 * Add beat information.
 *
 * @param serviceName service name
 * @param beatInfo    beat information
 */
public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
    NAMING_LOGGER.info("[BEAT] adding beat: {} to beat map.", beatInfo);
    String key = buildKey(serviceName, beatInfo.getIp(), beatInfo.getPort());
    BeatInfo existBeat = null;
    //fix #1733
    if ((existBeat = dom2Beat.remove(key)) != null) {
        existBeat.setStopped(true);
    }
    dom2Beat.put(key, beatInfo);
    executorService.schedule(new BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
    MetricsMonitor.getDom2BeatSizeMonitor().set(dom2Beat.size());
}

BeatReactor 在构造器中实例化了一个 ScheduledThreadPoolExecutor 在调用注册方法(addBeatInfo)时创建定时任务,在给定的延时后给 nacos 发送心跳信息

class BeatTask implements Runnable {

    BeatInfo beatInfo;

    public BeatTask(BeatInfo beatInfo) {
        this.beatInfo = beatInfo;
    }

    @Override
    public void run() {
        if (beatInfo.isStopped()) {
            return;
        }
        long nextTime = beatInfo.getPeriod();
        try {
            JsonNode result = serverProxy.sendBeat(beatInfo, BeatReactor.this.lightBeatEnabled);
            long interval = result.get("clientBeatInterval").asLong();
            boolean lightBeatEnabled = false;
            if (result.has(CommonParams.LIGHT_BEAT_ENABLED)) {
                lightBeatEnabled = result.get(CommonParams.LIGHT_BEAT_ENABLED).asBoolean();
            }
            BeatReactor.this.lightBeatEnabled = lightBeatEnabled;
            if (interval > 0) {
                nextTime = interval;
            }
            int code = NamingResponseCode.OK;
            if (result.has(CommonParams.CODE)) {
                code = result.get(CommonParams.CODE).asInt();
            }
            if (code == NamingResponseCode.RESOURCE_NOT_FOUND) {
                Instance instance = new Instance();
                instance.setPort(beatInfo.getPort());
                instance.setIp(beatInfo.getIp());
                instance.setWeight(beatInfo.getWeight());
                instance.setMetadata(beatInfo.getMetadata());
                instance.setClusterName(beatInfo.getCluster());
                instance.setServiceName(beatInfo.getServiceName());
                instance.setInstanceId(instance.getInstanceId());
                instance.setEphemeral(true);
                try {
                    serverProxy.registerService(beatInfo.getServiceName(),
                            NamingUtils.getGroupName(beatInfo.getServiceName()), instance);
                } catch (Exception ignore) {
                }
            }
        } catch (NacosException ex) {
            NAMING_LOGGER.error("[CLIENT-BEAT] failed to send beat: {}, code: {}, msg: {}",
                    JacksonUtils.toJson(beatInfo), ex.getErrCode(), ex.getErrMsg());

        }
        # 循环发送心跳信息
        executorService.schedule(new BeatTask(beatInfo), nextTime, TimeUnit.MILLISECONDS);
    }
}

BeatTask#run 方法中可以看到在执行 registerService 后会重复创建定时任务以达到在特定时间重复向 nacos 注册服务信息。

综上可知,spring 服务想要持续向 nacos 发送心跳信息,需手动调用一次nacos的实例注册方法,nacos 配置类修改为:

/**
 * @author: kkfan
 * @create: 2021-07-08 15:54:44
 * @description: nacos 配置
 */
@Configuration
@EnableNacosDiscovery(globalProperties = @NacosProperties)
// 加载 nacos 服务配置信息
@PropertySource(value = "classpath:nacos.properties")
public class NacosConfiguration {

    @Value("${nacos.group-name:PLATFORM-01}")
    private String groupName;

    @Value("${server.port}")
    private String port;

    @Value("${nacos.service-name:platform1}")
    private String serviceName;

    @NacosInjected
    private NamingService namingService;

    @NacosInjected(properties = @NacosProperties(encode = "UTF-8"))
    private NamingService namingServiceUTF8;

    @PostConstruct
    public void init() {
        try {
            InetAddress address = InetAddress.getLocalHost();
            if (namingService != namingServiceUTF8) {
                throw new RuntimeException("nacos service registration failed");
            } else {
                namingService.registerInstance(serviceName, groupName, address.getHostAddress(), Integer.parseInt(port));
            }
        } catch (UnknownHostException | NacosException e) {
            e.printStackTrace();
        }
    }

}

  • @NacosInjected 是一个核心注解,用于在 Spring Beans 中注入ConfigServiceNamingService 实例,并使这些实例可缓存。 这意味着如果它们的 @NacosProperties 相等,则实例将是相同的,无论属性是来自全局还是自定义的 Nacos 属性。

spring 集成 openfeign

openfeign 是一种声明式的web服务客户端,在 spring cloud 中,仅需创建一个接口并对其进行几行注释即可实现调用远程服务就像调用本地方法一样,开发者完全感知不到是在调用远程方法,更没有像 HttpClient 那样相对繁琐的请求参数封装与响应解析。但遗憾的是官方只提供了 Spring Cloud 版本。本文将参照 spring-cloud-openfeignspring mvc 项目中使用 feign 实现远程服务的调用。

本文参考 spring-cloud-starter-openfeign 版本为 2.0.0.RELEASE,以下简称 openfeign

spring-cloud-openfeign 源码分析

  1. 从开启 openfeign 服务注解 @EnableFeignClients 开始
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {

   ...
}

EnableFeignClientsspringIOC 容器导入了一个 FeignClientsRegistrar 实例。

class FeignClientsRegistrar implements ImportBeanDefinitionRegistrar,
      ResourceLoaderAware, EnvironmentAware {

}

FeignClientsRegistrar 实现了 ImportBeanDefinitionRegistrar 接口,使用 @Import,如果括号中导入的类是 ImportBeanDefinitionRegistrar 的实现类,则会调用接口方法 registerBeanDefinitions,将其中要注册的类注册成 bean

@Override
public void registerBeanDefinitions(AnnotationMetadata metadata,
      BeanDefinitionRegistry registry) {
   // 注册默认配置
   registerDefaultConfiguration(metadata, registry);
   // 注册 feignClients
   registerFeignClients(metadata, registry);
}

BeanDefinitionRegistryspring 中动态注册 beanDefinition 的接口。

registerDefaultConfiguration 用来注册 EnableFeignClients 中提供的自定义配置类中的 Bean,我们主要来看 registerFeignClients

public void registerFeignClients(AnnotationMetadata metadata,
      BeanDefinitionRegistry registry) {
   // 类扫描
   ClassPathScanningCandidateComponentProvider scanner = getScanner();
   scanner.setResourceLoader(this.resourceLoader);
   // 存储类扫描路径
   Set<String> basePackages;
   // 获取EnableFeignClients注解属性
   Map<String, Object> attrs = metadata
         .getAnnotationAttributes(EnableFeignClients.class.getName());
   // 注解filter -> FeignClient
   AnnotationTypeFilter annotationTypeFilter = new AnnotationTypeFilter(
         FeignClient.class);
   // 获取EnableFeignClients上是否配置clients属性
   final Class<?>[] clients = attrs == null ? null
         : (Class<?>[]) attrs.get("clients");
   // if ... else 主要是确定类扫描路径和添加扫描过滤器
   if (clients == null || clients.length == 0) {
      // 类路径扫描器添加过滤器
      scanner.addIncludeFilter(annotationTypeFilter);
      // 获取EnableFeignClients上配置的扫描路径 若不存在则获取EnableFeignClients类所在路径
      basePackages = getBasePackages(metadata);
   }
   // 若配置了clients
   else {
      final Set<String> clientClasses = new HashSet<>();
      basePackages = new HashSet<>();
      // 获取 clients 配置类所在的包路径
      for (Class<?> clazz : clients) {
         basePackages.add(ClassUtils.getPackageName(clazz));
         clientClasses.add(clazz.getCanonicalName());
      }
      // 定义filter 根据给定的 ClassMetadata 对象确定匹配项。
      AbstractClassTestingTypeFilter filter = new AbstractClassTestingTypeFilter() {
         @Override
         protected boolean match(ClassMetadata metadata) {
            String cleaned = metadata.getClassName().replaceAll("\#34;, ".");
            return clientClasses.contains(cleaned);
         }
      };
      // 添加filter
      scanner.addIncludeFilter(
            new AllTypeFilter(Arrays.asList(filter, annotationTypeFilter)));
   }
   // 开始根据包路径扫描 FeignClient
   for (String basePackage : basePackages) {
      // 扫描 FeignClient bean 定义
      Set<BeanDefinition> candidateComponents = scanner
            .findCandidateComponents(basePackage);
      for (BeanDefinition candidateComponent : candidateComponents) {
         // 判断类是否为带注解的Bean
         if (candidateComponent instanceof AnnotatedBeanDefinition) {
            // 验证注解类是否是一个接口(注意是接口)
            AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
            AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
            Assert.isTrue(annotationMetadata.isInterface(),
                  "@FeignClient can only be specified on an interface");
            // 获取FeignClient上配置的属性
            Map<String, Object> attributes = annotationMetadata
                  .getAnnotationAttributes(
                        FeignClient.class.getCanonicalName());
            // 获取 FeignClient 定义名称
            String name = getClientName(attributes);
            registerClientConfiguration(registry, name,
                  attributes.get("configuration"));
            # 注册 feign client
            registerFeignClient(registry, annotationMetadata, attributes);
         }
      }
   }
}

注意: FeignClient 注解标注的是接口 registerFeignClients 方法主要是为了获取 FeignClient 注解标注的接口

下面看注册 FeignClient 方法:

private void registerFeignClient(BeanDefinitionRegistry registry,
      AnnotationMetadata annotationMetadata, Map<String, Object> attributes) {
   // 利用 BeanDefinitionBuilder 向 spring 容器中注入 bean

   String className = annotationMetadata.getClassName();

   // 这里要注意 FeignClientFactoryBean 将会在集成 ribbon 说明
   BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(FeignClientFactoryBean.class);

   ...

   AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();

   ...

   // 到此完成了从 FeignClient 注释的接口到 BeanDefinition 转化
   BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className,
         new String[] { alias });
   // 将转化后的 BeanDefinition 注入 spring 容器
   BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
}

到此 openfeign 完成了将 FeignClient 注解注释的接口信息注入通过 BeanDefinition 注入 spring 容器。

仿 openfeign 实现 FeignClient 接口发现与注册

  1. openfeign 中复制以下源码修改:
  1. 仿照 openfeignFeignClientsConfiguration 添加 FeignConfig 配置类
/**
 * @author: kkfan
 * @create: 2021-07-08 15:54:44
 * @description: feign 配置
 */
@Configuration
@EnableFeignClients(basePackages = "com.kk.feign")
public class FeignConfig {

    public FeignConfig() {
        try {
            // ribbon全局配置读入
            ConfigurationManager.loadPropertiesFromResources("ribbon.properties");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @NacosInjected
    private NamingService namingService;

    @Value("${nacos.group-name:PLATFORM-01}")
    private String groupName;

    @Bean
    public static FeignContext feignContext() {
        return new FeignContext();
    }

    @Bean
    public FeignLoggerFactory feignLoggerFactory() {
        return new DefaultFeignLoggerFactory(null);
    }

    @Bean
    public Feign.Builder feignBuilder(Retryer retryer) {
        return Feign.builder()
                .retryer(retryer);
    }

    @Bean
    public Retryer feignRetryer() {
        return Retryer.NEVER_RETRY;
    }

    @Bean
    public Decoder feignDecoder() {
        return new JacksonDecoder();
    }

    @Bean
    public Encoder feignEncoder() {
        return new JacksonEncoder();
    }

    @Bean
    public Contract feignContract() {
        return new Contract.Default();
    }

    @Bean
    public FeignClientProperties feignClientProperties() {
        return new FeignClientProperties();
    }

    @Bean
    public Targeter feignTargeter() {
        return new Targeter.DefaultTargeter();
    }

}

至此完成了 feign 的集成,但还存在以下问题:

  1. FeignClient 注解类中的 SpringMvc 的注解不支持;
  2. 未和 nacos 集成使用,只能在 FeignClient 中指明调用地址。

下面来解决上面两个问题:

  1. 支持 SpringMvc 注解 参考 openfeign 中的 SpringMvcContract 把相关代码拷出来,相关代码如下:

注意由 spring 版本不同导致的兼容问题

修改 FeignConfig#feignContract 如下:

@Bean
public Contract feignContract() {
    return new SpringMvcContract();
}

  1. feign + nacos 集成 这部分实现主要为从 nacos 中获取已注册服务列表,feign 根据在 FeignClient 上配置的服务名来调用对应的服务,这部分将在下一节关于集成 ribbon 实现负载均衡中体现。

集成Ribbon

在集成完 nacos + feign 后下一个问题是 nacosfeign 都集成好了,如何把他们合在一起使用呢,我们接着看在上节中注册 feignClient 是说到的 FeignClientFactoryBean

class FeignClientFactoryBean
      implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
    ...
}

其实现了 FactoryBean 接口,我们知道如果要使用 Bean 工厂,可以手动实现一个 FactoryBean 的类,改接口有三个方法如下:

public interface FactoryBean<T> {
    String OBJECT_TYPE_ATTRIBUTE = "factoryBeanObjectType";

    @Nullable
    T getObject() throws Exception;

    @Nullable
    Class<?> getObjectType();

    default boolean isSingleton() {
        return true;
    }
}

其中 isSingleton 是用来判断生产的 bean 是否是单例,有默认实现,我们不需要手动实现。getObject 方法是获得生产出来的 bean 对象,getObjectType 是用于获得生产对象的类。

现在来找下 FeignClientFactoryBeangetObject 的实现,代码如下:

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

/**
 * @param <T> the target type of the Feign client
 * @return a {@link Feign} client created with the specified data and the context
 * information
 */
<T> T getTarget() {
    FeignContext context = this.applicationContext.getBean(FeignContext.class);
    Feign.Builder builder = feign(context);

    if (!StringUtils.hasText(this.url)) {
        if (!this.name.startsWith("http")) {
            this.url = "http://" + this.name;
        }
        else {
            this.url = this.name;
        }
        this.url += cleanPath();
        return (T) loadBalance(builder, context,
                new HardCodedTarget<>(this.type, this.name, this.url));
    }
    ...
}

可以看到调用了一个 loadBalance 方法,从字面意思上看负载均衡,应该就是想要的,接着往下看:

protected <T> T loadBalance(Feign.Builder builder, FeignContext context,
        HardCodedTarget<T> target) {
    Client client = getOptional(context, Client.class);
    if (client != null) {
        builder.client(client);
        Targeter targeter = get(context, Targeter.class);
        return targeter.target(this, builder, context, target);
    }

    throw new IllegalStateException(
            "No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-netflix-ribbon?");
}

该方法接收一个 feign builder 和一个 feign context,打个断点调试下这段代码:

可以看到 getOption 从上下文中获取了一个 Client 实例 LoadBalancerFeignClient 后添加到 feign builder 中,现在问题就解决了,在 spring 集成 openfeign 一节中有创建 feignBuilder,在其中加入ribbon client 即可,代码如下:

@Bean
public Feign.Builder feignBuilder(Retryer retryer) {
    return Feign.builder()
            .retryer(retryer)
            .client(ribbonClient())
            .requestInterceptor(new KkRequestInterceptor(new ObjectMapper()));
}

/**
 * 构建负载均衡
 * @return
 */
private RibbonClient ribbonClient() {
    return RibbonClient.builder().lbClientFactory(clientName -> {
        log.info("初始化客户端: ---------》" + clientName);
        IClientConfig config = ClientFactory.getNamedConfig(clientName);

//            ZoneAwareLoadBalancer zb = new ZoneAwareLoadBalancer(config, zoneAvoidanceRule(), ribbonPing(), ribbonServerList(), ribbonServerListFilter(), ribbonServerListUpdater());
        ILoadBalancer lb = ClientFactory.getNamedLoadBalancer(clientName);
        ZoneAwareLoadBalancer zb = (ZoneAwareLoadBalancer) lb;
        zb.setRule(zoneAvoidanceRule());
        zb.setServersList(getByServerName(clientName));
        return LBClient.create(zb, config);
    }).build();
}

其中 ribbon 负载均衡策略如下:

/**
 * Ribbon负载均衡策略实现
 * 使用ZoneAvoidancePredicate和AvailabilityPredicate来判断是否选择某个server,前一个判断判定一个zone的运行性能是否可用,
 * 剔除不可用的zone(的所有server),AvailabilityPredicate用于过滤掉连接数过多的Server。
 * @return
 */
private IRule zoneAvoidanceRule() {
    return new ZoneAvoidanceRule();
}

可用服务列表根据服务名称从nacos中读取:

/**
 * 从nacos读取服务, 封装节点
 * @param name
 * @return
 */
private List<Server> getByServerName(String name) {
    List<Server> servers = new ArrayList<>();
    try {
        List<Instance> allInstances = namingService.getAllInstances(name, groupName);
        allInstances.forEach(x -> {
            Server server = new Server(x.getIp(), x.getPort());
            server.setZone(name);
            servers.add(server);
        });
    } catch (NacosException e) {
        e.printStackTrace();
    }
    return servers;
}

集成完 ribbon 后至此就完成了 spring 集成 openfeign 中的 feign + nacos 集成小节。

相关推荐

墨尔本一华裔男子与亚裔男子分别失踪数日 警方寻人

中新网5月15日电据澳洲新快网报道,据澳大利亚维州警察局网站消息,22岁的华裔男子邓跃(Yue‘Peter’Deng,音译)失踪已6天,维州警方于当地时间13日发布寻人通告,寻求公众协助寻找邓跃。华...

网络交友须谨慎!美国犹他州一男子因涉嫌杀害女网友被捕

伊森·洪克斯克(图源网络,侵删)据美国广播公司(ABC)25日报道,美国犹他州一名男子于24日因涉嫌谋杀被捕。警方表示,这名男子主动告知警局,称其杀害了一名在网络交友软件上认识的25岁女子。雷顿警...

一课译词:来龙去脉(来龙去脉 的意思解释)

Mountainranges[Photo/SIPA]“来龙去脉”,汉语成语,本指山脉的走势和去向,现比喻一件事的前因后果(causeandeffectofanevent),可以翻译为“i...

高考重要考点:range(range高考用法)

range可以用作动词,也可以用作名词,含义特别多,在阅读理解中出现的频率很高,还经常作为完形填空的选项,而且在作文中使用是非常好的高级词汇。...

C++20 Ranges:现代范围操作(现代c++白皮书)

1.引言:C++20Ranges库简介C++20引入的Ranges库是C++标准库的重要更新,旨在提供更现代化、表达力更强的方式来处理数据序列(范围,range)。Ranges库基于...

学习VBA,报表做到飞 第二章 数组 2.4 Filter函数

第二章数组2.4Filter函数Filter函数功能与autofilter函数类似,它对一个一维数组进行筛选,返回一个从0开始的数组。...

VBA学习笔记:数组:数组相关函数—Split,Join

Split拆分字符串函数,语法Split(expression,字符,Limit,compare),第1参数为必写,后面3个参数都是可选项。Expression为需要拆分的数据,“字符”就是以哪个字...

VBA如何自定义序列,学会这些方法,让你工作更轻松

No.1在Excel中,自定义序列是一种快速填表机制,如何有效地利用这个方法,可以大大增加工作效率。通常在操作工作表的时候,可能会输入一些很有序的序列,如果一一录入就显得十分笨拙。Excel给出了一种...

Excel VBA入门教程1.3 数组基础(vba数组详解)

1.3数组使用数组和对象时,也要声明,这里说下数组的声明:'确定范围的数组,可以存储b-a+1个数,a、b为整数Dim数组名称(aTob)As数据类型Dimarr...

远程网络调试工具百宝箱-MobaXterm

MobaXterm是一个功能强大的远程网络工具百宝箱,它将所有重要的远程网络工具(SSH、Telnet、X11、RDP、VNC、FTP、MOSH、Serial等)和Unix命令(bash、ls、cat...

AREX:携程新一代自动化回归测试工具的设计与实现

一、背景随着携程机票BU业务规模的不断提高,业务系统日趋复杂,各种问题和挑战也随之而来。对于研发测试团队,面临着各种效能困境,包括业务复杂度高、数据构造工作量大、回归测试全量回归、沟通成本高、测试用例...

Windows、Android、IOS、Web自动化工具选择策略

Windows平台中应用UI自动化测试解决方案AutoIT是开源工具,该工具识别windows的标准控件效果不错,但是当它遇到应用中非标准控件定义的UI元素时往往就无能为力了,这个时候选择silkte...

python自动化工具:pywinauto(python快速上手 自动化)

简介Pywinauto是完全由Python构建的一个模块,可以用于自动化Windows上的GUI应用程序。同时,它支持鼠标、键盘操作,在元素控件树较复杂的界面,可以辅助我们完成自动化操作。我在...

时下最火的 Airtest 如何测试手机 APP?

引言Airtest是网易出品的一款基于图像识别的自动化测试工具,主要应用在手机APP和游戏的测试。一旦使用了这个工具进行APP的自动化,你就会发现自动化测试原来是如此简单!!连接手机要进行...

【推荐】7个最强Appium替代工具,移动App自动化测试必备!

在移动应用开发日益火爆的今天,自动化测试成为了确保应用质量和用户体验的关键环节。Appium作为一款广泛应用的移动应用自动化测试工具,为测试人员所熟知。然而,在不同的测试场景和需求下,还有许多其他优...

取消回复欢迎 发表评论: