入门指南:使用 Netty 解码数据的简明步骤

259 阅读11分钟

记录使用 netty 的过程,本人是一个菜鸟。

废话不多说直接开始

业务是程序需要接收两个互联网设备的数据,数据格式不一样,设备A是String,设备B是需要将每个字节进行解析。

实现处理多个设备过程

思路:我们定义一个转接处理器 TypeHandle。

public class WSServerInitializer extends ChannelInitializer<NioSocketChannel> {

    @Override
    protected void initChannel(NioSocketChannel nioSocketChannel) {
        nioSocketChannel.pipeline().addLast(new TypeHandle());
    }
}

在这里继承 ChannelInitializer,它是一个父类方法。重写 channelRead 进行处理数据。

通过 ctx.pipeline().addLast(new StringHandle()); 进行添加处理器,通过ctx.fireChannelRead(str);进行数据转发。这样我们就可以在符合目标的处理器中处理数据了。字符串数据直接通过转字符串就可以了,不过多介绍这一点了。

public class TypeHandle extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {

        String str = ((ByteBuf) msg).toString(StandardCharsets.UTF_8);
        if (str.startsWith("*CS") && str.endsWith("#")) {
            ctx.pipeline().addLast(new StringHandle());
            ctx.fireChannelRead(str);
        } else {
            ctx.pipeline().addLast(new ByteDecodeHandle());
            ctx.fireChannelRead(msg);
        }
    }
}

看一下 StringHandle 的类,继承 SimpleChannelInboundHandler 类,并泛型指定 String。

SimpleChannelInboundHandler<String> 是 Netty 框架中用于处理特定类型消息(字符串类型)的处理器。它是 Netty 中的一个泛型类,用于处理入站数据

public class StringHandle extends SimpleChannelInboundHandler<String> {

    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, String s) {

    }
}

处理字节数据

首先我们先认识一下 ByteToMessageDecoder 解码器,用于将字节解码为消息对象,在网路通信中,数据是以字节流形式进行传输的,而 ByteToMessageDecoder 作用是实现了这一个过程。

触发时机

在自定义的 ByteDecodeHandle 类中我们继承了 ByteToMessageDecoder,所以当设备数据发送之后,会自动进入 decode 进行解码数据。decode 方法就是我们的解码方法,在这里我们主要对 ByteBuf 进行处理,它就是我们的数据。

public class ByteDecodeHandle extends ByteToMessageDecoder {

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {

    }

}

crc 校验

需要将数据传入指定算法中,首先 COPY 一个 bytebuf,因为读数据时,bytebuf中的指针会位移。不包括最后的2个字节数据,这是根据实际业务规定的。最终通过 calculateCRC16 进行校验。因为是 COPY 的 bytebuf,最后要释放一下资源,通过 release

ByteBuf byteBuf = in.copy();
// 校验 CRC
// 创建一个字节数组,长度为 copiedByteBuf 的可读字节数减去2(不包括倒数第二个字节)
byte[] byteArray = new byte[byteBuf.readableBytes() - 2];
// 从 copiedByteBuf 中读取数据到字节数组中,从0读到倒数第二个字节
byteBuf.readBytes(byteArray);
// 释放 copiedByteBuf
byteBuf.release();
int i = CRC16.calculateCRC16(byteArray);

判断数据是否完整

image.png

getShortLE 是 bytebuf 的方法,用于获取小端序2个字节数据,参数6表示字节偏移6,可以看上面文档。通过 readableBytes 方法获取可读长度,如果小于数据长度,表示当前数据不完整。

// 解析数据长度
short dataLength = in.getShortLE(6);
// 判断数据是否完整
if (in.readableBytes() < dataLength) {
    System.out.println("数据不完整!");
}

读取 IMEI 16位字节的呢

由于 IMEI 是16位的字节数,我目前业务的实现方式是,将字节数组每一位转成 char,最后拼接起来转换成数字。

// IMEI,读入 16位
byte[] dst = new byte[16];
in.getBytes(26, dst);

StringBuilder sb = new StringBuilder();
// 将字节数组转换为字符串
for (byte b : dst) {
    if (b == 0) break;
    sb.append((char) b);
}
// 将字符串解析为数字
Long IMEI = Long.parseLong(sb.toString());

最后说一下读字节的方式,去除 LE 表示大端序

# 获取四个字节
int productSN = in.getIntLE(8);
# 获取一个字节
byte deviceType = in.getByte(12);
# 获取一个字节,结果是 char
char hardwareVersion = (char) in.getByte(13);
# 获取两个字节
short backgroundAngle = in.getShortLE(18);

