套接字编程?那是什么?

这篇文章,我们来看看网络编程,一个合格的后端开发者,网络编程是他必经的一条道路,可能有的小伙伴接触过网络原理,知道OSI模型,TCP/IP模型等等,知道很多网络通信的流程,知道很多复杂的原理,虽然底层很复杂,但好在我们并不需要直接的去操作那些来进行网络通讯,已经有大佬为我们封装好了,我们只需要会使用封装好的接口就好了。实际上,网络编程就是围绕着socket也就是我们所说的套接字进行的。所以有的时候就会有人说,网络编程实际上就是套接字编程,从某些意义上也没啥问题。

文件描述符fd

还记得那句经典的话吗?“Linux下一切皆文件”,Linux 系统中把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行 I/O 操作的系统调用都会通过文件描述符。网络的连接也是一种I/O操作,所以我通过fd来进行一系列操作。

socket编程的流程

无论是服务端还是客户端,都是通过socket进行连接然后通信的,但两方需要进行的事情并不相同,具体流程如下:

socket通信的流程

我们可以看到server端和client端都需要先通过socket()创建socket,然后server端需要绑定socket和端口号,在通过listen()让绑定好的socket进入监听状态,这个时候我们server端就已经准备好进行连接了,之后我们的client就可以通过connet()和server端进行连接了,成功连接以后,server端和client端就可以进行消息传输了,待传输就结束,双方断开连接,关闭socket。fd在这里的体现并不明显,甚至都没有提到fd,其实fd就是一个socket的索引,这个等一下我们介绍API写代码的时候就可以知道了。接下来我们着重从server方面来了解这些函数。

socket()

1
2
3
#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain, int type, int protocol);

这就是socket函数的原型,它返回一个int数据,这个返回的数据就是我们所说的fd,它用来标志是哪一个socket。socket函数需要三个参数,分别是domain,type,protocol。

domain代表的是用于设置网络通信的域,函数socket()根据这个参数选择协议族。它有以下这几个值:

名称 含义 名称 含义
PF_UNIX,PF_LOCAL 本地通信 PF_X25 ITU-T X25 / ISO-8208协议
AF_INET,PF_INET IPv4 Internet协议 PF_AX25 Amateur radio AX.25
AF_INET6,PF_INET6 IPv6 Internet协议 PF_ATMPVC 原始ATM PVC访问
PF_IPX IPX-Novell协议 PF_APPLETALK Appletalk
PF_NETLINK 内核用户界面设备 PF_PACKET 底层包访问

我们常使用的是AF_INET和AF_INET6。

type代表的是用于设置套接字通信的类型,它的值如下:

名称 含义
SOCK_STREAM Tcp连接,提供序列化的、可靠的、双向连接的字节流。支持带外数据传输
SOCK_DGRAM 支持UDP连接(无连接状态的消息)
SOCK_SEQPACKET 序列化包,提供一个序列化的、可靠的、双向的基本连接的数据传输通道,数据长度定常。每次调用读系统调用时数据需要将全部数据读出
SOCK_RAW RAW类型,提供原始网络协议访问
SOCK_RDM 提供可靠的数据报文,不过可能数据会有乱序
SOCK_PACKET 这是一个专用类型,不能呢过在通用程序中使用

我们常用的是SOCK_STREAM和SOCK_DGRAM。

protocol用于制定某个协议的特定类型,即type类型中的某个类型。通常某协议中只有一种特定类型,这样protocol参数仅能设置为0;但是有些协议有多种特定的类型,就需要设置这个参数来选择特定的类型。

当我们写好这些函数,那么内核就会给我们一个fd,标志这个我们创建好的socket。

bind()

1
2
3
#include<sys/types.h>
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr *my_addr, socklen_t addrlen);

好了,我们现在已经创建好了socket了,下一步我们要调用bind函数,将我们创建好的socket和端口进行绑定,那为什么一定要绑定端口呢?想象这样一个问题,现在我们在电脑上同时运行QQ和WeChat,那么我们通过WeChat发出的消息为什么不会被QQ接收到呢?因为两个进程创建的连接所绑定的端口号不同,所以才没有出现我们所说的问题,也就是说绑定端口号是为了将来别的程序和服务器进程连接后,不会出现数据错发的问题。所以我们服务端创建好socket后一定要绑定端口号。

我们来看bind函数,bind函数会返回一个int数据,这个数据如果是0就代表绑定成功,如果是-1就代表绑定失败,所以我们可以根据返回值来判断是否绑定成功了。bind函数有三个参数,分别是sockfd,sockaddr结构体,socklen_t,也就是结构体的大小。

