手撸架构,Netty 与 RPC 面试48问

技术栈传送门
JAVA 基础手撸架构,Java基础面试100问_vincent-CSDN博客
JAVA 集合手撸架构,JAVA集合面试60问_vincent-CSDN博客
JVM 虚拟机手撸架构,JVM面试30问_vincent-CSDN博客
并发编程手撸架构,并发编程面试123问_vincent-CSDN博客
Spring 手撸架构,Spring面试63问_vincent-CSDN博客
Spring cloud 手撸架构,Spring cloud面试45问_vincent-CSDN博客
SpringBoot手撸面试,Spring Boot面试41问_vincent-CSDN博客
Netty 与 RPC手撸架构,Netty 与 RPC面试48问_vincent-CSDN博客
Doubo 手撸架构,Dubbo面试49问_vincent-CSDN博客
Redis手撸架构,Redis面试41问_vincent-CSDN博客
Zookeeper手撸架构,Zookeeper面试27问_vincent-CSDN博客
Mysql 手撸架构,Mysql 面试126问_vincent-CSDN博客
MyBatis手撸架构,MyBatis面试42问_vincent-CSDN博客
MongoDB 手撸架构,MongDB 面试50问_vincent-CSDN博客
Elasticsearch手撸架构,Elasticsearch 面试25问_vincent-CSDN博客
RabbitMQ 手撸架构,RabbitMQ 面试49问_vincent-CSDN博客
Kafka 手撸架构,Kafka 面试42问_vincent-CSDN博客
Docker手撸架构,Docker 面试25问_vincent-CSDN博客
Nginx手撸架构,Nginx 面试40问_vincent-CSDN博客
算法常用排序算法总结(1)-- 比较排序_vincent-CSDN博客_比较排序
常用排序算法总结(2)-- 非比较排序算法_vincent-CSDN博客_非比较排序的算法有
分布式事务分布式事务解决方案(总览)_vincent-CSDN博客
HTTP太厉害了,终于有人能把TCP/IP 协议讲的明明白白了_vincent-CSDN博客_tcp和ip

IO模型(BIO,NIO,AIO)及其区别

先弄清楚同步、异步,阻塞、非阻塞概念。

io操作分为两部分,发起io请求,和io数据读写。阻塞、非阻塞主要是针对线程发起io请求后,是否立即返回来定义的,立即返回称为非阻塞io,否则称为阻塞io。

同步、异步主要针对io数据读写来定义的,读写数据过程中不阻塞线程称为异步io,否则,称为同步io。

BIO:一个连接一个线程,客户端有连接请求时服务器端就需要启动一个线程进行处理。线程开销大。
伪异步IO:将请求连接放入线程池,一对多,但线程还是很宝贵的资源。

线程发起io请求后,一直阻塞(阻塞io),直到数据就绪后,用户线程将数据写入socket空间,或从socket空间读取数据(同步)。

NIO:一个请求一个线程,但客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。

线程发起io请求后,立即返回(非阻塞io)。用户线程不阻塞等待,但是,用户线程要定时轮询检查数据是否就绪,当数据就绪后,用户线程将数据从用户空间写入socket空间,或从socket空间读取数据到用户空间(同步)。

IO多路复用(NIO):上述NIO实现中,需要用户线程定时轮训,去检查IO数据是否就绪,占用应用程序线程资源。IO多路复用模型中,将检查IO数据是否就绪的任务,交给系统级别的select或poll模型,由系统进行监控,减轻用户线程负担。

当用户线程发起io请求后,将socket连接及关注事件注册到selector(多路复用器,os级别线程)上,selector循环遍历socket连接,看是否有关注数据就绪,如果连接有数据就绪后,就通知应用程序,建立线程进行数据读写。同BIO对比,NIO中线程处理的都是有效连接(数据就绪),且一个线程可以分管处理多个连接上的就绪数据,节省线程资源开销。

1)select:注册的socket事件由数组管理,长度有限制,轮询查找时需要遍历数组。

2)poll:注册的socket事件由链表实现,数量没有限制,遍历链表轮询查找。

3)epoll:基于事件驱动思想,采用reactor模式,通过事件回调,无需使用某种方式主动检查socket状态,被动接收就绪事件即可。

AIO:线程发起io请求后,立即返回(非阻塞io),当数据读写完成后,OS通知用户线程(异步)。这里数据写入socket空间,或从socket空间读取数据到用户空间由OS完成,用户线程无需介入,所以也就不会阻塞用户线程,即异步。

AIO基于时间驱动思想,采用proactor模式。数据完成后,由os主动通知应用程序,通过epoll实现,节省了NIO中selector循环遍历检测数据就绪的资源开销。同时,数据copy操作(用户空间<->socket空间)是由os完成的,无需应用程序参与,大大提高应用程序效率。 

BIO 和AIO区别

BIO是面向流的,NIO是面向缓冲区的;

BIO的各种流是阻塞的,而NIO是非阻塞的;

BIO的Stream是单向的,而NIO的channel是双向的。

NIO的特点:事件驱动模型、单线程处理多任务、非阻塞I/O,I/O读写不再阻塞,而是返回0、基于block的传输比基于流的传输更高效、更高级的IO函数zero-copy、IO多路复用大大提高了Java网络应用的可伸缩性和实用性。基于Reactor线程模型。