处理十六进制数据

有时候我们接收到的数据是十六进制数据,但是使用 ByteToMessageDecoder 接收到的数据并不是十六进制的,我们需要做一些处理。

通过 ByteBufUtil.hexDump(msg1).toUpperCase(); 将数据转换成字符串。我们发现结果是十六进制的正常数据。

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    // 将数据转换成十六进制
    ByteBuf msg1 = (ByteBuf) msg;
    String hexDump = ByteBufUtil.hexDump(msg1).toUpperCase();

看下面数据,假设我们拿到 AA,市面上方式是这样加密的:AA = 65

image.png

我们需要将 netty 读取到的 6565,转换成 AA,通过 Long.parseLong 转换成十进制。

Long.parseLong(((char) in.readByte()) + String.valueOf((char) in.readByte()), 16);

再来看一个低位数据和高位数据的案例

// 首先由于十六进制数据是两个数据进行拼接的,我们将两个数据进行拼接,作为一个十六进制数据。
String one = ((char) one1 + String.valueOf((char) one2));
String two = ((char) one3 + String.valueOf((char) one3));
// 我们拿到两个十六进制数据后,进行低位在前高位在后进行计算。two 是高位,one是低位。所以 高位+低位,转十六进制。
Long result = Long.parseLong(two + one, 16);

再来看处理二进制数据。还是按照低位在前,高位在后,进入拼接转换成十六进制,并转换成二级制数据。

String one = ((char) one1 + String.valueOf((char) one2));
String two = ((char) one3 + String.valueOf((char) one4));
String result = Integer.toBinaryString(Integer.parseInt(two + one, 16));

十六进制粘包如何处理呢?

首先写一个成员变量,用于积累缓冲区

// 用于累积数据的缓冲区
private StringBuffer cumulationBuffer = new StringBuffer();

方案就是,接收到数据之后,判断是否结尾,假设是 55 结尾表示完整数据。

判断字符串,是否以 55 结尾,如果是表示完整数据,需要与累计的缓冲区合并转换成 bytebuf,并清除缓冲区。

否则将当前字符串赋值给缓冲区。

通过Unpooled.wrappedBuffer(hexDump.getBytes())将十六进制字符串转换成 bytebuf

try {
    // 将数据转换成十六进制
    String hexDump = ByteBufUtil.hexDump(msg).toUpperCase();
    log.info("接收到数据:{}", hexDump);
    ByteBuf buf = Unpooled.wrappedBuffer(hexDump.getBytes());
    if (hexDump.endsWith("55")) {
        log.info("表示------入库数据");
        if (cumulationBuffer.length() > 0) {
            hexDump = cumulationBuffer + hexDump;
        }
        ByteBuf buffer = Unpooled.wrappedBuffer(hexDump.getBytes());
        // 进入处理数据
        decode(buffer);
        cumulationBuffer = new StringBuffer();
    } else {
        log.info("表示------半包数据");
        cumulationBuffer.append(hexDump);
    }
    buf.release();
} finally {
    msg.release();
}

Netty 多客户端实现方案

目的:通过以下方式,Netty 客户端可以不断尝试连接到服务器,直到成功为止。同时,通过 ExecutorService 提交任务,可以使连接操作在单独的线程中执行,避免阻塞主线程。

@Slf4j
public class WSServer {

    // Netty客户端的引导类,用于配置和启动客户端
    private final Bootstrap bootstrap;

    /**
     * 构造函数,初始化Bootstrap并提交任务到ExecutorService
     * 
     * @param executorService 用于提交连接任务的ExecutorService
     * @param port            服务器端口
     * @param acu             包含服务器IP和其他配置信息的ACU对象
     */
    public WSServer(ExecutorService executorService, Integer port, ACU acu) {
        bootstrap = new Bootstrap();
        // 配置Bootstrap
        bootstrap.group(new NioEventLoopGroup())          // 设置EventLoopGroup处理所有的I/O操作
                 .channel(NioSocketChannel.class)         // 指定通道类型为NioSocketChannel
                 .handler(new WSServerInitializer(acu));  // 设置通道的处理器
        
        // 提交连接任务到ExecutorService
        executorService.submit(() -> getWsServer(acu.getIp(), port));
    }

