目录
前言
TCP通道的建立
自定义应用层协议
请求体
响应体
请求和响应的序列化
序列化器
请求的序列化
响应的序列化
任务机制
KTTCPSocketTask
任务超时
管理器
KTTCPSocketManager
请求的发送
响应的接收
将响应派发给对应任务
Demo
参考资料
前言
本文的起因是希望像《美团点评移动网络优化实践》中的方案一样、建设一个可以将HTTP请求转化成二进制数据包、并且在自建的TCP长连接通道上传输。当然、直接TCP双向通讯也是没有问题的。
以前用的Websocket、简单粗暴。如果你只想要一个全双工的TCP长连接、Websocket作为和HTTP一样的应用层协议完全够用。
但本文主要是尝试自己用socket(虽然并不是完全原生)构建一个能够像HTTP请求一样使用的TCP通道。并且最终、将HTTP请求放在自建的TCP加密通道上传输。
关于网络层一些基础知识、或许《当被尬聊网络协议、我们可以侃点什么?》可以帮到你。
自己对Socket通道的建设一开始也不太懂、所以有很多地方借鉴了《一步一步构建你的iOS网络层 - TCP篇》的思路。十分感谢
TCP通道的建立
首先、我们需要一个类似websocket的应用层协议。
参照SRWebSocket来看、除了全双工通信之外。我们还需要处理心跳、重连、粘包这三个特殊的概念(SSL在CocoaAsyncSocket下已经封装了实现)。
此外。由于原生socket比较麻烦、所以借助了一个开源框架CocoaAsyncSocket来操作scoket(类似NSLayoutConstraint与Masonry的关系)。具体使用的是基于GCD的GCDAsyncSocket(似乎以前还有个基于Runloop的AsyncSocket、但是我用的时候已经没有了。大概和NSURLCollection被NSURLSession淘汰了一样)。
CocoaAsyncSocket初始状态下就具备连接、断开、发送以及读取等基本功能。
这里主要对CocoaAsyncSocket添加了重连、专属线程等易用性的封装、并且将scoket事件通过代理进行回调。
头文件
@class KTTCPSocket;
@protocol KTTCPSocketDelegate <NSObject>
@optional
/**
链接成功
@param sock KTTCPSocket
@param host 主机
@param port 端口
*/
- (void)socket:(KTTCPSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port;
/**
最终链接失败
连接失败 + N次重连失败
@param sock KTTCPSocket
*/
- (void)socketCanNotConnectToService:(KTTCPSocket *)sock;
/**
链接失败并重连
@param sock KTTCPSocket
@param error error
*/
- (void)socketDidDisconnect:(KTTCPSocket *)sock error:(NSError *)error;
/**
接收到了数据
@param sock KTTCPSocket
@param data 二进制数据
*/
- (void)socket:(KTTCPSocket *)sock didReadData:(NSData *)data;
@end
/**
对GCDAsyncSocket进行封装的工具类。
具备自动重连、读写数据等基础操作
*/
@interface KTTCPSocket : NSObject
@property (nonatomic,readonly) NSString *host;//主机
@property (nonatomic,readonly) uint16_t port;//端口
@property (nonatomic) NSUInteger maxRetryCount;//重连次数
@property (nonatomic, weak) id delegate;
- (instancetype)init NS_UNAVAILABLE;
/**
构造方法
@param host 主机号
@param port 端口号
@return KTTCPSocket实例
*/
- (instancetype)initSocketWithHost:(NSString *)host port:(uint16_t)port NS_DESIGNATED_INITIALIZER;
/**
关闭连接--注意关闭之后就没办法再次开启了。不然没办法判断socke对象该何时销毁
*/
- (void)close;
/**
连接
*/
- (void)connect;
/**
重连并且重置次数
*/
- (void)reconnect;
/**
链接状态
@return 是否已经链接
*/
- (BOOL)isConnected;
/**
写入数据
@param data 二进制数据
*/
- (void)writeData:(NSData *)data;
@end
业务代码
写入数据
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
由于TCP面向字节流、所以并不需要我们调用发送之类的方法、他会按照顺序一个字节一个字节的把数据进行传输。
读取数据
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
if ([self.delegate respondsToSelector:@selector(socket:didReadData:)]) {
[self.delegate socket:self didReadData:data];
}
[self.socket readDataWithTimeout:-1 tag:socketTag];
}
readDataWithTimeout方法会持续监听一次缓存区、当接收到数据立刻通过代理交付。这里也就相当于递归调用了。
重连
链接失败的重连:
//链接失败
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)error {
// NSLog(@"TCPSocket--连接已断开.error:%@", error);
if ([self.delegate respondsToSelector:@selector(socketDidDisconnect:error:)]) {
[self.delegate socketDidDisconnect:self error:error];
}
[self tryToReconnect];
}
//尝试自动重连
- (void)tryToReconnect {
if (self.isConnecting || !self.isNetworkReachable) {
return;
}
self.currentRetryCount -= 1;
//如果还有尝试次数就自动重连
if (self.currentRetryCount >= 0) {
NSLog(@"尝试重连");
[self connect];
} else if ([self.delegate respondsToSelector:@selector(socketCanNotConnectToService:)]) {
//自动重连失败
NSLog(@"重连失败");
[self.delegate socketCanNotConnectToService:self];
}
}
连接失败会监听重连次数、超过次数则宣告失败
网络波动的重连:
//网络波动
- (void)didReceivedNetworkChangedNotification:(NSNotification *)notif {
[self reconnectIfNeed];
}
//切换到后台
- (void)didReceivedAppBecomeActiveNotification:(NSNotification *)notif {
[self reconnectIfNeed];
}
- (void)reconnectIfNeed {
if (self.isConnecting || self.isConnected) { return; }
[self reconnect];
}
网络波动会重置连接次数并重连
线程的常驻
- (void)socketWillBeConnect {
if (self.socketThread == nil) {
//保存异步线程
self.socketThread = [NSThread currentThread];
[[NSRunLoop currentRunLoop] addPort:self.machPort forMode:NSDefaultRunLoopMode];
while (self.machPort) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}
}
}
由于为长连接新开辟了一个线程、所以需要使用Runloop来维持线程的生存。
自定义通讯协议报文
这里需要解释一下TCP的两个概念
面向字节流传输
TCP协议将数据看做有序排列的二进制位、并按照8位分割成有序的字节流。
就像之前在谈到写入数据的时候说的一样、你并不需要主动调用发送函数。Socket在接收到数据的时候就会直接按照流的模式发送以数据段。
TCP缓冲区
应用层提供给TCP协议的数据会被先放入缓冲区中、并没有真正的发送。只有在合适的时候或者应用程序显示地要求将数据发送时、TCP才会将数据组织成合适的数据段发送出去。
对于接收方、在正式交付给上层应用之前、接收到的数据也会被放在缓冲区备用。
上图中、"未发送"部分的数据、就是存放在缓冲区的
总之、接收方的socket永远不可能知道“发送端发送的数据包长”
如果发送方这样发送:
while (1) {
[self writeData:@"123"];
}
假设接收方缓冲区为10个长度
那么他将接收到1231231231、2312312312、3123123123。
这也就是我们所说的粘包。
什么是报文
我们可以先来看看TCP数据段的报文格式
简而言之。报文是0000040200000401000000287b226d736d73223这样的16进制字符串、而报文格式也就是关于报文该如何解释的一套规定。
自定义通讯协议的报文格式
以本文的Demo举个例子:
#define ReqTypeLengthForDemo (4)/** 消息类型的长度 */
#define IdentifierLengthForDemo (4)/** 消息序号的长度 */
#define ContentLengthForDemo (4)/** 消息有效载荷的长度 */
#define HeaderLengthForDemo (ReqTypeLengthForDemo + IdentifierLengthForDemo + ContentLengthForDemo)/** Demo消息响应的头部长度 */
当然、你也可以设计的再复杂一些。包括协议版本、内容类型、校验和等等元素:
#define ReqTypeLength (4)/** 消息类型的长度 */
#define VersionLength (4)/** 协议版本号的长度 */
#define IdentifierLength (4)/** 消息序号的长度 */
#define ContentTypeLength (4)/** 内容类型的长度 */
#define VerifyLength (32)/** 校验和的长度 */
#define ContentLength (4)/** 消息有效载荷的长度 */
#define HeaderLength (ReqTypeLength + VersionLength + IdentifierLength + ContentTypeLength + VerifyLength + ContentLength)/** 消息响应的头部长度 */
请求体
这里我仿造了NSURLRequest进行设计、希望通过KTTCPSocketRequest可以直接进行TCP通信。在极简状态下他应该长这样:
//通讯类型标识符
typedef enum : NSUInteger {
// 心跳
KTTCP_type_heatbeat = 0x00000001,
KTTCP_type_notification_xxx = 0x00000002,
KTTCP_type_notification_yyy = 0x00000003,
KTTCP_type_notification_zzz = 0x00000004,
// 通知类型最多到400
KTTCP_type_max_notification = 0x00000400,
KTTCP_type_dictionary = 0x00000402,//内容为字典类型
KTTCP_type_http_get = 0x00000403//内容为字典类型
} KTTCPSocketRequestType;
/**
将单次TCP需要发送的资源进行整合、类似NSURLRequest的作用
*/
@interface KTTCPSocketRequest : NSObject
@property (nonatomic, assign) NSUInteger timeoutInterval;//超时
/**
请求构造方法
@param type 请求类型
@param parameters 内容数据
@return 请求实例
*/
+(instancetype)requestWithType:(KTTCPSocketRequestType)type parameters:(NSDictionary *)parameters;
@end
一个超时时间属性、一个根据参数以及请求类型实例化的构造方法。
响应体
为了适应不同的通讯协议类型、我使用了基类和继承的方式:
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
0
针对不同的通讯协议结构、使用不同的响应体进行解析。
请求和响应的序列化
通俗来讲、就是将请求体对象转化成需要发送的数据包、以及将接收到的数据包解析成的响应体对象。
在这里我依旧参考AFNNetworking的AFURLResponseSerialization采用了协议+继承的方式进行设计。
序列化器
首先、我们需要一个协议、让所有序列化器各自实现请求和响应的序列化动作。
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
1
请求的序列化
调用通过上面的代理进行
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
2
最终需要发送的数据包formattedData通过configFormattedDataForDemo方法进行生成
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
3
这里、就是按照我们刚才制定的通讯协议格式进行拼接。
响应的序列化
在接收到TCP协议呈递上来的数据之后调用代理由序列化器处理
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
4
序列化器内部对数据包进行拆分
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
5
可以看到、通过对协议每个字段的解析、进而确定单个数据包应有的长度并进行截取。这也是粘包问题的解决办法。
单个数据包的解析、由响应体根据自身的数据包自行解析
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
6
任务机制
你可以参考NSURLSessionTask的作用来理解。
KTTCPSocketTask
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
7
其中taskIdentifier与请求时的序列号进行绑定、并且在收到服务器消息时通过序列号匹配是否有对应的task需要被处理。
任务超时
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
8
任务开始时会启动一个定时器、当到达超时时间则将超时错误加入回调执行。
管理器
同样、可以参照AFURLSessionManager来理解
KTTCPSocketManager
负责将请求(KTTCPSocketRequest)发送、以及当收到响应时将数据派发给对应的task。
- (void)writeData:(NSData *)data {
if (data.length == 0) { return; }
[self.socket writeData:data withTimeout:-1 tag:socketTag];
}
9
请求的发送
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
if ([self.delegate respondsToSelector:@selector(socket:didReadData:)]) {
[self.delegate socket:self didReadData:data];
}
[self.socket readDataWithTimeout:-1 tag:socketTag];
}
0
这里通过[self.mutableTaskByTaskIdentifier setObject:task forKey:taskIdentifier];将任务与对应序列号绑定备用。
响应的接收
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
if ([self.delegate respondsToSelector:@selector(socket:didReadData:)]) {
[self.delegate socket:self didReadData:data];
}
[self.socket readDataWithTimeout:-1 tag:socketTag];
}
1
这里通过协议方法tryGetResponseDataWithSerializer让代理器生成对应的响应体、具体过程上文已经说过了。
将响应派发给对应任务
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
if ([self.delegate respondsToSelector:@selector(socket:didReadData:)]) {
[self.delegate socket:self didReadData:data];
}
[self.socket readDataWithTimeout:-1 tag:socketTag];
}
2
通过不同的请求类型决定不同的动作、如果是响应报文则派发给对应序列号的任务。
Demo
这里我用的Node.js搭建的服务器、并且支持通过TCP让Node代替我们进行HTTP请求。
这样我们就可以大概实现美团这种客户端向长连接服务器发送TCP请求、长连接服务器向业务服务器发送HTTP请求的基本操作。
这样做除了提高请求的成功率以及速度之外。还有一个很重要的作用就是可以很大程度上免去被抓包以及篡改的担心(自定义通讯协议)。
不过、加密通道以及UDP/HTTP降级策略Demo里并没有写。因为不难么难了~(其中加密通道可以借鉴HTTPS的方案、用公钥来协商秘钥就好)。
Demo用起来也没啥问题、亲切可用
客户端
服务器
参考资料
一步一步构建你的iOS网络层 - TCP篇
iOS使用AsyncSocket循环接收消息的问题
iOS使用GCDAsyncSocket实现消息推送
AsyncSocket中tag参数的用处
作者:kirito_song
链接:https://www.jianshu.com/p/bd628cbc6d71
本文由 投稿者 创作,文章地址:https://blog.isoyu.com/archives/ios-conglingkaishizijiantcptongdao.html
采用知识共享署名4.0 国际许可协议进行许可。除注明转载/出处外,均为本站原创或翻译,转载前请务必署名。最后编辑时间为:8 月 20, 2018 at 08:09 下午