在Reactor模式中,事件分发器等待某个事件或者可应用或个操作的状态发生,事件分发器就把这个事件传给事先注册的事件处理函数或者回调函数,由后者来做实际的读写操作。如在Reactor中实现读:注册读就绪事件和相应的事件处理器、事件分发器等待事件、事件到来,激活分发器,分发器调用事件对应的处理器、事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。

为什么要用 Netty 

  1. 统一的 API,支持多种传输类型,阻塞和非阻塞的。
  2. 简单而强大的线程模型。
  3. 自带编解码器解决 TCP 粘包/拆包问题。
  4. 自带各种协议栈。
  5. 真正的无连接数据包套接字支持。
  6. 比直接使用 Java 核心 API 有更高的吞吐量、更低的延迟、更低的资源消耗和更少的内存复制。
  7. 安全性不错,有完整的 SSL/TLS 以及 StartTLS 支持。
  8. 社区活跃成熟稳定,经历了大型项目的使用和考验,而且很多开源项目都使用到了 Netty, 比如我们经常接触的 Dubbo、RocketMQ 等等。

Netty 应用场景

作为 RPC 框架的网络通信工具 :我们在分布式系统中,不同服务节点之间经常需要相互调用,这个时候就需要 RPC 框架了。不同服务节点之间的通信是如何做的呢?可以使用 Netty 来做。比如我调用另外一个节点的方法的话,至少是要让对方知道我调用的是哪个类中的哪个方法以及相关参数吧!

实现一个自己的 HTTP 服务器 :通过 Netty 我们可以自己实现一个简单的 HTTP 服务器,这个大家应该不陌生。说到 HTTP 服务器的话,作为 Java 后端开发,我们一般使用 Tomcat 比较多。一个最基本的 HTTP 服务器可要以处理常见的 HTTP Method 的请求,比如 POST 请求、GET 请求等等。

实现一个即时通讯系统 :使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统,这方面的开源项目还蛮多的,可以自行去 Github 找一找。实现消息推送系统 :市面上有很多消息推送系统都是基于 Netty 来做的。

Netty 原理

Netty 是一个高性能、异步事件驱动的 NIO 框架,基于 JAVA NIO 提供的 API 实现。它提供了对TCP、UDP 和文件传输的支持,作为一个异步 NIO 框架,Netty 的所有 IO 操作都是异步非阻塞的,通过 Future-Listener 机制,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果。

Netty 的特点

  • 高并发:Netty 是一款基于 NIO(Nonblocking IO,非阻塞IO)开发的网络通信框架,对比于 BIO(Blocking I/O,阻塞IO),他的并发性能得到了很大提高。
  • 传输快:Netty 的传输依赖于零拷贝特性,尽量减少不必要的内存拷贝,实现了更高效率的传输。
  • 封装好:Netty 封装了 NIO 操作的很多细节,提供了易于使用调用接口。

Netty 核心组件

  • Channel:Netty 网络操作抽象类,它除了包括基本的 I/O 操作,如 bind、connect、read、write 等。
  • EventLoop:主要是配合 Channel 处理 I/O 操作,用来处理连接的生命周期中所发生的事情。
  • ChannelFuture:Netty 框架中所有的 I/O 操作都为异步的,因此我们需要 ChannelFuture 的 addListener()注册一个 ChannelFutureListener 监听事件,当操作执行成功或者失败时,监听就会自动触发返回结果。
  • ChannelHandler:充当了所有处理入站和出站数据的逻辑容器。ChannelHandler 主要用来处理各种事件,这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。
  • ChannelPipeline:为 ChannelHandler 链提供了容器,当 channel 创建时,就会被自动分配到它专属的 ChannelPipeline,这个关联是永久性的。

EventloopGroup 了解么?和 EventLoop 有什么关系

EventLoopGroup 包含多个 EventLoop(每一个 EventLoop 通常内部包含一个线程),上面我们已经说了 EventLoop 的主要作用实际就是负责监听网络事件并调用事件处理器进行相关 I/O 操作的处理。

并且 EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全。

上图是一个服务端对 EventLoopGroup 使用的大致模块图,其中 Boss EventloopGroup 用于接收连接,Worker EventloopGroup 用于具体的处理(消息的读写以及其他逻辑处理)。

从上图可以看出:当客户端通过 connect 方法连接服务端时,bossGroup 处理客户端连接请求。当客户端处理完成后,会将这个连接提交给 workerGroup 来处理,然后 workerGroup 负责处理其 IO 相关操作。

Bootstrap 和 ServerBootstrap 介绍

Bootstrap 是客户端的启动引导类/辅助类,具体使用方法如下:

EventLoopGroup group = new NioEventLoopGroup(); 
try { 
    //创建客户端启动引导/辅助类:
    Bootstrap Bootstrap b = new Bootstrap();
    //指定线程模型 
    b.group(group). ......
    // 尝试建立连接
    ChannelFuture f = b.connect(host, port).sync(); 
    f.channel().closeFuture().sync();
 } finally { 
    // 优雅关闭相关线程组资源 group.shutdownGracefully(); 
}

ServerBootstrap 客户端的启动引导类/辅助类,具体使用方法如下:

// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理 
EventLoopGroup bossGroup = new NioEventLoopGroup(1); 
EventLoopGroup workerGroup = new NioEventLoopGroup(); 
try { 
    //2.创建服务端启动引导/辅助类:
    ServerBootstrap ServerBootstrap b = new ServerBootstrap(); 
    //3.给引导类配置两大线程组,确定了线程模型 
    b.group(bossGroup, workerGroup)
    ...... 
    // 6.绑定端口
     ChannelFuture f = b.bind(port).sync(); 
    // 等待连接关闭
    f.channel().closeFuture().sync(); 
} finally { 
    //7.优雅关闭相关线程组资源 
    bossGroup.shutdownGracefully(); 
    workerGroup.shutdownGracefully(); 
} 