    /**
     * 尝试连接到WebSocket服务器,并在失败时重试
     * 
     * @param ip   服务器IP
     * @param port 服务器端口
     */
    public void getWsServer(String ip, int port) {
        while (true) {
            try {
                // 尝试启动连接
                start(ip, port);
                break;  // 如果连接成功,跳出循环
            } catch (Exception exception) {
                // 连接失败时记录错误信息并重试
                log.error("netty...连接--{}--{}---失败", ip, port);
                log.error("{}---原因:{}", ip, exception.toString());
                try {
                    // 等待30秒后重试
                    Thread.sleep(30000);
                } catch (InterruptedException ignored) {
                }
            }
        }
    }

    /**
     * 启动连接到指定的IP和端口
     * 
     * @param ip   服务器IP
     * @param port 服务器端口
     * @throws InterruptedException 如果连接过程中线程被中断
     */
    public void start(String ip, int port) throws InterruptedException {
        // 连接到服务器并等待连接完成
        bootstrap.connect(ip, port).sync().channel();
        // 连接成功,记录日志
        log.info("netty...连接--{}--{}---成功", ip, port);
    }
}

将连接交给Spring 管理,可以初始化多个Netty 客户端,方便在应用程序的其他部分中进行依赖注入。同时,通过使用配置属性类 DataProperties,可以灵活地从外部配置文件中读取必要的属性值,使得配置更加灵活和可维护。

@Configuration  
public class WSServerConfig {  
  
    private final ExecutorService executorService = Executors.newFixedThreadPool(5);  

    @Autowired  
    private DataProperties dataProperties;  

    @Bean(value = "wsServer-1-1")  
    public WSServer wsServer() {  
        ACU acu = new ACU(1, 1, 5, 1, dataProperties.getIp1(),  
        dataProperties.getData1(), 1);  
        return new WSServer(executorService, dataProperties.getPort(), acu);  
    }
}    

ModbusTCP 和 acu

这是两种协议,acu 整合多个数据源,形成点表,我们使用tcp去发送指定的遥信与遥控可以获取数据和控制。

CRC 校验,首先我们需要发送带校验的指令

010410000074

image.png

通过 CRC工具获取校验值 EDF4,那发送的命令就变成了,010410000074F4ED,寄存器数量为什么 * 2 呢,因为一个寄存器数量 = 2 个字节。一个数据是 4 个字节,58 * 4 ,也等于 58 * 2 个寄存器。

具体命令是什么意思呢?

  1. 01:地址
  2. 04:功能码
  3. 10 00:寄存器地址
  4. 00 74:寄存器数量(58 * 2 = 116)(116 十六进制 = 74)
  5. F4:校验值
  6. ED:校验值

接收到数据:

01 04 E8 29 5C 8F 3D CC CC 4C 3D 29 5C 8F 3D 33 33 A7 41 00 00 00 00 00 00 00 00 00 00 00 00 67 66 D6 41 00 00 92 42 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 00 00 00 BC B7 46 00 00 E8 41 00 00 16 46 00 00 00 00 00 00 00 40 00 00 80 3F 00 00 80 3F 00 00 00 00 00 F6 B6 46 00 00 F0 41 00 00 16 46 00 00 00 00 00 00 40 40 00 00 80 3F 00 00 80 3F 00 00 00 00 00 06 B9 46 00 00 F0 41 00 00 16 46 00 00 00 00 00 00 80 40 00 00 80 3F 00 00 80 3F 00 00 00 00 00 1A B6 46 00 00 F0 41 00 00 16 46 00 00 00 00 00 00 A0 40 00 00 80 3F 00 00 80 3F 00 00 00 00 00 DA B8 46 00 00 E8 41 00 00 16 46 00 00 00 00 00 00 C0 40 00 00 80 3F 00 00 80 3F 00 00 00 00 00 E8 B7 46 00 00 0C 42 00 00 16 46 00 00 00 00 CD CC 26 42 9A 8D

具体命令是什么意思呢?

  1. 01:地址
  2. 04:功能码
  3. 5C:可读字节数
  4. 后面所有表示数据区
  5. F4:校验值
  6. ED:校验值

CRC 校验

需要把上面的字符串,从 0 ~ 长度 - 4,因为我们不需要最后四位校验,校验后得到的结果,与最后四位进行比较,一致校验成功。

// 开始处理 CRC
String calculatedCRC = NettyUtil.calculateCRC16(hexDump.substring(0, hexDump.length() - 4));
Assert.isTrue(calculatedCRC.equals(hexDump.substring(hexDump.length() - 4)), "crc 校验错误!");

数据是怎么解的呢?

数据说明:数据为32位浮点数,解码顺序4321;

