netty-第一部分-NIO-2 核心概念2 Buffer,TCP粘包与Channel和Buffer的简单使用流程

Buffer

上一篇文章简单说了ChannelBuffer的关系,还有Channel的使用。这篇就要讲Buffer的一些细节了。

Buffer顾名思义,缓冲区,即内存中一块存放数据的地方。同时提供一些api供我们读写这块内存。
常用的实现类是ByteBufferIntBuffer等(可以猜到还有什么ShortBufferCharBuffer

我们就拿ByteBuffer来说,其余的举一反三即可

ByteBuffer结构

既然以Byte开头,说明他的存储是以字节为单位。注意,
作为一个Buffer
都会有以下性质:

  • capacity 容量
  • position 位置
  • limit 限制位置
  • 分为读写模式,按需切换
  • 非线程安全的

结合下图就能明白,基本结构类似于带指针的数组
在这里插入图片描述
既然带指针,很容易就联想到指针用来描述读写位置。
对于处于写模式ByteBufferposition代表写入的位置,比如我第一次写一个字节后,position就会后移一位,指向下一次写入的位置。而limit则等于capacity,因为最多写满就没空间写了。
对于处于读模式ByteBufferposition代表读取位置,比如我当前ByteBuffer没有被读过,我读一个字节后position向后移一位,代表下次读从position开始,相当于对读取位置的记录,而limit此时就不是和容量一致了,而是当前ByteBuffer中有多少字节,limit指向最后一个字节的位置,后面不能读,因为后面没数据。
上面仅通过文字叙述了ByteBuffer指针的变动,在下面代码示例后会画图展示使用过程中中的指针变化。
下面给出一些常见方法的使用

//获取channel
SocketChannel channel = SocketChannel.open();
channel.connect(new InetSocketAddress("http://google.com", 80));

//给ByteBuffer分配空间
ByteBuffer bf = ByteBuffer.allocate(16);

//写入数据
//ByteBuffer默认为写模式,不需要切换
//方法1:channel.read(),从sokcet读出数据写到ByteBuffer中
int readBytes = channel.read(bf);//方法返回读取字节数
//方法2:直接调用ByteBuffer自己的put()方法
bf.put((byte)66);


//想读取数据要切换为读模式
bf.filp();

//读取数据
//同样两个方法
//方法1:channel.write(),从ByteBuffer中读数据写到channel中
int writeBytes = channel.write(bf);//返回本次读取字节数
//方法2:直接调用ByteBuffer自己的get()方法
byte x = bf.get();
//前面提到过,读时position指针会变化,就是在调用get方法时
//那么我们读完指针走到头想想重读一遍怎么办?使用下面的rewind方法
//rewind方法把position指针重置为0
bf.rewind();

//切换为写模式有两个方法
bf.clear();
bf.compact();
//clear方法会把position指针直接置为0,不管之前读的时候读没读完,意味着从头开始写
//compact方法会根据postion和limit把剩下没读的数据移到前面,然后position放在未读数据部分的后一位,相当于保留未读部分继续写

//mark与reset
bf.mark();
bf.reset();
//mark方法记录当前position位置
//reset方法将position重置会mark方法记录的position位置

//ByteBuffer与字符串互转
//这块课上讲的,走神了又听了一遍,decode和encode顾名思义编码和解码。
//把字符串编码成UTF_8编码存入ByteBuffer
ByteBuffer buffer1 = StandardCharsets.UTF_8.encode("你好");
ByteBuffer buffer2 = Charset.forName("utf-8").encode("你好");
debug(buffer1);
debug(buffer2);
//ByteBuffer中的UTF_8解码为对应字符
CharBuffer buffer3 = StandardCharsets.UTF_8.decode(buffer1);
System.out.println(buffer3.getClass());
System.out.println(buffer3.toString());

指针变化图这块偷个懒,把满老师课上的图拿出来。

写模式下,position 是写入位置,limit 等于容量,下图表示写入了 4 个字节后的状态
在这里插入图片描述
flip 动作发生后,position 切换为读取位置,limit 切换为读取限制
在这里插入图片描述

读取 4 个字节后,状态
在这里插入图片描述

clear 动作发生后,状态

在这里插入图片描述

compact 方法,是把未读完的部分向前压缩,然后切换至写模式
在这里插入图片描述

ByteBuffer的分散读和集中写

分散读,可以理解为将一个缓冲区或文件的内数据,按容量分块读到不同的Buffer中。
集中写,多个Buffer的数据写到一个缓冲区或文件中

//这里直接抄了老师的代码

//分散读
//这里是根路径helloword文件夹下的txt文件
try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) {
    FileChannel channel = file.getChannel();
    //分配空间
    ByteBuffer a = ByteBuffer.allocate(3);
    ByteBuffer b = ByteBuffer.allocate(3);
    ByteBuffer c = ByteBuffer.allocate(5);
    
    //分散读到三个ByteBuffer里,直接read方法传一个ByteBuffer数组
    channel.read(new ByteBuffer[]{a, b, c});
    
    //查看读取结果
    a.flip();
    b.flip();
    c.flip();
    debug(a);
    debug(b);
    debug(c);
} catch (IOException e) {
    e.printStackTrace();
}


