一、前言
相信现在很多App都会有通讯功能,可能它要求是tcp、udp或者websocket等,每次开发者需要自己再去找个轮子,这样繁琐且耗时,所以本文旨意在打造一个通用的可配置化的IM SDK。文笔有限,如有不妥或者错误之处,恳请在评论、私信或者邮箱里指出,万分感谢。
先上图
这里直接模拟两个用户通讯,详情使用读者可以直接移步Github查看NettyIM
二、功能介绍
- 支持TCP协议
- 支持WebSocket的ws、wss协议
- 支持UDP协议
- 内置一套默认私有协议实现
- 支持断线重连、连接重试
- 地址自动切换
- 支持消息重发、消息确认机制
- 支持心跳机制
- tcp协议、udp协议、websocket都支持握手鉴权
- 提供Netty消息处理器注册
- 支持自定义编解码器
- 连接状态、消息状态监听
- 支持单个消息设置是否需要确认包、是否失败重发
- 支持各种参数配置
三、Netty
什么是Netty?
Netty 是一个利用 Java 的高级网络的能力,隐藏其背后的复杂性而提供一个易于使用的 API 的客户端/服务器框架。
Netty 是一个广泛使用的 Java 网络编程框架(Netty 在 2011 年获得了Duke's Choice Award,见https://www.java.net/dukeschoice/2011)。它活跃和成长于用户社区,像大型公司 Facebook 和 Instagram 以及流行 开源项目如 Infinispan, HornetQ, Vert.x, Apache Cassandra 和 Elasticsearch 等,都利用其强大的对于网络抽象的核心代码。
以上是摘自《Essential Netty In Action》这本书
为什么选择Netty?
Netty是业界最流行的NIO框架之一,它的健壮性、功能、性能、可定制性和可扩展性在同类框架中都是首屈一指的,它已经得到成百上千的商用项目验证,例如Hadoop的RPC框架avro使用Netty作为底层通信框架。很多其它业界主流的RPC框架,也使用Netty来构建高性能的异步通信能力。
通过对Netty的分析,我们将它的优点总结如下:
- API使用简单,开发门槛低;
- 功能强大,预置了多种编解码功能,支持多种主流协议;
- 定制能力强,可以通过ChannelHandler对通信框架进行灵活的扩展;
- 性能高,通过与其它业界主流的NIO框架对比,Netty的综合性能最优;
- 成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼;
- 社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功能会被加入;
- 经历了大规模的商业应用考验,质量已经得到验证。在互联网、大数据、网络游戏、企业应用、电信软件等众多行业得到成功商用,证明了它可以完全满足不同行业的商业应用。
正是因为这些优点,Netty逐渐成为Java NIO编程的首选框架。
以上是摘自[《Netty 权威指南》—— 选择Netty的理由
](http://ifeve.com/netty-2-6/)
四、多种协议支持
1、 TCP
简介:
TCP协议是一种在计算机网络中常用的传输层协议,它负责提供可靠的、面向连接的数据传输服务。TCP保证数据的可靠传输,提供流量控制、拥塞控制和错误恢复等功能,以保证网络的可靠性和稳定性。TCP常用于许多应用层协议,如HTTP、FTP、Telnet等。
优点:
- 可靠性
- 应答机制:在TCP协议中,每个数据包都有一个序号和确认号,接收端会对每个数据包进行确认应答。如果发送端在一定时间内没有收到确认应答,就会认为数据包丢失,需要重新发送数据包。
- 重传机制:如果某个数据包没有按序到达或者丢失,接收端会要求发送端重新发送该数据包。发送端会定期重传未收到确认应答的数据包,直到接收端确认收到为止。
- 滑动窗口机制:TCP协议使用滑动窗口机制进行流量控制。发送端和接收端都有一个窗口大小,用于控制数据包的发送和接收。发送端根据接收端的窗口大小来控制发送速率,接收端根据自己的窗口大小来控制接收速率,以避免网络拥塞和数据丢失。
- 拥塞控制机制:TCP协议使用拥塞控制机制来避免网络拥塞。如果网络出现拥塞,TCP会降低发送速率,以避免数据丢失和网络崩溃。
- 有序性:
- 序号机制:在TCP协议中,每个数据包都有一个序号,用于标识数据包在数据流中的位置。发送端会按照序号将数据包进行排序,并将序号添加到数据包的首部。接收端会按照序号将数据包进行排序,以保证数据的有序传输。
- 应答机制:在TCP协议中,接收端会对每个数据包进行确认应答。如果发送端在一定时间内没有收到确认应答,就会认为数据包丢失,需要重新发送数据包。这样可以保证数据包按序到达。
- 滑动窗口机制:TCP协议使用滑动窗口机制进行流量控制。发送端和接收端都有一个窗口大小,用于控制数据包的发送和接收。发送端根据接收端的窗口大小来控制发送速率,接收端根据自己的窗口大小来控制接收速率,以避免网络拥塞和数据丢失。
- 面向连接:
TCP协议在传输数据之前需要建立连接,传输完成后需要释放连接,这样可以保证数据的有序传输
缺点:
- 传输效率低: TCP协议提供可靠的数据传输,但是为了保证数据的可靠性和完整性,会进行确认和重传等操作,这会增加网络传输的延迟和开销,降低传输效率。
- 面向连接: TCP协议需要在传输数据之前建立连接,传输完成后释放连接,这样会增加网络开销和复杂度。
- 不适合实时应用: 由于TCP协议对数据传输进行确认和重传等操作,这会增加网络延迟,不适合实时应用,如视频会议、在线游戏等。
- 安全性差: TCP协议没有提供加密和身份验证等安全机制,容易受到网络攻击和窃听。
总结:
如果我们通讯对实时要求不高、但对数据可靠性、完整性、有序性有要求,tcp是个不错的选择,但注意这里的可靠、有序性仅代表在传输层是可靠的,并不能保证我们应用层通讯的可靠性,所以在很多采用TCP协议的通讯上都会在应用层上加上确认机制和重传机制或者使用UDP协议加上TCP的一些可靠机制去实现。
2、UDP
简介:
UDP协议是一种用户数据报协议,它是一种简单的、无连接的传输层协议,不提供可靠的数据传输、数据有序性和错误恢复机制。UDP协议直接将应用层的数据报发送到网络层,不需要建立连接和维护状态,因此传输效率高。UDP协议常用于实时应用,如音视频传输、在线游戏等。
优点:
- 传输效率高: UDP协议不需要建立连接和维护状态,直接将应用层的数据报发送到网络层,因此传输效率高。
- 实时性好: 由于UDP协议不提供可靠性和错误恢复机制,因此能够快速传输数据。
- 传输数据较小: UDP协议的数据报头较小,只有8个字节,相比TCP协议的20个字节要小很多,因此在传输数据量较小的情况下,UDP协议的开销相对较小。
- 简单: UDP协议是一种简单的协议,实现起来比较容易,适合于一些简单的应用场景
缺点:
- 不可靠性: UDP是无连接的,因此它不提供可靠的数据传输。它不会跟踪数据包是否已到达目标,也不会重新发送丢失的数据包。这意味着,如果数据包在传输过程中丢失或损坏,接收方将无法知道,并且无法要求发送方重新发送。
- 无序性: UDP不保证数据包的顺序。如果发送方发送的数据包按照A、B、C的顺序发送,但接收方却按照C、A、B的顺序接收,那么接收方将无法正确地重构原始数据。
- 低效性: 由于UDP不提供数据包的可靠性和有序性,因此它可能需要发送更多的数据包来确保数据的正确性。这会导致网络拥塞和低效率的数据传输。
- 难以控制流量: UDP不提供拥塞控制机制,因此发送方可能会发送过多的数据包,导致网络拥塞。这可能会对网络中的其他流量产生负面影响。
总结:
UDP是一种无连接的传输协议,不保证数据包的可靠性、完整性和顺序。因此,在使用UDP进行数据传输时,需要在应用层自行实现相关机制来检测和纠正错误,例如在每个数据包中添加序列号和校验和等信息,来检测数据包是否有丢失和损坏、添加seq/ack机制,确保数据发送到对端、添加超时重传等机制来实现可靠性,还有个点是数据报大小对传输效率的影响,当IP数据报大于MTU,这个时候发送方IP层就需要分片。把数据报分成若干片,使每一片都小于MTU,而接收方IP层则需要进行数据报的重组。这样就会多做许多事情,可能会导致数据包的丢失或延迟,因为每个分片都是独立传输的,可能会按照不同的路径到达目的地,也可能会在传输过程中丢失一些分片。因此,应该尽量避免 UDP 分片。鉴于 Internet 上的标准 MTU 值为 576 字节,所以最好将 UDP 的数据长度控制在 548 字节(MTU(576) - IPHeader(20) - UDPHeader(8)),但是考虑到IP头部选项和一些没有预料到的其他头部信息,UDP 数据包的最大安全负载应该是 508 字节(MTU(576) - IPHeader(60) - UDPHeader(8))
3、WebSocket
简介:
WebSocket是一种应用层协议,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输。 协议支持文本和二进制数据。客户端和服务器都可以发送和接收这两种类型的数据。此外,WebSocket 还支持 ping 和 pong 消息,用于检测连接是否仍然处于活动状态。
优点:
- 兼容性: 更好的支持 Web,并支持 HTTP 代理和中介
- 实时性: WebSocket是基于TCP传输层协议 可以在客户端和服务器之间实现实时的双向通信,使得客户端可以即时地接收到服务器端的数据,从而实现实时更新
- 安全性:支持使用 SSL/TLS(wss协议) 加密传输数据,这可以确保数据在传输过程中不被窃听或篡改。
- 支持扩展: WebSocket 定义了扩展,用户可以扩展协议、实现部分自定义的子协议,例如压缩扩展、加密扩展、认证扩展...等。
缺点:
- 不支持所有浏览器: 尽管 WebSockets 已经成为现代浏览器的标准功能,但某些旧版本的浏览器可能不支持它。这可能会导致应用程序无法在所有浏览器中正常工作
- 安全性问题: WebSocket 技术需要在客户端和服务器之间建立持久连接,这可能会导致一些安全问题。例如,攻击者可以利用 WebSocket 连接来进行跨站点脚本攻击(XSS)和跨站点请求伪造(CSRF)等攻击
- 容易受到网络波动的影响: WebSocket 本身是基于 TCP 协议实现的,因此在网络波动较大的情况下,可能会出现连接断开、传输延迟等问题
总结:
如果是考虑到兼容web,且需要tcp协议的一些特性,且不想自己做一些应用层的事情,例如握手、认证、加密等。websocket是个不错的选择。对于数据格式来说,websocket支持了文本和二进制两种,使用者也可以直接简单的使用。
五、框架设计
1、因为IM和OKhttp具有一定的共性, 所以本库借鉴OKhttp设计思想,来让我们看一下构造一个IMClient可以有多精简。
- 通用配置
IMClient.Builder builder = new IMClient.Builder()
.setConnectTimeout(10, TimeUnit.SECONDS) //设置连接超时
.setResendCount(3)//设置失败重试数
.setConnectRetryInterval(1000,TimeUnit.MILLISECONDS)//连接尝试间隔
.setConnectionRetryEnabled(true)//是否连接重试
.setSendTimeout(6,TimeUnit.SECONDS)//设置发送超时
.setHeartIntervalBackground(30,TimeUnit.SECONDS)//后台心跳间隔
.setEventListener(eventListener!=null?eventListener:new DefaultEventListener(userId)) //事件监听,可选
.setMsgTriggerReconnectEnabled(true) //如果连接已经断开,消息发送是否触发重连
.setProtocol(protocol) //哪种协议 IMProtocol.PRIVATE、IMProtocol.WEB_SOCKET、IMProtocol.UDP
.setOpenLog(true);//是否开启日志
- TCP协议配置
//以下支持两种数据传输格式,一种protobuf,一种string格式
builder.setCodec(codecType == 0?new DefaultTcpProtobufCodec():new DefaultTcpStringCodec())//默认的编解码,开发者可以使用自己的protobuf或者其他格式的编解码
.setShakeHands(codecType == 0? new DefaultProtobufMessageShakeHandsHandler(getDefaultTcpHands()):new DefaultStringMessageShakeHandsHandler(getDefaultStringHands())) //设置握手认证,可选
.setHeartBeatMsg(codecType == 0? getDefaultProtobufHeart(): getDefaultStringHeart()) //设置心跳,可选
.setAckConsumer(codecType == 0?new DefaultProtobufAckConsumer():new DefaultStringAckConsumer()) //设置消息确认机制,如果需要消息回执,必选
.registerMessageHandler(codecType == 0?new DefaultProtobufMessageReceiveHandler(onMessageArriveListener):new DefaultStringMessageReceiveHandler(onMessageArriveListener)) //消息接收处理器
.registerMessageHandler(codecType == 0?new DefaultReplyReceiveHandler(onReplyListener):new DefaultStringMessageReplyHandler(onReplyListener)) //消息状态接收处理器
.registerMessageHandler(codecType == 0?new DefaultProtobufHeartbeatRespHandler():new DefaultStringHeartbeatRespHandler()) //心跳接收处理器
.setTCPLengthFieldLength(2)//本库拆包采用消息头包含消息长度的协议,装包拆包的长度字段的占用字节数,默认值为2
.addAddress(new Address(ip,9081,Address.Type.TCP))
.setMaxFrameLength(65535*100); //设置最大帧长 //私有tcp和websocket生效
- WebSocket协议配置
builder.setHeartBeatMsg(getDefaultWsHeart())
.setAckConsumer(new DefaultWSAckConsumer())
.registerMessageHandler(new DefaultWSMessageReceiveHandler(onMessageArriveListener))
.registerMessageHandler(new DefaultWSMessageReplyHandler(onReplyListener))
.registerMessageHandler(new DefaultWsHeartbeatRespHandler())
.addAddress(new Address(ip,8804,Address.Type.WS))
.setMaxFrameLength(65535*100)
// .addAddress(new Address(ip,8804,Address.Type.WSS))//支持WSS协议,请在scheme带上wss标识
.addWsHeader("user",userId); //webSocket特有的,可以用来鉴权使用
- UDP协议配置
builder.setCodec(new DefaultUdpStringCodec(new InetSocketAddress(ip,8804), CharsetUtil.UTF_8)) //String的编解码,开发者可以设定为自己的格式
.setShakeHands(new DefaultStringMessageShakeHandsHandler(getDefaultStringHands())) //设置握手认证,可选
.setHeartBeatMsg(getDefaultStringHeart()) //设置心跳,可选
.setAckConsumer(new DefaultStringAckConsumer()) //设置确认机制
.registerMessageHandler(new DefaultStringMessageReceiveHandler(onMessageArriveListener)) //消息接收处理器
.registerMessageHandler(new DefaultStringMessageReplyHandler(onReplyListener)) //消息状态接收处理器
.registerMessageHandler(new DefaultStringHeartbeatRespHandler()) //心跳接收处理器
.addAddress(new Address(ip, 8804, Address.Type.UDP));
上述很多配置项都是可选项,例如你没有握手的要求、没有心跳设计、没有消息回执,setShakeHands、setHeartBeatMsg、setAckConsumer都可以是不设置的。所有的Default开头的实现,开发者都可以替换成自己的实现类。
整个框架的核心实现在几个内置拦截器中:
Response getResponseWithInterceptorChain(SubsequentCallback callback) throws IOException, InterruptedException, AuthException, SendTimeoutException {
// Build a full stack of interceptors.
List interceptors = new ArrayList<>();
if (client.interceptors()!=null&&client.interceptors().size()>0){
interceptors.addAll(client.interceptors());
}
interceptors.add(retryAndFollowUpInterceptor);
interceptors.add(new BridgeInterceptor(client));
// interceptors.add(new CacheInterceptor());
interceptors.add(new ConnectInterceptor(client));
interceptors.add(new CallServerInterceptor(callback));
Interceptor.Chain chain = new RealInterceptorChain(
interceptors, null, null, null, 0, originalRequest,this, eventListener,client.connectTimeout(),
client.sendTimeout());
return chain.proceed(originalRequest);
}
是不是似曾相识的感觉,这里拦截器功能和okhttp雷同,retryAndFollowUpInterceptor进行连接重试、发送重试、地址切换,BridgeInterceptor主要进行数据的装配,ConnectInterceptor是真正的进行连接和一些编解码器等一些配置的地方,CallServerInterceptor进行数据的写和读,,完成这一套拦截器,那么我们整体一个从建立连接到消息发送和接收的大致流程就有了。
五、鉴权设计
鉴权设计是保证通讯的安全性和可靠性的重要手段。一般来说,通讯SDK鉴权设计需要考虑以下几个方面:
- 身份认证:通讯SDK需要验证客户端的身份,以确保只有合法的客户端才能使用SDK提供的服务。身份认证可以采用用户名密码、API密钥等方式进行。
- 数据加密:通讯SDK需要对通讯过程中的数据进行加密,以保证数据的机密性和完整性。数据加密可以采用对称加密、非对称加密等方式进行。
- 防止中间人攻击:通讯SDK需要防止中间人攻击,以保证通讯过程中的数据不被篡改。防中间人攻击可以采用数字证书、SSL/TLS等方式进行
- ...等
本库采用了身份认证,即在消息通讯之前要先与服务端进行身份认证。如果需要握手认证,在TCP和UDP协议上开发者需要添加以下配置:
builder.setShakeHands(MessageShakeHandsHandler shakeHandler) //配置握手鉴权机制
public interface MessageShakeHandsHandler {
/**
* 发送给服务端的握手包
* @return
*/
K ShakeHands();
/**
* 是否是握手包回应包 * @param msg
* @return
*/
boolean isShakeHands(Object msg);
/**
* 客户端端自己判断返回的握手认证回应包是否成功
* @param pack
* @return
*/
boolean isShakeHandsOk(T pack) ;
}
接口中的ShakeHands()方法将在连接建立后会调用,发起一个握手认证,当服务端返回消息后会经过isShakeHands(Object msg)判别是否是握手响应包,是则走isShakeHandsOk(T pack),否则消息流转到下一个消息处理器。当isShakeHandsOk(T pack)返回值代表是否成功,true后会建立心跳机制如果有设置的话,fasle会立马断开此连接。
在websocket协议上由于已存在握手过程,所以我们不需要自己去写这个过程,我们可以在websocket协议头上带上我们的header,填上我们的认证信息,然后服务端对header去做判断即可。在websocket协议上开发者需添加以下配置:
builder.addWsHeader(String key, String value) //添加websocket的协议头
总结:
在tcp和websocket下,这里的握手认证包,建议开发者可以设置为用户id+token的组合形式,用户id可以知道这个会话来自哪个客户端,token用来检查连接发起的是否合法。udp协议虽然没有连接,但是依然可以在业务上去做握手认证,如果服务端一直收到一个用户的包但之前没有做握手认证,服务端可以拒绝处理业务或者一些其他处理。
六、心跳设计
TCP协议实现中是有保活机制的,也就是TCP的KeepAlive机制,大概就是如果一个TCP连接在7200秒(2小时)内没有活动,则内核将发送9个keepalive消息,每个消息之间相隔75秒。如果在发送完所有keepalive消息后仍然没有收到响应,则连接将被关闭。这些默认参数显然是不能满足我们的要求的。另外还几个比较重要的原因,例如NAT超时、服务器判断设备是否还在线等原因, 所以我们需要实现自己的一个心跳机制。
- 设置心跳包和心跳间隔
//设置心跳包,heartBeatMsg的数据类型一定要是你的编解码器所支持的格式
builder.setHeartBeatMsg(Object heartBeatMsg)
//设置前台的心跳间隔,这里的间隔是指在无任何消息发送情况下的空闲时间,而不是固定的间隔时间发送心跳包
builder.setHeartIntervalForeground(int interval, TimeUnit unit)
builder.setHeartIntervalBackground(long interval, TimeUnit unit)//设置后台的心跳间隔
- 设置读空闲和读空闲是否触发重连
//设置读空闲是否触发重连,如果为true则一段时间内一直如果没有收到服务端返回的任何消息,则触发重新连接,false的话,设置读空闲的配置失效,不触发重连。
builder.setReaderIdleReconnectEnabled(boolean readerIdleReconnectEnabled)
builder.setReaderIdleTimeForeground(long interval, TimeUnit unit)//设置前台读空闲时间
setReaderIdleTimeBackground(long interval, TimeUnit unit) //设置后台读空闲时间
总结:
其实心跳机制还有很多事情是可以做的,不仅是保活、判断在线这些,我们还可以利用心跳的RTT(Round-Trip Time,往返时间)去判断网络情况,我们是否要控制消息发送速度或者更改连接、改变心跳间隔。因为一个网络不佳的情况下,我们频繁去做的消息发送,消息的延迟和阻塞是必然的,还占用了没必要的带宽。
七、消息确认设计
TCP协议是个可靠面向流的传输协议,内部既有确认机制且保证数据有序,那我们为什么还要进行ACK机制设计呢,TCP是传输层协议它只能保证传输层的可靠,是端到端的,但并不能保证应用层的可靠性,例如你应用在接收到数据的时候发生异常,这个消息是不是就丢了。再比如在高并发、高负载、高延迟、不稳定网络等情况下,TCP 协议的性能会受到受到很大影响,且整个IM系统也不能保证可靠性,对于一个IM系统来说,可靠的定义至少是不丢消息、消息不重复、不乱序,这样才算一个比较稳定的IM。
- 不丢消息
要保证消息的不丢失,可以模拟TCP协议中的ACK机制,我们定义一套业务层的ACK机制,只要当对方回了ACK我们才认为对方已经收到消息。例如有ClientA --> Server--> ClientB,ClientA发送消息给Server时候,需要等待Server返回ACK代表已发送的,而Server转发消息给ClientB,需要等待ClientB返回ACK才代表ClientB收到,例如ClientB不在线Server可以把消息储存起来,等ClientB上线主动推送离线消息或者等ClientB上线主动拉去。如果ACK回执的消息也丢失了呢,这需要去做个消息发送重试机制,如果一定的时间内没有收到ACK则重发此消息,重试一定次数都没有收到ACK则认为消息发送失败。
- 消息不重复
在上述的重试机制中,就可能出现消息重复的问题,例如一端发送消息给服务端,在等待ACK的超时后,客服端重新发送消息,发送完后才收到ACK,这样其实服务端就收到两条一样的消息。消息去重处理方式比较简单,每个消息带上一个msgId,如果已经接收到同样的msgId则直接回ACK,不需要额外处理。
- 不乱序
保证消息的有序,可以借鉴TCP协议中使用序列号,服务端为每一个消息都编上一个序号seqid,客户端根据服务端返回的消息回执中的seqid去为消息进行排序。这样就可以根据序列号和确认号来保证数据的有序传输。
在本库中实现了消息确认机制和消息重传机制,而消息的去重和消息的排序需要业务层自己去实现。
1、注册一个消息确认机制
/**
* 设置ACK机制,如果设置了,在request里有needACK,则必须收到ACK包 不然会回调onFailure
*
* @param ackConsumer
* @return
*/
public Builder setAckConsumer(Consumer ackConsumer) {
this.ackConsumer = ackConsumer;
return this;
}
/**
* 用于特定消息的消费,例子:我发送了一个特别的消息,然后想订阅该特定消息的后续响应
* @param
*/
public interface Consumer {
/**
*
* @param t 接收的消息
* @param requestTag 消息的唯一标识
* @return
*/
boolean Observable(T t,String requestTag);
/**
* 处理该消息
* @param t
*/
void accept(T t);
}
接口中的Observable(T t,String requestTag)如果返回true,则代表消息已经收到ack,走发送成功回调。如果是fasle则会一直等待一个消息的发送周期,超时、重发、重试,如果一个周期里都没有正确的ack返回则消息发送失败。
accept(T t)会在Observable(T t,String requestTag)方法返回ture的时候回调,去处理该消息。
2、构建一个需要消息确认回执的消息
Request.Builder builder = new Request.Builder().
setNeedACK(appMessage.getHead().getMsgId()) //此消息需要ack,且此消息的ack包的tag是msgId
.setSendRetry(true) // 此消息是否超时重发,如果一定时间内没有收到ACK重发此消息
.setBody(getStringMsgPack(appMessage)) //发送的消息内容
.build();
Builder setAckConsumer(Consumer ackConsumer)
3、发送消息
public void sendMsg(Request request, Callback callback){
imClient.newCall(request).enqueue(callback);
}
总结:
如果你的消息是有序的,那么你可以通过Delay ACk即延迟发送ack,而不是对每个消息都要进行确认,你可以在一段时间内回一个ack(包含序号)或者在传输数据的时候顺带携带一个ack信息,这样可减少带宽,提供利用率。收到一个ack代表ack序号之前的数据都准确无误的收到。
八、写在最后
感谢大家的阅读!也欢迎大家指出问题、提交issue或者评论都可以哈,希望此篇文章对你有帮助。Github地址