项目背景
近期公司数字工厂项目因生产需要,需要引入一个智能模块控制器,来实现产品在产线上的智能流转,以减少人工干预、降低人为的识别错误率。因此通过在市场上的调研、采购,最终选中了一款产品来实现此需求功能。
智能模块
选品的模块是一个智能模块,模块支持串口通信、RS485/网口通信、Modbus RTU/TCP互转模式。从此可以看出,该模块支持tcp协议通信。根据我们的业务场景,将此模块作为tcp client模块,自己搭建tcp service端,来实现功能控制实现。
交互流程图
- A服务作为上游系统,通过调用B服务的http接口将触发指令通知到B服务
- B服务作为中间节点承上启下,就是我们需要开发出来的应用程序
- iot模块就是上文提到的智能模块硬件,能够接收参数信息。当然该模块本身内嵌了处理程序,以及暴露了接口供我们调用
- B服务有两个端口:1)http端口:8080 供与A服务做http业务交互 2)tcp端口:502 供与IoT模块做tcp交互
上代码
pom:
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- netty -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.63.Final</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.15</version>
</dependency>
</dependencies>
<!-- maven package -->
<build>shang
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
TcpServer
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
@Slf4j
@Component
public class TcpServer {
public static final Charset UTF_8 = StandardCharsets.UTF_8;
@Value("${server.address}")
private String SERVER_IP;
@Value("${netty.port}")
private Integer SERVER_PORT;
@SneakyThrows
@Async
public void run() {
final EventLoopGroup bossGroup = new NioEventLoopGroup();
final EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
// 主从多线程模型
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
// 启用TCP保活检测,多久检测一次还是取决于操作系统本身
// .option(ChannelOption.TCP_NODELAY, true)
// 关闭Nagle算法,有数据发送时就马上发送
.option(ChannelOption.SO_BACKLOG, 1024)
.option(ChannelOption.SO_REUSEADDR, true)
.localAddress(SERVER_IP, SERVER_PORT)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel channel) {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new LineBasedFrameDecoder(64 * 1024));
pipeline.addLast(new StringDecoder(UTF_8));
pipeline.addLast(new JsonDecoder());
pipeline.addLast(new StringEncoder(UTF_8));
pipeline.addLast(new JsonEncoder());
pipeline.addLast(new TcpServerHandler());
}
});
// 指定连接队列大小,可以暂存1024个连接
bootstrap.option(ChannelOption.SO_BACKLOG, 1024);
// 启用Nagle算法,将小的数据包组装为更大的帧然后进行发送
// bootstrap.option(ChannelOption.TCP_NODELAY, true);
// 使用内存池
bootstrap.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
// 异步地绑定服务器,调用sync()方法阻塞,等待直到绑定完成
ChannelFuture bindFuture = bootstrap.bind().sync();
bindFuture.addListener((ChannelFutureListener) channelFuture -> {
String flag = bindFuture.isSuccess() ? " ok" : " failure";
log.info("[TcpServer] start on {}:{} {}", SERVER_IP, SERVER_PORT, flag);
});
// 获取Channel的CloseFuture,并且阻塞当前线程直到它完成
ChannelFuture closeFuture = bindFuture.channel().closeFuture().sync();
closeFuture.addListener((ChannelFutureListener) channelFuture -> {
String flag = bindFuture.isSuccess() ? " ok" : " failure";
log.info("[TcpServer] stop on {}:{} {}", SERVER_IP, SERVER_PORT, flag);
});
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
// 关闭EventLoopGroup释放所有的资源
workerGroup.shutdownGracefully().sync();
bossGroup.shutdownGracefully().sync();
}
}
}
TcpServerHandler
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.util.concurrent.GlobalEventExecutor;
import lombok.extern.slf4j.Slf4j;
import java.net.InetSocketAddress;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
public class TcpServerHandler extends SimpleChannelInboundHandler<String> {
//定义一个channle 组,管理所有的channel
//GlobalEventExecutor.INSTANCE) 是全局的事件执行器,是一个单例
public static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
/**
* key: 设备编号 value: 设备编号绑定的模块channel
*/
public static Map<String, Channel> CHANNEL_MAP = new ConcurrentHashMap<>();
/**
* 有客户端与服务器发生连接时执行此方法 1.打印提示信息 2.将客户端保存到 channelGroup 中
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
EquipmentBindingModuleModel model = this.findEquipmentModel(ctx);
if (Objects.nonNull(model)) {
CHANNEL_MAP.put(model.getEquipmentNumber(), channel);
log.info("[TcpServerHandler]有新的客户端与服务器发生连接 客户端地址:{}, 设备编号:{}", channel.remoteAddress(), model.getEquipmentNumber());
}
}
/**
* 获取到channel内的ip地址,找到与之绑定的设备配置信息
*
* @param ctx
* @return
*/
private EquipmentBindingModuleModel findEquipmentModel(ChannelHandlerContext ctx) {
Channel channel = ctx.channel();
InetSocketAddress inetSocketAddress = (InetSocketAddress) channel.remoteAddress();
// 获取到channel的ip
String address = inetSocketAddress.getAddress()
.getHostAddress();
if (InitConfigurationFileServer.EQUIPMENT_BINDING_MODULE_MODEL_MAP.containsKey(address)) {
return InitConfigurationFileServer.EQUIPMENT_BINDING_MODULE_MODEL_MAP.get(address);
}
return null;
}
/**
* 当有客户端与服务器断开连接时执行此方法,此时会自动将此客户端从 channelGroup 中移除 1.打印提示信息
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
EquipmentBindingModuleModel model = this.findEquipmentModel(ctx);
if (Objects.nonNull(model)) {
CHANNEL_MAP.remove(model.getEquipmentNumber());
log.info("[TcpServerHandler]有客户端与服务器断开连接 客户端地址:{}, 设备编号:{}", channel.remoteAddress(), model.getEquipmentNumber());
}
}
/**
* 表示channel 处于活动状态
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
EquipmentBindingModuleModel model = this.findEquipmentModel(ctx);
if (Objects.nonNull(model)) {
log.info(ctx.channel()
.remoteAddress() + " [TcpServerHandler].处于活动状态, 设备编号:{} ", model.getEquipmentNumber());
}
}
/**
* 表示channel 处于不活动状态
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
EquipmentBindingModuleModel model = this.findEquipmentModel(ctx);
if (Objects.nonNull(model)) {
log.info(ctx.channel()
.remoteAddress() + " [TcpServerHandler].处于不活动状态, , 设备编号:{} ", model.getEquipmentNumber());
}
}
/**
* 读取到客户端发来的数据数据
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
Channel channel = ctx.channel();
log.info("[TcpServerHandler] 有客户端发来的数据 地址{}, 内容{}", channel.remoteAddress(), msg);
EquipmentBindingModuleModel model = this.findEquipmentModel(ctx);
if (Objects.nonNull(model)) {
log.info("[TcpServerHandler] 有客户端发来的数据 地址: {}, 设备编号: {}, 内容: {}", ctx.channel()
.remoteAddress(), model.getEquipmentNumber(), msg);
}
}
@Override
public boolean acceptInboundMessage(Object msg) throws Exception {
log.info("[TcpServerHandler] 有客户端发来的数据 msg={}", msg.toString());
return super.acceptInboundMessage(msg);
}
/**
* 处理异常
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
EquipmentBindingModuleModel model = this.findEquipmentModel(ctx);
if (Objects.nonNull(model)) {
log.error("[TcpServerHandler].发生异常。设备编号:{} , 异常信息:{}", model.getEquipmentNumber(), cause.getMessage());
} else {
log.error("[TcpServerHandler].发生异常。异常信息:{}", cause.getMessage());
}
//关闭通道
ctx.close();
}
}
JsonEncoder
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageEncoder;
import io.netty.util.ReferenceCountUtil;
import java.util.List;
import java.util.Map;
/**
* @createTime 2023-08-08 20:11
* @Description TODO
*/
public class JsonEncoder extends MessageToMessageEncoder<Map<String, Object>> {
@Override
protected void encode(ChannelHandlerContext ctx, Map<String, Object> msg, List<Object> out) throws Exception {
ReferenceCountUtil.release(msg);
}
}
JsonDecoder不写了,extends MessageToMessageDecoder即可。
NoticeIotProcessor
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.util.HashedWheelTimer;
import io.netty.util.Timer;
import io.netty.util.TimerTask;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.util.StringUtils;
import java.net.InetSocketAddress;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
@Slf4j
@Component
public class NoticeIotProcessor implements BiConsumer<Integer, String> {
final Timer timer = new HashedWheelTimer(Executors.defaultThreadFactory(), 100, TimeUnit.MILLISECONDS, 1024);
@Override
public void accept(Integer craftId, String equipmentNumber) {
log.info("[NoticeIotProcessor] craftId:{},equipmentNumber:{}", craftId, equipmentNumber);
CompletableFuture.runAsync(() -> {
StopWatch stopWatch = new StopWatch("equipmentNumber");
stopWatch.start(equipmentNumber);
if (StringUtils.isEmpty(craftId) || StringUtils.isEmpty(equipmentNumber)) {
log.info("[NoticeIotProcessor] 请求参数为空.return");
return;
}
CraftEnum currentCraftEnum = CraftEnum.getCraftEnumByCode(craftId);
if (Objects.isNull(currentCraftEnum)) {
log.info("[NoticeIotProcessor] 根据工艺id:{}未找到对应面板工艺信息.return", craftId);
return;
}
LineWayEnum currentLineWayEnum = LineWayEnum.getLineWayEnumByCode(currentCraftEnum.getLineId());
if (Objects.isNull(currentLineWayEnum)) {
log.info("[NoticeIotProcessor] 根据模块线路:{}为未找到对应modbus控制线路.return", currentCraftEnum.getLineId());
return;
}
if (!TcpServerHandler.CHANNEL_MAP.containsKey(equipmentNumber)) {
log.error("[NoticeIotProcessor] 根据设备编号:{}未找到对应channel.return", equipmentNumber);
return;
}
Channel channel = TcpServerHandler.CHANNEL_MAP.get(equipmentNumber);
InetSocketAddress inetSocketAddress = (InetSocketAddress) channel.remoteAddress();
// 获取到channel的ip
String address = inetSocketAddress.getAddress().getHostAddress();
Integer delayTime = InitConfigurationFileServer.EQUIPMENT_BINDING_MODULE_MODEL_MAP.get(address).getDelayTime();
TimerTask task = timeout -> {
// 先断开
channel.writeAndFlush(this.getByteBuf(currentLineWayEnum.getDisconnectCommand())).sync();
log.info("[NoticeIotProcessor] 1发送[断开]指令完成,equipmentNumber={}", equipmentNumber);
};
timer.newTimeout(task, delayTime, TimeUnit.MILLISECONDS);
task = timeout -> {
// 再闭合
channel.writeAndFlush(this.getByteBuf(currentLineWayEnum.getCloseCommand())).sync();
log.info("[NoticeIotProcessor] 2发送[闭合]指令完成,equipmentNumber={}", equipmentNumber);
};
timer.newTimeout(task, 2L * delayTime, TimeUnit.MILLISECONDS);
task = timeout -> {
// 后断开
channel.writeAndFlush(this.getByteBuf(currentLineWayEnum.getDisconnectCommand())).sync();
log.info("[NoticeIotProcessor] 3发送[断开]指令完成,equipmentNumber={}", equipmentNumber);
};
timer.newTimeout(task, 3L * delayTime, TimeUnit.MILLISECONDS);
stopWatch.stop();
log.info("[PcbCraftProcessor] stopWatch:{}", StopWatchUtil.prettyPrintBySecond(stopWatch));
});
}
private ByteBuf getByteBuf(String command) {
ByteBuf buff = Unpooled.buffer();
buff.writeBytes(ConvertCode.hexString2Bytes(command));
return buff;
}
}
可以看到NoticeIotProcessor类中,使用到了Netty的时间轮。主要是为了解决连续给设备发多条指令的问题。当时在调试阶段时发现如果不对每次发送的指令做控制,设备似乎会响应不过来即没有回应。
当时第一个尝试方案就是用来Thread.sleep()来控制的,可以达到效果。 但是后期将服务部署起来,设备也接入工厂后,调试时发现设备的运作根本不在预期的效果控制里,不是乱控制就是没响应,脑袋嗡嗡嗡~
另外,这个类中用到的InitConfigurationFileServer是通过预加载机制把相关配置读进来使用而已,不用即可忽略。
随后通过思考便干掉了sleep,引入了Netty的Timer来做时间间隔的控制,单位控制在毫秒,可以达到效果。 再次也将该问题抛出来,即:要连续给设备连续发多个指令时行业内的解决方案是什么,欢迎有大佬指导,感谢~
LineWayEnum
import java.util.Objects;
/**
* @createTime 2023-08-09 16:53
* @Description modbus线路指令
*/
public enum LineWayEnum {
ONE(1, "01050000FF008C3A", "010500000000CDCA"),
TWO(2, "01050001FF00DDFA", "0105000100009C0A"),
THREE(3, "01050002FF002DFA", "0105000200006C0A"),
FOUR(4, "01050003FF007C3A", "0105000300003DCA");
/**
* 线路/第几路
*/
private final Integer lineId;
/**
* 闭合指令
*/
private final String closeCommand;
/**
* 断开指令
*/
private final String disconnectCommand;
public Integer getLineId() {
return lineId;
}
public String getCloseCommand() {
return closeCommand;
}
public String getDisconnectCommand() {
return disconnectCommand;
}
LineWayEnum(Integer lineId, String closeCommand, String disconnectCommand) {
this.lineId = lineId;
this.closeCommand = closeCommand;
this.disconnectCommand = disconnectCommand;
}
public static LineWayEnum getLineWayEnumByCode(Integer lineId) {
if (Objects.isNull(lineId)) {
return null;
}
LineWayEnum[] values = LineWayEnum.values();
for (LineWayEnum value : values) {
if (value.getLineId().equals(lineId)) {
return value;
}
}
return null;
}
}
这里是将设备调试后,便把ModBus的指令写死在枚举中,通过字符串的方式获取,发送时再转成16进制字节码的形式与硬件通信。
这里再引入一个点,即ModBus的指令可能随着设备参数的修改而变化,安装规则是需要对末尾2位做检查码(CRC),这里有篇文章有介绍:blog.csdn.net/qq_35358125…
ConvertCode
public class ConvertCode {
/**
* @Title:bytes2HexString
* @Description:字节数组转16进制字符串
* @param b
* 字节数组
* @return 16进制字符串
* @throws
*/
public static String bytes2HexString(byte[] b) {
StringBuffer result = new StringBuffer();
String hex;
for (int i = 0; i < b.length; i++) {
hex = Integer.toHexString(b[i] & 0xFF);
if (hex.length() == 1) {
hex = '0' + hex;
}
result.append(hex.toUpperCase());
}
return result.toString();
}
/**
* @Title:hexString2Bytes
* @Description:16进制字符串转字节数组
* @param src 16进制字符串
* @return 字节数组
*/
public static byte[] hexString2Bytes(String src) {
int l = src.length() / 2;
byte[] ret = new byte[l];
for (int i = 0; i < l; i++) {
ret[i] = (byte) Integer.valueOf(src.substring(i * 2, i * 2 + 2), 16).byteValue();
}
return ret;
}
/**
* @Title:string2HexString
* @Description:字符串转16进制字符串
* @param strPart 字符串
* @return 16进制字符串
*/
public static String string2HexString(String strPart) {
StringBuffer hexString = new StringBuffer();
for (int i = 0; i < strPart.length(); i++) {
int ch = (int) strPart.charAt(i);
String strHex = Integer.toHexString(ch);
hexString.append(strHex);
}
return hexString.toString();
}
/**
* @Title:hexString2String
* @Description:16进制字符串转字符串
* @param src
* 16进制字符串
* @return 字节数组
* @throws
*/
public static String hexString2String(String src) {
String temp = "";
for (int i = 0; i < src.length() / 2; i++) {
//System.out.println(Integer.valueOf(src.substring(i * 2, i * 2 + 2),16).byteValue());
temp = temp+ (char)Integer.valueOf(src.substring(i * 2, i * 2 + 2),16).byteValue();
}
return temp;
}
/**
* @Title:char2Byte
* @Description:字符转成字节数据char-->integer-->byte
* @param src
* @return
* @throws
*/
public static Byte char2Byte(Character src) {
return Integer.valueOf((int)src).byteValue();
}
/**
* @Title:intToHexString
* @Description:10进制数字转成16进制
* @param a 转化数据
* @param len 占用字节数
* @return
* @throws
*/
public static String intToHexString(int a,int len){
len<<=1;
String hexString = Integer.toHexString(a);
int b = len -hexString.length();
if(b>0){
for(int i=0;i<b;i++) {
hexString = "0" + hexString;
}
}
return hexString;
}
/**
* 将16进制的2个字符串进行异或运算
* http://blog.csdn.net/acrambler/article/details/45743157
* @param strHex_X
* @param strHex_Y
* 注意:此方法是针对一个十六进制字符串一字节之间的异或运算,如对十五字节的十六进制字符串异或运算:1312f70f900168d900007df57b4884
先进行拆分:13 12 f7 0f 90 01 68 d9 00 00 7d f5 7b 48 84
13 xor 12-->1
1 xor f7-->f6
f6 xor 0f-->f9
....
62 xor 84-->e6
即,得到的一字节校验码为:e6
* @return
*/
public static String xor(String strHex_X,String strHex_Y){
//将x、y转成二进制形式
String anotherBinary=Integer.toBinaryString(Integer.valueOf(strHex_X,16));
String thisBinary=Integer.toBinaryString(Integer.valueOf(strHex_Y,16));
String result = "";
//判断是否为8位二进制,否则左补零
if(anotherBinary.length() != 8){
for (int i = anotherBinary.length(); i <8; i++) {
anotherBinary = "0"+anotherBinary;
}
}
if(thisBinary.length() != 8){
for (int i = thisBinary.length(); i <8; i++) {
thisBinary = "0"+thisBinary;
}
}
//异或运算
for(int i=0;i<anotherBinary.length();i++){
//如果相同位置数相同,则补0,否则补1
if(thisBinary.charAt(i)==anotherBinary.charAt(i))
result+="0";
else{
result+="1";
}
}
return Integer.toHexString(Integer.parseInt(result, 2));
}
/**
* Convert byte[] to hex string.这里我们可以将byte转换成int
* @param src byte[] data
* @return hex string
*/
public static String bytes2Str(byte[] src){
StringBuilder stringBuilder = new StringBuilder("");
if (src == null || src.length <= 0) {
return null;
}
for (int i = 0; i < src.length; i++) {
int v = src[i] & 0xFF;
String hv = Integer.toHexString(v);
if (hv.length() < 2) {
stringBuilder.append(0);
}
stringBuilder.append(hv);
}
return stringBuilder.toString();
}
/**
* @return 接收字节数据并转为16进制字符串
*/
public static String receiveHexToString(byte[] by) {
try {
/*io.netty.buffer.WrappedByteBuf buf = (WrappedByteBuf)msg;
ByteBufInputStream is = new ByteBufInputStream(buf);
byte[] by = input2byte(is);*/
String str = bytes2Str(by);
str = str.toLowerCase();
return str;
} catch (Exception ex) {
ex.printStackTrace();
System.out.println("接收字节数据并转为16进制字符串异常");
}
return null;
}
/**
* "7dd",4,'0'==>"07dd"
* @param input 需要补位的字符串
* @param size 补位后的最终长度
* @param symbol 按symol补充 如'0'
* @return
* N_TimeCheck中用到了
*/
public static String fill(String input, int size, char symbol) {
while (input.length() < size) {
input = symbol + input;
}
return input;
}
}
ConvertCode工具类从网上抄来的,记录一下,感谢大佬。