Netty使用及常用组件(三)

解决粘包/半包

什么是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粘包/半包发生的原因

由于TCP协议本身的机制(面向连接的可靠地协议-三次握手机制)客户端与服务器会维持一个连接(Channel),数据在连接不断开的情况下,可以持续不断地将多个数据包发往服务器,但是如果发送的网络数据包太小,那么他本身会启用Nagle算法(可配置是否启用)对较小的数据包进行合并(基于此,TCP的网络延迟要UDP的高些)然后再发送(超时或者包大小足够)。那么这样的话,服务器在接收到消息(数据流)的时候就无法区分哪些数据包是客户端自己分开发送的,这样产生了粘包;服务器在接收到数据库后,放到缓冲区中,如果消息没有被及时从缓存区取走,下次在取数据的时候可能就会出现一次取出多个数据包的情况,造成粘包现象

UDP:本身作为无连接的不可靠的传输协议(适合频繁发送较小的数据包),他不会对数据包进行合并发送(也就没有Nagle算法之说了),他直接是一端发送什么数据,直接就发出去了,既然他不会对数据合并,每一个数据包都是完整的(数据+UDP头+IP头等等发一次数据封装一次)也就没有粘包一说了。

分包产生的原因就简单的多:就是一个数据包被分成了多次接收。

更具体的原因至少包括:

1. 应用程序写入数据的字节大小大于套接字发送缓冲区的大小

2. 进行MSS大小的TCP分段。MSS是最大报文段长度的缩写。MSS是TCP报文段中的数据字段的最大长度。数据字段加上TCP首部才等于整个的TCP报文段。所以MSS并不是TCP报文段的最大长度,而是:MSS=TCP报文段长度-TCP首部长度。

解决粘包半包

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

  1. 在包尾增加分割符,比如回车换行符进行分割,例如FTP协议;

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

(3)将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度,使用LengthFieldBasedFrameDecoder,后面会有详细说明和使用。

辨析channelRead和channelReadComplete

两者的区别:

Netty是在读到完整的业务请求报文后才调用一次业务ChannelHandler的 channelRead方法,无论这条报文底层经过了几次SocketChannel的 read调用。

但是 channelReadComplete方法并不是在业务语义上的读取消息完成后被触发的,而是在每次从SocketChannel 成功读到消息后,由系统触发,也就是说如果一个业务消息被TCP协议栈发送了N次,则服务端的channelReadComplete方法就会被调用N次。

我们用代码来看看cn.tuling.nettybasic.checkread:

 

我们在客户端发送5次较小报文

服务端输出:

 

很明显,channelRead是一个报文执行一次,执行的执行次数和客户端发送报文数一样,channelReadComplete虽然也执行了多次,但是和客户端发送报文数没什么关系,而且也没什么规律可寻。

编解码器框架

什么是编解码器

每个网络应用程序都必须定义如何解析在两个节点之间来回传输的原始字节,以及如何将其和目标应用程序的数据格式做相互转换。这种转换逻辑由编解码器处理,编解码器由编码器和解码器组成,它们每种都可以将字节流从一种格式转换为另一种格式。那么它们的区别是什么呢?

如果将消息看作是对于特定的应用程序具有具体含义的结构化的字节序列—它的数据。那么编码器是将消息转换为适合于传输的格式(最有可能的就是字节流);而对应的解码器则是将网络字节流转换回应用程序的消息格式。因此,编码器操作出站数据,而解码器处理入站数据。我们前面所学的解决粘包半包的其实也是编解码器框架的一部分。

解码器

将字节解码为消息——ByteToMessageDecoder

将一种消息类型解码为另一种——MessageToMessageDecoder。

因为解码器是负责将入站数据从一种格式转换到另一种格式的,所以Netty 的解码器实现了ChannelInboundHandler。

什么时候会用到解码器呢?很简单:每当需要为ChannelPipeline 中的下一个ChannelInboundHandler 转换入站数据时会用到。此外,得益于ChannelPipeline 的设计,可以将多个解码器链接在一起,以实现任意复杂的转换逻辑。

比如一个实际的业务场景,两端通信,通过JSON交换信息,而且JSON文本需要加密,接收端就可以:

网络加密报文 -> 经过ByteToMessageDecoder -> String类型的JSON明文;

String类型的JSON文本-> 经过MessageToMessageDecoder -> Java里的对象

所以我们可以把ByteToMessageDecoder 看成一次解码器,MessageToMessageDecoder 看成二次或者多次解码器