//集中写
try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) {
    FileChannel channel = file.getChannel();
    
    ByteBuffer d = ByteBuffer.allocate(4);
    ByteBuffer e = ByteBuffer.allocate(4);
    channel.position(11);
	
    d.put(new byte[]{'f', 'o', 'u', 'r'});
    e.put(new byte[]{'f', 'i', 'v', 'e'});
    d.flip();
    e.flip();
    debug(d);
    debug(e);
    
    //集中写到channel里,直接write方法传一个ByteBuffer数组
    channel.write(new ByteBuffer[]{d, e});
} catch (IOException e) {
    e.printStackTrace();
}

TCP粘包与处理

经典的TCP粘包半包问题
我们使用SocketChannel就基于TCP连接,当然有可能出现粘包半包问题,这里简单讲一下。
首先我们都知道TCP是面向字节流的,且有滑动窗口机制,当接受方窗口较大,发送方第一条消息较小,不足以填满发送窗口(发送方窗口一般小于等于接收方返回的报文所携带的接收窗口大小),此时TCP会等待下条消息,直到填满发送窗口,再去发送,即把多条消息或者说请求合并为一条发送,并且TCP不会对消息之间做间隔,因为TCP面向流,感知不到每次请求,只能感知到字节数据。所以需要我们手动给每条消息划分边界。这就是粘包。
同样情况下的半包是,当发送方窗口不足以容纳整条消息,就会将超出大小的部分放在之后的TCP报文中发送,并且这种分割TCP是不会做提醒的,同理因为它是面向字节流的协议,只处理数据流,是不区分不同请求的,可能我们从高层次看可以分清第一个包第二个包,但是TCP处理时这些数据仅仅是一个一个字节,是一视同仁的。所以就出现一个请求被拆分为两个或多个TCP包,也就是半包。我们同样需要对消息划分边界来保证正确处理半包数据。

既然这样,我们在接收到数据时就按字节流处理,读字节流直到读到我们定义的边界,就拆分出一条消息,完成分包。

三种方法:

  • 固定请求大小(缺乏灵活性,且单个消息很小会做填充,很浪费性能)
  • 特殊字符作为边界(例如以\n做终结符,读到表示一个\n消息读完。但是很明显,消息内含有终结符就会出现读一半的情况,需要对终结符转义处理)
  • 自定义消息结构 (每条消息头部都是一个长度,表示该消息的字节数,自然可以通过这个长度分包)

具体的解决方案可以参考HTTP的解决方案
在网上找了一个简洁的描述,来自这篇文章https://blog.csdn.net/weixin_42002747/article/details/124334361
HTTP报文格式如下:

HTTP请求报文格式
1)请求行:以\r\n结束;
2)请求头:以\r\n结束;
3)\r\n
3)数据;

HTTP响应报文格式
1)响应行:以\r\n结束;
2)响应头:以\r\n结束;
3)\r\n
4)数据;

1)遇到第一个\r\n表示读取请求行或响应行结束;
2) 遇到\r\n\r\n表示读取请求头或响应头结束;
3)对于body数据,根据请求头或响应头的Content-Length字段可以得知body长度,读对应长度即可。还有个什么chunked协议也可以,不过我不太了解这个。

可以看到,HTTP利用后两种方法巧妙的解决了问题。以请求为例,请求行和请求头内部字段一般不会有\r\n,所以不需要做转义处理,而对于数据部分,我们则直接通过请求头中的Content-Length来找到该条消息体的末尾。

粘包半包处理练习

