目录
一、socket是什么?
二、socket连接与HTTP连接的区别
三、如何建立一个socket连接?
四、使用OC底层socket,实现客户端socket编程
五、使用CocoaAsyncSocket,实现客户端socket编程
1、心跳保活
2、断线重连
6、数据粘包的处理
一、socket是什么?
-
概述:socket翻译过来就是“插座”嘛,装逼叫法是“套接字”,它是针对TCP和UDP协议封装的一套接口,它内部包含了进行网络数据传输必须的五种信息:数据传输使用的协议、客户端的IP地址、客户端的端口号、服务端的IP地址和服务端的端口号,因此可以使用socket来完成端到端的双向通信。
-
本质:要记住socket本身并不是协议,也就是说它本身并没有规定两个终端之间该怎样传输数据,它仅仅是对TCP和UDP协议封装的一套接口,记住socket只是一套API,我们使用这套API(即使用socket)来间接地使用TCP协议或UDP协议实现数据的传输。
通常情况下,单台服务器可以支持十万个socket连接的并发。
二、socket连接与HTTP连接的区别
socket连接通常情况下就是一个TCP连接(因为我们通常选择使用TCP协议来完成连接),这个连接一旦建立成功,就会一直存在那里,客户端和服务端就可以相互通信,直到一方主动断开连接,也就是说socket连接是个长连接。某些情况下,如果我们需要服务端和客户端互相传东西,就可以用socket来实现。
前面一篇我们也说过,HTTP连接其实也是一个TCP连接,只不过这个连接是一次性的,即客户端发起请求,服务端给个响应,然后这个请求就算完成,连接就会主动地断掉,所以HTTP连接是个短连接,因此如果我们想要保证客户端一直在线上,就需要不断地向服务端发送请求,即轮询,但是轮询是十分消耗资源的,要慎重使用。大多数情况下,我们开发用的是HTTP连接。
也就是说socket连接和HTTP连接的本质都是一个TCP连接,至于内部是怎么实现这个长连接和短连接机制的,现在还没有看。
三、如何建立一个socket连接?
前面我们提到了socket其实就是对TCP和UDP协议封装的一套接口,因此当我们使用TCP协议进行连接时,我们建立的socket连接就是一个TCP连接,当我们使用UDP协议进行连接时,我们建立的socket连接就是一个UDP连接。通常情况下,我们会采用TCP协议,因此,每建立一个socket连接就会在内部调用一下TCP连接建立的三次握手,每断开一个socket连接就会在内部调用一下TCP连接断开的四次握手。
建立一个socket连接有如下步骤:
- 第一步:创建服务端socket,服务端socket开始监听来自客户端socket的连接请求。
- 第二步:创建客户端socket,客户端socket向服务端socket发起连接请求。
- 第三步:服务端socket接收到客户端socket连接请求后,会建立一个新的线程,把服务端socket的描述发送给客户端socket,客户端socket确认该描述后,连接就建立好了,服务端socket可以继续监听其它客户端socket的连接请求。
- 第四步:连接建立之后,服务端和客户端就可以相互读写数据了。
- 第五步:服务端和客户端两端都可以主动的断开已建立的socket连接。
四、使用OC底层socket,实现客户端socket编程
就着第3小节建立socket的步骤,我们现在使用OC底层socket实现一下socket编程。
1、服务端socket的搭建
注意:实际开发中,服务端自己会写服务端socket,这里我们使用GCDAsyncSocket实现一个服务端socket仅做演示使用,实际开发中没人会用iOS做服务端socket。
- 把GCDAsyncSocket文件拖进项目中:
![](https://img.haomeiwen.com/i1116725/9d87966a81f93ff6.png)
- 代码
//
// ViewController.h
// SocketSeverDemo
//
// Created by 意一yiyi on 2018/3/26.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
@end
//
// ViewController.m
// SocketSeverDemo
//
// Created by 意一yiyi on 2018/3/26.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import "ViewController.h"
#import "GCDAsyncSocket.h"
#define kServerIPAddress @"192.168.0.133"
#define kServerPort 8080
@interface ViewController ()<GCDAsyncSocketDelegate>
@property (strong, nonatomic) GCDAsyncSocket *serverSocket;// 服务端socket
@property (strong, nonatomic) NSMutableArray *clientSockets;// 用来保存所有的客户端socket
@property (weak, nonatomic) IBOutlet UITextField *tf;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.clientSockets = [NSMutableArray array];
}
#pragma mark - public methods
// 监听
- (IBAction)listen:(id)sender {
[self initServerSocket];
}
// 断开连接
- (IBAction)disconnect:(id)sender {
[self.serverSocket disconnect];
}
// 发送数据
- (IBAction)send:(id)sender {
if (self.clientSockets == nil) {
return;
}
NSData *data = [self.tf.text dataUsingEncoding:NSUTF8StringEncoding];
[self.clientSockets enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[obj writeData:data withTimeout:-1 tag:0];// tag:消息标记
}];
}
#pragma mark - GCDAsyncSocketDelegate
// 服务端socket连接客户端socket成功的回调
- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(nonnull GCDAsyncSocket *)newSocket {
NSLog(@"===========>连接客户端成功\n");
// 连接成功后,立马读取来自客户端的数据
[newSocket readDataWithTimeout:-1 tag:0];
// 保存客户端socket
[self.clientSockets addObject:newSocket];
}
// 服务端socket读取客户端socket数据成功后的回调
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSLog(@"%@", [NSString stringWithFormat:@"===========>读取客户端数据成功:%@\n", text]);
// 注意:我们在读取到数据后,需要在这里再次调用[sock readDataWithTimeout:-1 tag:0];方法来读取数据,框架本身就是这么设计的,否则我们就只能接收一次数据,之后再也接收不到数据
[sock readDataWithTimeout:-1 tag:0];
}
// 服务端socket与客户端socket断开连接的回调
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err {
NSLog(@"%@", [NSString stringWithFormat:@"===========>与客户端断开连接:%@\n", err]);
// sokect断开连接时,需要清空代理和服务端本身的socket
self.serverSocket.delegate = nil;
self.serverSocket = nil;
}
#pragma mark - 初始化服务端socket
// 初始化服务端socket
- (void)initServerSocket {
[self createServerSocket];
[self listenClientSocket];
}
// 创建服务端socket
- (void)createServerSocket {
self.serverSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
}
// 服务端socket开始监听来自指定端口客户端socket的连接请求
- (void)listenClientSocket {
NSError *error = nil;
BOOL result = [self.serverSocket acceptOnPort:kServerPort error:&error];
if (result && error == nil) {
NSLog(@"===========>服务端开始监听");
}else {
NSLog(@"%@", [NSString stringWithFormat:@"==========>服务端监听失败:%@", error]);
}
}
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
// Dispose of any resources that can be recreated.
}
@end
2、客户端socket的搭建
- 代码
//
// ViewController.h
// ClientSocket
//
// Created by 意一yiyi on 2018/5/8.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
@end
//
// ViewController.m
// ClientSocket
//
// Created by 意一yiyi on 2018/5/8.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import "ViewController.h"
#import <sys/types.h>
#import <arpa/inet.h>
#import <netinet/in.h>
#import <sys/socket.h>
#define kServerIPAddress "192.168.0.133"
#define kServerPort 8080
@interface ViewController ()
@property (assign, nonatomic) int clientSocket;// 客户端socket
@property (weak, nonatomic) IBOutlet UITextField *tf;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
#pragma mark - public methods
// 连接
- (IBAction)connect:(id)sender {
[self initClientSocket];
}
// 断开连接
- (IBAction)disconnect:(id)sender {
close(self.clientSocket);
}
// 发送数据
- (IBAction)send:(id)sender {
const char *send_data = [self.tf.text UTF8String];
send(self.clientSocket, send_data, strlen(send_data) + 1, 0);
}
#pragma mark - 初始化客户端socket
// 初始化客户端socket
- (void)initClientSocket {
[self createClientSocket];
[self connectToServerSocket];
}
// 创建客户端socket
- (void)createClientSocket {
// 每次连接前,先断开连接
if (self.clientSocket != 0) {
close(self.clientSocket);
self.clientSocket = 0;
}
self.clientSocket = CreateClientSocket();
}
// 创建客户端socket,返回-1代表创建失败,创建成功会返回一个正整数
static int CreateClientSocket() {
int clinetSocket = 0;
// 创建一个socket,并返回该socket的文件描述符,如果描述符为-1表示创建失败
// 第一个参数addressFamily,通常是IPv4(AF_INET)或 IPv6(AF_INET6)
// 第二个参数type,表示socket的类型,通常是流stream(SOCK_STREAM)或数据报文datagram(SOCK_DGRAM),因为TCP是基于数据流的,所以选择SOCK_STREAM
// 第三个参数protocol,通常设置为0,以便让系统自动为选择我们合适的协议,对于stream socket来说会是TCP协议(IPPROTO_TCP),而对于datagram来说会是UDP协议(IPPROTO_UDP)
clinetSocket = socket(AF_INET, SOCK_STREAM, 0);// 采用TCP协议
return clinetSocket;
}
// 连接到服务端socket
- (void)connectToServerSocket {
// 连接到服务端socket
if (ConnectToServerSocket(self.clientSocket, kServerIPAddress, kServerPort) == 0) {
printf("===========>连接服务端失败\n");
}else {
printf("===========>连接服务端成功\n");
// 连接成功后,立马读取来自服务端的数据
[self receiveData];
}
}
// 连接到服务端socket,返回0代表连接失败,返回非0和非-1代表连接成功
static int ConnectToServerSocket(int client_socket, const char *server_ip, unsigned short port) {
// 生成一个sockaddr_in类型结构体
struct sockaddr_in sAddr = {0};
sAddr.sin_len = sizeof(sAddr);
// 设置IPv4
sAddr.sin_family = AF_INET;
// inet_aton是一个改进的方法来将一个字符串IP地址转换为一个32位的网络序列IP地址。如果这个函数成功,函数的返回值非零,如果输入地址不正确则会返回零。
inet_aton(server_ip, &sAddr.sin_addr);
// htons是将整型变量从主机字节顺序转变成网络字节顺序,赋值端口号
sAddr.sin_port = htons(port);
// 用客户端socket和服务端IP地址、服务端端口号,向服务端socket发起连接请求,连接成功返回0,连接失败返回-1。
// 注意:该接口调用会阻塞当前线程,直到服务器返回。
if (connect(client_socket, (struct sockaddr *)&sAddr, sizeof(sAddr)) == 0) {// 此处代表连接成功
return client_socket;// 返回client_socket,此时client_socket为self.client_socket,其值为非0和非-1
}
return 0;// 此处代表连接失败
}
// 接收数据
- (void)receiveData {
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(receiveDataOnSubThread) object:nil];
[thread start];
}
// 开辟一个子线程来接收数据
- (void)receiveDataOnSubThread {
while (1) {// 要不断的接收来自服务端的数据,直到接收到某个特定的字符串才结束
char receiveData[1024] = {0};
recv(self.clientSocket, receiveData, sizeof(receiveData), 0);
if (strlen(receiveData) == 0) {
return;
}
printf("===========>读取服务端数据成功:%s\n", receiveData);
}
}
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
// Dispose of any resources that can be recreated.
}
@end
3、效果
![](https://img.haomeiwen.com/i1116725/b70f6820b52fa567.gif)
五、使用CocoaAsyncSocket,实现客户端socket编程
第4小节我们使用OC底层socket简单的实现了客户端socket编程,客户端与服务端就可以简单的相互通信了。但在实际开发中,我们还需要考虑一些问题,比如:心跳保活、断线重连等,接下来我们会使用CocoaAsyncSocket实现客户端socket编程,并简单的把这几个问题解决进去。
1、心跳保活
- 什么是心跳保活?
心跳保活是指:客户端每隔一定的时间间隔就向服务端发送一个心跳数据包,用以保证当前socket连接处于活跃状态。
- 为什么要做心跳保活?
心跳保活直接的目的:是为了保证当前socket连接处于活跃状态。
心跳保活本质的目的:是为了避免国内运营商NAT超时导致的socket连接中断。
上面两个目的其实是一回事,这么说,socket连接一旦建立之后就一直挂在那,除非某一端主动断开,但实际情况中数据传输往往要经过路由器、网关和防火墙等多个中间结点,而国内运营商会在检测到数据传输链路上一段时间没有数据传输时,自动断开这种处于非活跃状态的连接,这个就是所谓的国内运营商NAT超时。因此我们需要做一个心跳保活,客户端每隔一段时间就向服务端发一个心跳数据包,来避免运营商把我们的连接中断。
-
那心跳保活如何做呢?
-
心跳保活的时间间隔取多少合适?
国内运营商NAT超时的时间为5分钟,所以我们通常将心跳保活的时间间隔设置为3~5分钟。 -
客户端做些什么?
客户端每隔一定的时间间隔,向服务端写一个心跳数据包,这个心跳数据包的数据内容中应该包含有用于区别不同客户端的唯一标识,因为服务端要针对不同的客户端处理不同的数据。
有一个问题,既然发送心跳数据包的目的是为了保证socket连接处于活跃状态,那为什么非要客户端来发送心跳数据包而不是服务端来发送呢?服务端发心跳数据包同样也能保证运营商链路不杀掉socket连接嘛,那为什么不让服务端发心跳数据包?因为如果服务端发的话,一旦连接真的断掉了,服务端就再也联系不上这个断开的客户端了,客户端也没有感知,就不会立即的重连。而如果是客户端发送心跳数据包,发现连接断了,可以主动的发起对服务端的重连。 -
服务端做些什么?
服务端需要做的就是一个心跳检测,来记录每个客户端发来的心跳数据包,从而对每个客户端是否在线、连接是否断开有一个感知与记录,这样在连接断开的时候,服务端就可以实现相应的业务处理。
首先,服务端在收到心跳数据包之后,会专门有一个字典用来记录指定的客户端的心跳信息,这个字典会把心跳数据包中标志客户端唯一性的字符串作为key,把系统当前时间作为value,这样客户端每发一次心跳数据包,服务端接收后就会用新的系统时间覆盖一下该客户端对应的原系统时间。
其次,服务端每隔一定的时间间隔(这个时间间隔一定要小于心跳时间间隔),会去数据库读取指定客户端的对应的系统时间,然后和当前系统时间作对比,如果(当前系统时间-数据库存储的客户端对应的系统时间>客户端的心跳间隔),则表明客户端下线了,连接断开了,服务端要移除掉与该客户端的连接,否则表明客户端在线,连接正常。 -
当然了,这样仅仅是最简单粗暴的心跳保活处理,频繁的心跳保活会消耗用户流量和电量, 更细致的心跳保活处理无非就是根据App的实际状态尽量延长心跳间隔,比如当服务端和客户端在进行数据交互的时候,我们可以取消心跳保活,只有在没有数据传输时再启用心跳保活等等。
-
2、断线重连
- 为什么要做断线重连?
通常情况下,除非是我们客户端主动断开连接(如退出账号,App退出到后台等),不需要断线重连;其它情况下连接如果断开了(如服务端出问题、网断了等),就必须启动断线重连,来尽量使连接处于一个正常连接的状态,这样才能保证业务的正常运行。
- 那断线重连怎么做呢?
断线重连的做法,大的方向都是在断线后尝试重连几次,如果还是无法重连成功,就不再进行重连,认为是服务端出了问题。
具体做法的话,我们可以让重连时间以2的指数级增长,比如说检测到socket连接断开后,就立刻进行第一次重连,如果第一次重连没连成功,则2秒后进行第二次重连,以次类推,再隔4秒后进行第三次重连,再隔8秒后进行第四次重连......一共进行6次重连,总耗时62秒钟,直到大于62秒后就不再重连,认为服务端出了问题。而任意的一次重连成功,都会重置这个重连时间。
3、服务端socket的搭建
- 把GCDAsyncSocket文件拖进项目中:
![](https://img.haomeiwen.com/i1116725/9d87966a81f93ff6.png)
- 代码
//
// ViewController.h
// SocketSeverDemo
//
// Created by 意一yiyi on 2018/3/26.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
@end
//
// ViewController.m
// SocketSeverDemo
//
// Created by 意一yiyi on 2018/3/26.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import "ViewController.h"
#import "GCDAsyncSocket.h"
#define kServerIPAddress @"192.168.0.133"
#define kServerPort 8080
#define kHeartBeatCheckTime 3// 心跳检测间隔
#define kHeartBeatTime 5// 心跳间隔
@interface ViewController ()<GCDAsyncSocketDelegate>
@property (strong, nonatomic) GCDAsyncSocket *serverSocket;// 服务端socket
@property (strong, nonatomic) NSMutableArray *clientSockets;// 用来保存所有的客户端socket
@property (strong, nonatomic) NSTimer *checkTimer;// 心跳检测计时器
@property (strong, nonatomic) NSMutableDictionary *heartBeatDict;// 存储客户端信条信息的字典
@property (weak, nonatomic) IBOutlet UITextField *tf;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.clientSockets = [NSMutableArray array];
self.heartBeatDict = [NSMutableDictionary dictionary];
}
#pragma mark - public methods
// 监听
- (IBAction)listen:(id)sender {
[self initServerSocket];
}
// 断开连接
- (IBAction)disconnect:(id)sender {
[self.serverSocket disconnect];
self.serverSocket.delegate = nil;
self.serverSocket = nil;
[self.checkTimer invalidate];
self.checkTimer = nil;
}
// 发送数据
- (IBAction)send:(id)sender {
if (self.clientSockets == nil) {
return;
}
NSData *data = [self.tf.text dataUsingEncoding:NSUTF8StringEncoding];
[self.clientSockets enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[obj writeData:data withTimeout:-1 tag:0];// tag:消息标记
}];
}
#pragma mark - GCDAsyncSocketDelegate
// 服务端socket连接客户端socket成功的回调
- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(nonnull GCDAsyncSocket *)newSocket {
NSLog(@"===========>连接客户端成功\n");
// 连接成功后,立马读取来自客户端的数据
[newSocket readDataWithTimeout:-1 tag:0];
// 保存客户端socket
[self.clientSockets addObject:newSocket];
// 连接成功后添加定时器
[self addTimer];
}
// 服务端socket读取客户端socket数据成功后的回调
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
// 注意:我们在读取到数据后,需要在这里再次调用[sock readDataWithTimeout:-1 tag:0];方法来读取数据,框架本身就是这么设计的,否则我们就只能接收一次数据,之后再也接收不到数据
[sock readDataWithTimeout:-1 tag:0];
if ([text hasPrefix:@"HeartBeat"]) {// 心跳数据包
NSLog(@"%@", [NSString stringWithFormat:@"===========>心跳数据包:%@\n", text]);
if (self.heartBeatDict.count == 0) {// 第一次读取到心跳数据包直接添加
[self.heartBeatDict setObject:[self getCurrentTime] forKey:text];
}else {// 后面读取到心跳数据包,覆盖之前指定客户端的
[self.heartBeatDict enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
[self.heartBeatDict setObject:[self getCurrentTime] forKey:text];
}];
}
}else {
// 业务数据
NSLog(@"%@", [NSString stringWithFormat:@"===========>读取客户端数据成功:%@\n", text]);
}
}
// 服务端socket与客户端socket断开连接的回调
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err {
NSLog(@"%@", [NSString stringWithFormat:@"===========>与客户端断开连接:%@\n", err]);
}
#pragma mark - 初始化服务端socket
// 初始化服务端socket
- (void)initServerSocket {
[self createServerSocket];
[self listenClientSocket];
}
// 创建服务端socket
- (void)createServerSocket {
self.serverSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
}
// 服务端socket开始监听来自指定端口客户端socket的连接请求
- (void)listenClientSocket {
NSError *error = nil;
BOOL result = [self.serverSocket acceptOnPort:kServerPort error:&error];
if (result && error == nil) {
NSLog(@"===========>服务端开始监听");
}else {
NSLog(@"%@", [NSString stringWithFormat:@"==========>服务端监听失败:%@", error]);
}
}
#pragma mark - 心跳检测
- (void)addTimer {
if (self.checkTimer) {
return;
}
self.checkTimer = [NSTimer scheduledTimerWithTimeInterval:kHeartBeatCheckTime target:self selector:@selector(checkLongConnect) userInfo:nil repeats:YES];// 心跳检测需要小于心跳间隔,因为心跳间隔设置的为3分钟,那么此处设置为1分钟
[[NSRunLoop currentRunLoop] addTimer:self.checkTimer forMode:NSRunLoopCommonModes];
}
// 心跳检测
- (void)checkLongConnect {
[self.heartBeatDict enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
// 获取当前时间
NSString *currentTimeStr = [self getCurrentTime];
// 如果某个客户端socket发送心跳连接的时间和当前时间相差3分钟以上,说明客户端3分钟都没给服务端发心跳数据包了,说明客户端出问题了,服务端应主动断开连接
if (([currentTimeStr doubleValue] - [obj doubleValue]) > kHeartBeatTime) {
NSLog(@"===========>客户端\"%@\"出问题了,服务端移除了与它的连接", key);
[self.heartBeatDict removeObjectForKey:key];
}else {
NSLog(@"===========>客户端\"%@\"与服务端正常连接中...", key);
}
}];
}
// 获取当前时间
- (NSString *)getCurrentTime {
NSDate *date = [NSDate dateWithTimeIntervalSinceNow:0];
NSTimeInterval currentTime = [date timeIntervalSince1970];
NSString *currentTimeStr = [NSString stringWithFormat:@"%.0f", currentTime];
return currentTimeStr;
}
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
// Dispose of any resources that can be recreated.
}
@end
4、客户端socket的搭建
- 把GCDAsyncSocket文件拖进项目中:
![](https://img.haomeiwen.com/i1116725/9d87966a81f93ff6.png)
- 代码
创建了一个ProjectSocket
的单例
//
// ProjectSocket.h
// SocketClientDemo
//
// Created by 意一yiyi on 2018/5/8.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import <Foundation/Foundation.h>
typedef NS_ENUM(NSUInteger, DisconnectType) {
DisconnectTypeByClient = 0,// 客户端断开连接
DisconnectTypeByServer = 1,// 服务端断开连接
};
@interface ProjectSocket : NSObject
+ (instancetype)sharedSocket;
- (void)connect;
- (void)disconnect;
- (void)sendData:(NSString *)data;
@end
//
// ProjectSocket.m
// SocketClientDemo
//
// Created by 意一yiyi on 2018/5/8.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import "ProjectSocket.h"
#import "GCDAsyncSocket.h"// 基于TCP协议
#define kServerIPAddress @"192.168.0.133"
#define kServerPort 8080
#define kHeartBeatTime 5// 心跳间隔
@interface ProjectSocket ()<GCDAsyncSocketDelegate>
@property (strong, nonatomic) GCDAsyncSocket *clientSocket;// 客户端socket
@property (assign, nonatomic) BOOL isConnected;// 客户端socket已连接服务端socket
@property (strong, nonatomic) NSTimer *heartBeatTimer;// 心跳保活定时器
@property (assign, nonatomic) NSTimeInterval reconnectTime;// 重连时间
@end
@implementation ProjectSocket
static ProjectSocket *pSocket = nil;
+ (instancetype)sharedSocket {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
pSocket = [[ProjectSocket alloc] init];
pSocket.isConnected = NO;
pSocket.reconnectTime = 0.0;
});
return pSocket;
}
#pragma mark - public methods
// 连接到服务端socket
- (void)connect {
[self initClientSocket];
}
// 断开与服务端socket的连接
- (void)disconnect {
[self.clientSocket disconnect];
}
// 向服务端socket写数据
- (void)sendData:(NSString *)data {
NSData *sendData = [data dataUsingEncoding:NSUTF8StringEncoding];
[self.clientSocket writeData:sendData withTimeout:-1 tag:0];// tag:消息标记
}
#pragma mark - private methods
// 读取服务端socket的数据
- (void)receiveData {
[self.clientSocket readDataWithTimeout:-1 tag:0];
}
#pragma mark - GCDAsyncSocketDelegate
// 客户端socket连接服务端socket成功的回调
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
NSLog(@"===========>连接服务端成功\n");
self.isConnected = YES;
// 由于CocoaAsyncSocket支持排队读写,所以我们在连接成功后,立马读取来自服务端的数据,所有读/写操作将排队,并且在socket连接时,操作将按顺序出列和处理
[self receiveData];
// 心跳写在这:连接成功后添加心跳,保证socket一直处于活跃状态
[self addHeartBeat];
// 重新开始一次正常连接的时候,要清零重连时间
self.reconnectTime = 0;
}
// 客户端socket读取服务端socket数据成功后的回调
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSLog(@"%@", [NSString stringWithFormat:@"===========>读取服务端数据成功:%@\n", text]);
// 注意:我们在读取到数据后,需要在这里再次调用[self.clientSocket readDataWithTimeout:-1 tag:0];方法来读取数据,框架本身就是这么设计的,否则我们就只能接收一次数据,之后再也接收不到数据
[self receiveData];
}
// 客户端socket与服务端socket断开连接的回调
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err {
NSLog(@"%@", [NSString stringWithFormat:@"===========>与服务端断开连接:%@\n", err]);
// sokect断开连接时,需要清空代理和客户端本身的socket
self.clientSocket.delegate = nil;
self.clientSocket = nil;
self.isConnected = NO;
// 断开连接时,移除心跳保活
[self removeHeartBeat];
// 如果是被用户自己中断的那么直接断开连接,否则启用断线重连
if (err == nil) {
[self disconnect];
}else {
[self reconnect];
}
}
#pragma mark - 初始化客户端socket
// 初始化客户端socket
- (void)initClientSocket {
if (!self.isConnected) {// 客户端socket未连接服务端socket的情况下,才创建客户端socket,并发起向服务端socket的连接请求
[self createClientSocket];
[self connectToServerSocket];
}
}
// 创建客户端socket
- (void)createClientSocket {
// 创建客户端socket,并指定代理对象为self,代理队列必须为主队列
self.clientSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
}
// 连接到服务端socket
- (void)connectToServerSocket {
NSError *error = nil;
// 向指定IP地址和端口号的服务端socket发起连接请求,注意是IP地址而不是DNS名称,可以设定连接的超时时间,如果不想设置超时时间可设置为负数
// 如果检测到错误,此方法将返回NO,并设置错误指针(可能的错误是无主机,无效接口或套接字已连接)
// 如果未检测到错误,则此方法将启动后台连接操作并立即返回YES。但这里未检测到错误不一定是连接成功了,也有可能是主机无法访问,主机无法访问的时候也会返回YES的,所以连接成功与否是要看下面的回调的
self.isConnected = [self.clientSocket connectToHost:kServerIPAddress onPort:kServerPort viaInterface:nil withTimeout:-1 error:&error];
if(!self.isConnected) {
self.isConnected = NO;
NSLog(@"%@", [NSString stringWithFormat:@"===========>连接服务端失败:%@\n", error]);
}
}
#pragma mark - 心跳保活
// 添加心跳
- (void)addHeartBeat {
[self removeHeartBeat];
// 心跳时间设置为3分钟,NAT超时一般为3~5分钟
self.heartBeatTimer = [NSTimer scheduledTimerWithTimeInterval:kHeartBeatTime repeats:YES block:^(NSTimer * _Nonnull timer) {
// 和服务端约定好发送什么作为心跳标识,尽可能的减小心跳包的大小
[self sendData:@"HeartBeat===我是海燕"];
}];
[[NSRunLoop currentRunLoop]addTimer:self.heartBeatTimer forMode:NSRunLoopCommonModes];
}
// 取消心跳
- (void)removeHeartBeat {
[self.heartBeatTimer invalidate];
self.heartBeatTimer = nil;
}
#pragma mark - 断线重连
// 断线重连
- (void)reconnect {
[self disconnect];
// 重连时间以2的指数级增长,比如说检测到socket连接断开后,就立刻进行第一次重连,如果第一次重连没连成功,则2秒后进行第二次重连,以次类推,再隔4秒后进行第三次重连,再隔8秒后进行第四次重连......一共进行6次重连,总耗时62秒钟,直到大于62秒后就不再重连,认为服务端出了问题。而任意的一次重连成功,都会重置这个重连时间。
if (self.reconnectTime > 62) {// 已经经过6次重连了
NSLog(@"==================>服务端出问题了,重连不上啊");
return;
}
NSLog(@"==================>reconnectTime:%f", self.reconnectTime);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.reconnectTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self initClientSocket];
});
// 重连时间以2指数级增长
if (self.reconnectTime == 0) {
self.reconnectTime = 2;
}else {
self.reconnectTime *= 2;
}
}
@end
ViewController
//
// ViewController.h
// SocketClientDemo
//
// Created by 意一yiyi on 2018/3/26.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
@end
//
// ViewController.m
// SocketClientDemo
//
// Created by 意一yiyi on 2018/3/26.
// Copyright © 2018年 意一yiyi. All rights reserved.
//
#import "ViewController.h"
#import "ProjectSocket.h"
@interface ViewController ()
@property (weak, nonatomic) IBOutlet UITextField *tf;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
- (IBAction)connect:(id)sender {
[[ProjectSocket sharedSocket] connect];
}
- (IBAction)disconnect:(id)sender {
[[ProjectSocket sharedSocket] disconnect];
}
- (IBAction)send:(id)sender {
[[ProjectSocket sharedSocket] sendData:self.tf.text];
}
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
// Dispose of any resources that can be recreated.
}
@end
5、效果
![](https://img.haomeiwen.com/i1116725/5d8b75ef65be7e35.gif)
可以看到:
- 连接成功后,服务端和客户端可以正常相互发送数据。
- 连接成功后,客户端每隔5秒会向服务端发送一个心跳数据包,服务端每隔3秒会检测心跳。
- 当客户端主动断开连接时,不触发断线重连,而当服务端断开连接时,触发了客户端的断线重连。
6、数据粘包的处理
上面的代码已经解决了心跳保活和断线重连的问题,现在我们还需要处理一下数据粘包的问题。
- 什么是数据粘包?
在上面的代码中,我们总是客户端或服务端点击一次发送按钮只发送一条数据给另一端,这样接收到的数据自然没问题。但是现在问题来了,如果我们需要某一端同一时间发送多条数据给另一端,会怎样?我们演示一下,现在假设给客户端点一次发送按钮要给服务端发送5条数据,如下:
// 向服务端socket写数据
- (void)sendData:(NSString *)data {
NSData *sendData = [@"江南忆" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData1 = [@"最忆是杭州" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData2 = [@"山寺月中寻桂子" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData3 = [@"郡亭枕头看潮头" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData4 = [@"何日更重游" dataUsingEncoding:NSUTF8StringEncoding];
[self.clientSocket writeData:sendData withTimeout:-1 tag:0];// tag:消息标记
[self.clientSocket writeData:sendData1 withTimeout:-1 tag:0];// tag:消息标记
[self.clientSocket writeData:sendData2 withTimeout:-1 tag:0];// tag:消息标记
[self.clientSocket writeData:sendData3 withTimeout:-1 tag:0];// tag:消息标记
[self.clientSocket writeData:sendData4 withTimeout:-1 tag:0];// tag:消息标记
}
当服务端和客户端连接成功,我们点击客户端的发送按钮时,服务端收到数据,打印如下:
===========>读取客户端数据成功:江南忆最忆是杭州山寺月中寻桂子郡亭枕头看潮头何日更重游
===========>读取客户端数据成功:江南忆最忆是杭州山寺月中寻桂子郡亭枕头看潮头何日更重游
===========>读取客户端数据成功:江南忆
===========>读取客户端数据成功:最忆是杭州山寺月中寻桂子郡亭枕头看潮头何日更重游
===========>读取客户端数据成功:江南忆
===========>读取客户端数据成功:最忆是杭州山寺月中寻桂子郡亭枕头看潮头何日更重游
===========>读取客户端数据成功:江南忆最忆是杭州
===========>读取客户端数据成功:山寺月中寻桂子郡亭枕头看潮头何日更重游
我们就看到了,本来服务端每触发一次读取到数据的回调应该只读取到一条数据才对,但是现在接收到的数据,好像是好几条数据合并起来一块儿传过来的,而且还是随机合并的,这就是数据粘包。
那为什么会出现数据粘包呢?
前面我们不是说过TCP协议主要就是一个负责对数据进行封装和打包的传输层协议嘛,数据粘包出现的原因就是TCP内部采用了Nagle优化算法来打包数据,它会将多次间隔较小且数据量较小的数据自动合并成一个比较大的数据库来一块传输。TCP这样做有点很明显,这样可以减少广域网小分组的数目,从而减少网络的拥塞。
而UDP则不会这个算法问题出现数据粘包,因为它没有采用这种优化算法。(至于UDP会不会因为别的因素出现数据粘包,可去查。)
- 为什么要处理数据粘包?
数据粘包都使得接收到的数据错误了,不能不处理啊。
- 那怎么处理数据粘包?
要处理数据粘包也很简单,核心的思路就是:发送方发送数据时给每条数据添加一个包头,接收方接收到数据时根据包头信息来读取到真正的数据。总之,数据粘包需要数据发送方和接收方一块儿处理才能完成的,不是一端能解决的。
具体来说就是,数据发送方要给数据添加上一个包头就可以了,而数据接收方在接收到数据之后,就可以根据包头来识别真正的一段数据了,当然这个包头需要两方协商格式。
下面我们还是通过例子来看看怎么做,同样的还是假设客户端要发数据,服务端要接收数据。
则发送方即客户端需要新增一个“发送数据前给每条数据添加一个包头”的方法:
// 向服务端socket写数据
- (void)sendData:(NSString *)data {
NSData *sendData = [@"江南忆" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData1 = [@"最忆是杭州" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData2 = [@"山寺月中寻桂子" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData3 = [@"郡亭枕头看潮头" dataUsingEncoding:NSUTF8StringEncoding];
NSData *sendData4 = [@"何日更重游" dataUsingEncoding:NSUTF8StringEncoding];
[self sendData:sendData msgType:@"txt"];
[self sendData:sendData1 msgType:@"txt"];
[self sendData:sendData2 msgType:@"txt"];
[self sendData:sendData3 msgType:@"txt"];
[self sendData:sendData4 msgType:@"txt"];
}
// 发送数据前给每条数据添加一个包头
- (void)sendData:(NSData *)data msgType:(NSString *)type {
NSInteger dataSize = data.length;
// 包头
NSMutableDictionary *headDict = [@{} mutableCopy];
[headDict setObject:type forKey:@"type"];// 消息的类型,比如说文本消息、图片消息、语音消息、视频消息等
[headDict setObject:[NSString stringWithFormat:@"%ld", dataSize] forKey:@"size"];// 消息体的大小
// 包头转化成jsonStr
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:headDict options:NSJSONWritingPrettyPrinted error:nil];
NSString *jsonStr = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
// 包头转化为data
NSData *tempData = [jsonStr dataUsingEncoding:NSUTF8StringEncoding];
NSMutableData *sendData = [NSMutableData dataWithData:tempData];
// 分界,拼接“\r\n”两个字符在包头后面,用来标志包头的结束
[sendData appendData:[GCDAsyncSocket CRLFData]];
// 包头后面拼接上原数据
[sendData appendData:data];
[self.clientSocket writeData:sendData withTimeout:-1 tag:0];// tag:消息标记
}
我们看一下新增的这个方法做了什么:
- 我们创建了一个包头
headDict
,里面存储了我们将要发送数据的类型和长度(当然我么也可以在包头中添加更多的数据信息),类型就是为了我们区分不同的消息做不同的处理,而长度则是为了帮助我们取获取包头后面指定长度的数据。 - 然后我们把
headDict
转为为jsonStr
,有转化为data
,并且在包头后面拼接了[GCDAsyncSocket CRLFData]
(其实就“\r\n”两个字符),用来标志包头的结束。 - 最后我们把真正的数据拼接在了包头后面。
这样我么发送方就算完成了,但正如上面所说数据粘包的处理需要发送方和接收方共同的处理,那现在我们就会看下服务端接收到数据之后需要做什么。不过在此之前,我们先看一下这三个方法:
// 读取数据,只要读取到数据,就会触发读取数据成功的回调
- (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;
// 读取数据,直到读到指定长度的数据后,才会触发读取数据成功的回调
- (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;
// 读取数据,直到读到data这个边界数据后,才会触发读取数据成功的回调
- (void)readDataToData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;
没做数据粘包处理前,我们一直使用的是第一个读取数据的方法,即只要读取到数据就触发回调,现在要处理数据粘包问题就得依赖于下面这两个方法了。具体的,服务端代码修改如下:
// 服务端socket连接客户端socket成功的回调
- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(nonnull GCDAsyncSocket *)newSocket {
NSLog(@"===========>连接客户端成功\n");
// 连接成功后,立马读取来自客户端的数据
// [newSocket readDataWithTimeout:-1 tag:0];// 修改为下面这个读取方法
[newSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:0];
// 保存客户端socket
[self.clientSockets addObject:newSocket];
// 连接成功后添加定时器
[self addTimer];
}
我们再解释一下上面的代码做了什么:
- 当服务端和客户端连接成功后,服务端会立马开始读取客户端的消息,但是并不是一读取数据就触发回调,而是会在读取完包头信息之后才触发回调。
- 读取完包头数据触发回调之后,我们在回调里又会根据包头信息里存储的真实数据的长度去读取真实的数据,并根据不同的消息类型做不同的处理。这样就算读取完了一条消息。
- 紧接着我们再开始读取下一条消息。
好了,我们运行处理过数据粘包的服务端和客户端代码,连接之后,客户端发送数据,服务端收到数据后打印如下:
===========>收到了文字消息:江南忆
===========>收到了文字消息:最忆是杭州
===========>收到了图片消息
===========>收到了文字消息:郡亭枕头看潮头
===========>收到了图片消息
可见已经处理了数据粘包的问题,当然如果我们还有更高的要求,也可以在数据的尾部加上包尾等等。
github:服务端socket,包含以上三个问题的处理
github:客户端socket,包含以上三个问题的处理
参考博客:
【iOS开发】之CocoaAsyncSocket使用
iOS即时通讯,从入门到“放弃”?
即时通讯下数据粘包、断包处理实例(基于CocoaAsyncSocket)
网友评论