从上面的示例中,我们可以看出:

Bootstrap 通常使用 connet() 方法连接到远程的主机和端口,作为一个 Netty TCP 协议通信中的客户端。另外,Bootstrap 也可以通过 bind() 方法绑定本地的一个端口,作为 UDP 协议通信中的一端。ServerBootstrap通常使用 bind() 方法绑定本地的端口上,然后等待客户端的连接。Bootstrap 只需要配置一个线程组— EventLoopGroup ,而 ServerBootstrap需要配置两个线程组— EventLoopGroup ,一个用于接收连接,一个用于具体的处理。

默认情况 Netty 起多少线程?何时启动?

Netty 默认是 CPU 处理器数的两倍,bind 完之后启动

Netty 高性能

  1. IO 线程模型:同步非阻塞,用最少的资源做更多的事。
  2. 内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。
  3. 内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
  4. 串形化处理读写:避免使用锁带来的性能开销。
  5. 高性能序列化协议:支持 protobuf 等高性能序列化协议。
     

多路复用通讯方式

Netty 架构按照 Reactor 模式设计和实现,它的服务端通信序列图如下:

客户端通信序列图如下:

Netty 的 IO 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个

客户端 Channel,由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于

频繁 IO 阻塞导致的线程挂起。

异步通讯 NIO

由于 Netty 采用了异步通信模式,一个 IO 线程可以并发处理 N 个客户端连接和读写操作,这从根

本上解决了传统同步阻塞 IO 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极

大的提升。

零拷贝(DIRECT BUFFERS 使用堆外直接内存)

  1. Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
  2. Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的Buffer。
  3. Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环 write 方式导致的内存拷贝问题

内存池(基于内存池的缓冲区重用机制)

随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制。

Netty 线程模型

大部分网络框架都是基于 Reactor 模式设计开发的。

Reactor 模式基于事件驱动,采用多路复用将事件分发给相应的 Handler 处理,非常适合处理海量 IO 的场景。

在 Netty 主要靠 NioEventLoopGroup 线程池来实现具体的线程模型的 。

我们实现服务端的时候,一般会初始化两个线程组:

bossGroup :接收连接。

workerGroup :负责具体的处理,交由对应的 Handler 处理。下面我们来详细看一下 Netty 中的线程模型吧!

高效的 Reactor 线程模型

常用的 Reactor 线程模型有三种,Reactor 单线程模型, Reactor 多线程模型, 主从 Reactor 多线程模型。

Reactor 单线程模型

Reactor 单线程模型,指的是所有的 IO 操作都在同一个 NIO 线程上面完成,NIO 线程的职责如下:

  1. 作为 NIO 服务端,接收客户端的 TCP 连接;
  2. 作为 NIO 客户端,向服务端发起 TCP 连接;
  3. 读取通信对端的请求或者应答消息;
  4.  向通信对端发送消息请求或者应答消息。

由于 Reactor 模式使用的是异步非阻塞 IO,所有的 IO 操作都不会导致阻塞,理论上一个线程可以独立处理所有 IO 相关的操作。从架构层面看,一个 NIO 线程确实可以完成其承担的职责。例如,通过Acceptor 接收客户端的 TCP 连接请求消息,链路建立成功之后,通过 Dispatch 将对应的 ByteBuffer派发到指定的 Handler 上进行消息解码。用户 Handler 可以通过 NIO 线程将消息发送给客户端。

Reactor 多线程模型

Rector 多线程模型与单线程模型最大的区别就是有一组 NIO 线程处理 IO 操作。 有专门一个NIO 线程-Acceptor 线程用于监听服务端,接收客户端的 TCP 连接请求; 网络 IO 操作-读、写等由一个 NIO 线程池负责,线程池可以采用标准的 JDK 线程池实现,它包含一个任务队列和 N个可用的线程,由这些 NIO 线程负责消息的读取、解码、编码和发送;

主从 Reactor 多线程模型

服务端用于接收客户端连接的不再是个 1 个单独的 NIO 线程,而是一个独立的 NIO 线程池。Acceptor 接收到客户端 TCP 连接请求处理完成后(可能包含接入认证等),将新创建的SocketChannel 注册到 IO 线程池(sub reactor 线程池)的某个 IO 线程上,由它负责SocketChannel 的读写和编解码工作。Acceptor 线程池仅仅只用于客户端的登陆、握手和安全认证,一旦链路建立成功,就将链路注册到后端 subReactor 线程池的 IO 线程上,由 IO 线程责后续的 IO 操作。

无锁设计、线程绑定

Netty 采用了串行无锁化设计,在 IO 线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。

Netty 的 NioEventLoop 读取到消息之后,直接调用 ChannelPipeline 的 fireChannelRead(Object msg),只要用户不主动切换线程,一直会由 NioEventLoop 调用到用户的 Handler,期间不进行线程切换,这种串行化处理方式避免了多线程操作导致的锁的竞争,从性能角度看是最优的。

高性能的序列化框架

Netty 默认提供了对 Google Protobuf 的支持,通过扩展 Netty 的编解码接口,用户可以实现其它的高性能序列化框架,例如 Thrift 的压缩二进制编解码框架。

SO_RCVBUF 和 SO_SNDBUF:通常建议值为 128K 或者 256K。