sockfd代表一个socket的文件描述符,我们之前创建好的socket的文件描述符就可以在此传入。

接下来是一个sockaddr结构体指针,那我们先看看sockaddr结构体的内部:

1
2
3
4
5
6
7
8
#include<socket.h>
struct sockaddr
{
__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};
#define __SOCKADDR_COMMON(sa_prefix) \ //这里是个宏定义,传进来一个sa_然后和family拼接,就变成了sa_family
sa_family_t sa_prefix##family

我们可以看到sockaddr结构体内部有两个参数,一个sa_family,一个是sa_data[14]。sa_family代表的是地址族,sa_data[14]包含套接字中的目标地址和端口信息,但它们两个混在了一起并不是我们喜欢的,所以就有了一个新的结构体sockaddr_in:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <netinet/in.h>
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */

/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr)
- __SOCKADDR_COMMON_SIZE
- sizeof (in_port_t)
- sizeof (struct in_addr)];
}sockaddr_in;
#define __SOCKADDR_COMMON(sa_prefix) \ //这里是个宏定义,传进来一个sin_然后和family拼接,就变成了sin_family
sa_family_t sa_prefix##family

struct in_addr
{
in_addr_t s_addr;
};

我们可以看到sockaddr_in就将目标地址和端口信息分开了。而且看起来好像还多了一个属性sin_zero,这个属性是为了让sockaddr与sockaddr_in两个数据结构保持大小相同而保留的空字节,所以我们使用的时候不需要考虑它。

知道了这些,我们看看在编程的时候应该如何做,我们先声明一个sockaddr_in变量,然后初始化,将sin_family初始化成我们创建socket的时候使用的协议族(也就是domain属性)。之后我们需要绑定的是目的地址和端口号,这里要注意的是我们所输入的数据需要被转换为网络字节序才可以,所以我们需要使用htons(port)和htonl(address)函数才可以比如:

1
2
3
4
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);

这里INADDR_ANY代表的是任意地址,比如一台电脑有3块网卡,分别连接三个网络,那么这台电脑就有3个ip地址了,如果某个应用程序需要监听某个端口,那他要监听哪个网卡地址的端口呢?如果绑定某个具体的ip地址,你只能监听你所设置的ip地址所在的网卡的端口,其它两块网卡无法监听端口,如果我需要三个网卡都监听,那就需要绑定3个ip,也就等于需要管理3个套接字进行数据交换,这样岂不是很繁琐?所以我们使用INADDR_ANY不管是那个地址的端口给我返回的信息我们都处理,这样就将三个地址当做了一个地址对待。所以我们使用INADDR_ANY,当然我们需要使用htonl将它转换为网络字节序然后在放到.sin_addr.s_addr中。

之后就是绑定端口号了,计算机端口号从0到65535,其中众所周知端口: 1 - 1023 (1-25之间为众所周知的端口 , 256 - 1023 为UNIX系统占用),这里说的众所周知端口是一些早已经被固定的端口号,比如80端口分配给WWW服务,21端口分配给FTP服务。注册端口: 1024 -49151 分配给进程或者应用。这些端口号在还没有被服务器资源占用时,可以由用户的APP动态注册获得。动态端口号:49152 - 65535 被称为动态端口号一般不固定分配某种服务而是动态分配的。一般可以使用 65000以上的就可以随便用。我们这里使用9999就可以,当然我们需要使用htons将它也转换为网络字节序

之后我们进行一次强转就可以了:(struct sockaddr*)&servaddr。

最后一个参数是我们写好的地址大小了,我们使用sizeof就可以了。

所以完整的使用应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
int listenfd = socket(AF_INET, SOCK_STREAM, 0); 
if (listenfd == -1) return -1; //如果创建失败就会返回

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);

if (-1 == bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) { //如果绑定失败就会返回
return -2;
}

listen()

好啦现在已经到了倒数第二步了,我们需要调用listen函数让绑定好的socket进入被动监听模式,让服务器一直监听端口是否有人连接。

1
2
#include<sys/socket.h>
extern int listen (int __fd, int __n) __THROW;

我们可以看到listen函数需要两个参数一个是fd,一个是n。其中fd就是socket的fd,我们需要那个socket进入监听状态我们就将那个socket的fd放到里面,n代表的是等待队列10假如我们建立一个连接需要1秒,但同时有12个连接,我们设置的等待队列长度为10,那么有一个会进行连接,剩下10个会等待连接,1个会拒绝连接。

