Netty+SpringBoot 打造一个 TCP 长连接通讯方案

项目背景

公司某物联网项目需要使用socket长连接进行消息通讯,捣鼓了一版代码上线,结果BUG不断,本猿寝食难安,于是求助度娘,数日未眠项目终于平稳运行了,本着开源共享的精神,本猿把项目代码提炼成了一个demo项目,尽量摒弃了其中丑陋的业务部分,希望与同学们共同学习进步。

一、项目架构

本项目使用了netty、redis以及springboot2.2.0

二、项目模块

本项目目录结构如下图:

在这里插入图片描述
netty-tcp-core是公共模块,主要是工具类。netty-tcp-server是netty服务端,服务端仅作测试使用,实际项目中我们只使用了客户端。netty-tcp-client是客户端,也是本文的重点。

三、业务流程

我们实际项目中使用RocketMQ作为消息队列,本项目由于是demo项目于是改为了BlockingQueue。数据流为:

生产者->消息队列->消费者(客户端)->tcp通道->服务端->tcp通道->客户端。

当消费者接收到某设备发送的消息后,将判断缓存中是否存在该设备与服务端的连接,如果存在并且通道活跃则使用该通道发送消息,如果不存在则创建通道并在通道激活后立即发送消息,当客户端收到来自服务端的消息时进行响应的业务处理。

四、代码详解

1.消息队列
由于本demo项目移除了消息中间件,于是需要自己创建一个本地队列模拟真实使用场景

package org.example.client;import org.example.client.model.NettyMsgModel;import java.util.concurrent.ArrayBlockingQueue;/*** 本项目为演示使用本地队列 实际生产中应该使用消息中间件代替(rocketmq或rabbitmq)** @author ReWind00* @date 2023/2/15 11:20*/
public class QueueHolder {private static final ArrayBlockingQueue<NettyMsgModel> queue = new ArrayBlockingQueue<>(100);public static ArrayBlockingQueue<NettyMsgModel> get() {return queue;}
}

使用一个类保存队列的静态实例以便在任何类中都可以快速引用。接下来我们需要启动一个线程去监听队列中的消息,一但消息投递到队列中,我们就取出消息然后异步多线程处理该消息。

