什么是 RPC ?
rpc解决了什么问题
- RPC (Remote Procedure Call)即远程过程调用,是分布式系统常见的一种通信方法。它允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不用程序员显式编码这个远程调用的细节。
简单的说:
- RPC就是从一台机器(客户端)上通过参数传递的方式调用另一台机器(服务器)上的一个函数或方法(可以统称为服务)并得到返回的结果。
- RPC会隐藏底层的通讯细节(不需要直接处理Socket通讯或Http通讯)。
- 客户端发起请求,服务器返回响应(类似于Http的工作方式)RPC在使用形式上像调用本地函数(或方法)一样去调用远程的函数(或方法)。
最终解决的问题:让分布式或者微服务系统中不同服务之间的调用(远程调用)像本地调用一样简单!调用者感知不到远程调用的逻辑。为此rpc需要解决三个问题(实现的关键):
- Call ID映射。我们怎么告诉远程机器(注册中心)我们要调用哪个函数呢?
在本地调用中,函数体是直接通过函数指针来指定的,我们调用具体函数,编译器就自动帮我们调用它相应的函数指针。
但是在远程调用中,是无法调用函数指针的,因为两个进程的地址空间是完全不一样。所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <--> Call ID} 的对应表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。 - 序列化和反序列化。客户端怎么把参数值传给远程的函数呢?
在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。
但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。 - 数据网络传输。远程调用往往是基于网络的,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。
网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty(基于NIO通信方式作为高性能网络服务的前提)也属于这层的东西。
RPC调用流程:
- 服务消费方(client)以本地调用方式调用服务;
- client stub接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;找到服务地址,并将消息发送到服务端;(client stub封装、发送)
- server stub收到消息后进行解码,根据解码结果调用本地的服务;本地服务执行并将结果返回给server stub;server stub将返回结果打包成消息并发送至消费方。(server stub解码、调用、返回与发送)
4.client stub接收到消息,并进行解码。服务消费方接收返回结果;
一个完整的RPC架构里面包含了四个核心的组件,分别是Client ,Server,Client Stub以及Server Stub,这个Stub大家可以理解为存根(调用与返回)。分别说说这几个组件:
- 客户端(Client): 服务的调用方。
- 服务端(Server):真正的服务提供者。
- 客户端存根:存放服务端的地址消息,再将客户端的请求参数打包成网络消息,然后通过网络远程发送给服务方。
- 服务端存根:接收客户端发送过来的消息,将消息解包,并调用本地的方法。
小结:RPC 的目标就是封装调用过程,用户无需关心这些细节,可以像调用本地方法一样即可完成远程服务调用。
要实现一个RPC不算难,难的是实现一个高性能、高可靠、高可用的RPC框架(需要考虑的问题)
- 如何解决获取实例的问题?既然系统采用分布式架构,那一个服务势必会有多个实例,所以需要一个服务注册中心,比如在Dubbo中,就可以使用Zookeeper作为注册中心,在调用时,从Zookeeper获取服务的实例列表,再从中选择一个进行调用。也可以同Nacos做服务注册中心;
- 如何选择实例?就要考虑负载均衡,例如dubbo提供了4种负载均衡策略;
- 如果每次都去注册中心查询列表,效率很低,那么就要加缓存;
- 客户端总不能每次调用完都等着服务端返回数据,所以就要支持异步调用;
- 服务端的接口修改了,老的接口还有人在用,这就需要版本控制;
- 服务端总不能每次接到请求都马上启动一个线程去处理,于是就需要线程池;
常用的RPC框架
- Dubbo: Dubbo 是阿里巴巴公司开源的一个高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring框架无缝集成。目前 Dubbo 已经成为 Spring Cloud Alibaba 中的官方组件。
- gRPC :gRPC 是可以在任何环境中运行的开源高性能RPC框架。它可以通过可插拔的支持来有效地连接数据中心内和跨数据中心的服务,以实现负载平衡,跟踪,运行状况检查和身份验证。它也适用于分布式计算的最后一英里,以将设备,移动应用程序和浏览器连接到后端服务。
- Hessian是一个轻量级的 remoting-on-http 工具,使用简单的方法提供了 RMI 的功能。 相比 WebService,Hessian 更简单、快捷。采用的是二进制 RPC协议,因为采用的是二进制协议,所以它很适合于发送二进制数据。
数据交互为什么用 RPC,不用 HTTP?
除 RPC 之外,常见的多系统数据交互方案还有分布式消息队列、HTTP 请求调用、数据库和分布式缓存等。RPC 和 HTTP 调用是没有经过中间件的,它们是端到端系统的直接数据交互。
首先需要指正,这两个并不是并行概念。RPC 是一种设计,就是为了解决不同服务之间的调用问题,完整的 RPC 实现一般会包含有 传输协议 和 序列化协议 这两个。
而 HTTP 是一种传输协议,RPC 框架完全可以使用 HTTP 作为传输协议,也可以直接使用 TCP,使用不同的协议一般也是为了适应不同的场景使用 TCP 和使用 HTTP 各有优势:
(1)传输效率:
- TCP:通常自定义上层协议,可以让请求报文体积更小
- HTTP:如果是基于HTTP 1.1 的协议,请求中会包含很多无用的内容
(2)性能消耗,主要在于序列化和反序列化的耗时
- TCP,可以基于各种序列化框架进行,效率比较高
- HTTP,大部分是通过 json 来实现的,字节大小和序列化耗时都要更消耗性能
(3)跨平台:
- TCP:通常要求客户端和服务器为统一平台
- HTTP:可以在各种异构系统上运行
总结:
- RPC 的 TCP 方式主要用于公司内部的服务调用,性能消耗低,传输效率高。
- HTTP主要用于对外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。
Java知识点
调用如何实现客户端无感(动态代理技术)? 与静态代理的区别。
静态代理:每个代理类只能为一个接口服务,这样会产生很多代理类。普通代理模式,代理类Proxy的Java代码在JVM运行时就已经确定了,也就是静态代理在编码编译阶段就确定了Proxy类的代码。而动态代理是指在JVM运行过程中,动态的创建一个类的代理类,并实例化代理对象。
JDK 动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用业务方法前调用InvocationHandler
处理。代理类必须实现 InvocationHandler
接口,并且,JDK 动态代理只能代理实现了接口的类。
JDK 动态代理类基本步骤,如果想代理没有实现接口的对象?
JDK 动态代理类基本步骤:
- 编写需要被代理的类和接口
- 编写代理类,需要实现
InvocationHandler
接口,重写invoke()
方法; - 使用
Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)
动态创建代理类对象,通过代理类对象调用业务方法。
CGLIB 框架实现了对无接口的对象进行代理的方式。JDK 动态代理是基于接口实现的,而 CGLIB 是基于继承实现的。它会对目标类产生一个代理子类,通过方法拦截技术过滤父类的方法调用。代理子类需要实现 MethodInterceptor
接口。
CGLIB 底层是通过 asm 字节码框架实时生成类的字节码,达到动态创建类的目的,效率较 JDK 动态代理低。Spring 中的 AOP 就是基于动态代理的,如果被代理类实现了某个接口,Spring 会采用 JDK 动态代理,否则会采用 CGLIB。
写一个动态代理的例子
利用Java的反射技术(Java Reflection),在运行时创建一个实现某些给定接口的新类(也称“动态代理类”)及其实例(对象),代理的是接口(Interfaces),不是类(Class),也不是抽象类。在运行时才知道具体的实现,spring aop就是此原理。
// 1、创建代理对象的接口 interface DemoInterface { String hello(String msg); } // 2、创建具体被代理对象的实现类 class DemoImpl implements DemoInterface { @Override public String hello(String msg) { System.out.println("msg = " + msg); return "hello"; } } // 3、创建一个InvocationHandler实现类,持有被代理对象的引用,invoke方法中:利用反射调用被代理对象的方法 class DemoProxy implements InvocationHandler { private DemoInterface service; public DemoProxy(DemoInterface service) { this.service = service; } @Override public Object invoke(Object obj, Method method, Object[] args) throws Throwable { System.out.println("调用方法前..."); Object returnValue = method.invoke(service, args); System.out.println("调用方法后..."); return returnValue; } } public class Solution { public static void main(String[] args) { DemoProxy proxy = new DemoProxy(new DemoImpl()); //4.使用Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)动态创建代理类对象,通过代理类对象调用业务方法。 DemoInterface service = (DemoInterface)Proxy.newProxyInstance( DemoInterface.class.getClassLoader(), new Class<?>[]{DemoInterface.class}, proxy ); System.out.println(service.hello("呀哈喽!")); } }
输出:
调用方法前... msg = 呀哈喽! 调用方法后... hello
拓展:
newProxyInstance:
- loader: 用哪个类加载器去加载代理对象
- interfaces:动态代理类需要实现的接口
- h:动态代理方法在执行时,会调用h里面的invoke方法去执行
invoke三个参数:
- obj:就是代理对象,newProxyInstance方法的返回对象
- method:调用的方法
- args: 方法中的参数
序列化与反序列化
对象是怎么在网络中传输的?
通过将对象序列化成字节数组,即可将对象发送到网络中。在 Java 中,想要序列化一个对象:
- 要序列化对象所属的类必须实现了
Serializable
接口; - 并且其内部属性必须都是可序列化的。
序列化对象主要由两种用途:
- 把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中或数据库中;
比如最常见的是Web服务器中的Session对象,当有 10万用户并发访问,就有可能出现10万个Session对象,内存可能吃不消,于是Web容器就会把一些session先序列化到硬盘中,等要用了,再把保存在硬盘中的对象还原到内存中。 - 用于网络传输对象的字节序列。
当两个进程在进行远程通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以二进制序列的形式在网络上传送。发送方需要把这个Java对象转换为字节序列,才能在网络上传送;接收方则需要把字节序列再恢复为Java对象。
在 Java 序列化期间,哪些变量未序列化?
序列化期间,静态变量(static修饰)和瞬态变量(transient修饰)未被序列化:
- 由于静态变量属于类,而不是对象,序列化保存的是对象的状态,静态变量保存的是类的状态。因此在 Java 序列化过程中不会保存它们。注意:static修饰的变量是不可序列化,同时也不可串行化。
- 瞬态变量也不包含在 Java 序列化过程中, 并且不是对象的序列化状态的一部分。
如果有一个属性(字段)不想被序列化的,则该属性必须被声明为 transient
!
- 一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法被访问(如银行卡号、密码等不想用序列化机制保存)。
- transient关键字只能修饰变量,而不能修饰方法和类。注意,本地变量是不能被transient关键字修饰的。变量如果是用户自定义类变量,则该类需要实现Serializable接口。
如何实现对象的序列化和反序列化?
将需要序列化的类实现Serializable接口就可以了,Serializable接口中没有任何方法,可以理解为一个标记,即表明这个类可以序列化。JDK 中提供了 ObjectOutStream 类来对对象进行序列化。对象序列化(将对象转化为字节序列)包括如下步骤:
- 创建一个对象输出流,它可以包装一个其他类型的目标输出流,如文件输出流;
ObjectOutputStream out = new ObjectOutputStream(new fileOutputStream(“D:\\objectfile.obj”));
- 通过对象输出流的writeObject()方法写对象。
out.writeObject(“Hello”);
对象的反序列化(将字节序列重建成一个对象的过程)步骤如下:
- 创建一个对象输入流,它可以包装一个其他类型的源输入流,如文件输入流;
ObjectInputStream in = new ObjectINputStream(new fileInputStream(“D:\\objectfile.obj”));
- 通过对象输入流的readObject()方法读取对象。
String obj1 = (String)in.readObject();
区别可外部接口:
Externalizable 给我们提供 writeExternal() 和 readExternal() 方法, 这让我们灵活地控制 Java 序列化机制, 而不是依赖于 Java 的默认序列化。正确实现 Externalizable 接口可以显著提高应用程序的性能。
框架中实现了哪几种序列化方式,介绍一下?
实现了 JSON、Kryo、Hessian 和 Protobuf 的序列化。后三种都是基于字节的序列化。
- JSON 是一种轻量级的数据交换语言,该语言以易于让人阅读的文字为基础,用来传输由属性值或者序列性的值组成的数据对象,类似 xml,Json 比 xml更小、更快更容易解析。JSON 由于采用字符方式存储,占用相对于字节方式较大,并且序列化后类的信息会丢失,可能导致反序列化失败。
- Kryo 是一个快速高效的 Java 序列化框架,旨在提供快速、高效和易用的 API。无论文件、数据库或网络数据 Kryo 都可以随时完成序列化。 Kryo 还可以执行自动深拷贝、浅拷贝。这是对象到对象的直接拷贝,而不是对象->字节->对象的拷贝。kryo 速度较快,序列化后体积较小,但是跨语言支持较复杂。
- Hessian 是一个基于二进制的协议,Hessian 支持很多种语言,例如 Java、python、c++,、net/c#、D、Erlang、PHP等,它的序列化和反序列化也是非常高效。速度较慢,序列化后的体积较大。
- protobuf(Protocol Buffers)是由 Google 发布的数据交换格式,提供跨语言、跨平台的序列化和反序列化实现,底层由 C++ 实现,其他平台使用时必须使用 protocol compiler 进行预编译生成 protoc 二进制文件。性能主要消耗在文件的预编译上。序列化反序列化性能较高,平台无关。
serialVersionUID的作用
Java的序列化机制是通过 在运行时 判断类的serialVersionUID来验证版本一致性的(用于实现对象的版本控制)。
serialVersionUID 是一个 private static final long 型 ID, 当它被印在对象上时, 它通常是对象的哈希码,你可以使用 serialver 这个 JDK 工具来查看序列化对象的 serialVersionUID。
在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常。(InvalidCastException)
类的serialVersionUID的默认值完全依赖于Java编译器的实现,对于同一个类,用不同的Java编译器编译,有可能会导致不同的 serialVersionUID,也有可能相同。为了提高serialVersionUID的独立性和确定性,强烈建议在一个可序列化类中显示的定义serialVersionUID,为它赋予明确的值。
显式地定义serialVersionUID有两种用途:
- 在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的serialVersionUID;
- 在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。
网络传输(基于Netty)
简单介绍Netty
Netty概述:
- Netty是一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端;
- Netty基于 NIO 的,封装了 JDK 的 NIO,让我们使用起来更加方法灵活。
特点和优势:
- 使用简单:封装了 NIO 的很多细节,使用更简单。
- 功能强大:预置了多种编解码功能,支持多种主流协议。
- 定制能力强:可以通过 ChannelHandler 对通信框架进行灵活地扩展。
- 性能高:通过与其他业界主流的 NIO 框架对比,Netty 的综合性能最优。
为什么Netty性质高?
- IO 线程模型:同步非阻塞,用最少的资源做更多的事。
- 内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。
- 内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
- 串行化处理读写:避免使用锁带来的性能开销。
- 高性能序列化协议:支持 protobuf 等高性能序列化协议。
简单说一下IO模型,BIO、NIO与AIO
Java的IO阻塞和非阻塞以及同步和异步的问题:
- 是否阻塞:关注的接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的;可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。
- 同步或者异步:关注的是应用程序是否需要自己去向系统内核问询以及任务完成时,消息通知的方式(即应用程序与系统内核的交互)。对于同步模式来说,应用层需要不断向操作系统内核询问数据是否读取完毕,当数据读取完毕,那此时系统内核将数据返回给应用层,应用层即可以用取得的数据做其他相关的事情;对于异步模式来说,应用层无需主动向系统内核问询,在系统内核读取完文件数据之后,会主动通知应用层数据已经读取完毕,此时应用层即可以接收系统内核返回过来的数据,再做其他事情。
- 总结:同步/异步是宏观上(进程间通讯,通常表现为网络IO的处理上),阻塞/非阻塞是微观上(进程内数据传输,通常表现为对本地IO的处理上);阻塞和非阻塞是同步/异步的表现形式。由上描述基本可以总结一句简短的话,同步和异步是目的,阻塞和非阻塞是实现方式。
(1)BIO(同步阻塞):
- 传统BIO,一个连接一个线程,客户端有连接请求时服务器端就需要启动一个线程进行处理。线程开销大。数据的读写必须阻塞在一个线程内,等待其完成。
- 伪异步IO:将请求连接放入线程池,一对多,但线程还是很宝贵的资源,但底层还是同步阻塞IO。具体是将客户端的Socket请求封装成一个task任务(实现Runnable类)然后投递到线程池中去,配置相应的队列实现。
- 存在的问题:在读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。这就是阻塞IO存在的弊端,对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性。
(2)NIO(同步非阻塞):
- 一个请求一个线程,但客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。主要解决BIO高负载、高并发的(网络)应用的性能问题。
- Buffer是一个对象,包含一些要写入或者读出的数据(读写都要经过Buffer缓冲区),实际上是一个数组,提供对数据结构化访问以及维护读写位置等信息。
- 对数据的读取和写入要通过Channel通道,通道与流不同之处在于通道时双向的(可以读、写或者同时进行),而流只在一个方向移动。一般使用的SocketChannle和ServerSocketChannle一般都是SelectableChannel,用于网络读写的Channel,用于文件操作的Channel是FileChannel。
- 多路复用器 Selector:当Channel管道注册到Selector选择器以后,Selector会分配给每个管道一个key值唯一标识,Selector选择器是以轮询的方式进行查找注册的所有Channel,当我们的Channel准备就绪或者监听到相应的事件状态的时候,selector就会识别这个事件状态,通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。
(3)AIO(异步非阻塞):
- 一个有效请求一个线程,客户端的I/O请求都是由OS先完成了,再通知服务器应用去启动线程进行处理。
- 在NIO编程之上引入了异步通道的概念,并提供了异步文件和异步套接字的实现,从而真正实现了异步非阻塞。AIO它不需要通过多路复用器对注册的通道进行轮询操作即可以实现异步读写,从而简化了NIO编程模型。
- 适用于连接数目多且连接比较长(重操作)的架构,充分调用OS参与并发操作,编程比较复杂;而 NIO方式适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中。
Netty线性模型?
Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池, boss 线程池和 worker 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 worker 线程池,其中 worker 线程池负责请求的 read 和 write 事件,由对应的Handler 处理。
- 单线程模型:所有I/O操作都由一个线程完成,即多路复用、事件分发和处理都是在一个Reactor 线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送/读取请求或应答/响应消息。一个NIO 线程同时处理成百上千的链路,性能上无法支撑,速度慢,若线程进入死循环,整个程序不可用,对于高负载、大并发的应用场景不合适。
- 多线程模型:有一个NIO 线程(Acceptor) 只负责监听服务端,接收客户端的TCP 连接请求;NIO 线程池负责网络IO 的操作,即消息的读取、解码、编码和发送;1 个NIO 线程可以同时处理N 条链路,但是1 个链路只对应1 个NIO 线程,这是为了防止发生并发操作问题。但在并发百万客户端连接或需要安全认证时,一个Acceptor 线程可能会存在性能不足问题。
- 主从多线程模型:Acceptor 线程用于绑定监听端口,接收客户端连接,将SocketChannel 从主线程池的 Reactor 线程的多路复用器上移除,重新注册到Sub 线程池的线程上,用于处理I/O 的读写等操作,从而保证 mainReactor 只负责接入认证、握手等操作;
如何解决 TCP 的粘包拆包问题
TCP 是以流的方式来处理数据,一个完整的包可能会被 TCP 拆分成多个包进行发送;也可能把小的封装成一个大的数据包发送(多个小的数据包合并发送),对于接收端的应用程序拿到缓冲区的数据不知如何拆分。
TCP 粘包/拆包的原因:应用程序写入的字节大小大于套接字发送缓冲区的大小,会发生拆包现象,而应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上(将发送端的缓冲区填满一次性发送),这将会发生粘包现象;总之:出现TCP 粘包/拆包的关键:套接字缓冲区大小限制与应用程序写入数据大小的关系。
Netty 自带解决方式,Netty对解决粘包和拆包的方案做了抽象,提供了一些解码器(Decoder)来解决粘包和拆包的问题。如:
- 消息定长:FixedLengthFrameDecoder 类
- 包尾增加特殊字符分割:
- 行分隔符类:LineBasedFrameDecoder
- 自定义分隔符类 :DelimiterBasedFrameDecoder
- 将消息分为消息头和消息体:LengthFieldBasedFrameDecoder 类。适用于消息头包含消息长度的协议(最常用)。
分为有头部的拆包与粘包、长度字段在前且有头部的拆包与粘包、多扩展头部的拆包与粘包。
基于Netty进行网络读写的程序,可以直接使用这些Decoder来完成数据包的解码。对于高并发、大流量的系统来说,每个数据包都不应该传输多余的数据(所以补齐的方式不可取),LenghtFieldBasedFrameDecode更适合这样的场景。
常见的解决方案:
- 发送端将每个包都封装成固定的长度,比如100字节大小。如果不足100字节可通过补0或空等进行填充到指定长度;
- 发送端在每个包的末尾使用固定的分隔符,例如\r\n。如果发生拆包需等待多个包发送过来之后再找到其中的\r\n进行合并;例如,FTP协议;
- 将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;
- 通过自定义协议进行粘包和拆包的处理(本框架demo使用的,其中有字段标明包的长度)。
说下 Netty 零拷贝
Netty 的零拷贝主要包含三个方面:
- Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
- Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。
- Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
说下 Netty 重要组件
核心组件:
- Bytebuf(字节容器):网络通信最终都是通过字节流进行传输的。 ByteBuf 就是 Netty 提供的一个字节容器,其内部是一个字节数组。 当我们通过 Netty 传输数据的时候,就是通过 ByteBuf 进行的。可以将ByteBuf看作是 Netty 对 Java NIO 提供了 ByteBuffer字节容器(复杂和繁琐)的封装和抽象。
- Bootstrap 和 ServerBootstrap(启动引导类):
- Bootstrap 通常使用 connet() 方法连接到远程的主机和端口,作为一个 Netty TCP 协议通信中的客户端。另外,Bootstrap 也可以通过 bind() 方法绑定本地的一个端口,作为 UDP 协议通信中的一端。
- ServerBootstrap通常使用 bind() 方法绑定本地的端口上,然后等待客户端的连接。
- Bootstrap 只需要配置一个线程组— EventLoopGroup ,而 ServerBootstrap需要配置两个线程组— EventLoopGroup ,一个用于接收连接,一个用于具体的 IO 处理。
- Channel接口(Netty 网络操作抽象类):可以进行基本的 I/O 操作,如 bind、connect、read、write 等。 一旦客户端成功连接服务端,就会新建一个Channel同该用户端进行绑定。常见的实现类:
NioServerSocketChannel
(服务端)NioSocketChannel
(客户端)
- 这两个
Channel
可以和 BIO 编程模型中的ServerSocket
以及Socket
两个概念对应上。 - EventLoop接口(事件循环):主要是配合 Channel 处理 I/O 操作,用来处理连接的生命周期中所发生的事情。 实际就是负责监听网络事件并调用事件处理器进行相关 I/O 操作(读写)的处理。
- 与Channel 的关系:Channel为Netty网络操作(读写等操作)的抽象类,EventLoop负责处理注册在其上的Channel的I/O操作,两者配合进行I/O操作。
- 与EventloopGroup 关系:EventLoopGroup 包含多个 EventLoop(每一个 EventLoop 通常内部包含一个线程),它管理着所有的 EventLoop 的生命周期。并且,EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全。
三者关系
- ChannelFuture接口(操作执行结果):Netty是异步非阻塞的,Netty 框架中所有的 I/O 操作都为异步的,因此我们需要 ChannelFuture 的 addListener()方法注册一个 ChannelFutureListener 监听事件,当操作执行成功或者失败时,监听就会自动触发返回结果。
- ChannelFuture 的 channel() 方法获取连接相关联的Channel 。
- ChannelFuture 接口的 sync()方法让异步的操作编程同步的。bind()是异步的,但是,你可以通过
sync()
方法将其变为同步。
与用户逻辑与数据流密切相关:
- ChannelHandler(消息处理器):充当了所有处理入站和出站数据的逻辑容器。ChannelHandler 主要用来处理各种事件(消息的具体处理器),这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。
- ChannelPipeline(ChannelHandler对象的链表):为 ChannelHandler 链提供了容器,当 channel 创建时,就会被自动分配到它专属的 ChannelPipeline,这个关联是永久性的。当
ChannelHandler
被添加到的 ChannelPipeline 它得到一个 ChannelHandlerContext。
Netty 是如何保持长连接的(心跳机制)
首先 TCP 协议的实现中也提供了 keepalive 报文用来探测对端是否可用。TCP 层将在定时时间到后发送相应的 KeepAlive 探针以确定连接可用性。打开该设置:
ChannelOption.SO_KEEPALIVE, true
TCP 心跳的问题:
考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态一直向当前服务器发送些必然会失败的请求。
Netty 中提供了 IdleStateHandler
类专门用于处理心跳。IdleStateHandler
的构造函数如下:
public IdleStateHandler(long readerIdleTime, long writerIdleTime, long allIdleTime,TimeUnit unit){ }
- 第一个参数是隔多久检查一下读事件是否发生,如果
channelRead()
方法超过 readerIdleTime 时间未被调用则会触发超时事件调用userEventTrigger()
方法; - 第二个参数是隔多久检查一下写事件是否发生,writerIdleTime 写空闲超时时间设定,如果
write()
方法超过 writerIdleTime 时间未被调用则会触发超时事件调用userEventTrigger()
方法; - 第三个参数是全能型参数,隔多久检查读写事件;
- 第四个参数表示当前的时间单位。
所以这里可以分别控制读,写,读写超时的时间,单位为秒,如果是0表示不检测,所以如果全是0,则相当于没添加这个 IdleStateHandler,连接是个普通的短连接。
Nacos注册中心
待补充。。。
设计模式
责任链模式与在netty中的应用
适用场景:对于一个请求来说,如果有个对象都有机会处理它,而且不明确到底是哪个对象会处理请求时,我们可以考虑使用责任链模式实现它,让请求从链的头部往后移动,直到链上的一个节点成功处理了它为止
优点:
- 发送者不需要知道自己发送的这个请求到底会被哪个对象处理掉,实现了发送者和接受者的解耦
- 简化了发送者对象的设计
- 可以动态的添加节点和删除节点
缺点:
所有的请求都从链的头部开始遍历,对性能有损耗
极差的情况,不保证请求一定会被处理