动机
Netty 提供了大量的开箱即用的组件, 这些组件对使用者透明了很多技术实现细节, 粘包拆包就是其中很有趣的部分.
本人在使用LengthFieldBasedFrameDecoder(在自定义通讯协议的场景下经常被使用: 作为TCP流 -> 业务数据包的拆包解析器)时对这个组件的参数还有用法产生了困惑,查阅了一些资料也阅读了源码…写这篇文章的目的就是为了梳理这一块的知识.
什么是粘包与拆包
这篇文章简明扼要解释了何为粘包,拆包TCP粘包,拆包及解决方法
首先, 粘包与拆包这两个行为并非是NIO独有的 只要你的应用层协议是基于TCP协议拟定且进行长通讯,就一定会涉及拆包粘包开发者在写业务代码时往往感知不到的原因是底层框架已经实现了相关的细节
这一点与是否是BIO或NIO通讯无关. 个人认为粘包 拆包跟IO模式完全是两个不同维度的概念.
为什么我们经常把粘包拆包问题与NIO一起谈
在TCP相关的API中没有packet相关的概念
严谨点说, TCP不存在packet这个概念(TCP中是以segment作为细粒度的单位), TCP相关的API往往提供的是读写流的获取与相关的操作, 以JAVA为例:
- ServerSocket & Socket -> BIO模型
- ServerSocketChannel & SocketChannel -> NIO模型
无论是使用哪种API, 涉及都是流相关的操作, 开发者是感知不到所谓的TCP Packet对象(本就不存在这个)
-> 从代码层面上讲一般情况下(HTTP1.0) BIO模式下一次完整的流读取 = 一次完整的HTTP请求
为了增加说服力,本人以JLHTTP(基于BIO的一款轻量级Web容器)部分源码为例:
//维护的基于BIO的ServerSocket对象
ServerSocket serv = HTTPServer.this.serv; // keep local to avoid NPE when stopped
while (serv != null && !serv.isClosed()) {
final Socket sock = serv.accept();
executor.execute(new Runnable() {
public void run() {
try {
try {
sock.setSoTimeout(socketTimeout);
sock.setTcpNoDelay(true); // we buffer anyway, so improve latency
handleConnection(sock, sock.getInputStream(), sock.getOutputStream());
} finally {
try {
// RFC7230#6.6 - close socket gracefully
// (except SSL socket which doesn't support half-closing)
if (!(sock instanceof SSLSocket)) {
sock.shutdownOutput(); // half-close socket (only output)
transfer(sock.getInputStream(), null, -1); // consume input
}
} finally {
sock.close(); // and finally close socket fully
}
}
} catch (IOException ignore) {}
}
});
关键代码是handleConnection(sock, sock.getInputStream(), sock.getOutputStream());
这一行, 需要注意的是传递的sock对象是调用一次java.net.ServerSocket#accept获取的:
protected void handleConnection(Socket socket, InputStream in, OutputStream out) throws IOException {
in = new BufferedInputStream(in, 4096);
out = new BufferedOutputStream(out, 4096);
Request req;
Response resp;
do {
// create request and response and handle transaction
req = null;
resp = new Response(out);
try {
req = new Request(in);
req._remote = socket.getInetAddress();//xyj,201901,add remoteAddr
handleTransaction(req, resp);
} catch (Throwable t) {
//异常处理
..............
..............
..............
break; // proceed to close connection
} finally {
resp.close(); // close response and flush output
}
// consume any leftover body data so next request can be processed
transfer(req.getBody(), null, -1);
// RFC7230#6.6: persist connection unless client or server close explicitly (or legacy client)
} while (!"close".equalsIgnoreCase(req.getHeaders().get("Connection"))
&& !"close".equalsIgnoreCase(resp.getHeaders().get("Connection")) && req.getVersion().endsWith("1.1"));
}
在handleConnection(…) 函数 用java.net.Socket#getInputStream(仅一次)后获取的InputStream对象中读取的数据流(read()函数读到-1为止)反序列化了一个完整的
HTTP请求对象
因此从代码层面上讲 BIO模式下的粘包拆包是伪命题 -> 代码层面上不需要考虑这个点
NIO模式 与粘包拆包
大家把NIO Netty 粘包与拆包放在一起聊的原因很可能是因为在一些场景下,从代码/实现层面上需要考虑部分/全部粘包/拆包细节
首先, 思考一下为什么会有这个问题吧
从TCP API 角度上看, 无论NIO还是BIO都是流的操作,怎么BIO下的伪命题在NIO下变成大问题了呢
老生常谈, 浅谈一下多路复用
以Netty这个NIO的封装框架为例, 线程模型分为 parent & child 两种, 分别对应处理连接与执行业务逻辑(此上下文中,业务还涵盖了相关的统一的处理等),需要注意的是,parent线程不再专属于某一个客户端(BIO下),而是N个客户端分享同一个parent线程(多路复用)
即使parent线程获取了数据流并且封装成对应的流相关对象,那个对象也不再能映射成一个完整的HTTP请求/业务数据包了...极端场景下,对流的读取甚至返回null
那么如何 粘包拆包
简单的思维方式就是: 解析通过读取流反序列化的对象(e.g Netty中的ByteBuf)
, 看看这个对象中的二进制流(代码层面上的byte[])满不满足一个完整的业务数据包, 这里的业务数据包, 可以指一个完整的Http请求对象…也可以是特定协议下的对应的通讯模型
ps. Netty自带了众多开箱即用的Decoder组件,建议允许下使用这些组件
本文仅代表本人观点, 如果有不足地方烦请大佬们指正.
参考:
JLHTTP
https://blog.youkuaiyun.com/wxy941011/article/details/80428470