accept()

我们来看最后一步,accpet接受连接:

1
2
extern int accept (int __fd, __SOCKADDR_ARG __addr,socklen_t *__restrict __addr_len);
# define __SOCKADDR_ARG struct sockaddr *__restrict

accept函数会返回一个int数据,这个数据就是我们之前监听到的连接过来的socket的fd,就好比说,我们原先在本机上打开了一个文件,让这个文件监听端口,然后等到客户端连接服务端后,监听文件检测到端口可读,就将端口的信息读取了出来,在本机上打开,所以我们本机通过accpet接受这个文件,并为它分配一个fd去管理它,如果连接失败了就返回-1。

accept函数需要三个参数,分别是fd,sockaddr,socklen_t。

fd,这个fd我们需要的是监听文件的fd,sockaddr和socklent_t代表的是监听到的文件的地址结构体和地址的长度。我们可以理解,我们从监听fd中获取到了客户端连接过来的fd的地址信息,长度,并把信息装进sockaddr中,长度装在socklen_t里面。

我们一般会这么写:

1
2
3
struct sockaddr_in client;
socklen_t len =sizeof(client);
int clientfd = accept(listenfd, (struct sockaddr *)&servaddr, &len);

默认情况下,当程序执行到accept后会阻塞在这里不动,当有外来连接之后才会继续进行。当然了其实这是因为监听文件是阻塞的,我们也可以修改监听文件为非阻塞的,那么它就不会等待,当运行到accept后发现如果没有连接那么就跳过继续执行下面的。

将监听文件修改为非阻塞的代码为:

1
2
3
int flag = fcntl(listenfd, F_GETFL, 0);
flag |=O_NONBLOCK;
fcntl(listenfd, F_SETFL,flag);

设置为非阻塞后,如果没有连接accpet就会返回-1,然后继续下去。

数据的接收和发送—recv和send

数据的接收和发送很简单,用到了两个函数一个是recv函数,一个是send函数

1
2
3
#include <sys/socket.h>
extern ssize_t recv (int __fd, void *__buf, size_t __n, int __flags);
extern ssize_t send (int __fd, const void *__buf, size_t __n, int __flags);

我们可以看到两个函数非常相似,他们都需要四个参数,分别是fd,字符数组,字符串长度,flag。

在recv中,fd表示我要从那个客户端接收消息,第二个字符数组用来装我们接收到的字符,第三个代表我们要接受的字符串长度,第四个参数我们一般置0就好。recv会返回一个int数据,表示我们实际接收到的字符串长度是多少,这里要提的是。

在send中,fd表示我要向那个客户端发送消息,第二个字符数组用来装我们要发送的字符,第三个代表我们要发送的字符串长度,第四个参数我们同样置0就好。

简单的服务器和客户端通信

接下来我们使用一个简单的例子来看看代码是如何书写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include<stdio.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<sys/types.h>
#include<fcntl.h>
#include<unistd.h>

#define msg_length 128

int main(){

int listenfd = socket(AF_INET,SOCK_STREAM,0);
if(listenfd == -1)return -1;

struct sockaddr_in servaddr;
servaddr.sin_family=AF_INET;
servaddr.sin_addr.s_addr=htonl(INADDR_ANY);
servaddr.sin_port=htons(9999);
if( -1 == bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr))){
return -2;
}

#if 0 //修改为1开启非阻塞
int flag = fcntl(listenfd, F_GETFL, 0);
flag |=O_NONBLOCK;
fcntl(listenfd, F_SETFL,flag);
#endif

listen(listenfd,10);
struct sockaddr_in client;
socklen_t len =sizeof(client);
int clientfd = accept(listenfd, (struct sockaddr *)&servaddr, &len);

unsigned char msg[msg_length]={0};
int ret = recv(clientfd ,msg , msg_length, 0);
printf("msg: %s\n",msg);

char returnmsg[msg_length] = {"this is server,success recive msg"};
send(clientfd, returnmsg, msg_length , 0);
printf("clientfd: %d\n",clientfd);

}

我们可以看一下结果:

服务端和客户端沟通

看消息顺利的沟通了,但好像有一个问题,我们的服务器怎么只能用一次啊!我们加一个while(1)试试,发现当客户端断开连接后,服务端会重复的打印出我们消息,这不是我们想要的,那我们怎么解决呢?卖个关子,我们下回分解。