目录
- 前言
- 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<KTTCPSocketDelegate> 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
一个超时时间属性、一个根据参数以及请求类型实例化的构造方法。
响应体
为了适应不同的通讯协议类型、我使用了基类和继承的方式:
/**
响应体基类、不提供使用
*/
@interface KTTCPSocketResponse : NSObject
@property (nonatomic,readonly) KTTCPSocketRequestType type;//响应类型
@property (nonatomic,readonly) NSNumber *requestIdentifier;//序列号
@property (nonatomic,readonly) NSData *content;//内容
@end
/**
某一应用协议的响应体
*/
@interface KTTCPSocketResponseForXXX : KTTCPSocketResponse
@property (nonatomic,readonly) KTTCPSocketContentType contentType;//内容类型
@property (nonatomic,readonly) BOOL verify;//校验和情况
@property (nonatomic,readonly) KTTCPSocketVersion version;//协议版本号
/**
对响应体进行初始化
@param data 数据包
@param ipAddress 数据包源地址
@return 响应体
*/
+ (instancetype)responseWithData:(NSData *)data ipAddress:(NSString *)ipAddress;
@end
/****************<# Demo #>********************/
/**
某一应用协议的响应体
*/
@interface KTTCPSocketResponseForDemo : KTTCPSocketResponse
/**
对响应体进行初始化
@param data 数据包
@return 响应体
*/
+ (instancetype)responseWithData:(NSData *)data;
@end
针对不同的通讯协议结构、使用不同的响应体进行解析。
请求和响应的序列化
通俗来讲、就是将请求体对象转化成需要发送的数据包、以及将接收到的数据包解析成的响应体对象。
在这里我依旧参考AFNNetworking
的AFURLResponseSerialization
采用了协议+继承的方式进行设计。
-
序列化器
首先、我们需要一个协议、让所有序列化器各自实现请求和响应的序列化动作。
@protocol KTTCPSocketSerializerDelegate <NSObject>
/**
根据不同的策略将请求体格式化成数据包
@param req 请求体
*/
- (void)configRequestDataWithSerializerWithRequest:(KTTCPSocketRequest *)req;
/**
尝试根据不同的策略将响应数据包格式化成响应体
@return 响应体
*/
- (KTTCPSocketResponse *)tryGetResponseDataWithSerializer;
@end
-
请求的序列化
调用通过上面的代理进行
- (void)configRequestDataWithSerializerWithRequest:(KTTCPSocketRequest *)req {
if (req.type == KTTCP_type_heatbeat) {
[req setKTRequestIdentifier:@(KTTCP_identifier_heatbeat)];
req.formattedData = configFormattedDataForDemo(KTTCP_type_heatbeat, KTTCP_identifier_heatbeat, req.parameters);
return;
}
uint32_t requestIdentifier = [self.manager.socket currentRequestIdentifier];//获取唯一序列号
[req setKTRequestIdentifier:@(requestIdentifier)];//设置标识符
req.formattedData = configFormattedDataForDemo(req.type, requestIdentifier, req.parameters);//根据协议配置数据包
}
最终需要发送的数据包formattedData
通过configFormattedDataForDemo
方法进行生成
/**
生成二进制请求包
@param type 通讯类型
@param requestIdentifier 序列号
@param parameters 内容
@return 请求包
*/
NSMutableData * configFormattedDataForDemo(KTTCPSocketRequestType type,uint32_t requestIdentifier,NSDictionary *parameters) {
NSMutableData * formattedData = [NSMutableData new];
//内容转data
NSData * encodingContent = [ConvertToJsonStr(parameters) dataUsingEncoding:NSUTF8StringEncoding];
//协议拼接--类型标识符
[formattedData appendData:DataFromInteger(type)];
//协议拼接--序列号
[formattedData appendData:DataFromInteger(requestIdentifier)];
//协议拼接--请求体长度
uint32_t contengtLength = (uint32_t)encodingContent.length;
[formattedData appendData:DataFromInteger(contengtLength)];
//协议拼接--请求体
if (encodingContent != nil) { [formattedData appendData:encodingContent]; }
return formattedData;
}
这里、就是按照我们刚才制定的通讯协议格式进行拼接。
-
响应的序列化
在接收到TCP协议呈递上来的数据之后调用代理由序列化器处理
KTTCPSocketResponse *response = [self.serializer tryGetResponseDataWithSerializer];
序列化器内部对数据包进行拆分
- (KTTCPSocketResponse *)tryGetResponseDataWithSerializer {
NSData *totalReceivedData = self.manager.buffer;
//1.头部 -- 每个Response报文必有的16个字节(url+serNum+respCode+contentLen)
if (totalReceivedData.length < HeaderLengthForDemo) { return nil; }
//2.内容
NSData *responseData;
//根据定义的协议读取出Response.content的长度
uint32_t responseContentLength = IntegerFromData([self.manager.buffer subdataWithRange:NSMakeRange(HeaderLengthForDemo - ContentLengthForDemo, ContentLengthForDemo)]);
//3.单个响应包长度 Response.content的长度加上必有的16个字节即为整个Response报文的长度
uint32_t responseLength = HeaderLengthForDemo + responseContentLength;
if (totalReceivedData.length < responseLength) { return nil; }
//4. 根据上面解析出的responseLength截取出单个Response报文
if (self.manager.buffer.length < responseLength) { return nil; }//如果缓存池的长度不足一个数据包则不读取
responseData = [totalReceivedData subdataWithRange:NSMakeRange(0, responseLength)];
//更新缓存池 源缓存池-已经获取的长度
self.manager.buffer = [[totalReceivedData subdataWithRange:NSMakeRange(responseLength, totalReceivedData.length - responseLength)] mutableCopy];
KTTCPSocketResponseForDemo * response = [KTTCPSocketResponseForDemo responseWithData:responseData];
return response;//校验和通过则返回、否则部分返回
}
可以看到、通过对协议每个字段的解析、进而确定单个数据包应有的长度并进行截取。这也是粘包
问题的解决办法。
单个数据包的解析、由响应体根据自身的数据包自行解析
- (KTTCPSocketRequestType)type {
if (!_type) {
_type = IntegerFromData([self.data subdataWithRange:NSMakeRange(0, ReqTypeLengthForDemo)]);
}
return _type;
}
- (NSNumber *)requestIdentifier {
if (!_requestIdentifier) {
_requestIdentifier = @(IntegerFromData([self.data subdataWithRange:NSMakeRange(ReqTypeLengthForDemo , IdentifierLengthForDemo)]));
}
return _requestIdentifier;
}
- (uint32_t)contentLength {
if (!_contentLength) {
_contentLength = IntegerFromData([self.data subdataWithRange:NSMakeRange(ReqTypeLengthForDemo + IdentifierLengthForDemo, ContentLengthForDemo)]);
}
return _contentLength;
}
- (NSData *)content {
if (!_content) {
_content = [self.data subdataWithRange:NSMakeRange(HeaderLengthForDemo, self.contentLength)];
}
return _content;
}
任务机制
你可以参考
NSURLSessionTask
的作用来理解。
-
KTTCPSocketTask
@interface KTTCPSocketTask : NSObject
@property (nonatomic,readonly) KTTCPSocketTaskState state;//任务状态
@property (nonatomic,readonly) NSNumber *taskIdentifier;//任务ID
- (void)cancel;
- (void)resume;
@end
其中taskIdentifier
与请求时的序列号进行绑定、并且在收到服务器消息时通过序列号匹配是否有对应的task需要被处理。
-
任务超时
- (void)resume {
if (self.state != KTTCPSocketTaskState_Suspended) { return; }
//发起Request的同时也启动一个timer timer超时直接返回错误并忽略后续的Response
self.timer = [NSTimer scheduledTimerWithTimeInterval:self.request.timeoutInterval target:self selector:@selector(requestTimeout) userInfo:nil repeats:NO];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
self.state = KTTCPSocketTaskState_Running;
[self.manager resumeTask:self];//通知manager将task.request的数据写入Socket
}
#pragma mark - Private method
- (void)requestTimeout {
if (![self canResponse]) { return; }
self.state = KTTCPSocketTaskState_Completed;
[self completeWithResult:nil error:taskError(KTNetworkTaskError_TimeOut)];
}
任务开始时会启动一个定时器、当到达超时时间则将超时错误加入回调执行。
管理器
同样、可以参照
AFURLSessionManager
来理解
-
KTTCPSocketManager
负责将请求(KTTCPSocketRequest
)发送、以及当收到响应时将数据派发给对应的task。
@interface KTTCPSocketManager : NSObject
@property (nonatomic) NSUInteger timeoutInterval;//超时
@property (nonatomic,readonly) KTTCPSocket *socket;
@property (nonatomic,readonly) NSArray<KTTCPSocketTask *> *tasks;//当前在执行的任务
/**
通过指定协议的序列化方案进行初始化
@param serializer 指定协议
@return manager
*/
- (instancetype)initWithTCPSocketSerializer:(id<KTTCPSocketSerializerDelegate>)serializer;
/**
用指定地址去连接
@param host 主机
@param port 端口
@param block 回调
*/
- (void)contentWithHost:(NSString *)host port:(uint16_t)port blcok:(KTTCPSocketManagerContentBlock)block;
/**
发送信息
任务会自动开始
@param request 请求体
@param completionHandler 回调
@return 任务
*/
- (KTTCPSocketTask *)sendMsgWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler;
/**
创建任务
任务不会自动开始 需要自己[task resume];
@param request 请求体
@param completionHandler 回调
@return 任务
*/
- (KTTCPSocketTask *)TaskWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler;
@end
-
请求的发送
- (KTTCPSocketTask *)sendMsgWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler {
if (!request.timeoutInterval) { request.timeoutInterval = self.timeoutInterval; }
[self.serializer configRequestDataWithSerializerWithRequest:request];
KTTCPSocketTask *task = [self dataTaskWithRequest:request completionHandler:completionHandler];
[task resume];
return task;
}
//新建数据请求任务 调用方通过此接口定义Request的收到响应后的处理逻辑
- (KTTCPSocketTask *)dataTaskWithRequest:(KTTCPSocketRequest *)request completionHandler:(KTNetworkTaskCompletionHander)completionHandler {
__block NSNumber *taskIdentifier;
//1. 根据Request新建Task
KTTCPSocketTask *task = [KTTCPSocketTask taskWithRequest:request completionHandler:^(NSError *error, id result) {
//4. Request已收到响应 从派发表中删除
[self.tableLock lock];
[self.mutableTaskByTaskIdentifier removeObjectForKey:taskIdentifier];
[self.tableLock unlock];
!completionHandler ?: completionHandler(error, result);
}];
//2. 设置Task.manager 后续会通过Task.manager向Socket中写入数据
task.manager = self;
taskIdentifier = task.taskIdentifier;
//3. 将Task保存到派发表中
[self.tableLock lock];
[self.mutableTaskByTaskIdentifier setObject:task forKey:taskIdentifier];
[self.tableLock unlock];
return task;
}
//用socket发送数据包
- (void)resumeTask:(KTTCPSocketTask *)task {
if (self.socket.isConnected) {
[self.socket writeData:task.request.requestData];
}else {
KTError(@"TCP通道不通", KTNetworkTaskError_SocketNotConnect);
}
}
这里通过[self.mutableTaskByTaskIdentifier setObject:task forKey:taskIdentifier];
将任务与对应序列号绑定备用。
-
响应的接收
//接收到数据--放入缓存池并解析数据
- (void)socket:(KTTCPSocket *)sock didReadData:(NSData *)data {
[self.lock lock];
[self.buffer appendData:data];//加入缓存池
[self.lock unlock];
// [self.heatbeat reset];
[self readBuffer];//解析数据
}
//递归截取Response报文 因为读取到的数据可能已经"粘包" 所以需要递归
- (void)readBuffer {
if (self.isReading) { return; }
self.isReading = YES;
[self.lock lock];
KTTCPSocketResponse *response = [self.serializer tryGetResponseDataWithSerializer];//截取单个响应报文
[self.lock unlock];
[self dispatchResponse:response];//将报文派发给对应的task
self.isReading = NO;
if (!response) { return; }
[self readBuffer];//继续解析
}
这里通过协议方法tryGetResponseDataWithSerializer
让代理器生成对应的响应体、具体过程上文已经说过了。
-
将响应派发给对应任务
//将Response报文解析Response 然后交由对应的Task进行派发
- (void)dispatchResponse:(KTTCPSocketResponse *)response {
if (response == nil) { return; }
//根据报文类型标识符进行分发
if (response.type > KTTCP_type_max_notification) {/** 请求响应 */
//根据序列号取出指定的task
KTTCPSocketTask *task = self.mutableTaskByTaskIdentifier[response.requestIdentifier];
//通过task将响应报文回调
[task completeWithResponse:response error:nil];
} else if (response.type == KTTCP_type_heatbeat) {/** 心跳 */
NSLog(@"接收到心跳");
[self.heatbeat handleServerAckNum:response.requestIdentifier.intValue];
} else {/** 推送 */
//自行处理
}
}
通过不同的请求类型决定不同的动作、如果是响应报文则派发给对应序列号的任务。
Demo
这里我用的Node.js搭建的服务器、并且支持通过TCP让Node代替我们进行HTTP请求(虽然只写了Get)。
这样我们就可以大概实现美团这种客户端向长连接服务器发送TCP请求、长连接服务器向业务服务器发送HTTP请求的基本操作。
这样做除了提高请求的成功率以及速度之外。还有一个很重要的作用就是可以很大程度上免去被抓包以及篡改的担心(自定义通讯协议)。
不过、加密通道以及UDP/HTTP降级策略Demo里并没有写。因为不难么难了~(其中加密通道可以借鉴HTTPS的方案、用公钥来协商秘钥就好)。
Demo用起来也没啥问题、亲切可用
客户端
服务器
Deme可以《自取》
参考资料
一步一步构建你的iOS网络层 - TCP篇
iOS使用AsyncSocket循环接收消息的问题
iOS使用GCDAsyncSocket实现消息推送
AsyncSocket中tag参数的用处
网友评论