小包封大包,防止网络阻塞

SO_TCPNODELAY:NAGLE 算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法。

软中断 Hash 值和 CPU 绑定

软中断:开启 RPS 后可以实现软中断,提升网络吞吐量。RPS 根据数据包的源地址,目的地址以

及目的和源端口,计算出一个 hash 值,然后根据这个 hash 值来选择软中断运行的 cpu,从上层

来看,也就是说将每个连接和 cpu 绑定,并通过这个 hash 值,来均衡软中断在多个 cpu 上,提升

网络并行处理性能。

Netty 发送消息方式

  1. 直接写入 Channel 中,消息从 ChannelPipeline 当中尾部开始移动;
  2. 写入和 ChannelHandler 绑定的 ChannelHandlerContext 中,消息从 ChannelPipeline 中的下一个 ChannelHandler 中移动。

Netty 长连接、心跳机制

TCP 长连接和短连接

 TCP 在进行读写之前,server 与 client 之间必须提前建立一个连接。建立连接的过程,需要我们常说的三次握手,释放/关闭连接的话需要四次挥手。这个过程是比较消耗网络资源并且有时间延迟的。

所谓,短连接说的就是 server 端 与 client 端建立连接之后,读写完成之后就关闭掉连接,如果下一次再要互相发送消息,就要重新连接。短连接的有点很明显,就是管理和实现都比较简单,缺点也很明显,每一次的读写都要建立连接必然会带来大量网络资源的消耗,并且连接的建立也需要耗费时间。

长连接说的就是 client 向 server 双方建立连接之后,即使 client 与 server 完成一次读写,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。长连接的可以省去较多的 TCP 建立和关闭的操作,降低对网络资源的依赖,节约时间。对于频繁请求资源的客户来说,非常适用长连接。

为什么需要心跳机制?Netty 中心跳机制了解么?

在 TCP 保持长连接的过程中,可能会出现断网等网络异常出现,异常发生的时候, client 与 server 之间如果没有交互的话,它们是无法发现对方已经掉线的。为了解决这个问题, 我们就需要引入 心跳机制。

心跳机制的工作原理是: 在 client 与 server 之间在一定时间内没有数据交互时, 即处于 idle 状态时, 客户端或服务器就会发送一个特殊的数据包给对方, 当接收方收到这个数据报文后, 也立即发送一个特殊的数据报文, 回应发送方, 此即一个 PING-PONG 交互。所以, 当某一端收到心跳消息后, 就知道了对方仍然在线, 这就确保 TCP 连接的有效性.

TCP 实际上自带的就有长连接选项,本身是也有心跳包机制,也就是 TCP 的选项:SO_KEEPALIVE。但是,TCP 协议层面的长连接灵活性不够。所以,一般情况下我们都是在应用层协议上实现自定义心跳机制的,也就是在 Netty 层面通过编码实现。通过 Netty 实现心跳机制的话,核心类是 IdleStateHandler 。

Netty 心跳类型设置

readerIdleTime:为读超时时间(即测试端一定时间内未接受到被测试端消息)。
writerIdleTime:为写超时时间(即测试端一定时间内向被测试端发送消息)。
allIdleTime:所有类型的超时时间。

Netty 服务端和客户端的启动过程

服务端

// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理 
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(); 
try { 
//2.创建服务端启动引导/辅助类:
ServerBootstrap ServerBootstrap b = new ServerBootstrap(); 
//3.给引导类配置两大线程组,确定了线程模型 
b.group(bossGroup, workerGroup) 
// (非必备)打印日志 
.handler(new LoggingHandler(LogLevel.INFO)) 
// 4.指定 IO 模型 
.channel(NioServerSocketChannel.class) 
.childHandler(new ChannelInitializer<SocketChannel>() { 
@Override public void initChannel(SocketChannel ch) { 
ChannelPipeline p = ch.pipeline(); 
//5.可以自定义客户端消息的业务处理逻辑 
p.addLast(new HelloServerHandler()); } }); 
// 6.绑定端口,调用 sync 方法阻塞知道绑定完成
 ChannelFuture f = b.bind(port).sync();
 // 7.阻塞等待直到服务器Channel关闭(closeFuture()方法获取Channel 的CloseFuture对象,然后调用sync()方法) 
f.channel().closeFuture().sync(); 
} finally { 
//8.优雅关闭相关线程组资源 
bossGroup.shutdownGracefully(); 
workerGroup.shutdownGracefully(); 
}

1.首先你创建了两个 NioEventLoopGroup 对象实例:bossGroup 和 workerGroup。

bossGroup : 用于处理客户端的 TCP 连接请求。workerGroup :负责每一条连接的具体读写数据的处理逻辑,真正负责 I/O 读写操作,交由对应的 Handler 处理。举个例子:我们把公司的老板当做 bossGroup,员工当做 workerGroup,bossGroup 在外面接完活之后,扔给 workerGroup 去处理。一般情况下我们会指定 bossGroup 的 线程数为 1(并发连接量不大的时候) ,workGroup 的线程数量为 CPU 核心数 *2 。另外,根据源码来看,使用 NioEventLoopGroup 类的无参构造函数设置线程数量的默认值就是 CPU 核心数 *2 。

2.接下来 我们创建了一个服务端启动引导/辅助类:ServerBootstrap,这个类将引导我们进行服务端的启动工作。

3.通过 .group() 方法给引导类 ServerBootstrap 配置两大线程组,确定了线程模型。

通过下面的代码,我们实际配置的是多线程模型,这个在上面提到过。

EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup();

4.通过channel()方法给引导类 ServerBootstrap指定了 IO 模型为NIO

NioServerSocketChannel :指定服务端的 IO 模型为 NIO,与 BIO 编程模型中的ServerSocket对应NioSocketChannel : 指定客户端的 IO 模型为 NIO, 与 BIO 编程模型中的Socket对应5.通过 .childHandler()给引导类创建一个ChannelInitializer ,然后制定了服务端消息的业务处理逻辑 HelloServerHandler 对象6.调用 ServerBootstrap 类的 bind()方法绑定端口

客户端

//1.创建一个 NioEventLoopGroup 对象实例 
EventLoopGroup group = new NioEventLoopGroup(); 
try { 
//2.创建客户端启动引导/辅助类:
Bootstrap Bootstrap b = new Bootstrap(); 
//3.指定线程组 
b.group(group) 
//4.指定 IO 模型 
.channel(NioSocketChannel.class) 
.handler(new ChannelInitializer<SocketChannel>() { 
@Override public void initChannel(SocketChannel ch) throws Exception { 
ChannelPipeline p = ch.pipeline(); 
// 5.这里可以自定义消息的业务处理逻辑 
p.addLast(new HelloClientHandler(message)); 
} 
}); 
// 6.尝试建立连接 
ChannelFuture f = b.connect(host, port).sync(); 
// 7.等待连接关闭(阻塞,直到Channel关闭) 
f.channel().closeFuture().sync(); 
} finally { 
group.shutdownGracefully(); 
}

1.创建一个 NioEventLoopGroup 对象实例

2.创建客户端启动的引导类是 Bootstrap

3.通过 .group() 方法给引导类 Bootstrap 配置一个线程组

4.通过channel()方法给引导类 Bootstrap指定了 IO 模型为NIO

5.通过 .childHandler()给引导类创建一个ChannelInitializer ,然后制定了客户端消息的业务处理逻辑 HelloClientHandler 对象

6.调用 Bootstrap 类的 connect()方法进行连接,这个方法需要指定两个参数:

inetHost : ip 地址inetPort : 端口号public ChannelFuture connect(String inetHost, int inetPort) { return this.connect(InetSocketAddress.createUnresolved(inetHost, inetPort)); } public ChannelFuture connect(SocketAddress remoteAddress) { ObjectUtil.checkNotNull(remoteAddress, "remoteAddress"); this.validate(); return this.doResolveAndConnect(remoteAddress, this.config.localAddress()); }

connect 方法返回的是一个 Future 类型的对象

public interface ChannelFuture extends Future<Void> { ......}

也就是说这个方是异步的,我们通过 addListener 方法可以监听到连接是否成功,进而打印出连接信息。具体做法很简单,只需要对代码进行以下改动:

ChannelFuture f = b.connect(host, port).addListener(future -> { if (future.isSuccess()) { System.out.println("连接成功!"); } else { System.err.println("连接失败!"); }}).sync();

什么是 TCP 粘包/拆包?有什么解决办法呢?

Netty 和 Tomcat 的区别

  1. 作用不同:Tomcat 是 Servlet 容器,可以视为 Web 服务器,而 Netty 是异步事件驱动的网络应用程序框架和工具用于简化网络编程,例如TCP和UDP套接字服务器。
  2. 协议不同:Tomcat 是基于 http 协议的 Web 服务器,而 Netty 能通过编程自定义各种协议,因为 Netty 本身自己能编码/解码字节流,所有 Netty 可以实现,HTTP 服务器、FTP 服务器、UDP 服务器、RPC 服务器、WebSocket 服务器、Redis 的 Proxy 服务器、MySQL 的 Proxy 服务器等等。
     

什么是 TCP 粘包/拆包

TCP是个“流”协议,所谓流,就是没有界限的一串数据。大家可以想想河里的流水,是连成一片的,其间并没有分界线。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。

TCP粘包/拆包问题说明

假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况。

(1)服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;

(2)服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包;

(3)服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包;

(4)服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余内容D1_2和D2包的整包。

如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包。

TCP粘包/拆包发生的原因

问题产生的原因有三个,分别如下。

(1)应用程序write写入的字节大小大于套接口发送缓冲区大小;

(2)进行MSS大小的TCP分段;

(3)以太网帧的payload大于MTU进行IP分片。

粘包问题的解决策略

由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。

(1)消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格;

(2)在包尾增加回车换行符进行分割,例如FTP协议;

(3)将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度;

(4)更复杂的应用层协议。

Netty如何解决

1.使用 Netty 自带的解码器

LineBasedFrameDecoder : 发送端发送数据包的时候,每个数据包之间以换行符作为分隔,LineBasedFrameDecoder 的工作原理是它依次遍历 ByteBuf 中的可读字节,判断是否有换行符,然后进行相应的截取。DelimiterBasedFrameDecoder : 可以自定义分隔符解码器,LineBasedFrameDecoder 实际上是一种特殊的 DelimiterBasedFrameDecoder 解码器。FixedLengthFrameDecoder: 固定长度解码器,它能够按照指定的长度对消息进行相应的拆包。LengthFieldBasedFrameDecoder:

2.自定义序列化编解码器

在 Java 中自带的有实现 Serializable 接口来实现序列化,但由于它性能、安全性等原因一般情况下是不会被使用到的。

通常情况下,我们使用 Protostuff、Hessian2、json 序列方式比较多,另外还有一些序列化性能非常好的序列化方式也是很好的选择:

