前言
前段时间有需求要求与一个连接很多采集器的中继器主机进行通信来采集数据,正好之前也了解过 Nest 这个服务端框架,索性就用它吧!Nest(NestJS)是用于构建高效,可扩展的Node.js服务器端应用程序的框架。
通信方式
中继器主机是采用基于 TCP 长连接的通信方式,可以通过 WiFi 连接服务器并且主机在断开连接后会最短时间内重新连接到服务器。
所以 Nest 需要使用 TCP 服务来接收并处理数据。 按照 Nest 的官方文档就是如下示例:
...
async function bootstrap() {
const app = await NestFactory.createMicroservice(ApplicationModule, {
transport: Transport.TCP,
});
app.listen(() => console.log('Microservice is listening'));
}
bootstrap();
数据格式
一个完整的数据帧由帧头、类型、长度等组成的数据格式示例:
| 名称 | 固定帧头 | 类型 | 帧 ID | 数据长度 | 数据内容(JSON) | 数据检验 |
|---|---|---|---|---|---|---|
| 字节数 | 4 | 1 | 2 | 2 | N | 2 |
| 数据举例 | 47 41 4E 5A | 00 | 14 7D | 00 19 | {"name":"zzz","age":18} | 17 A0 |
简单的通信举例:
- 上报数据
主机发送字符串 {"name":"aaa","age":22} 给服务器,其数据流如下:
47 41 4E 5A 00 14 7D 00 19 7B 22 6E 61 6D 65 22 3A 22 7A 7A 7A 22 2C 22 61 67 65 22 3A 31 38 7D 5A 84
对数据流的解析如下表所示:
| 数据 | 说明 | |
|---|---|---|
| 固定帧头 | 47 41 4E 5A | 固定的帧头 |
| 类型 | 00 | 表示该数据帧为上报数据 |
| 帧 ID | 14 7D | 一个随机数,在收到上报应答时,需要回复的帧 ID 与之一致 |
| 数据长度 | 00 17 | 表示后面跟着 0x0017,即 23 字节的数据 |
| 数据内容(JSON) | 7B 22 6E 61 6D 65 22 3A 22 7A 7A 7A 222C 22 61 67 65 22 3A 31 38 7D | 表示数据内容,其格式为 JSON |
| 数据检验 | 5A 84 | 表示对数据内容的 CRC16 校验值 |
- 上报确认
服务器收到主机上报的数据,需要回复一个数据包,表示已经收到了主机发过来的数据,数据流如下:
47 41 4E 5A 01 14 7D 00 00 FF FF
对数据流的解析如下表所示:
| 数据 | 说明 | |
|---|---|---|
| 固定帧头 | 47 41 4E 5A | 固定的帧头 |
| 类型 | 01 | 表示该数据帧为上报数据 |
| 帧 ID | 14 7D | 一个随机数,在收到上报应答时,需要回复的帧 ID 与之一致 |
| 数据长度 | 00 00 | 表示后面跟着 0x0000,即 0 字节的数据 |
| 数据内容(JSON) | 上报确认不需要数据内容 | |
| 数据检验 | 5A 84 | 表示对数据内容的 CRC16 校验值 |
问题解决
特别提醒
看Nest的微服务模块关于TCP的源码就会知道 Transport.TCP这个接收的数据格式是固定的,即 pattern 为<json-length>#{"pattern": <pattern-name>, "data": <your-data>[, "id": <message-id>]}。这个就是关键问题的所在,如果采用官方原有的 TCP 服务就不能够自行有效处理上面所述的通信数据格式。
那既然如此,那就按照源码重新定义自己的 TCP 数据处理服务。服务类继承 Server 并实现 CustomTransportStrategy 接口:
...
import { Server, CustomTransportStrategy, TcpOptions } from '@nestjs/microservices';
...
export class WsServer extends Server implements CustomTransportStrategy {
...
- 服务监听
因为是混合应用程序,通过实例connectMicroservice()方法:
...
app.connectMicroservice({
strategy: new WsServer({
host: config.wsTcpHost,
port: config.wsTcpPort,
retryAttempts: config.wsRetryAttempts,
retryDelay: config.wsRetryDelay,
}),
});
// 启动所有微服务
await app.startAllMicroservicesAsync().then(() => {
new Logger('WsServer', true).log('正在监听中...');
});
...
- 数据拆包
因为TCP是“流”协议,接收和发送的数据流都是没有界限的 16 进制的码流,数据的发送也是以字节流的形式发送给硬件。
Nest 的原本 TCP 数据处理是由 json-socket.ts 文件的 JsonSocket 类来对接收的数据流数据处理。所以可以按照原来的方法进行修改来满足自定义数据流结构的处理。
关键代码示例:
...
// 处理数据
private handleData() {
// 收到的数据长度大于帧头长度
if (this.buffer.length >= 4 * 2) {
// 如果找到帧头,且帧头前面有数据,删除之
const headIndex = this.buffer.indexOf(this.packet.header);
if (headIndex > 0) {
this.buffer = this.buffer.slice(headIndex);
}
// 帧头存在
if (headIndex !== -1) {
// 判断数据长度是否满足条件
if (this.buffer.length >= (4 + 1 + 2 + 2) * 2) {
// 帧类型
const typeString = this.buffer.substring(4 * 2, (4 + 1) * 2);
this.packet.type = parseInt(typeString, 16);
// 帧ID
const frameIdString = this.buffer.substring(5 * 2, (5 + 2) * 2);
this.packet.id = parseInt(frameIdString, 16);
// 数据长度
const dataLenghtString = this.buffer.substring(7 * 2, (7 + 2) * 2);
this.packet.dataLenght = parseInt(dataLenghtString, 16);
if (isNaN(this.packet.dataLenght)) {
throw new CorruptedPacketLengthException(dataLenghtString);
}
// 判断缓冲区长度是都足够数据内容及校验
if (this.buffer.length - 9 * 2 >= (this.packet.dataLenght + 2) * 2) {
// 数据内容
const dataString = this.buffer.substring(9 * 2, (9 + this.packet.dataLenght) * 2);
this.packet.data = Buffer.from(dataString, 'hex').toString('utf-8');
// 数据校验
const checkString = this.buffer.substring((9 + this.packet.dataLenght) * 2, (9 + this.packet.dataLenght + 2) * 2);
this.packet.check = parseInt(checkString, 16);
// 计算校验值
const dataCheck = crc16modbus(Buffer.from(dataString, 'hex'));
// 判断校验
if (this.packet.check === dataCheck) {
// 消息处理
this.handleMessage(this.packet);
} else {
throw new InformException(`数据包 "${this.packet}" 的校验值 "${this.packet.check}" 不匹配`);
}
// 删除缓冲区一次完整数据
this.buffer = this.buffer.slice((9 + this.packet.dataLenght + 2) * 2);
if(this.packet.data) this.logger.log(`接收数据:${this.packet.data}`, this.route);
// 处理剩余数据
this.handleData();
} else {
// 缓冲数据长度及校验字段长度不足
}
} else {
this.logger.warn('缓冲数据帧ID、数据长度等字段长度不足', this.route);
}
} else {
this.logger.warn('缓冲数据未找到帧头', this.route);
}
} else {
// buffer没有4字节
}
}
...
- 数据封包
...
private formatMessageData(message: any, ishand: boolean) {
const dataJson = message.response;
const dataBuffer = Buffer.from(JSON.stringify(dataJson), 'utf-8');
// 发送数据
const type = ishand ? '01' : '00';
const frameId = this.transform.dec2Hex(message.id, 4);
const dataLenght = ishand ? '0000' : this.transform.dec2Hex(dataBuffer.length, 4);
const data = ishand ? '' : dataBuffer.toString('hex');
const check = crc16modbus(Buffer.from(data, 'hex')).toString(16);
if(type === '00'){
this.sendCmd = dataJson.cmd;
this.sendId = message.id;
}
const result = FRAME_HEADER + type + frameId + dataLenght + data + check;
this.logger.log(`${ishand ? '上报确认' : '发送数据'}:${result}`, this.route);
return result;
}
...
特别注意当数字转Hex的位数补足问题
...
/**
* @name: 数字转Hex (指定位数)
* @param {number} decimal 数字
* @param {number} length 位数
* @Author: weishour
*/
dec2Hex(decimal: number, length?: number): string {
let hex = decimal.toString(16);
if(length) while(hex.length < length) hex = '0' + hex;
return hex;
}
...
后记
到这里基本上解决了使用Nest与硬件TCP通信的问题,实际的应用中为了与硬件进行交互,还对使用了 WebSocket 来对数据进行了推送和指令的传递。总体开发下来而言,还是比较愉快的体验,特别是从一开始没有接触过 TCP 到使用解决问题,还是有满满的收获的。如果你坚持看到了这里,说明这篇文章也对你哪怕又会一点点帮助的话,请点个👍吧!同时也是写的第一次写文章,不到之处请包涵。