将字节解码为消息

抽象类ByteToMessageDecoder

将字节解码为消息(或者另一个字节序列)是一项如此常见的任务,Netty 为它提供了一个抽象的基类:ByteToMessageDecoder。由于你不可能知道远程节点是否会一次性地发送一个完整的消息,所以这个类会对入站数据进行缓冲,直到它准备好处理。

它最重要方法

decode(ChannelHandlerContext ctx,ByteBuf in,List<Object> out)

是必须实现的唯一抽象方法。decode()方法被调用时将会传入一个包含了传入数据的ByteBuf,以及一个用来添加解码消息的List。对这个方法的调用将会重复进行,直到确定没有新的元素被添加到该List,或者该ByteBuf 中没有更多可读取的字节时为止。然后,如果该List 不为空,那么它的内容将会被传递给ChannelPipeline 中的下一个ChannelInboundHandler。

将一种消息类型解码为另一种

在两个消息格式之间进行转换(例如,从String->Integer)

decode(ChannelHandlerContext ctx,I msg,List<Object> out)

对于每个需要被解码为另一种格式的入站消息来说,该方法都将会被调用。解码消息随后会被传递给ChannelPipeline中的下一个ChannelInboundHandler

MessageToMessageDecoder<T>,T代表源数据的类型

TooLongFrameException

由于Netty 是一个异步框架,所以需要在字节可以解码之前在内存中缓冲它们。因此,不能让解码器缓冲大量的数据以至于耗尽可用的内存。为了解除这个常见的顾虑,Netty 提供了TooLongFrameException 类,其将由解码器在帧超出指定的大小限制时抛出。

为了避免这种情况,你可以设置一个最大字节数的阈值,如果超出该阈值,则会导致抛出一个TooLongFrameException(随后会被ChannelHandler.exceptionCaught()方法捕获)。然后,如何处理该异常则完全取决于该解码器的用户。某些协议(如HTTP)可能允许你返回一个特殊的响应。而在其他的情况下,唯一的选择可能就是关闭对应的连接。

编码器

解码器的功能正好相反。Netty 提供了一组类,用于帮助你编写具有以下功能的编码器:

将消息编码为字节;MessageToByteEncoder<I>

将消息编码为消息:MessageToMessageEncoder<T>,T代表源数据的类型

还是用我们上面的业务场景,两端通信,通过JSON交换信息,而且JSON文本需要加密,发送端就可以:

Java里的对象-> 经过MessageToMessageEncoder -> String类型的JSON文本

String类型的JSON明文 -> 经过MessageToByteEncoder-> 网络加密报文;

所以我们可以把MessageToByteEncoder看成网络报文编码器,MessageToMessageEncoder看成业务编码器。

将消息编码为字节

encode(ChannelHandlerContext ctx,I msg,ByteBuf out)

encode()方法是你需要实现的唯一抽象方法。它被调用时将会传入要被该类编码为ByteBuf 的出站消息(类型为I 的)。该ByteBuf 随后将会被转发给ChannelPipeline中的下一个ChannelOutboundHandler

将消息编码为消息

encode(ChannelHandlerContext ctx,I msg,List<Object> out)

这是需要实现的唯一方法。每个通过write()方法写入的消息都将会被传递给encode()方法,以编码为一个或者多个出站消息。随后,这些出站消息将会被转发给ChannelPipeline中的下一个ChannelOutboundHandler

编解码器类

我们一直将解码器和编码器作为单独的实体讨论,但是有时在同一个类中管理入站和出站数据和消息的转换是很有用的。Netty 的抽象编解码器类正好用于这个目的,因为它们每个都将捆绑一个解码器/编码器对。这些类同时实现了ChannelInboundHandler 和ChannelOutboundHandler 接口。

为什么我们并没有一直优先于单独的解码器和编码器使用这些复合类呢?因为通过尽可能地将这两种功能分开,最大化了代码的可重用性和可扩展性,这是Netty 设计的一个基本原则。

相关的类:

抽象类ByteToMessageCodec

抽象类MessageToMessageCodec

实战 – 实现SSL/TLS和Web服务

Netty 为许多通用协议提供了编解码器和处理器,几乎可以开箱即用,这减少了我们花费的时间与精力。

通过SSL/TLS 保护Netty 应用程序