专门针对 Java 语言的:Kryo,FST 等等跨语言的:Protostuff(基于 protobuf 发展而来),ProtoBuf,Thrift,Avro,MsgPack 等等

Netty RPC 实现

RPC,即 Remote Procedure Call(远程过程调用),调用远程计算机上的服务,就像调用本地服务一样。RPC 可以很好的解耦系统,如 WebService 就是一种基于 Http 协议的 RPC。这个 RPC 整体框架如下:

 关键技术

1. 服务发布与订阅:服务端使用 Zookeeper 注册服务地址,客户端从 Zookeeper 获取可用的服务

地址。

2. 通信:使用 Netty 作为通信框架。

3. Spring:使用 Spring 配置服务,加载 Bean,扫描注解。

4. 动态代理:客户端使用代理模式透明化服务调用。

5. 消息编解码:使用 Protostuff 序列化和反序列化消息。

核心流程

1. 服务消费方(client)调用以本地调用方式调用服务;

2. client stub 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;

3. client stub 找到服务地址,并将消息发送到服务端;

4. server stub 收到消息后进行解码;

5. server stub 根据解码结果调用本地的服务;

6. 本地服务执行并将结果返回给 server stub;

7. server stub 将返回结果打包成消息并发送至消费方;

8. client stub 接收到消息,并进行解码;

9. 服务消费方得到最终结果。

RPC 的目标就是要 2~8 这些步骤都封装起来,让用户对这些细节透明。JAVA 一般使用动态代

理方式实现远程调用

消息编解码

息数据结构(接口名称+方法名+参数类型和参数值+超时时间+ requestID)

 客户端的请求消息结构一般需要包括以下内容:

1. 接口名称:在我们的例子里接口名是“HelloWorldService”,如果不传,服务端就不知道调用哪

个接口了;

2. 方法名:一个接口内可能有很多方法,如果不传方法名服务端也就不知道调用哪个方法;

3. 参数类型和参数值:参数类型有很多,比如有 bool、int、long、double、string、map、list,

甚至如 struct(class);以及相应的参数值;

4. 超时时间:

5. requestID,标识唯一请求 id,在下面一节会详细描述 requestID 的用处。

6. 服务端返回的消息 : 一般包括以下内容。返回值+状态 code+requestID

序列化

目前互联网公司广泛使用 Protobuf、Thrift、Avro 等成熟的序列化解决方案来搭建 RPC 框架,这

些都是久经考验的解决方案。

通讯过程

核心问题(线程暂停、消息乱序)

 如果使用 netty 的话,一般会用 channel.writeAndFlush()方法来发送消息二进制串,这个方法调用后对于整个远程调用(从发出请求到接收到结果)来说是一个异步的,即对于当前线程来说,将请求发送出来后,线程就可以往后执行了,至于服务端的结果,是服务端处理完成后,再以消息的形式发送给客户端的。于是这里出现以下两个问题:

1. 怎么让当前线程“暂停”,等结果回来后,再向后执行?

2. 如果有多个线程同时进行远程方法调用,这时建立在 client server 之间的 socket 连接上

会有很多双方发送的消息传递,前后顺序也可能是随机的,server 处理完结果后,将结

果消息发送给 client,client 收到很多消息,怎么知道哪个消息结果是原先哪个线程调用

的?如下图所示,线程 A 和线程 B 同时向 client socket 发送请求 requestA 和 requestB,

socket 先后将 requestB 和 requestA 发送至 server,而 server 可能将 responseB 先返

回,尽管 requestB 请求到达时间更晚。我们需要一种机制保证 responseA 丢给

ThreadA,responseB 丢给 ThreadB。

通讯流程

1. requestID 生成-AtomicLong

client 线程每次通过 socket 调用一次远程接口前,生成一个唯一的 ID,即 requestID

(requestID 必需保证在一个 Socket 连接里面是唯一的),一般常常使用 AtomicLong

从 0 开始累计数字生成唯一 ID;

2. 存放回调对象 callback 到全局 ConcurrentHashMap

将处理结果的回调对象 callback ,存放到全局 ConcurrentHashMap 里 面

put(requestID, callback);

3. synchronized 获取回调对象 callback 的锁并自旋 wait

当线程调用 channel.writeAndFlush()发送消息后,紧接着执行 callback 的 get()方法试

图获取远程返回的结果。在 get()内部,则使用 synchronized 获取回调对象 callback 的

锁,再先检测是否已经获取到结果,如果没有,然后调用 callback 的 wait()方法,释放

callback 上的锁,让当前线程处于等待状态。

4. 监听消息的线程收到消息,找到 callback 上的锁并唤醒

服务端接收到请求并处理后,将 response 结果(此结果中包含了前面的 requestID)发

送给客户端,客户端 socket 连接上专门监听消息的线程收到消息,分析结果,取到

requestID ,再从前面的 ConcurrentHashMap 里 面 get(requestID) ,从而找到

callback 对象,再用 synchronized 获取 callback 上的锁,将方法调用结果设置到

callback 对象里,再调用 callback.notifyAll()唤醒前面处于等待状态的线程。

 public Object get() {
     synchronized (this) { // 旋锁
         while (true) { // 是否有结果了
            If (!isDone){
             wait(); //没结果释放锁,让当前线程处于等待状态
            }else{//获取数据并处理
            }
         }
     }
 }