满老师课上的示例,这里直接贴上来。
有点像算法题,字符串分割,哈哈。

网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔
但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为

  • Hello,world\n
  • I’m zhangsan\n
  • How are you?\n

变成了下面的两个 byteBuffer (黏包,半包)

  • Hello,world\nI’m zhangsan\nHo
  • w are you?\n

现在要求你编写程序,将错乱的数据恢复成原始的按 \n 分隔的数据

public static void main(String[] args) {
    ByteBuffer source = ByteBuffer.allocate(32);
    //                     11            24
    source.put("Hello,world\nI'm zhangsan\nHo".getBytes());
    split(source);

    source.put("w are you?\nhaha!\n".getBytes());
    split(source);
}

private static void split(ByteBuffer source) {
	//切换读模式
    source.flip();
    //获取数据长度
    int oldLimit = source.limit();
    //遍历ByteBuffer可读的每一个位置
    for (int i = 0; i < oldLimit; i++) {
    	//读到分隔符
        if (source.get(i) == '\n') {
            System.out.println(i);
            //position是当前读取消息的起点,i+1是终点,作差为该消息的长度,声明对应大小的ByteBuffer
            ByteBuffer target = ByteBuffer.allocate(i + 1 - source.position());
            //读取到target 并打印
            // 0 ~ limit
            source.limit(i + 1);
            target.put(source); // 从source 读,向 target 写
            debugAll(target);
            source.limit(oldLimit);
        }
    }
    source.compact();
}

ByteBuffer使用直接内存

这个是在后面讲零拷贝才提到的,涉及到ByteBuffer,干脆放到这了。

  • ByteBuffer.allocate(10) ——> HeapByteBuffer 使用的还是 java 内存
  • ByteBuffer.allocateDirect(10) ——> DirectByteBuffer 使用的是操作系统内存

使用直接内存减少了一次数据拷贝,用户态与内核态的切换次数没有减少,原理是直接内存也处于内核中,那么可以通过虚拟内存映射,直接把直接内存映射为对应读取的内核缓冲区,减少了这次拷贝。
这块不理解没关系,简单提一下,后面文章还会详细讲并且继续优化为零拷贝。
在这里插入图片描述

Channel与Buffer通信过程简单总结

仅仅是一个最简单的服务端和客户端,实际使用会涉及到Selector,这里仅做一个单线程通信。
首先想通信就要建立连接
客户端发出连接请求
服务端处理连接请求(ServerSocketChannel处理,并且要设置监听端口)
客户端发送消息
服务端接收并读取

满老师的非阻塞部分的示例代码

//服务端
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); // 非阻塞模式
// 2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
// 3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
//处理连接请求,遍历SocketChannel接收数据
while (true) {
    // 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
    SocketChannel sc = ssc.accept(); // 非阻塞,线程还会继续运行,如果没有连接建立,但sc是null
    if (sc != null) {
        log.debug("connected... {}", sc);
        sc.configureBlocking(false); // 非阻塞模式
        channels.add(sc);
    }
    for (SocketChannel channel : channels) {
        // 5. 接收客户端发送的数据
        int read = channel.read(buffer);// 非阻塞,线程仍然会继续运行,如果没有读到数据,read 返回 0
        if (read > 0) {
            buffer.flip();
            debugRead(buffer);
            buffer.clear();
            log.debug("after read...{}", channel);
        }
    }
}

//客户端 写数据
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put("asdasdsasd");
sc.write(buffer);

当然实际的服务器与客户端不会这样简单,仅仅是客户端写,服务器读。
这里仅说明一下使用流程。
下篇会有Selector版本的使用流程,结合上Selector多路复用提高通信效率。

结语

在这里插入图片描述

写博客真的挺累的,这又写了两个半小时,但是感觉确实很专注。写的慢主要还是有些东西不懂需要查阅,还有些以前学过的需要复习回顾一下,所以写的很慢。感觉还是有收获的,复习才学到的,复习很久之前学到的,还能解锁一下新知识,因为有些东西想深入写一下就得去学一下。

感觉保证日更一篇吧,毕竟还要学东西,不能花自己一半乃至全部的时间写博客,反而不学东西了,那就舍本逐末了。

如果觉得这篇博客写的不错,可以给小弟点个赞。
如果想继续追更netty可以点个关注。

最后感谢您的耐心阅读,欢迎批评指正,下篇博客见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值