版权声明:本文为博主原创文章,未经博主允许不得转载。如需交流,QQ773882719. https://blog.csdn.net/chou_out_man/article/details/85277018
##
零、 目录
- IM系统简介
- Netty 简介
- Netty 环境配置
- 服务端启动流程
- 实战: 客户端和服务端双向通信
- 数据传输载体ByteBuf介绍
- 客户端与服务端通信协议编解码
- 实现客户端登录
- 实现客户端与服务端收发消息
- pipeline与channelHandler
- 构建客户端与服务端pipeline
- 拆包粘包理论与解决方案
- channelHandler的生命周期
- 使用channelHandler的热插拔实现客户端身份校验
- 客户端互聊原理与实现
- 群聊的发起与通知
- 群聊的成员管理(加入与退出,获取成员列表)
- 群聊消息的收发及Netty性能优化
- 心跳与空闲检测
- 总结
- 扩展
二、 Netty简介
- 回顾IO编程
-
场景: 客户端每隔两秒发送一个带有时间戳的“hello world”给服务端 , 服务端收到之后打印。
-
代码:
IOServer.java /** * @author 闪电侠 */ public class IOServer { public static void main(String[] args) throws Exception { ServerSocket serverSocket = new ServerSocket(8000); // (1) 接收新连接线程 new Thread(() -> { while (true) { try { // (1) 阻塞方法获取新的连接 Socket socket = serverSocket.accept(); // (2) 每一个新的连接都创建一个线程,负责读取数据 new Thread(() -> { try { int len; byte[] data = new byte[1024]; InputStream inputStream = socket.getInputStream(); // (3) 按字节流方式读取数据 while ((len = inputStream.read(data)) != -1) { System.out.println(new String(data, 0, len)); } } catch (IOException e) { } }).start(); } catch (IOException e) { } } }).start(); } } IOClient.java /** * @author 闪电侠 */ public class IOClient { public static void main(String[] args) { new Thread(() -> { try { Socket socket = new Socket("127.0.0.1", 8000); while (true) { try { socket.getOutputStream().write((new Date() + ": hello world").getBytes()); Thread.sleep(2000); } catch (Exception e) { } } } catch (IOException e) { } }).start(); } }
-
IO编程,模型在客户端较少的场景下运行良好 , 但是客户端比较多的业务来说 , 单机服务端可能需要支撑成千上万的连接, IO模型可能就不太合适了 , 原因:
- 在传统的IO模型中 , 每一个连接创建成功之后都需要一个线程来维护 , 每个线程包含一个while死循环, 那么1W个连接就对应1W个线程 , 继而1W个死循环。
- 线程资源受限: 线程是操作系统中非常宝贵的资源 , 同一时刻有大量的线程处于阻塞状态是非常严重的资源浪费,操作系统开销太大。
- 线程切换效率低下: 单机CPU核数固定 , 线程爆炸之后操作系统频繁的进行线程切换 , 应用性能几句下降
- IO编程中 , 数据读写是以字节流为单位。
-
为了解决这些问题 , JDK1.4之后提出了NIO
-
- NIO 编程
-
NIO 是如何解决一下三个问题。
- 线程资源受限
- NIO编程模型中 , 新来一个连接不再创建一个新的线程, 而是可以把这条连接直接绑定在某个固定的线程 , 然后这条连接所有的读写都由这个线程来负责 , 那么他是怎么做到的?
- 如上图所示,IO 模型中,一个连接来了,会创建一个线程,对应一个 while 死循环,死循环的目的就是不断监测这条连接上是否有数据可以读,大多数情况下,1w 个连接里面同一时刻只有少量的连接有数据可读,因此,很多个 while 死循环都白白浪费掉了,因为读不出啥数据。
- 而在 NIO 模型中,他把这么多 while 死循环变成一个死循环,这个死循环由一个线程控制,那么他又是如何做到一个线程,一个 while 死循环就能监测1w个连接是否有数据可读的呢? 这就是 NIO 模型中 selector 的作用,一条连接来了之后,现在不创建一个 while 死循环去监听是否有数据可读了,而是直接把这条连接注册到 selector 上,然后,通过检查这个 selector,就可以批量监测出有数据可读的连接,进而读取数据,下面我再举个非常简单的生活中的例子说明 IO 与 NIO 的区别。
- 在一家幼儿园里,小朋友有上厕所的需求,小朋友都太小以至于你要问他要不要上厕所,他才会告诉你。幼儿园一共有 100 个小朋友,有两种方案可以解决小朋友上厕所的问题:
- 每个小朋友配一个老师。每个老师隔段时间询问小朋友是否要上厕所,如果要上,就领他去厕所,100 个小朋友就需要 100 个老师来询问,并且每个小朋友上厕所的时候都需要一个老师领着他去上,这就是IO模型,一个连接对应一个线程。
- 所有的小朋友都配同一个老师。这个老师隔段时间询问所有的小朋友是否有人要上厕所,然后每一时刻把所有要上厕所的小朋友批量领到厕所,这就是 NIO 模型,所有小朋友都注册到同一个老师,对应的就是所有的连接都注册到一个线程,然后批量轮询。
- 这就是 NIO 模型解决线程资源受限的方案,实际开发过程中,我们会开多个线程,每个线程都管理着一批连接,相对于 IO 模型中一个线程管理一条连接,消耗的线程资源大幅减少
- NIO编程模型中 , 新来一个连接不再创建一个新的线程, 而是可以把这条连接直接绑定在某个固定的线程 , 然后这条连接所有的读写都由这个线程来负责 , 那么他是怎么做到的?
- 线程切换效率低下
- 由于NIO模型中线程数量大大降低 , 线程切换的效率也因此大幅度提高
- IO读写面向流
- IO读写是面向流的 , 一次性只能从流中读取一个或多个字节 , 并且读完之后无法再次读取 , 你需要自己缓存数据 , 而NIO的读写是面向Buffer的 , 你可以随意读取里面的任何一个字节数据 , 不需要你自己缓存数据 , 这一切只需要移动读写指针即可。
- 线程资源受限
-
原生NIO 实现
/** * 服务端 * */ class NIO_server_test_01{ public static void start () throws IOException { Selector serverSelect = Selector.open(); Selector clientSelect = Selector.open(); new Thread(() -> { try { ServerSocketChannel socketChannel = ServerSocketChannel.open(); socketChannel.socket().bind(new InetSocketAddress(8000)); // 监听端口 socketChannel.configureBlocking(false); // 是否阻塞 socketChannel.register(serverSelect, SelectionKey.OP_ACCEPT); while ( true ) { // 检测是否有新的连接 if(serverSelect.select(1) > 0) { // 1 是超时时间 select 方法返回当前连接数量 Set<SelectionKey> set = serverSelect.selectedKeys(); set.stream() .filter(key -> key.isAcceptable()) .collect(Collectors.toList()) .forEach(key ->{ try { // 每次来一个新的连接, 不需要创建新的线程 , 而是注册到clientSelector SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept(); clientChannel.configureBlocking(false); clientChannel.register(serverSelect, SelectionKey.OP_ACCEPT); }catch(Exception e) { e.printStackTrace(); }finally { set.iterator().remove(); } }); } } }catch (Exception e) { e.printStackTrace(); } }).start(); new Thread(() -> { try { // 批量轮询 有哪些连接有数据可读 while ( true ) { if(clientSelect.select(1) > 0) { clientSelect.selectedKeys().stream() .filter(key -> key.isReadable()) .collect(Collectors.toList()) .forEach(key -> { try { SocketChannel clientChannl = (SocketChannel) key.channel(); ByteBuffer bf = ByteBuffer.allocate(1024); // 面向byteBuffer clientChannl.read(bf); bf.flip(); System.out.println(Charset.defaultCharset().newDecoder().decode(bf).toString()); }catch ( Exception e) { e.printStackTrace(); }finally { clientSelect.selectedKeys().iterator().remove(); key.interestOps(SelectionKey.OP_READ); } }); } } }catch (Exception e) { e.printStackTrace(); } }).start(); } }
- 通常NIO 模型中会有两个线程每个线程中绑定一个轮询器selector , 在我们的例子中serverSelector负责轮询是否有新的连接 , clientSelector 负责轮询连接中是否有数据可读。
- 服务端检测到新的连接之后 , 不在创建一个新的线程 , 而是直接将连接注册到clientSelector中
- clientorSelector 被一个while死循环抱着 , 如果在某一时刻有多个连接数据可读 ,数据将会被clientSelector.select() 方法轮询出来。 进而批量处理 。
- 数据的读写面向buffer 而不是面向流。
-
原生NIO 进行网络开发的缺点:
- JDK 的NIO 编程需要了解很多概念, 编程复杂 , 对NIO 入门很不友好 , 编程模型不友好 , ByteBuffer的API简直反人类 (这是书里这么说的 , 不要喷我)。
- 对NIO 编程来说 , 一个比较适合的线程模型能充分发挥它的优势 , 而JDK没有给你实现 , 你需要自己实现 , 就连简单的协议拆包都要自己实现 (我感觉这样才根据创造力呀 )
- JDK NIO 底层由epoll 实现 , 该实现饱受诟病的空轮训bug会导致cpu 飙升100%
- 项目庞大之后 , 自己实现的NIO 很容易出现各类BUG , 维护成本高 (作者怎么把自己的过推向JDK haha~)
- 正因为如此 , 我连客户端的代码都懒得给你写了 (这作者可真够懒的) , 你可以直接使用IOClient 和NIO_Server 通信
-
JDK 的NIO 犹如带刺的玫瑰 , 虽然美好 , 让人向往 , 但是使用不当会让你抓耳挠腮 , 痛不欲生 , 正因为如此 , Netty横空出世!(作者这才华 啧啧啧~)
-
- Netty 编程
- Netty到底是何方神圣(被作者吹上天了都) , 用依据简单的话来说就是: Netty 封装了JDK 的NIO , 让你使用更加干爽 (干爽???) , 你不用在写一大堆复杂的代码了 , 用官方的话来说就是: Netty是一个异步事件驱动的网络应用框架 , 用于快速开发可维护的高性能服务器和客户端。
- Netty 相比 JDK 原生NIO 的优点 :
- 使用NIO 需要了解太多概念, 编程复杂 , 一不小心 BUG 横飞
- Netty 底层IO模型随意切换 , 而这一切只需要小小的改动 , 改改参数 , Netty乐意直接从NIO模型转换为IO 模型 。
- Netty 自带的拆包解包 , 异常检测可以让你从NIO 的繁重细节中脱离出来 , 让你只关心业务逻辑 。
- Netty 解决了JDK 的很多包括空轮训在内的BUG
- Netty社区活跃 , 遇到问题可以轻松解决
- Netty 已经经历各大RPC 框架 , 消息中间价 , 分布式通信中间件线上的广泛验证 , 健壮性无比强大
- 代码实例
-
maven 依赖
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.6.Final</version> </dependency>
-
NettyServer
/** * @author outman * */ class Netty_server_02 { public void start () { ServerBootstrap serverBootstrap = new ServerBootstrap(); NioEventLoopGroup boss = new NioEventLoopGroup(); NioEventLoopGroup woker = new NioEventLoopGroup(); serverBootstrap.group(boss ,woker) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<NioSocketChannel>() { @Override protected void initChannel(NioSocketChannel ch) throws Exception { ch.pipeline().addLast(new StringDecoder()); ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() { @Override protected void channelRead0(ChannelHandlerContext cxt, String msg) throws Exception { System.out.println(msg); } }); } }).bind(8000); } }
- 这么一小段代码就实现了我们前面NIO 编程中所有的功能 , 包括服务端启动 , 接收新连接 , 打印客户端传来的数据。
- 将NIO 中的概念与IO模型结合起来理解:
- boss 对应 IOServer 中接收新连接创建线程 , 主要负责创建新连接
- worker 对应 IOServer 中负责读取数据的线程 , 主要用于数据读取语句业务逻辑处理 。
- 详细逻辑会在后续深入讨论
-
NettyClient
/**
* @author outman
* */
class Netty_client_02 {public static void main(String[] args) throws InterruptedException { Bootstrap bootstrap = new Bootstrap(); NioEventLoopGroup group = new NioEventLoopGroup(); bootstrap.group(group).channel(NioSocketChannel.class).handler(new ChannelInitializer<Channel>() { @Override protected void initChannel(Channel ch) { ch.pipeline().addLast(new StringEncoder()); } }); Channel channel = bootstrap.connect("127.0.0.1", 8000).channel(); while (true) { channel.writeAndFlush(new Date() + ": hello world!"); Thread.sleep(2000); } }
}
-
在客户端程序中 , group 对应了我们IOClient 中 新起的线程。
-
剩下的逻辑 我们在后文中详细分析 , 现在你可以把 Netty_server_02 和Netty_client_02 复制到 你的IDE 中 运行起来 感受世界 的美好 (注意 先启动 服务端 再启动客户端 )
-
使用Netty 之后 整个世界都美好了, 一方面 Netty 对NIO 封装的如此完美 , 另一方面 , 使用Netty 之后 , 网络通信这块的性能问题几乎不用操心 , 尽情的让Netty 榨干你的CPU 吧~~
-