SSL和TLS这样的安全协议,它们层叠在其他协议之上,用以实现数据安全。我们在访问安全网站时遇到过这些协议,但是它们也可用于其他不是基于HTTP的应用程序,如安全SMTP(SMTPS)邮件服务器甚至是关系型数据库系统。

为了支持SSL/TLS,Java 提供了javax.net.ssl 包,它的SSLContext 和SSLEngine类使得实现解密和加密相当简单直接。Netty 通过一个名为SslHandler 的ChannelHandler实现利用了这个API,其中SslHandler 在内部使用SSLEngine 来完成实际的工作。

在大多数情况下,SslHandler 将是ChannelPipeline 中的第一个ChannelHandler。

HTTP 系列

HTTP 是基于请求/响应模式的:客户端向服务器发送一个HTTP 请求,然后服务器将会返回一个HTTP 响应。Netty 提供了多种编码器和解码器以简化对这个协议的使用。

一个HTTP 请求/响应可能由多个数据部分组成,FullHttpRequest 和FullHttpResponse 消息是特殊的子类型,分别代表了完整的请求和响应。所有类型的HTTP 消息(FullHttpRequest、LastHttpContent等等)都实现了HttpObject 接口。

HttpRequestEncoder 将HttpRequest、HttpContent 和LastHttpContent 消息编码为字节

HttpResponseEncoder 将HttpResponse、HttpContent 和LastHttpContent 消息编码为字节

HttpRequestDecoder 将字节解码为HttpRequest、HttpContent 和LastHttpContent 消息

HttpResponseDecoder 将字节解码为HttpResponse、HttpContent 和LastHttpContent 消息

HttpClientCodec和HttpServerCodec则将请求和响应做了一个组合。

聚合HTTP 消息

由于HTTP 的请求和响应可能由许多部分组成,因此你需要聚合它们以形成完整的消息。为了消除这项繁琐的任务,Netty 提供了一个聚合器HttpObjectAggregator,它可以将多个消息部分合并为FullHttpRequest 或者FullHttpResponse 消息。通过这样的方式,你将总是看到完整的消息内容。

HTTP 压缩

当使用HTTP 时,建议开启压缩功能以尽可能多地减小传输数据的大小。虽然压缩会带来一些CPU 时钟周期上的开销,但是通常来说它都是一个好主意,特别是对于文本数据来说。Netty 为压缩和解压缩提供了ChannelHandler 实现,它们同时支持gzip 和deflate 编码。

使用HTTPS

启用HTTPS 只需要将SslHandler 添加到ChannelPipeline 的ChannelHandler 组合中。

SSL和HTTP的代码参见模块netty-http

视频中实现步骤:

1、首先实现Http服务器并浏览器访问;

2、增加SSL控制;

3、实现客户端并访问。

序列化问题

Java序列化的目的主要有两个:

1.网络传输

2.对象持久化

当选行远程跨迸程服务调用时,需要把被传输的Java对象编码为字节数组或者ByteBuffer对象。而当远程服务读取到ByteBuffer对象或者字节数组时,需要将其解码为发送时的Java 对象。这被称为Java对象编解码技术。

Java序列化仅仅是Java编解码技术的一种,由于它的种种缺陷,衍生出了多种编解码技术和框架

Java序列化的缺点

Java序列化从JDK1.1版本就已经提供,它不需要添加额外的类库,只需实现java.io.Serializable并生成序列ID即可,因此,它从诞生之初就得到了广泛的应用。

但是在远程服务调用(RPC)时,很少直接使用Java序列化进行消息的编解码和传输,这又是什么原因呢?下面通过分析.Tava序列化的缺点来找出答案。

1  无法跨语言

对于跨进程的服务调用,服务提供者可能会使用C十+或者其他语言开发,当我们需要和异构语言进程交互时Java序列化就难以胜任。由于Java序列化技术是Java语言内部的私有协议,其他语言并不支持,对于用户来说它完全是黑盒。对于Java序列化后的字节数组,别的语言无法进行反序列化,这就严重阻碍了它的应用。

2  序列化后的码流太大

通过一个实例看下Java序列化后的字节数组大小。

3序列化性能太低

无论是序列化后的码流大小,还是序列化的性能,JDK默认的序列化机制表现得都很差。因此,我们边常不会选择Java序列化作为远程跨节点调用的编解码框架。

代码参见模块netty-basic下的包cn.tuling.nettybasic.serializable.protogenesis

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

30岁老阿姨

支持一下哦!!

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

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

打赏作者

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

抵扣说明:

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

余额充值