00 00 C0 41 为 24

String c1 = String.valueOf((char) '0') + (char) '0';
String c3 = String.valueOf((char) '0') + (char) '0';
String c5 = String.valueOf((char) 'C') + (char) '0';
String c7 = String.valueOf((char) '4') + (char) '1';
// 翻转后:41C00000
String s = c7 + c5 + c3 + c1;
// 将十六进制字符串转换成无符号整数:1103101952
int intBits = Integer.parseUnsignedInt(s, 16);
// 用于将整数类型的二进制位表示转换为对应的浮点数值
float v = Float.intBitsToFloat(intBits);

具体数据是如何对应呢,假设我们获取了点表。点表对应的是那个服务器的地址。我的101点表对应的是 101 的服务器数据。

image.png

这样一来,我拿到的第一个数据就对应第一个,第二个就对应第二个。

自制的工具类

public class NettyUtil {

    /**
     * 拼接字符串
     */
    public static String get16StringTo2(byte one1, byte one2) {
        return ((char) one1 + String.valueOf((char) one2)).trim();
    }

    /**
     * 转换一个字节
     * 根据两个字节,将十六进制转成十进制
     */
    public static Long readInDataInt(ByteBuf in) {
        return Long.parseLong(((char) in.readByte()) + String.valueOf((char) in.readByte()), 16);
    }

    /**
     * 低位在前,高位在后,拼接,转十进制
     */
    public static Long readInDataIntTo4(ByteBuf in) {
        String intTo1 = readInDataStringTo2(in);
        String intTo2 = readInDataStringTo2(in);
        return Long.parseLong(intTo2 + intTo1, 16);
    }

    /**
     * 低位在前,高位在后,拼接,转十进制
     */
    public static Double readInDataStringTo4(ByteBuf in) {
        String intTo1 = readInDataStringTo2(in);
        String intTo2 = readInDataStringTo2(in);
        return Integer.parseInt(intTo2 + intTo1, 16) / 2.0;
    }

    /**
     * 低位在前,高位在后,拼接
     */
    public static String readInDataStringTo2(ByteBuf in) {
        return NettyUtil.get16StringTo2(in.readByte(), in.readByte());
    }

    /**
     * 校验数据
     */
    public static boolean isValidHex(ByteBuf in, byte[] bytes) {
        // 确保有足够的数据来读取前8个字节
        if (in.readableBytes() < 16) {
            return false;
        }
        byte[] dst = new byte[16];
        in.readBytes(dst);
        // 校验前8个字节是否匹配
        for (int i = 0; i < 16; i++) {
            if (dst[i] != bytes[i]) {
                // 不匹配
                return false;
            }
        }
        return true;
    }

    /**
     * 校验数据
     */
    public static boolean isValidHex(ByteBuf in, byte[] bytes, boolean flag) {
        if (!flag) in = in.copy();
        boolean validHex = isValidHex(in, bytes);
        if (!flag) in.release();
        return validHex;
    }
    
    // 读取四位字节,返回一个字符串
    private static String readFourDigits(ByteBuf byteBuf) {
        String c1 = String.valueOf((char) byteBuf.readByte()) + (char) byteBuf.readByte();
        String c3 = String.valueOf((char) byteBuf.readByte()) + (char) byteBuf.readByte();
        String c5 = String.valueOf((char) byteBuf.readByte()) + (char) byteBuf.readByte();
        String c7 = String.valueOf((char) byteBuf.readByte()) + (char) byteBuf.readByte();
        return c7 + c5 + c3 + c1;
    }

    /**
     * 将十六进制字符串转换为32位浮点型
     */
    public static float hexToFloat(ByteBuf buf) {
        String digits = readFourDigits(buf);
        int intBits = Integer.parseUnsignedInt(digits, 16);
        return Float.intBitsToFloat(intBits);
    }

    /**
     * crc 校验使用
     */
    public static String calculateCRC16(String hexDump) {
        // 字符串转十六进制字节数组
        byte[] bytes = ByteBufUtil.decodeHexDump(hexDump);
        // crc 校验
        int crc = 0xFFFF;
        for (byte b : bytes) {
            crc ^= b & 0xFF;
            for (int j = 0; j < 8; j++) {
                if ((crc & 0x0001) != 0) {
                    crc >>= 1;
                    crc ^= 0xA001;
                } else {
                    crc >>= 1;
                }
            }
        }
        String format = String.format("%04X", crc);
        return format.substring(2, 4) + format.substring(0, 2);
    }

}