# rpc-framework **Repository Path**: codergjw/rpc-framework ## Basic Information - **Project Name**: rpc-framework - **Description**: RPC是远程过程调用,将远程服务调用到本地使用。项目实现通过Nacos实现注册中心,类调用的是使用的JDK动态代理实现,通过SpringBean将Bean交付Spring管理,通过注入Bean之前将被Service注解的接口注册到注册中心,使用代理来增强接口对象,屏蔽远程调用的底层细节,使用Channel缓存来缓存服务,同时使用负载均衡方式平衡各服务之间的负载,使用Netty中的时间轮算法来定时维 - **Primary Language**: Java - **License**: GPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 3 - **Forks**: 0 - **Created**: 2023-07-07 - **Last Updated**: 2023-07-13 ## Categories & Tags **Categories**: Uncategorized **Tags**: rpc, Java, 手写, 自实现 ## README # 手写 RPC 框架 ## 1、项目介绍 ### 1.1、什么是RPC? RPC即远程过程调用,通过名字我们就能看出RPC关注的时远程调用而非本地调用。 ![输入图片说明](image.png) RPC的出现就是为了让你调用远程方法像调用本地方法一样简单。 原理 ![输入图片说明](image1.png) ![输入图片说明](image2.png) ![输入图片说明](image3.png) - 一个完整的RPC框架应该实现**服务注册**、**服务发现**、**负载均衡**、**容错**等功能 - 服务端负责实现负载均衡 实现—个最基本的RPC框架应该至少包括下面几部分: 1. 注册中心∶注册中心负责服务地址的注册与查找,相当于目录服务。 2. 网络传输∶既然我们要调用远程的方法,就要发送网络请求来传递目标类和方法的信息以及方法的参数等数 3. 据到服务提供端。 4. 序列化和反序列化:要在网络传输数据就要涉及到序列化。 5. 传输协议︰这个协议是客户端(服务消费方)和服务端(服务提供方)交流的基础。 6. 动态代理:屏蔽远程方法调用的底层细节。 7. 负载均衡:避免单个服务器响应同—请求,容易造成服务器宕机、崩溃等问题。 更完善的一点的RPC框架可能还有监控模块(可以研究一下 Dubbo的监控模块的设计)。 ### 1.2、项目介绍 RPC是远程过程调用,将远程服务调用到本地使用。项目实现通过Nacos实现注册中心,类调用的是使用的JDK动态代理实现,通过SpringBean将Bean交付Spring管理,通过注入Bean之前将被Service注解的接口注册到注册中心,使用代理来增强接口对象,屏蔽远程调用的底层细节,使用Channel缓存来缓存服务,同时使用负载均衡方式平衡各服务之间的负载,使用Netty中的时间轮算法来定时维护服务缓存中的健康实例服务。利用心跳检测中客户端每3s进行一次写操作维护心跳,服务端进行检测,如果30s未检测到写操作就断开连接,节省资源。如果断开连接则需要链路检测进行重连 降低网络异常带来的资源浪费。使用自定义的协议、编码解码器、序列化反序列化实现Netty的网络通信。 ## 2、项目技术点 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677660573508-ef97262b-c45e-44ba-b84a-32c10829a837.png) ### 1、自定义注解Bean完成代理类的注入 #### 1.1、配置Rpc配置 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677670991442-128ba0d7-e51c-411a-b666-58855d392e94.png) - 将注解中的EnableRpc 中的参数配置到WenRpcConfig中。 - 初始化NacosTemplate #### 1.2、代理类的实现 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677671261784-7793d2f8-f6be-467a-9548-665055477388.png) ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677671382726-124b1ef2-4f6a-4eb7-b3a5-12ac3edd51f4.png) - 如果当前Bean是被WenService注解说明当前类是需要注册到Nacos注册中心的服务对象,调用WenServiceProvider方法中的publicService将原生的Bean注册到Nacos中 需要获取对应的接口名和版本号将当前服务缓存到本地缓存中 然后再将Instance对象注册到Nacos注册中心。 - 如果当前类是被WenReference 则 说明当前类是需要使用到服务,作为服务发现的方法,对当前bean进行增强生成一个代理类取代当前原始Bean ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677671678470-225b6df8-35ca-4b9a-9ef0-ac7bc139a30d.png) - 那代理类由做了哪些事呢? - - 代理类对当前的请求类做了发送请求的包装,将需要获取的服务的组别、版本号、接口名称、参数、请求Id然后再调用NettyClient中的发送请求方法来进行网络交互。 - NettyClient#sendRequest方法是进行编码、序列化、压缩、设置魔术等等来实现Tcp交互。 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677672102739-5b495c9f-3764-4b79-88e2-39db7df2d8af.png) - 缓存的作用就是减少NacosTemplate连接的交互 提高效率 - 缓存还有一个优点就是如果注册中心宕机 服务调用还是正常的 => 因为缓存服务还是能提供服务提供方的地址 直接进行交互。 - 那么缓存中如果出现已经宕机的服务怎么办? - - 出现宕机发现连接不上则将该缓存进行剔除 然后再 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677672292741-a6a571d3-bf3f-4a94-a5d7-5b0f7e4a06e8.png) ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677672385815-2aac2194-e285-422e-8e09-c67298192554.png) ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677672526427-0aa2dc23-79ba-4158-94e1-cfdec1f83403.png) ### 2、JDK动态代理Service RPC的主要目的就是让我们调用远程方法像调用本地方法一样简单,我们不需要关心远程方法调用的细节比如网络传输。 怎样才能屏蔽远程方法调用的底层细节呢?答案就是动态代理。简单来说,当你调用远程方法的时候,实际会通过代理对象来传输网络请求,不然的话,怎么可能直接就调用到远程方法。 ### 3、SPI服务发现机制 SPI ,全称为 Service Provider Interface,是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。 ```java private Compress loadCompress(byte compressType) { String compressName = CompressTypeEnum.getName(compressType); ServiceLoader load = ServiceLoader.load(Compress.class); for (Compress compress : load) { if (compress.name().equals(compressName)) { return compress; } } throw new WenRpcException("无对应的压缩类型"); } ``` ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677662354385-c0ccb75a-97a3-4eb3-a7d7-7ffb582fb3c8.png) 思考: - 可以使用SPI进行业务解耦 - 对于用到反射实例化的场景,可以使用map进行**缓存** 使用: SPI全称Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的API,它可以用来启用框架扩展和替换组件。 整体机制图如下: ![输入图片说明](image4.png) Java SPI 实际上是“**基于接口的编程+策略模式+配置文件**”组合实现的动态加载机制。 系统设计的各个抽象,往往有很多不同的实现方案,在面向的对象的设计里,一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。 Java SPI就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。所以SPI的核心思想就是**解耦**。 **使用场景** 概括地说,适用于:**调用者根据实际使用需要,启用、扩展、或者替换框架的实现策略** 比较常见的例子: - 数据库驱动加载接口实现类的加载 JDBC加载不同类型数据库的驱动 - 日志门面接口实现类加载 SLF4J加载不同提供商的日志实现类 - Spring Spring中大量使用了SPI,比如:对servlet3.0规范对ServletContainerInitializer的实现、自动类型转换Type Conversion SPI(Converter SPI、Formatter SPI)等 - Dubbo Dubbo中也大量使用SPI的方式实现框架的扩展, 不过它对Java提供的原生SPI做了封装,允许用户扩展实现Filter接口 **优缺点** - **优点**: 使用Java SPI机制的优势是实现解耦,使得第三方服务模块的装配控制的逻辑与调用者的业务代码分离,而不是耦合在一起。应用程序可以根据实际业务情况启用框架扩展或替换框架组件。 相比使用提供接口jar包,供第三方服务模块实现接口的方式,SPI的方式使得源框架,不必关心接口的实现类的路径,可以不用通过下面的方式获取接口实现类: - 代码硬编码import 导入实现类 - 指定类全路径反射获取:例如在JDBC4.0之前,JDBC中获取数据库驱动类需要通过**Class.forName("com.mysql.jdbc.Driver")**,类似语句先动态加载数据库相关的驱动,然后再进行获取连接等的操作 - 第三方服务模块把接口实现类实例注册到指定地方,源框架从该处访问实例 通过SPI的方式,第三方服务模块实现接口后,在第三方的项目代码的META-INF/services目录下的配置文件指定实现类的全路径名,源码框架即可找到实现类。 - **缺点**: - - 虽然ServiceLoader也算是使用的延迟加载,但是基本只能通过遍历全部获取,也就是接口的实现类全部加载并实例化一遍。如果你并不想用某些实现类,它也被加载并实例化了,这就造成了浪费。获取某个实现类的方式不够灵活,只能通过Iterator形式获取,不能根据某个参数来获取对应的实现类。 - 多个并发多线程使用ServiceLoader类的实例是不安全的。 ### 4、Netty 编解码器 上方的代码,我们需要定义两个类WenRpcDecoder和WenRpcEncoder,从名字上可以看出是做编解码的,使用TCP的方式,那么当发起一个网络请求的时候,势必要传递数据流,那么这个数据流的格式是什么,我们就需要进行定义了,我们可以称为自定义报文或者自定义协议 **Decoder:**接收到数据流后,按照自定义的协议进行解析(负责将消息从字节或其他序列形式转成指定的消息对象) **Encoder:** 发送数据的时候,按照自定义的协议进行构建并发送(将消息对象转成字节或其他序列形式在网络上传输) 协议说明:按顺序排 1. 4B magic code(魔法数) 2. 1B version(版本) 3. 4B full length(消息长度) 4. 1B messageType(消息类型) 5. 1B codec(序列化类型) 6. 1B compress(压缩类型) 7. 4B requestId(请求的Id) 8. body(object类型数据) ### 5、解决粘包和拆包问题 #### 5.1、TCP粘包问题 我们使用netty提供的LengthFieldBasedFrameDecoder做为自定义(不定长)长度的解码器,可以有效的解决TCP粘包,拆包等问题 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677660735257-ad868699-2313-481c-bf42-b310e16fc4ff.png) 假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下四种情况: 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包完全接收,期间发生多次拆包。** #### 5.2、粘包、拆包发生的原因 产生原因主要有这3种:滑动窗口、MSS/MTU限制、Nagle算法 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677660956046-82c38d4b-93e8-4b57-8790-f182ab188102.png) ##### 5.2.1、滑动窗口 TCP流量控制主要使用滑动窗口协议,滑动窗口是接收数据端使用的窗口大小,用来告诉发送端接收端的缓存大小,以此可以控制发送端发送数据的大小,从而达到流量控制的目的。 **这个窗口大小就是我们一次传输几个数据** 对所有数据帧按顺序赋予编号,发送方在发送过程中始终保持着一个发送窗口,只有落在发送窗口内的帧才允许被发送; 同时接收方也维持着一个接收窗口,只有落在接收窗口内的帧才允许接收。这样通过调整发送方窗口和接收方窗口的大小可以实现流量控制。 **现在来看一下滑动窗口是如何造成粘包、拆包的?** - 粘包: 假设发送方的每256 bytes表示一个完整的报文,接收方由于数据处理不及时,这256个字节的数据都会被缓存到SO_RCVBUF(接收缓存区)中。如果接收方的SO_RCVBUF中缓存了多个报文,那么对于接收方而言,这就是粘包。 - 拆包:考虑另外一种情况,假设接收方的窗口只剩了128,意味着发送方最多还可以发送128字节,而由于发送方的数据大小是256字节,因此只能发送前128字节,等到接收方ack 后,才能发送剩余字节。这就造成了拆包。 ##### 5.2.2、MSS和MTU - MSS:是Maximum Segement Size缩写,表示TCP报文中data部分的最大长度,是TCP协议在OSI五层网络模型中传输层对一次可以发送的最大数据的限制。 - MTU:最大传输单元是Maxitum Transmission Unit的简写,是OSI五层网络模型中链路层(datalink layer)对一次可以发送的最大数据的限制。 当需要传输的数据大于MSS或者MTU时,数据会被拆分成多个包进行传输。由于MSS是根据MTU计算出来的,因此当发送的数据满足MSS时,必然满足MTU。 为了更好的理解,我们先介绍一下在5层网络模型中应用通过TCP发送数据的流程: 1. 对于应用层来说,只关心发送的数据DATA,将数据写入socket在内核中的发送缓冲区SO_SNDBUF即返回,操作系统会将SO_SNDBUF中的数据取出来进行发送。 2. 传输层会在DATA前面加上TCP Header,构成一个完整的TCP报文。 3. 当数据到达网络层(network layer)时,网络层会在TCP报文的基础上再添加一个IP Header,也就是将自己的网络地址加入到报文中。 4. 到数据链路层时,还会加上Datalink Header和CRC。 5. 当到达物理层时,会将SMAC(Source Machine,数据发送方的MAC地址),DMAC(Destination Machine,数据接受方的MAC地址 )和Type域加入。 **可以发现数据在发送前,每一层都会在上一层的基础上增加一些内容** 下图演示了MSS、MTU在这个过程中的作用: ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677661674337-e6981f8c-781f-4a72-9f41-5811a682eb0c.png) MTU是以太网传输数据方面的限制,每个以太网帧都有最小的大小64bytes最大不能超过1518bytes。 刨去以太网帧的帧头。 (DMAC目的MAC地址48bit=6Bytes+(SMAC源MAC地址48bit=6Bytes+Type域2bytes))=14Bytes 和帧尾 CRC校验部分4Bytes(这个部分有时候大家也把它叫做FCS),那么剩下承载上层协议的地方也就是Data域最大就只能有1500Bytes这个值 我们就把它称之为MTU。 由于MTU限制了一次最多可以发送1500个字节,而TCP协议在发送DATA时,还会加上额外的TCP Header和Ip Header,因此刨去这两个部分,就是TCP协议一次可以发送的实际应用数据的最大大小,也就是MSS。 **MSS长度=MTU长度-IP Header-TCP Header** TCP Header的长度是20字节,IPv4中IP Header长度是20字节,IPV6中IP Header长度是40字节,因此:在IPV4中,以太网MSS可以达到1460byte;在IPV6中,以太网MSS可以达到1440byte。 需要注意的是MSS表示的一次可以发送的DATA的最大长度,而不是DATA的真实长度。 发送方发送数据时,当SO_SNDBUF中的数据量大于MSS时,操作系统会将数据进行拆分,使得每一部分都小于MSS,这就是拆包,然后每一部分都加上TCP Header,构成多个完整的TCP报文进行发送,当然经过网络层和数据链路层的时候,还会分别加上相应的内容。 需要注意: 默认情况下,与外部通信的网卡的MTU大小是1500个字节。而本地回环地址的MTU大小为65535,这是因为本地测试时数据不需要走网卡,所以不受到1500的限制。 ##### 5.2.3、Nagle算法 TCP/IP协议中,无论发送多少数据,总是要在数据(DATA)前面加上协议头(TCP Header+IP Header),同时,对方接收到数据,也需要发送ACK表示确认。 即使从键盘输入的一个字符,占用一个字节,可能在传输上造成41字节的包,其中包括1字节的有用信息和40字节的首部数据。这种情况转变成了4000%的消耗,这样的情况对于重负载的网络来是无法接受的。 为了尽可能的利用网络带宽,TCP总是希望尽可能的发送足够大的数据。 **一个连接会设置****MSS参数,因此,TCP/IP希望每次都能够以MSS尺寸的数据块来发送数据** Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。 Nagle算法的基本定义是任意时刻,最多只能有一个未被确认的小段。所谓“小段”,指的是小于MSS尺寸的数据块,所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的ACK确认该数据已收到。 Nagle算法的规则: 1. 如果SO_SNDBUF(发送缓冲区)中的数据长度达到MSS,则允许发送; 2. 如果该SO_SNDBUF中含有FIN,表示请求关闭连接,则先将SO_SNDBUF中的剩余数据发送,再关闭; 3. 设置了TCP_NODELAY=true选项,则允许发送。TCP_NODELAY是取消TCP的确认延迟机制,相当于禁用了Nagle 算法。 4. 未设置TCP_CORK选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送; 5. 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。 ### 6、Nacos 注册中心问题 #### 6.1、为什么使用注册中心? - 使用注册中心能够实现服务治理,服务动态扩容,以及服务调用的负载均衡 - 单个服务实现负载均衡需要自己搭建 - #### 6.2、Nacos 和 Eureka比较? 1. nacos在自动或手动下线服务,使用消息机制通知客户端,服务实例的修改很快响应;Eureka只能通过任务定时剔除无效的服务。 2. nacos可以根据namespace命名空间,DataId,Group分组,来区分不同环境(dev,test,prod),不同项目的配置。 3. Eureka 只支持AP 而 Nacos支持CP 和 AP 4. Nacos 使用的是长连接 而 Eureka 是属于定时联系 短连接。 因为Nacos 存在心跳检测机制 可以使服务保活。 ### 7、Timer定时任务 时间轮算法 #### 7.1、概念 Timer是一种定时器工具,用来在一个后台线程计划执行指定任务。它可以安排任务“执行一次”或者定期“执行多次”。 **我们这里仍旧使用netty的Timer,前面我们在链路检测狗中已经初步使用** netty中的HashedWheelTimer提供的是一个定时任务的一个优化实现方案,在netty中主要用于异步IO的定时规划触发(A timer optimized for approximated I/O timeout scheduling)。为了方便大家理解,可以先来看看这个图D ![img](https://cdn.nlark.com/yuque/0/2022/png/25484710/1671201110124-1ff1f249-3eee-4638-868b-e90fc75fbf66.png) - *workerThread* 单线程用于处理所有的定时任务,它会在每个tick执行一个bucket中所有的定时任务,以及一些其他的操作。意味着定时任务不能有较大的阻塞和耗时,不然就会影响定时任务执行的准时性和有效性。 - *wheel* 一个时间轮,其实就是一个环形数组,数组中的每个元素代表的就是未来的**某些时间**片段上需要执行的定时任务的集合。这里需要注意的就是不是某个时间而是某些时间。因为比方说我时间轮上的大小是10,时间间隔是1s,那么我1s和11s的要执行的定时任务都会在index为1的格子上。 - *tick* 工作线程当前运行的tick数,每一个tick代表worker线程当前的一次工作时间 - *hash* 在时间轮上的hash函数。默认是tick%bucket的数量,即将某个时间节点映射到了时间轮上的某个唯一的格子上。 - *bucket* 时间轮上的一个格子,它维护的是一个Timeout的双向链表,保存的是这个哈希到这个格子上的所有Timeout任务。 - *timeout* 代表一个定时任务,其中记录了自己的deadline,运行逻辑以及在bucket中需要呆满的圈数,比方说之前1s和11s的例子,他们对应的timeout中圈数就应该是0和1。 这样当遍历一个bucket中所有的timeout的时候,只要圈数为0说明就应该被执行,而其他情况就把圈数-1就好。 除此之外,netty的HashedWheelTimer实现还有两个东西值得关注,分别是*pending-timeouts*队列和*cancelled-timeouts*队列。这两个队列分别记录新添加的定时任务和要取消的定时任务,当*workerThread*每次循环运行时,它会先将*pending-timeouts*队列中一定数量的任务移动到它们对应的*bucket*,并取消掉*cancelled-timeouts*中所有的任务。由于添加和取消任务可以由任意线程发起,而相应的处理只会在*workerThread*里,所以为了进一步提高性能,这两个队列都是用了JCTools里面的MPSC(multiple-producer-single-consumer)队列。 #### 7.2、作用 **时间轮** 是一种 **实现延迟功能(定时器)** 的 **巧妙算法**。如果一个系统存在大量的任务调度,时间轮可以高效的利用线程资源来进行批量化调度。把大批量的调度任务全部都绑定时间轮上,通过时间轮进行所有任务的管理,触发以及运行。能够高效地管理各种延时任务,周期任务,通知任务等。但是精度不够。 ### 8、自实现负载均衡 举个例子:我们的系统中的某个服务的访问量特别大,我们将这个服务部署在了多台服务器上,当客户端发起请求的时候,多台服务器都可以处理这个请求。那么,如何正确选择处理该请求的服务器就很关键。假如,你就要一台服务器来处理该服务的请求,那该服务部署在多台服务器的意义就不复存在了。负载均衡就是为了避免单个服务器响应同—请求,容易造成服务器宕机、崩溃等问题,我们从负载均衡的这四个字就能明显感受到它的意义。 ### 9、Netty心跳检测 #### 9.1、心跳检测作用 长连接的维护需要一套机制来控制,如果没有保证连接的状态,发送完数据之后就会关闭连接。 - 心跳机制附带一个额外的功能:检测通讯双方的存活状态。 - 负载较高,CPU100%,无法响应如何业务请求,但是使用TCP探针仍然能够确定连接状态,典型的连接活的都是提供方已死的状态。 #### 9.2、心跳检测的逻辑 服务端 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677679516790-3ad8c66c-45e2-40fc-acd1-a64a9e4412a7.png) 如果出现超时则在当前NettyServerHandler#userEventTriggered 方法中执行判断方法 如果state == IdleState.READER_IDLE 如果10s未发送多请求,判定失效,进行关闭。 同时10s没有读操作 不进行处理,以免连接过多,每个都回复会造成网络压力。 服务端不需要关闭 而客户端需要关闭重连 客户端 客户端应该每隔一段时间发起心跳检测,比如3s发送一次,检测服务端是否健康,如果服务端关闭会触发channelInactive方法,代表服务端下线。Netty 中的 channelInactive() 方法是通过 Socket 连接关闭时挥手数据包触发的 服务端下线,我们要及时将缓存中的服务去除掉 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677680457864-e7327ddb-d55c-4e6d-9663-5b6386c1398a.png) ### 10、链路检测狗 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677680921239-3ecd2e16-2761-4fb1-89f3-8d948cf98f9c.png) timer.newTimeout(this,timeout,TimeUnit.SECONDS 进行定时任务 启动定时任务时 然后定时启动下面的方法 来实现重复重连操作 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677681234332-40645ce4-061d-495c-a87e-e77d295ba203.png) ### 11、序列化和反序列化 因为只有二进制才能在网络中进行传输,所以为了方便网络通信,必须得把对象转换为二进制数据才行。另外,不仅网络传输的时候需要用到序列化和反序列化,将对象存储到文件、数据库等场景都需要用到序列化和反序列化。 ![img](https://cdn.nlark.com/yuque/0/2023/png/25484710/1677659447568-681ad4bc-a0ad-4495-a89a-94c4b2f95cbc.png) #### 11.1、常见场景 - 对象在进行网络传输(比如远程方法调用RPC的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化; - 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化; - 将对象存储到数据库(如Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化; - 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。 总结:序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。 #### 11.2、序列化算法 ##### JDK自带的序列化 只需实现java.io.Serializable接口即可 ##### **serialVersionUID有什么作用?** 序列化号serialVersionUID属于版本控制的作用。反序列化时,会检查serialVersionUID是否和当前类的serialVersionUID一致。如果serialVersionUID不一致则会抛出InvalidClassException异常。强烈推荐每个序列化类都手动指定其serialVersionUID,如果不手动指定,那么编译器会动态生成默认的serialVersionUID。 **serialVersionUID不是被static变量修饰了吗?为什么还会被“序列化”?** static修饰的变量是静态变量,位于方法区,本身是不会被序列化的。static变量是属于类的而不是对象。你反序列之后,static变量的值就像是默认赋予给了对象一样,看着就像是static变量被序列化,实际只是假象罢了。 ##### **如果有些字段不想进行序列化?** 对于不想进行序列化的变量,可以使用transient关键字修饰。 - transient只能修饰变量,不能修饰类和方法。 - static变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。 ##### **为什么不推荐的理由** - 不能跨语言调用 - 性能较差,序列化之后,体积任然很大 - 存在安全问题︰序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。 ##### Kryo **Kryo**是一个快速有效的Java二进制对象图序列化框架。主要有序列化反序列化更高效、序列化之后字节数据更小、更易用等特点。应用场景对象存入文件、数据库,或者在网络中传输。 Kryo的优点 Kyro的Output和Input类,也是一个装饰器类,可以内置Java IO的InputStream和OutputStream,也可以实现网络传输和存入文件。Kyro广泛用在Rpc框架中,如Dubbo框架。 - Rpc框架比较关注的是性能,扩展性,通用性,Kyro的性能与其他几种序列化方式对比中表现较好; - Kyro的Api也比较友好; - 不过,Kyro兼容性不是很好,**使用时应注意序列化和反序列化两边的类结构是否一致** - Kyro序列化时,不需要对象实现Serializable 使用方式 ```java public class KryoSerialization implements Serialization { @Override public byte[] serialize(Object obj) throws IOException { Kryo kryo = kryoLocal.get(); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); Output output = new Output(byteArrayOutputStream); kryo.writeObject(output, obj); output.close(); return byteArrayOutputStream.toByteArray(); } @Override public T deserialize(byte[] bytes, Class clazz) throws IOException { Kryo kryo = kryoLocal.get(); ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes); Input input = new Input(byteArrayInputStream); input.close(); return kryo.readObject(input, clazz); } private static final ThreadLocal kryoLocal = ThreadLocal.withInitial(() -> { Kryo kryo = new Kryo(); kryo.setReferences(true); kryo.setRegistrationRequired(false); return kryo; }); } Serialization serialization = new KryoSerialization(); byte[] bytes = serialization.serialize(person); Person readPerson = serialization.deserialize(bytes, Person.class); System.out.println(readPerson); ``` ##### Protocol **Protocol Buffers**是Google公司开发的一种数据描述语言,类似于XML能够将结构化数据序列化,可用于数据存储、通信协议等方面。现阶段支持C++、JAVA、Python等三种编程语言。对象序列化成Protocol Buffer之后可读性差,但是相比xml,json,它占用小,速度快,适合做数据存储或 RPC 数据交换格式。 **protostuff**是一个基于protobuf实现的序列化方法,它较于protobuf最明显的好处是,在几乎不损耗性能的情况下做到了不用我们写.proto文件来实现序列化。 ```java public class ProtostuffSerialization implements Serialization { private Objenesis objenesis = new ObjenesisStd(); @Override public byte[] serialize(Object obj) throws IOException { Class clazz = obj.getClass(); LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE); try { Schema schema = RuntimeSchema.createFrom(clazz); return ProtostuffIOUtil.toByteArray(obj, schema, buffer); } catch (Exception e) { throw e; } finally { buffer.clear(); } } @Override public T deserialize(byte[] bytes, Class clazz) throws IOException { T message = objenesis.newInstance(clazz); Schema schema = RuntimeSchema.createFrom(clazz); ProtostuffIOUtil.mergeFrom(bytes, message, schema); return message; } } ```