private void setDone(Response res) {
 this.res = res;
 isDone = true;
 synchronized (this) { //获取锁,因为前面 wait()已经释放了 callback 的锁了
     notifyAll(); // 唤醒处于等待的线程
 }
}

 RMI 实现方式

 Java 远程方法调用,即 Java RMI(Java Remote Method Invocation)是 Java 编程语言里,一种用于实现远程过程调用的应用程序编程接口。它使客户机上运行的程序可以调用远程服务器上的对象。远程方法调用特性使 Java 编程人员能够在网络环境中分布操作。RMI 全部的宗旨就是尽可能简化远程接口对象的使用。

 实现步骤

1. 编写远程服务接口,该接口必须继承 java.rmi.Remote 接口,方法必须抛出java.rmi.RemoteException 异常;

2. 编写远程接口实现类,该实现类必须继承 java.rmi.server.UnicastRemoteObject 类;

3. 运行 RMI 编译器(rmic),创建客户端 stub 类和服务端 skeleton 类;

4. 启动一个 RMI 注册表,以便驻留这些服务;

5. 在 RMI 注册表中注册服务;

6. 客户端查找远程对象,并调用远程方法;

1:创建远程接口,继承 java.rmi.Remote 接口
public interface GreetService extends java.rmi.Remote {
    String sayHello(String name) throws RemoteException;
}
2:实现远程接口,继承 java.rmi.server.UnicastRemoteObject 类
public class GreetServiceImpl extends java.rmi.server.UnicastRemoteObject
        implements GreetService {
    private static final long serialVersionUID = 3434060152387200042L;
    public GreetServiceImpl() throws RemoteException {
        super();
    }
    @Override
    public String sayHello(String name) throws RemoteException {
        return "Hello " + name;
    } }
 3:生成 Stub 和 Skeleton;
 4:执行 rmiregistry 命令注册服务
 5:启动服务
 LocateRegistry.createRegistry(1098);
 Naming.bind("rmi://10.108.1.138:1098/GreetService", new GreetServiceImpl());
 6.客户端调用
 GreetService greetService = (GreetService)
 Naming.lookup("rmi://10.108.1.138:1098/GreetService");
 System.out.println(greetService.sayHello("Jobs"));

Protoclol Buffer

protocol buffer 是 google 的一个开源项目,它是用于结构化数据串行化的灵活、高效、自动的方法,例如 XML,不过它比 xml 更小、更快、也更简单。你可以定义自己的数据结构,然后使用代码生成器生成的代码来读写这个数据结构。你甚至可以在无需重新部署程序的情况下更新数据结构。

特点

Protocol Buffer 的序列化 & 反序列化简单 & 速度快的原因是:

1. 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等)

2. 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成

Protocol Buffer 的数据压缩效果好(即序列化后的数据量体积小)的原因是:

1. 采用了独特的编码方式,如 Varint、Zigzag 编码方式等等

2. 采用 T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑

Thrift

Apache Thrift 是 Facebook 实现的一种高效的、支持多种编程语言的远程服务调用的框架。本文将从Java 开发人员角度详细介绍 Apache Thrift 的架构、开发和部署,并且针对不同的传输协议和服务类型给出相应的 Java 实例,同时详细介绍 Thrift 异步客户端的实现,最后提出使用 Thrift 需要注意的事项。

目前流行的服务调用方式有很多种,例如基于 SOAP 消息格式的 Web Service,基于 JSON 消息格式的 RESTful 服务等。其中所用到的数据传输方式包括 XML,JSON 等,然而 XML 相对体积太大,传输效率低,JSON 体积较小,新颖,但还不够完善。本文将介绍由 Facebook 开发的远程服务调用框架Apache Thrift,它采用接口描述语言定义并创建服务,支持可扩展的跨语言服务开发,所包含的代码生成引擎可以在多种语言中,如 C++, Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, Smalltalk 等创建高效的、无缝的服务,其传输数据采用二进制格式,相对 XML 和 JSON 体积更小,对于高并发、大数据量和多语言的环境更有优势。本文将详细介绍 Thrift 的使用,并且提供丰富的实例代码加以解释说明,帮助使用者快速构建服务。

为什么要 Thrift

1、多语言开发的需要

2、性能问题

序列化协议

序列化(编码)是将对象序列化为二进制形式(字节数组),主要用于网络传输、数据持久

化等;而反序列化(解码)则是将从网络、磁盘等读取的字节数组还原成原始对象,主要

用于网络传输对象的解码,以便完成远程调用。

影响序列化性能的关键因素:

序列化后的码流大小(网络带宽的占用)

序列化的性能(CPU 资源占用)

是否支持跨语言(异构系统的对接和开发语言切换)

Java

默认提供的序列化:无法跨语言、序列化后的码流太大、序列化的性能差

XML

优点:人机可读性好,可指定元素或特性的名称。

缺点:序列化数据只包含数据本身以及类的结构,不包括类型标识和程序集信息;只能序列化公共属性和字段;不能序列化方法;文件庞大,文件格式复杂,传输占带宽。适用场景:当做配置文件存储数据,实时数据转换。

JSON

是一种轻量级的数据交换格式

优点:兼容性高、数据格式比较简单,易于读写、

序列化后数据较小,可扩展性好,兼容性好、与 XML 相比,其协议比较简单,解析速度比

较快。

缺点:数据的描述性比 XML 差、不适合性能要求为 ms 级别的情况、额外空间开销比较大。适用场景(可替代XML):跨防火墙访问、可调式性要求高、基于 Webbrowser 的 Ajax 请求、传输数据量相对小,实时性要求相对低(例如秒级别)的服务。

