Netty自定义TCP协议通讯实例
网络编程的基本模型就是客户机到服务器模型,简单来说就是进程与进程之间的通讯
两个进程之间必须要有一个提供固定的位置,让另一个进程知道这个位置并建立联系,这样两个进程就可以相互通讯
其中提供固定位置的叫服务端,连接固定位置的叫客户端
实际上因为是双向通讯,服务端客户端并没有太大的界限
今天写一个实例,使用netty4来实现一个自定义的tcp报文解析
先说说自定义tcp协议的意义
文本协议:
对协议头的 size 没特别要求对解析速度没特别要求需要协议轻松
支持变长 header 且保持较友好的扩展性(如像 HTTP 一样可以随意增加 header)
可升级协议且不增加协议本身复杂度
要求容易调试
二进制协议:
要求协议头 size 要足够小
要求解析速度要足够快
协议头相对较固定,变动的可能性较低,但仍然需要留一些扩展位
比如即时通讯就很适合用二进制协议,因为即时通讯需要频繁收发消息,对于传输和解析的速度都有比较高的要求
另外一个就是二进制协议没有协议文档几乎不可读
进入代码阶段
本文代码链接:后端代码
一、服务端
1.新建工程
这次不需要boot了,新建一个空的maven项目引入依赖
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.53.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
<!-- log -->
<!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-api -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.25</version>
</dependency>
<!-- https://mvnrepository.com/artifact/ch.qos.logback/logback-classic -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-codec/commons-codec -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
其中logback是给lombok的@Slf4j的实现,commons-codec用于获取md5
2.实现一个服务端
netty的基本使用就是建立一个worker和一个boss EventLoopGroup,然后配置编码解码器以及自定义handler,事件监听等,使得开发者的核心可以转移到处理数据。
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true).handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(
// 自定义解码器
new DecodeHandler(),
new StringEncoder(CharsetUtil.UTF_8),
// 自定义的文件处理handler
new TcpServerHandler());
}
});
try {
int port = 8888;
String host = "127.0.0.1";
ChannelFuture future = bootstrap.bind(host, port).sync();
log.info("启动服务器 端口[{}]", port);
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("%s", e);
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
除了常规配置之外,我们要做的就是实现一个自定义的解码器DecodeHandler以及消息处理器TcpServerHandler
3.协议制定
开始解码前我们需要有一个协议,才能知道该怎么解
业务的报文处理
解析
定义协议 PS:x表示占几个字节
[[版本x2][报文总长度x2][消息类型x1][文件标识x16][分片序号x2][文件总大小x8][分片文件长度x2][分片文件数据]]
消息类型 0x01 上传文件 0x02 合并文件
示例报文(16进制):
0055041d015b886f17f511ed7908128e68520b8aa300000000000001159cf50400504b03041400000000003b0a3948000000000000000000000000180000007068616e746f6d6a732d322e312e312d77696e646f77732f504b0304140000000000670a39480000000000000000000000001c0000007068616e746f6d6a732d322e312e312d77696e646f77732f62696e2f504b0304140000000800bd0a394821ec01d2cbbf140100a01b01290000007068616e746f6d6a732d322e312e312d77696e646f77732f62696e2f7068616e746f6d6a732e657865ec9a8b3fd3ff1ec777373336b5106313b94d482a22e6325b85264285ad92cba2167329b731b799cbeae474fd955427bf2ebf748e44c45c6a2315e9942256a9c8a52531b9ec7cbf9ddbe35c1e8fd31f70be0f1eefafd79efb787edf9feff7cdc383ef4e31040e814010c0a74a0581d442fe7a5021fffba04221102dd25d2d48b5fa23e35aa8cf23e3c0e898043237fe6054fcee38f2dedd070e1ce491f7ec23c7271e20c71c207b6d0d20c71d8cd867a3a98931fddb1aebdb1ef4245caee4fffd73edd4267e3250c79107f987809a3193cc27ffedb5f81f5f6fe2b32e8195c15f0bd4df7646f27997c19af2e375ad7e063fe30a58937fbcff81b30f3ff5c7eb7bf9d13fd6f9951f7e05cc197cc48fd737f1e3ae8395f7b7f513800a726e4005f3a41f755bccde68d0e1df7bc0a441203e503844e07739e8ef991c02836a40b11088d8000a210341d504944bc3c021920928840954b0bb7820874120a81feff867857081d7f17f3d8543a85900f8e3e39ff5af85fa8b0216f20e02896561e062601d70dd58dc7feed1fc6e0c1c0bf9df47281d0367407efed8ce0cb103ca8f6bfbabd78f9be85f0e3ed00a805b0d9cfee8c58feb2f00eabf715480b3894f88df0b7e41feebb54008402d02d87fe52490ff1f3f7bacb1715a0dee93b1a63a1a1588db19d4fccaf6eb7d730c7c7f2ed0573b185c0702d963fc7a56150db579201e865a5ec7ee68ed24950f0e86c75771542b6c77b7d5c1be3db2860a069b7c1bcf52a56437eb7b29111780fdf157f52da82ec0be86f0d7b4318bbd4cd11117cd39c8ac61c8a275ace0ee8b86f136b4d00e518c92e2f7fda95aa5520d6c80af7511598bd0b9ed3c5dc1ac8a4712b4ab14b86634ae4cd27560d74c8e2451db890383310317ba8862eceb17b13138b65eac4fbf50f3c28e59b27334119f00756bb177350fb6db88c0464477b8cfb02ec2be94eb71ec8d535c89a38d9ff7ae264a911832cc6c8dc4199a3a6ac5ba8cbabed63472bb458c615f12c99a4371af7e106e0da3d80de2c2a6590e656be19b22e4b68eac59f1de52172a8731ec62ea9d93217b2f4678315c901c8ca7565dda3e071fe65d4ae34420c7d69d1ae2835a495d1d6253469adb175a8655e3e0b8ec8059b1674434c7923fd39b3c4644536259dc52a2ba71faf5b95e5e560a47
推荐使用NetAssist调试
4.解码器
解码器的意义在于先一步处理得到报文
TCP是一个“流”协议,所谓流,就是没有界限的一长串二进制数据。TCP作为传输层协议并不不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行数据包的划分,所以在业务上认为是一个完整的包,可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。
所以我们需要在解码器对每一包进行校验,小于校验和的包需要粘包,大于校验和的包需要拆包,这样才能正确解析
至于怎么解决这个可以专门分析,网上也有很多方案,这里不谈
除了粘包拆包问题,我们还可能有对原始报文进行解密的操作,为了保证下游的handler拿到的bytebuf是与协议文档一样的格式,我们需要先解密,并把业务部分报文传达下去,甚至可以在定义了一定格式的报文后,直接在decoder中把二进制数据变成对象或者等类型,下游再解析。
public class DecodeHandler extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf byteBuf, List<Object> out) throws Exception {
// 取到tag与报文长度后再处理,各2字节
if (byteBuf.readableBytes() < 4) {
return;
}
// 记录当前ByteBuf的读指针位置,以便下面取报文长度字节
// pos是一个完整报文的开始位置,报文整体会在ByteBuf中移动,类似内存管理,所以基于字节的判断报文长度等等,都是基于pos,否则可以在byteBuf.readBytes()之后加,byteBuf.discardReadBytes();整理ByteBuf,使pos回到0开始位置
int pos = byteBuf.readerIndex();
//不同协议头建议使用策略模式处理
short protocol = byteBuf.readShort();//前面两个字节是协议版本 0x55
int msgLen = byteBuf.readShort();//第三第四个字节表示报文长度
// 收到的报文长度不足一个完整的报文,继续接收
if (byteBuf.readableBytes() < msgLen) {
return;
}
// 提出完整报文(readBytes读到msg中),放到list给下一个Handler处理
if (msgLen > 0) {
out.add(byteBuf.readBytes(msgLen));
}
}
}
5.自定义handler
从上面的DecodeHandler可以看到,前4个字节用来做协议版本和报文长度,已经read了,丢到out中给到下游的是这四个字节以外的数据
// 前面4个字节已经在decode阶段跳过了
byte msgType = byteBuf.readByte();// 读取一个字节为消息类型
switch (msgType) {
case 0x01:
upload(byteBuf);
break;
case 0x02:
merge(byteBuf);
break;
}
所以在handler中可以直接读取一个字节判断当前的报文类型再分发
然后根据已有的协议进行解析
// 业务的报文处理
// 解析
// 定义协议 PS:x表示占几个字节
// [[版本x2][报文总长度x2][消息类型x1][文件标识x16][分片序号x2][文件总大小x8][分片文件长度x2][分片文件数据]]
// 消息类型 0x01 上传文件 0x02 合并文件
byte[] filenameBytes = new byte[16];
buf.readBytes(filenameBytes);// 读取16字节是文件名
String filename = ByteUtils.bytesToHex(filenameBytes);// 我这里是md5不会有中文没有乱码的问题
short fileseq = buf.readShort();// 读取2字节是分片序号
long filesize = buf.readLong();// 读取8字节是文件总大小
short chunksize = buf.readShort();// 读取2字节是分片文件长度
byte[] dataBytes = new byte[chunksize];
buf.readBytes(dataBytes);
已经有大佬写好框架,只需要做解析的同学,做这个不算太难,稍微有些枯燥,需要认真细心,不要解错
二、客户端
相对服务端而言,客户端需要搜集本次通讯的报文数据,无论是protobuf也好byte数据也好,都比服务端要复杂一些
由于我们的netty实现了一个tcp服务端,所以可以客户端只要使用tcp协议就可以通讯,我们使用java提供的socketapi即可
核心代码
/**
* TODO 本来发送是不需要返回值的,但是这里为了使用latch来计算发了多少,通过返回true来表示发送成功一个
*
* @param msg
* @return
*/
synchronized public boolean sendMsgBySocket(ByteBuffer msg) {
try {
// Socket socket = getSocketClient();
//这段代码仅仅调试用,尽量一个不要开多个socket跟服务端通信,应该使用同一个链路,多次发送消息,服务端解决粘包和拆包的问题
// 要连接的服务端IP地址和端口-----------------------------------
String host = "127.0.0.1";
int port = 8888;
// 与服务端建立连接
this.socket = new Socket(host, port);
//---------------------------------------------------------
// 建立连接后获得输出流
OutputStream outputStream = socket.getOutputStream();
byte[] msgArray = msg.array();
// 报文头start-----------------------------------------------
// 55表示协议版本
short protocol = 0x55;
ByteBuffer header = ByteBuffer.allocate(4);
header.putShort(protocol);// 版本x2
// 写入本次报文总长度 固定是37 + 分片文件长度
short length = (short) (msgArray.length);
header.putShort(length);// 报文总长度x2
// 报文头end-----------------------------------------------
outputStream.write(header.array());
outputStream.write(msgArray);
outputStream.flush();
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int read = 0;
while (true) {
// 死循环直到服务器返回 这里可以添加超时机制,防止服务器出问题后无限循环
read = inputStream.read(bytes);
if (read > 0) {
String result = new String(bytes);
log.info("receive : {}", result);
if ("ok".equals(result)) {
// 假定回复ok就是成功了
return true;
}
break;
}
}
} catch (Exception e) {
log.error("[{}]", e);
}
return false;
}
这里的含义是我们先组装好要发送的报文ByteBuffer,然后调用这个发起socket通讯
其他分享的代码
这次做的是通过tcp分片上传大文件
思想还是和上篇一样,客户端分片,通过md5获得唯一标识以免重复,每个报文包括分片序号,唯一标识,总大小分片大小还有分片数据
因为我们要规定每个分片的最大大小才能计算分片数量
所以这里可以利用FileChannel,分段读取,无需切片文件,也避免把整个文件读取到内存
FileChannel fileChannel = FileChannel.open(Paths.get(file.getAbsolutePath()),
EnumSet.of(StandardOpenOption.READ));
// 每个分片最大1024,所以只有最后一片不一定是1024,但是也能算出来
int lastSize = (int) (fileChannel.size() - ((chunk - 1) * 1024));// 总长度减去前片的和
for (int chunkNum = 0; chunkNum < chunk; chunkNum++) {
int size = chunkNum == chunk ? lastSize : chunkSize;
ByteBuffer buffer = ByteBuffer.allocate(size);
fileChannel.read(buffer);
我规定每个分片最大1024字节也就是1kb,所以每次最多读1k,最后一片可能小于等于1k,每循环一次就把报文放到线程池中等待发送
利用CountDownLatch设定一个屏障,要求发送的总数达到才关闭线程池,在服务端返回ok后latch.countDown()达到计数的效果
// 设定一个计数器
CountDownLatch latch = new CountDownLatch(chunk);
fixedThreadPool.execute(() -> {
// 将组装好的报文,在线程池中创建线程运行
client.sendMsgBySocket(msgBuffer);
// 应该要判断是否成功的。这里不判断认为都成功
latch.countDown();
});
// 所有线程没发送完全之前 一直等待
latch.await();
分片大小1k确实有点小,不过这是为了看测试效果,实际上要根据通讯网络来定义分片大小,带宽好的大一些