public class LoopThread implements Runnable {@Overridepublic void run() {for (int i = 0; i < MAIN_THREAD_POOL_SIZE; i++) {executor.execute(() -> {while (true) {//取走BlockingQueue里排在首位的对象,若BlockingQueue为空,阻断进入等待状态直到try {NettyMsgModel nettyMsgModel = QueueHolder.get().take();messageProcessor.process(nettyMsgModel);} catch (InterruptedException e) {log.error(e.getMessage(), e);}}});}}
}

使用take方法会使该线程一直阻塞直到队列收到消息后进入下一次循环。
2.执行类
process方法来自于MessageProcessor类,该类为单例,但是会有多线程同时执行。

public void process(NettyMsgModel nettyMsgModel) {String imei = nettyMsgModel.getImei();try {synchronized (this) { //为避免收到同一台设备多条消息后重复创建客户端,必须加锁if (redisCache.hasKey(NETTY_QUEUE_LOCK + imei)) { //上一条消息处理中log.info("imei={}消息处理中,重新入列", imei);//放回队列重新等待消费 延迟x秒(实际项目中应该使用rocketmq或者rabbitmq实现延迟消费)new Timer().schedule(new TimerTask() {@Overridepublic void run() {QueueHolder.get().offer(nettyMsgModel);}}, 2000);log.info("imei={}消息处理中,重新入列完成", imei);return;} else {//如果没有在连接中的直接加锁redisCache.setCacheObject(NETTY_QUEUE_LOCK + imei, "1", 120, TimeUnit.SECONDS);}}//缓存中存在则发送消息if (NettyClientHolder.get().containsKey(imei)) {NettyClient nettyClient = NettyClientHolder.get().get(imei);if (null != nettyClient.getChannelFuture() && nettyClient.getChannelFuture().channel().isActive()) { //通道活跃直接发送消息if (!nettyClient.getChannelFuture().channel().isWritable()) {log.warn("警告,通道不可写,imei={},channelId={}", nettyClient.getImei(),nettyClient.getChannelFuture().channel().id());}nettyClient.send(nettyMsgModel.getMsg());} else {log.info("client imei={},通道不活跃,主动关闭", nettyClient.getImei());nettyClient.close();//重新创建客户端发送this.createClientAndSend(nettyMsgModel);}} else {  //缓存中不存在则创建新的客户端this.createClientAndSend(nettyMsgModel);}} catch (Exception e) {log.error(e.getMessage(), e);} finally {//执行完后解锁redisCache.deleteObject(NETTY_QUEUE_LOCK + imei);}}

其中imei是我们设备的唯一标识,我们可以用imei作为缓存的key来确认是否已创建过连接。由于我们消息的并发量可能会很大,所以存在当某设备的连接正在创建的过程中,另一个线程收到该设备消息也开始创建连接的情况,所以我们使用synchronized 代码块以及redis分布式锁来避免此情况的发生。当一条消息获得锁后,在锁释放前,后续消息将会被重新放回消息队列并延迟消费。

获取锁的线程会根据imei判断缓存是否存在连接,如果存在直接发送消息,如果不存在则进入创建客户端的方法。

private void createClientAndSend(NettyMsgModel nettyMsgModel) {log.info("创建客户端执行中imei={}", nettyMsgModel.getImei());//此处的DemoClientHandler可以根据自己的业务定义NettyClient nettyClient = SpringUtils.getBean(NettyClient.class, nettyMsgModel.getImei(), nettyMsgModel.getBizData(),this.createDefaultWorkGroup(this.workerThread), DemoClientHandler.class);executor.execute(nettyClient); //执行客户端初始化try {//利用锁等待客户端激活synchronized (nettyClient) {long c1 = System.currentTimeMillis();nettyClient.wait(5000); //最多阻塞5秒 5秒后客户端仍然未激活则自动解锁long c2 = System.currentTimeMillis();log.info("创建客户端wait耗时={}ms", c2 - c1);}if (null != nettyClient.getChannelFuture() && nettyClient.getChannelFuture().channel().isActive()) { //连接成功//存入缓存NettyClientHolder.get().put(nettyMsgModel.getImei(), nettyClient);//客户端激活后发送消息nettyClient.send(nettyMsgModel.getMsg());} else { //连接失败log.warn("客户端创建失败,imei={}", nettyMsgModel.getImei());nettyClient.close();//可以把消息重新入列处理}} catch (Exception e) {log.error("客户端初始化发送消息异常===>{}", e.getMessage(), e);}
}

当netty客户端实例创建后使用线程池执行初始化,由于是异步执行,我们此时立刻发送消息很可能客户端还没有完成连接,因此必须加锁等待。进入synchronized 代码块,使用wait方法等待客户端激活后解锁,参数5000为自动解锁的毫秒数,意思是如果客户端出现异常情况迟迟未能连接成功并激活通道、解锁,则最多5000毫秒后该锁自动解开。

这参数在实际使用时可以视情况调整,在并发量很大的情况下,5秒的阻塞可能会导致线程池耗尽,或内存溢出。待客户端创建成功并激活后则立即发送消息。
3.客户端

package org.example.client;import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateHandler;
import io.netty.util.CharsetUtil;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.client.handler.BaseClientHandler;
import org.example.core.util.SpringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;/*** @author ReWind00* @date 2023/2/15 9:59*/
@Slf4j
@Component
@Scope("prototype")
@Getter
@NoArgsConstructor
public class NettyClient implements Runnable {@Value("${netty.server.port}")private int port;@Value("${netty.server.host}")private String host;//客户端唯一标识private String imei;//自定义业务数据private Map<String, Object> bizData;private EventLoopGroup workGroup;private Class<BaseClientHandler> clientHandlerClass;private ChannelFuture channelFuture;public NettyClient(String imei, Map<String, Object> bizData, EventLoopGroup workGroup, Class<BaseClientHandler> clientHandlerClass) {this.imei = imei;this.bizData = bizData;this.workGroup = workGroup;this.clientHandlerClass = clientHandlerClass;}@Overridepublic void run() {try {this.init();log.info("客户端启动imei={}", imei);} catch (Exception e) {log.error("客户端启动失败:{}", e.getMessage(), e);}}public void close() {if (null != this.channelFuture) {this.channelFuture.channel().close();}NettyClientHolder.get().remove(this.imei);}public void send(String message) {try {if (!this.channelFuture.channel().isActive()) {log.info("通道不活跃imei={}", this.imei);return;}if (!StringUtils.isEmpty(message)) {log.info("队列消息发送===>{}", message);this.channelFuture.channel().writeAndFlush(message);}} catch (Exception e) {log.error(e.getMessage(), e);}}private void init() throws Exception {//将本实例传递到handlerBaseClientHandler clientHandler = SpringUtils.getBean(clientHandlerClass, this);Bootstrap b = new Bootstrap();//2 通过辅助类去构造server/clientb.group(workGroup).channel(NioSocketChannel.class).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000).option(ChannelOption.SO_RCVBUF, 1024 * 32).option(ChannelOption.SO_SNDBUF, 1024 * 32).handler(new ChannelInitializer<SocketChannel>() {@Overrideprotected void initChannel(SocketChannel ch) throws Exception {ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024 * 1024, Unpooled.copiedBuffer("\r\n".getBytes())));ch.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8));// String解码。ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));// String解码。
//                        // 心跳设置ch.pipeline().addLast(new IdleStateHandler(0, 0, 600, TimeUnit.SECONDS));ch.pipeline().addLast(clientHandler);}});this.connect(b);}private void connect(Bootstrap b) throws InterruptedException {long c1 = System.currentTimeMillis();final int maxRetries = 2; //重连2次final AtomicInteger count = new AtomicInteger();final AtomicBoolean flag = new AtomicBoolean(false);try {this.channelFuture = b.connect(host, port).addListener(new ChannelFutureListener() {public void operationComplete(ChannelFuture future) throws Exception {if (!future.isSuccess()) {if (count.incrementAndGet() > maxRetries) {log.warn("imei={}重连超过{}次", imei, maxRetries);} else {log.info("imei={}重连第{}次", imei, count);b.connect(host, port).addListener(this);}} else {log.info("imei={}连接成功,连接IP:{}连接端口:{}", imei, host, port);flag.set(true);}}}).sync(); //同步连接} catch (Exception e) {log.error(e.getMessage(), e);}log.info("设备imei={},channelId={}连接耗时={}ms", imei, channelFuture.channel().id(), System.currentTimeMillis() - c1);if (flag.get()) {channelFuture.channel().closeFuture().sync(); //连接成功后将持续阻塞该线程}}
}

netty客户端为多实例,每个实例绑定一个线程,持续阻塞到客户端关闭为止,每个客户端中可以保存自己的业务数据,以便在后续与服务端交互时处理业务使用。客户端执行连接时,给了2次重试的机会,如果3次都没连接成功则放弃。后续可以选择将该消息重新入列消费。我们实际项目中,此处还应该预先给服务端发送一条登录消息,待服务端确认后才能执行后续通讯,这需要视实际情况进行调整。

另一个需要注意的点是EventLoopGroup是从构造函数传入的,而不是在客户端中创建的,因为当客户端数量非常多时,每个客户端都创建自己的线程组会极大的消耗服务器资源,因此我们在实际使用中是按业务去创建统一的线程组给该业务下的所有客户端共同使用的,线程组的大小需要根据业务需求灵活配置。

在init方法中,我们给客户端加上了一个handler来处理与服务端的交互,下面来看一下具体实现。

package org.example.client.handler;import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;
import org.example.client.NettyClient;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;import java.util.Map;/*** @author ReWind00* @date 2023/2/15 10:09*/
@Slf4j
@Component
@Scope("prototype")
public class DemoClientHandler extends BaseClientHandler {private final String imei;private final Map<String, Object> bizData;private final NettyClient nettyClient;private int allIdleCounter = 0;private static final int MAX_IDLE_TIMES = 3;public DemoClientHandler(NettyClient nettyClient) {this.nettyClient = nettyClient;this.imei = nettyClient.getImei();this.bizData = nettyClient.getBizData();}@Overridepublic void channelActive(ChannelHandlerContext ctx) throws Exception {log.info("客户端imei={},通道激活成功", this.imei);synchronized (this.nettyClient) { //当通道激活后解锁队列线程,然后再发送消息this.nettyClient.notify();}}@Overridepublic void channelInactive(ChannelHandlerContext ctx) throws Exception {log.warn("客户端imei={},通道断开连接", this.imei);}@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {log.info("客户端imei={},收到消息:  {}", this.imei, msg);//处理业务...if ("shutdown".equals(msg)) {this.nettyClient.close();}}@Overridepublic void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {if (evt instanceof IdleStateEvent) {IdleStateEvent e = (IdleStateEvent) evt;boolean flag = false;if (e.state() == IdleState.ALL_IDLE) {this.allIdleCounter++;log.info("客户端imei={}触发闲读或写第{}次", this.imei, this.allIdleCounter);if (this.allIdleCounter >= MAX_IDLE_TIMES) {flag = true;}}if (flag) {log.warn("读写超时达到{}次,主动断开连接", MAX_IDLE_TIMES);ctx.channel().close();}}}@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {log.error("客户端imei={},连接异常{}", imei, cause.getMessage(), cause);}
}

DemoClientHandler也是多实例bean,每个实例持有自己的NettyClient引用,以便在后续处理具体业务。在channelActive方法中,我们可以看到执行了客户端实例的notify方法,此处就是在客户端创建成功并且通道激活后解除wait锁的地方。channelRead方法就是我们处理服务端发送过来的消息的方法,我们的具体业务应该在该方法执行,当然不建议长时间阻塞客户端的工作线程,可以考虑异步处理。

最后我们看一下客户端缓存类。

package org.example.client;import java.util.concurrent.ConcurrentHashMap;/*** @author ReWind00* @date 2023/2/15 11:01*/
public class NettyClientHolder {private static final ConcurrentHashMap<String, NettyClient> clientMap = new ConcurrentHashMap<>();public static ConcurrentHashMap<String, NettyClient> get() {return clientMap;}}

由于netty的通道无法序列化,因此不能存入redis,只能缓存在本地内存中,其本质就是一个ConcurrentHashMap。

五、测试

package org.example.client.controller;import org.example.client.QueueHolder;
import org.example.client.model.NettyMsgModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;/*** @author ReWind00* @date 2023/2/15 13:48*/
@RestController
@RequestMapping("/demo")
public class DemoController {/*** 间隔发送两条消息*/@GetMapping("testOne")public void testOne() {QueueHolder.get().offer(NettyMsgModel.create("87654321", "Hello World!"));try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}QueueHolder.get().offer(NettyMsgModel.create("87654321", "Hello World Too!"));}/*** 任意发送消息** @param imei* @param msg*/@GetMapping("testTwo")public void testTwo(@RequestParam String imei, @RequestParam String msg) {QueueHolder.get().offer(NettyMsgModel.create(imei, msg));}/*** 连续发送两条消息 第二条由于redis锁将会重新放回队列延迟消费*/@GetMapping("testThree")public void testThree() {QueueHolder.get().offer(NettyMsgModel.create("12345678", "Hello World!"));QueueHolder.get().offer(NettyMsgModel.create("12345678", "Hello World Too!"));}
}

测试接口代码如上,调用testOne,日志如下:
在这里插入图片描述
可以看到第一条消息触发了客户端创建流程,创建后发送了消息,而5秒后的第二条消息直接通过已有通道发送了。

测试接口代码如上,调用testTwo,日志如下:
在这里插入图片描述
发送shutdown可以主动断开已有连接。

测试接口代码如上,调用testThree,日志如下:
在这里插入图片描述可以看到第二条消息重新入列并被延迟消费了。

六、源码

https://gitee.com/jaster/netty-tcp-demo

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/195045.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

python爬取网站数据,作为后端数据

一. 内容简介 python爬取网站数据&#xff0c;作为后端数据 二. 软件环境 2.1vsCode 2.2Anaconda version: conda 22.9.0 2.3代码 链接&#xff1a; 三.主要流程 3.1 通过urllib请求网站 里面用的所有的包 ! pip install lxml ! pip install selenium ! pip install…

【数据结构】希尔排序(最小增量排序)

&#x1f466;个人主页&#xff1a;Weraphael ✍&#x1f3fb;作者简介&#xff1a;目前正在学习c和算法 ✈️专栏&#xff1a;数据结构 &#x1f40b; 希望大家多多支持&#xff0c;咱一起进步&#xff01;&#x1f601; 如果文章有啥瑕疵 希望大佬指点一二 如果文章对你有帮助…

蓝桥杯 大小写转换

islower/isupper函数 islower和issupper是C标准库中的字符分类函数&#xff0c;用于检查一个字符是否为小写字母或大写字母 需要头文件< cctype>,也可用万能头包含 函数的返回值为bool类型 char ch1A; char ch2b; //使用islower函数判断字符是否为小写字母 if(islower(…

Flutter NestedScrollView 、SliverAppBar全解析,悬浮菜单的应用

在我们开发过程中经常会使用到悬浮菜单的使用&#xff0c;当我们滑动到指定位置后&#xff0c;菜单会自动悬浮。 实现效果如下&#xff08;左为滑动前、右为滑动后&#xff09;&#xff1a; 上述便是通过NestedScrollView 、SliverAppBar实现的效果&#xff0c;通过两个控件我…

文件包含_具体场景、zip、php相关问题

具体场景—上传可控的文件 具体场景—远程文件包含 具体场景—伪协议

基于plc的柔性制造系统供料检测单元的设计(论文+源码)

1.系统设计 本次基于plc的柔性制造系统供料检测单元的设计&#xff0c;其系统结构框图如图2.1所示&#xff0c;系统采用西门子S7-200 型号的PLC作为主控制器&#xff0c;并结合温度传感器&#xff0c;重量传感器&#xff0c;限位开关&#xff0c;变频器等器件来构成整个系统&a…

0基础如何学习软件测试?10分钟给你安排明白

先上一张学习路线&#xff1a; 在测试行业已经呆了5年多了&#xff0c;也算得上行业经验资深了吧&#xff0c;基本上也是摸清了这个行业的发展。 所以今天也想对有转行想法的朋友分享一下经验&#xff0c;能够让你对这个行业有个大致的了解和对以后的发展有所规划&#xff0c;…

07.智慧商城——商品详情页、加入购物车、拦截器封装token

01. 商品详情 - 静态布局 静态结构 和 样式 <template><div class"prodetail"><van-nav-bar fixed title"商品详情页" left-arrow click-left"$router.go(-1)" /><van-swipe :autoplay"3000" change"onCha…

机械人必须要了解的丝杆螺母参数

丝杆螺母是机械中重要的零部件之一&#xff0c;主要用于将旋转运动转化为直线运动&#xff0c;或者将直线运动转化为旋转运动。只有正确了解丝杆螺母的参数&#xff0c;才能进行选型。 1、螺纹规格&#xff1a;丝杆螺母的螺纹规格是按照国家标准进行分类的&#xff0c;常见的有…

HTTP HTTPS 独特的魅力

目录 HTTP协议 HTTP协议的工作过程 首行 请求头&#xff08;header&#xff09; HOST Content-Length​编辑 User-Agent&#xff08;简称UA&#xff09; Referer Cookie 空行 正文&#xff08;body&#xff09; HTTP响应详解 状态码 报文格式 HTTP响应格式 如何…

Fourier分析导论——第5章——实数据R上的Fourier变换(E.M. Stein R. Shakarchi)

第5章 实数域ℝ上的Fourier变换 The theory of Fourier series and integrals has always had major difficulties and necessitated a large math- ematical apparatus in dealing with questions of con- vergence. It engendered the development of methods of summa…

Mysql分组查询每组最新的一条数据

在工作中遇到一个问题&#xff0c;需要查出每个公司最新的那条数据。 所以需根据公司进行分组&#xff1a; 未进行分组时&#xff1a; select a.id, b.name companyName, result_asset ,result_liability ,result_net_asset, a.create_time ,a.is_deleted from bus_proper…

企业APP软件定制开发的关键步骤|网站小程序搭建

企业APP软件定制开发的关键步骤|网站小程序搭建 在当今数字化快速发展的时代&#xff0c;企业越来越意识到拥有自己的APP软件对于提高业务效率和用户体验的重要性。然而&#xff0c;企业APP软件定制开发并不是一项简单的任务&#xff0c;它需要经过一系列关键步骤来确保最终的产…

解锁编程潜能:探索亚马逊CodeWhisperer,打造编程世界的声音引导者

文章目录 前言一、什么是 Amazon CodeWhisperer&#xff1f;二、如何使用CodeWhisperer&#xff1f;安装CodeWhisperer插件配置CodeWhisperer生成注释和文档 总结 前言 随着CHATGPT的一声巨响&#xff0c;大语言模型已经成为了一个备受瞩目的创新应用。亚马逊云科技作为全球领…

Hive Lateral View explode列为空时导致数据异常丢失

一、问题描述 日常工作中我们经常会遇到一些非结构化数据&#xff0c;因此常常会将Lateral View 结合explode使用&#xff0c;达到将非结构化数据转化成结构化数据的目的&#xff0c;但是该方法对应explode的内容是有非null限制的&#xff0c;否则就有可能造成数据缺失。 SE…

十大热门骨传导蓝牙耳机排行榜,精选最佳的五款骨传导蓝牙耳机

排行榜十大热门骨传导耳机&#xff0c;哪些才是综合实力最强的骨传导耳机&#xff1f; 近年来&#xff0c;骨传导耳机越来越受欢迎。由于骨传导耳机不需要插入耳朵&#xff0c;用户能够同时感知周围环境的声音&#xff0c;不会完全隔绝外界&#xff0c;增加了使用时的安全性。…

keepalived安装配置(服务器主备、负载均衡)

系统拓扑 安装keepalived 主备服务器上都需要安装 在线安装 yum install -y keepalived 离线安装 # todo 服务器准备 虚拟机ip&#xff1a;192.168.11.56 主服务器&#xff1a;192.168.11.53 备服务器&#xff1a;192.168.11.54 配置文件修改 keepalived安装之后&…

后端接口性能优化分析-问题发现问题定义

&#x1f44f;作者简介&#xff1a;大家好&#xff0c;我是爱吃芝士的土豆倪&#xff0c;24届校招生Java选手&#xff0c;很高兴认识大家&#x1f4d5;系列专栏&#xff1a;Spring源码、JUC源码&#x1f525;如果感觉博主的文章还不错的话&#xff0c;请&#x1f44d;三连支持&…

工业镜头中的远心镜头与普通镜头的光路

普通镜头&#xff1a; 主光线与镜头光轴有角度&#xff0c;工件上下移动时&#xff0c;像的大小有变化。 FOV&#xff1e;镜头前端直径。 物方远心镜头&#xff1a; 物方主光线平行于光轴&#xff0c;物距发生改变时&#xff0c;像高不会发生改变&#xff0c;测得的物体尺寸大…

基于 Junit 的接口自动化测试框架实现!

分层的自动化测试 5~10 年前&#xff0c;我们接触的自动化测试更关注的是 UI 层的自动化测试&#xff0c;Mercury 的 WinRunner/QTP 是那个时代商业性自动化测试产品的典型代表&#xff0c;在那个时代大家单纯想的都是能用一个自动化操作的工具替代人力的点击&#xff0c;商业…