Fastjson

采用一种“假定有序快速匹配”的算法

优点:接口简单易用、目前 java 语言中最快的 json 库。

缺点:过于注重快,而偏离了“标准”及功能性、代码质量不高,文档不全。

适用场景:协议交互、Web 输出、

Thrift

Android 客户端Thrift,不仅是序列化协议,还是一个 RPC 框架。

优点:序列化后的体积小, 速度快、支持多种语言和丰富的数据类型、对于数据字段的增删具有较强的兼容性、支持二进制压缩编码。

缺点:使用者较少、跨防火墙访问时,不安全、不具有可读性,调试代码时相对困难、不能与其他传输层协议共同使用(例如 HTTP)、无法支持向持久层直接读写数据,即不适合做数据持久化序列化协议。

适用场景:分布式系统的 RPC 解决方案Avro,Hadoop 的一个子项目,解决了 JSON 的冗长和没有 IDL 的问题。优点:支持丰富的数据类型、简单的动态语言结合功能、具有自我描述属性、提高了数据解析速度、快速可

压缩的二进制数据形式、可以实现远程过程调用 RPC、支持跨编程语言实现。缺点:对于

习惯于静态类型语言的用户不直观。适用场景:在 Hadoop 中做 Hive、Pig 和 MapReduce

的持久化数据格式。

Protobuf

将数据结构以.proto 文件进行描述,通过代码生成工具可以生成对应数据结构的POJO 对象和 Protobuf 相关的方法和属性。

优点:序列化后码流小,性能高、结构化数据存储格式(XML JSON 等)、通过标识字段的顺序,可以实现协议的前向兼容、结构化的文档更容易管理和维护。

缺点:需要依赖于工具生成代码、支持的语言相对较少,官方只支持Java 、C++ 、python。适用场景:对性能要求高的 RPC 调用、具有良好的跨防火墙的访问属性、适合应用层对象的持久化

其它

protostuff 基于 protobuf 协议,但不需要配置 proto 文件,直接导包即可

Jboss marshaling 可以直接序列化 java 类, 无须实 java.io.Serializable 接口

Message pack 一个高效的二进制序列化格式

Hessian 采用二进制协议的轻量级 remoting onhttp 工具

kryo 基于 protobuf 协议,只支持 java 语言,需要注册(Registration),然后序列化(Output),反序列化(Input)

如何选择序列化协议

具体场景

对于公司间的系统调用,如果性能要求在 100ms 以上的服务,基于 XML 的 SOAP 协议是一个值得考虑的方案。

基于 Web browser 的 Ajax,以及 Mobile app 与服务端之间的通讯,JSON 协议是首选。对于性能要求不太高,或者以动态类型语言为主,或者传输数据载荷很小的的运用场景,JSON也是非常不错的选择。

对于调试环境比较恶劣的场景,采用 JSON 或 XML 能够极大的提高调试效率,降低系统开发成本。

当对性能和简洁性有极高要求的场景,Protobuf,Thrift,Avro 之间具有一定的竞争关系。对于 T 级别的数据的持久化应用场景,Protobuf 和 Avro 是首要选择。如果持久化后的数据存储在 hadoop 子项目里,Avro 会是更好的选择。

对于持久层非 Hadoop 项目,以静态类型语言为主的应用场景,Protobuf 会更符合静态类型语言工程师的开发习惯。由于 Avro 的设计理念偏向于动态类型语言,对于动态语言为主的应用场景,Avro 是更好的选择。

如果需要提供一个完整的 RPC 解决方案,Thrift 是一个好的选择。

如果序列化之后需要支持不同的传输层协议,或者需要跨防火墙访问的高性能场景,Protobuf 可以优先考虑。

protobuf 的数据类型有多种:bool、double、float、int32、int64、string、bytes、enum、message。protobuf 的限定符:required: 必须赋值,不能为空、optional:字段可以赋值,也可以不赋值、repeated: 该字段可以重复任意次数(包括 0 次)、枚举;只能用指定的常量集中的一个值作为其值;

protobuf 的基本规则:每个消息中必须至少留有一个 required 类型的字段、包含 0 个或多个 optional 类型的字段;repeated 表示的字段可以包含 0 个或多个数据;[1,15]之内的标识号在编码的时候会占用一个字节(常用),[16,2047]之内的标识号则占用 2 个字节,标识号一定不能重复、使用消息类型,也可以将消息嵌套任意多层,可用嵌套消息类型来代替组。

protobuf 的消息升级原则:不要更改任何已有的字段的数值标识;不能移除已经存在的required 字段,optional 和 repeated 类型的字段可以被移除,但要保留标号不能被重用。新添加的字段必须是 optional 或 repeated。因为旧版本程序无法读取或写入新增的required 限定符的字段。

编译器为每一个消息类型生成了一个.java 文件,以及一个特殊的 Builder 类(该类是用来创建消息类接口的)。如:

UserProto.User.Builder builder = UserProto.User.newBuilder();
builder.build();

Netty 中的使用:ProtobufVarint32FrameDecoder 是用于处理半包消息的解码类;

ProtobufDecoder(UserProto.User.getDefaultInstance())这是创建的 UserProto.java 文件中的解

码类;ProtobufVarint32LengthFieldPrepender 对 protobuf 协议的消息头上加上一个长度为32 的整形字段,用于标志这个消息的长度的类;ProtobufEncoder 是编码类

将 StringBuilder 转换为 ByteBuf 类型:copiedBuffer()方法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏目 "

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值