简介
刚过完热热闹闹的年,找个时间整理了Socket有关的知识,刚好我之前做的项目需要用到CocoaAsyncSocket。下面按照目录聊一聊,文章会有点长。
目录
1、结合7大网络协议讲解HTTP与TCP与Socket的区别、概念
2、TCP三次握手、传输数据、四次握手断开连接的原理图解
3、Socket的缓冲区以及阻塞模式
4、Socket编程中我们经常使用到的函数
5、CocoaAsyncSocket
HTTP与TCP与Socket
我们先看一张图:
OSI七层网络协议这是OSI七层网络协议的层级关系,从应用层到物理层,下面我们主要讲我们比较常用的HTTP与TCP与IP协议。
IP协议:TCP/IP 中的 IP 是网络协议 (Internet Protocol) 的缩写。IP 网络负责源主机与目标主机之间的数据包传输。
TCP协议:(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的通信协议,数据在传输前要建立连接,传输完毕后还要断开连接。TCP 层位于 IP 层之上,是最受欢迎的因特网通讯协议之一,人们通常用 TCP/IP 来泛指整个因特网协议族。
HTTP协议:HyperText Transfer Protocol(超文本转移协议,即HTTP)是用于从 WWW 服务器传输超文本到本地浏览器的传送协议。HTTP 是典型的 TCP 应用。
Socket:socket 被翻译为“套接字”,是支持TCP/IP协议的网络通信的基本操作单元,socket是在应用层和传输层之间的一个抽象层,它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用已实现进程在网络中通信。它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。
下面用一个图简单讲解: TCP、IP、Socket之间的关系所谓协议族(Protocol Family),就是一组协议(多个协议)的统称。最常用的是 TCP/IP 协议族,它包含了 TCP、IP、UDP、Telnet、FTP、SMTP 等上百个互为关联的协议,由于 TCP、IP 是两种常用的底层协议,所以把它们统称为 TCP/IP 协议族。
TCP三次握手
TCP建立连接时要传输三个数据包,俗称三次握手(Three-way Handshaking)。可以形象的比喻为下面的对话:
套接字A:“你好,套接字B,我这里有数据要传送给你,建立连接吧。”
套接字B:“好的,我这边已准备就绪。”
套接字A:“谢谢你受理我的请求。”
在TCP的数据报结构中,有三个比较重要的字段:
1) 序号:Seq(Sequence Number)序号占32位,用来标识从计算机A发送到计算机B的数据包的序号,计算机发送数据时对此进行标记。
2)确认号:Ack(Acknowledge Number)确认号占32位,客户端和服务器端都可以发送。
3) 标志位:每个标志位占用1Bit,共有6个,分别为 URG、ACK、PSH、RST、SYN、FIN,具体含义如下:
URG:紧急指针(urgent pointer)有效。
ACK:确认序号有效。
PSH:接收方应该尽快将这个报文交给应用层。
RST:重置连接。
SYN:建立一个新连接。
FIN:断开一个连接。
对英文字母缩写的总结:Seq 是 Sequence 的缩写,表示序列;Ack(ACK) 是 Acknowledge 的缩写,表示确认;SYN 是 Synchronous 的缩写,愿意是“同步的”,这里表示建立同步连接;FIN 是 Finish 的缩写,表示完成。
三次握手图解:
三次握手图解 注意:无论是客服端或者是服务端的序号Seq都是随机生成的。三次握手的重点是:双方怎么确定对方已经收到自己的数据包,是通过ACK字段来确认的,公式为:ACK=Seq + 1。关于TCP建立连接、断开连接、传输数据是调用底层的C语言的函数,后面会讲解到~
传输数据
TCP建立连接之后,开始传输数据了,看图:
传输数据注意:1)在传输数据的过程中,ACK = Seq + 传输的字节数 + 1。加上字节数的目的是为了确认数据是否丢失。2)如果在传输的过程中,发生数据丢失或者传输超时,则TCP会重传数据,ACK 包丢失的情况,一样会重传,重传的次数根据业务来决定,如果业务系统要求很高,则会不断地重传。
四次握手断开连接
建立连接非常重要,它是数据正确传输的前提;断开连接同样重要,它让计算机释放不再使用的资源。如果连接不能正常断开,不仅会造成数据传输错误,还会导致套接字不能关闭,持续占用资源,如果并发量高,服务器压力堪忧。
建立连接需要三次握手,断开连接需要四次握手,可以形象的比喻为下面的对话:
套接字A:“任务处理完毕,我希望断开连接。”
套接字B:“哦,是吗?请稍等,我准备一下。”
等待片刻后……(这里的稍等片刻是为了给套接字B做准备)
套接字B:“我准备好了,可以断开连接了。”
套接字A:“好的,谢谢合作。”
Socket缓冲区以及阻塞模式
socket缓冲区以及阻塞模式是在传输数据的过程中发生的。
TCP套接字的I/O缓冲区示意图 这些I/O缓冲区特性可整理如下:当程序调用write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。
TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。
1、I/O缓冲区在每个TCP套接字中单独存在;
2、I/O缓冲区在创建套接字时自动生成;
3、即使关闭套接字也会继续传送输出缓冲区中遗留的数据;
4、关闭套接字将丢失输入缓冲区中的数据。
阻塞模式:
对于TCP套接字(默认情况下)
当使用 write()/send() 发送数据时:
1)首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么 write()/send() 会被阻塞(暂停执行),直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒 write()/send() 函数继续写入数据。
2)如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send() 也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。
3)如果要写入的数据大于缓冲区的最大长度,那么将分批写入。
4)直到所有数据被写入缓冲区 write()/send() 才能返回。
当使用 read()/recv() 读取数据时:
1)首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。
2)如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 read()/recv() 函数再次读取。
3)直到读取到数据后 read()/recv() 函数才会返回,否则就一直被阻塞。
也就是:写入数据,缓冲池容量不够就分批写入;如果读取的数据小于缓冲池的长度,就会不断积压堵塞;TCP协议正在向网络发送数据,缓冲池锁定;
Socket编程中我们经常使用到的函数
了解了TCP建立连接、传输数据、断开原理之后,那是什么触发TCP可以做这样的操作呢?
其实TCP底层也是封装了C语言函数。就是这些函数触发了TCP做响应的操作~
经常使用到的函数有:
// socket()函数用于根据指定的地址族、数据类型和协议来分配一个套接口的描述字及其所用的资源。
socket(af,type,protocol)
bind(sockid, local addr, addrlen) 在connect()或listen()调用前使用。当用socket()创建套接口后,它便存在于一个名字空间(地址族)中,但并未赋名。bind()函数通过给一个未命名套接口分配一个本地名字来为套接口建立本地捆绑(主机地址/端口号).
// 创建一个套接口并监听申请的连接.
listen( Sockid ,quenlen)
// 用于建立与指定socket的连接.
connect(sockid, destaddr, addrlen)
// 在一个套接口接受一个连接.
accept(Sockid,Clientaddr, paddrlen)
// 用于向一个已经连接的socket发送数据,如果无错误,返回值为所发送数据的总数,否则返回SOCKET_ERROR。
send(sockid, buff, bufflen)
//读取数据
read()函数
// 用于已连接的数据报或流式套接口进行数据的接收。
recv()
// 指向一指定目的地发送数据,sendto()适用于发送未建立连接的UDP数据包 (参数为SOCK_DGRAM)
sendto(sockid,buff,…,addrlen)
// 用于从(已连接)套接口上接收数据,并捕获数据发送源的地址。
recvfrom()
// 关闭Socket连接
close(socked)
这些函数先做一个了解,下面会结合CocoaAsyncSocket第三方做一个简单讲解~
CocoaAsyncSocket
CocoaAsyncSocket是谷歌的开发者,基于BSD-Socket写的一个IM框架,它给Mac和iOS提供了易于使用的、强大的异步套接字库,向上封装出简单易用OC接口。省去了我们面向Socket以及数据流Stream等繁琐复杂的编程。重要的是:它是线程安全的。
我们看看它的接口:
通过CocoaAsyncSocket尝试建立
- (BOOL)connectToHost:(NSString *)host
onPort:(uint16_t)port
withTimeout:(NSTimeInterval)timeout
error:(NSError **)errPtr
{
return [self connectToHost:host onPort:port viaInterface:nil withTimeout:timeout error:errPtr];
}
- (BOOL)connectToHost:(NSString *)inHost
onPort:(uint16_t)port
viaInterface:(NSString *)inInterface
withTimeout:(NSTimeInterval)timeout
error:(NSError **)errPtr
{
LogTrace();
// Just in case immutable objects were passed
//这里是要copy,防止发生变化
NSString *host = [inHost copy];
NSString *interface = [inInterface copy];
__block BOOL result = NO;
__block NSError *preConnectErr = nil;
dispatch_block_t block = ^{ @autoreleasepool {
// Check for problems with host parameter
//在尝试建立连接之前检查IP地址、端口号等参数,是一些容错判断
if ([host length] == 0)
{
NSString *msg = @"Invalid host parameter (nil or \"\"). Should be a domain name or IP address string.";
preConnectErr = [self badParamError:msg];
return_from_block;
}
// Run through standard pre-connect checks
//这里是前置条件判断,你必须有interface才可以去找个本地的IP、port。
if (![self preConnectWithInterface:interface error:&preConnectErr])
{
return_from_block;
}
// We've made it past all the checks.
// It's time to start the connection process.
flags |= kSocketStarted;
LogVerbose(@"Dispatching DNS lookup...");
// It's possible that the given host parameter is actually a NSMutableString.
// So we want to copy it now, within this block that will be executed synchronously.
// This way the asynchronous lookup block below doesn't have to worry about it changing.
NSString *hostCpy = [host copy];
int aStateIndex = stateIndex;
__weak GCDAsyncSocket *weakSelf = self;
dispatch_queue_t globalConcurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(globalConcurrentQueue, ^{ @autoreleasepool {
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"
NSError *lookupErr = nil;
//在这个方法内尝试建立socket连接,这个方法里面调用C语言的函数
NSMutableArray *addresses = [[self class] lookupHost:hostCpy port:port error:&lookupErr];
__strong GCDAsyncSocket *strongSelf = weakSelf;
if (strongSelf == nil) return_from_block;
if (lookupErr)
{
dispatch_async(strongSelf->socketQueue, ^{ @autoreleasepool {
[strongSelf lookup:aStateIndex didFail:lookupErr];
}});
}
else
{
NSData *address4 = nil;
NSData *address6 = nil;
//这里是将获取到的addresses遍历,判断是属于IPv4还是IPv6,判断后并赋值
for (NSData *address in addresses)
{
if (!address4 && [[self class] isIPv4Address:address])
{
address4 = address;
}
else if (!address6 && [[self class] isIPv6Address:address])
{
address6 = address;
}
}
dispatch_async(strongSelf->socketQueue, ^{ @autoreleasepool {
[strongSelf lookup:aStateIndex didSucceedWithAddress4:address4 address6:address6];
}});
}
#pragma clang diagnostic pop
}});
[self startConnectTimeout:timeout];
result = YES;
}};
if (dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey))
block();
else
dispatch_sync(socketQueue, block);
if (errPtr) *errPtr = preConnectErr;
return result;
}
我们来看看在上面方法中调用的一个方法:
+ (NSMutableArray *)lookupHost:(NSString *)host port:(uint16_t)port error:(NSError **)errPtr
{
LogTrace();
NSMutableArray *addresses = nil;
NSError *error = nil;
if ([host isEqualToString:@"localhost"] || [host isEqualToString:@"loopback"])
{
// Use LOOPBACK address
struct sockaddr_in nativeAddr4;//初始化一个IPV4地址结构体 ,这个结构包含主机、端口号等基本信息
nativeAddr4.sin_len = sizeof(struct sockaddr_in);//整个结构体大小
nativeAddr4.sin_family = AF_INET;//协议族,IPV4?IPV6
nativeAddr4.sin_port = htons(port);//端口
nativeAddr4.sin_addr.s_addr = htonl(INADDR_LOOPBACK);//IP地址
memset(&(nativeAddr4.sin_zero), 0, sizeof(nativeAddr4.sin_zero));
struct sockaddr_in6 nativeAddr6;//同理,初始化一个IPV6地址结构体
nativeAddr6.sin6_len = sizeof(struct sockaddr_in6);
nativeAddr6.sin6_family = AF_INET6;
nativeAddr6.sin6_port = htons(port);
nativeAddr6.sin6_flowinfo = 0;
nativeAddr6.sin6_addr = in6addr_loopback;
nativeAddr6.sin6_scope_id = 0;
// Wrap the native address structures
//将地址结构体转成二进制address
NSData *address4 = [NSData dataWithBytes:&nativeAddr4 length:sizeof(nativeAddr4)];
NSData *address6 = [NSData dataWithBytes:&nativeAddr6 length:sizeof(nativeAddr6)];
//并将IPV4、IPV6地址放进全局地址数组中,后面要用的地址直接从这里遍历提取出来
addresses = [NSMutableArray arrayWithCapacity:2];
[addresses addObject:address4];
[addresses addObject:address6];
}
else
{//说明传进来的IP地址没有转换成接口地址结构,需要转换一下
NSString *portStr = [NSString stringWithFormat:@"%hu", port];
struct addrinfo hints, *res, *res0;
memset(&hints, 0, sizeof(hints));
hints.ai_family = PF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
int gai_error = getaddrinfo([host UTF8String], [portStr UTF8String], &hints, &res0);
if (gai_error)
{
error = [self gaiError:gai_error];
}
else
{
NSUInteger capacity = 0;
for (res = res0; res; res = res->ai_next)
{
if (res->ai_family == AF_INET || res->ai_family == AF_INET6) {
capacity++;
}
}
addresses = [NSMutableArray arrayWithCapacity:capacity];
for (res = res0; res; res = res->ai_next)
{
if (res->ai_family == AF_INET)
{
// Found IPv4 address.
// Wrap the native address structure, and add to results.
NSData *address4 = [NSData dataWithBytes:res->ai_addr length:res->ai_addrlen];
[addresses addObject:address4];
}
else if (res->ai_family == AF_INET6)
{
// Fixes connection issues with IPv6
// https://github.com/robbiehanson/CocoaAsyncSocket/issues/429#issuecomment-222477158
// Found IPv6 address.
// Wrap the native address structure, and add to results.
struct sockaddr_in6 *sockaddr = (struct sockaddr_in6 *)res->ai_addr;
in_port_t *portPtr = &sockaddr->sin6_port;
if ((portPtr != NULL) && (*portPtr == 0)) {
*portPtr = htons(port);
}
NSData *address6 = [NSData dataWithBytes:res->ai_addr length:res->ai_addrlen];
[addresses addObject:address6];
}
}
freeaddrinfo(res0);
if ([addresses count] == 0)
{
error = [self gaiError:EAI_FAIL];
}
}
}
if (errPtr) *errPtr = error;
return addresses;
}
我们看到这是一个有返回值的方法,reture一个addresses,也就是说在这个方法中,已经生成好了后面我们要用的addresses。
其中在 if ([host isEqualToString:@"localhost"] || [host isEqualToString:@"loopback"])
{}中,初始化地址结构体,并将传进来的IP地址、断开等参数给结构体赋值。
localhost 是个域名,不是地址,它可以被配置为任意的 IP 地址,不过通常情况下都指向 127.0.0.1(ipv4)和[::1](ipv6)
loopback 是一个特殊的网络接口(可理解成虚拟网卡),用于本机中各个应用之间的网络交互
当IP地址不属于localhost类型也不属于loopback类型,我们需要获取一个其他的本地地址。在else{}中,说明传进来的IP地址没有转换成接口地址结构,需要转换一下,这时候我们利用getaddrinfo()解决了把主机名和服务名转换成套接口地址结构的问题。
然后到这个方法:
- (BOOL)connectWithAddress4:(NSData *)address4 address6:(NSData *)address6 error:(NSError **)errPtr
在这个方法中,根据传进来的address调用绑定Socket的方法
[self createSocket:AF_INET connectInterface:connectInterface4 errPtr:errPtr];
[self createSocket:AF_INET6 connectInterface:connectInterface6 errPtr:errPtr];
来到绑定的方法,使用bind()函数绑定Socket套接字,bind()函数在connect()或listen()调用前使用。当用socket()创建套接口后,它便存在于一个名字空间(地址族)中,但并未赋名。bind()函数通过给一个未命名套接口分配一个本地名字来为套接口建立本地捆绑(主机地址/端口号).
- (BOOL)bindSocket:(int)socketFD toInterface:(NSData *)connectInterface error:(NSError **)errPtr
{
// Bind the socket to the desired interface (if needed)
if (connectInterface)
{
LogVerbose(@"Binding socket...");
if ([[self class] portFromAddress:connectInterface] > 0)
{
// Since we're going to be binding to a specific port,
// we should turn on reuseaddr to allow us to override sockets in time_wait.
int reuseOn = 1;
setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &reuseOn, sizeof(reuseOn));
}
const struct sockaddr *interfaceAddr = (const struct sockaddr *)[connectInterface bytes];
//绑定socket
int result = bind(socketFD, interfaceAddr, (socklen_t)[connectInterface length]);
if (result != 0)//不成功,获取错误信息
{
if (errPtr)
*errPtr = [self errnoErrorWithReason:@"Error in bind() function"];
return NO;
}
}
return YES;
}
如果result=0,说明绑定成功了。如果没有成功,需要关闭socket,避免不必要的浪费资源。关闭socket调用close()函数;
绑定成功之后,我们开始建立连接,调用connect()函数。
- (void)connectSocket:(int)socketFD address:(NSData *)address stateIndex:(int)aStateIndex
{
// If there already is a socket connected, we close socketFD and return
if (self.isConnected)
{
[self closeSocket:socketFD];
return;
}
// Start the connection process in a background queue
__weak GCDAsyncSocket *weakSelf = self;
dispatch_queue_t globalConcurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(globalConcurrentQueue, ^{
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"
int result = connect(socketFD, (const struct sockaddr *)[address bytes], (socklen_t)[address length]);
__strong GCDAsyncSocket *strongSelf = weakSelf;
if (strongSelf == nil) return_from_block;
dispatch_async(strongSelf->socketQueue, ^{ @autoreleasepool {
if (strongSelf.isConnected)
{
[strongSelf closeSocket:socketFD];
return_from_block;
}
if (result == 0)//这里是已经连接上了
{
[self closeUnusedSocket:socketFD];
//在这个方法中,回调一个代理方法出去,让app程序调用。并且里面开始调用写入数据和读取数据的方法。
[strongSelf didConnect:aStateIndex];
}
else
{
[strongSelf closeSocket:socketFD];
// If there are no more sockets trying to connect, we inform the error to the delegate
if (strongSelf.socket4FD == SOCKET_NULL && strongSelf.socket6FD == SOCKET_NULL)
{
NSError *error = [strongSelf errnoErrorWithReason:@"Error in connect() function"];
[strongSelf didNotConnect:aStateIndex error:error];
}
}
}});
#pragma clang diagnostic pop
});
LogVerbose(@"Connecting...");
}
来到这里,我们就可以在app中调用连接成功的代理方法~程序已经开始去检测有没有需要读取的数据或者需要将数据写入缓冲区 了。
总结一下,建立连接的步骤:
1)检测传进来的IP地址、port端口号是否存在,并根据IP、port创建地址结构体
2)调用bind()函数将主机地址、端口号对socket进行捆绑,并使用listen()监听连接状态
3)调用connect()函数连接连接,回调给应用程序。
篇幅问题,先写到这里,下去会写CocoaAsyncSocket中的读取数据和写入数据以及CocoaAsyncSocket线程是安全的。上面的缓冲区和阻塞模式也是在读取数据和写入数据的时候需要运用的。
谢谢~
网友评论