我们这个TCP客户端将从命令行接收两个参数,一个是IP地址或域名,另一个是端口,并尝试连接在这个IP地址的TCP服务端。
TCP端的创建流程:
- 判断命令行参数个数,够不够
- 利用getaddrinfo()函数和命令行传递的参数来配置远程地址
- 创建socket
- 连接socket
- 进入循环等待本地terminal或socket来的新数据
5.1 创建fd_set集合
5.2 调用select(), 阻塞在这里,直到有socket已准备好
5.3 在select()返回,检查远程socket是否有数据回来
5.4 在select()返回,检查terminal输入
判断命令的参数对不对
因为加上命令本身所以,main函数接收到参数个数是3个,小于3个,我们就认为不对。
c
if(argc < 3) {
fprintf(stderr,"usage: client hostname port\n");
return 1;
}
配置远程地址
我们用getaddrinfo()函数来完成远程地址的配置。
c
struct addrinfo hints;
memset(&hints,0,sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
struct addrinfo *peer_address;
if(getaddrinfo(argv[1],argv[2],&hints,&peer_address)){
fprintf(stderr,"getaddrinfo() failed. (%d) \n",errno);
return 1;
}
getaddrinfo的函数原型:
c
int getaddrinfo(const char *restrict node,
const char *restrict service,
const struct addrinfo *restrict hints,
struct addrinfo **restrict res);
指定它的node和service参数就可以识别出一个远程主机和它的一个服务。我们传了ip地址或域名给node参数,这个可以定位到互联网上一台主机,对于service参数我们传了端口号,这个可以定位到这个台主机上的服务,addrinfo类型参数,我们构造了一个给它:
c
struct addrinfo hints;
memset(&hints,0,sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
构造这个参数时,我们用memset函数将其内存内容全部初始化为0,然后对它的ai_socktype指定为SOCK_STREAM,这表示我们要创建TCP连接。在这里我们并没有设置ai_family的值(AF_INET 或 AF_INET6),也就是说我们不指定使用IPv4或IPv6,我们让getaddrinfo()决定使用哪一个。getaddrinfo()配置完成后,会返回0,失败则会返回非0值。如果是成功的话,那么配置好的远程套接字回通过getaddrinfo()的最后一个参数res返回。在上述例子中,远程套接字的信息就保存在peer_address中。
打印配置好的远程套接字信息(可选)
c
printf("Remote address is :\n");
char address_buffer[100];
char service_buffer[100];
getnameinfo(peer_address->ai_addr,peer_address->ai_addrlen,address_buffer,sizeof(address_buffer),service_buffer,sizeof(service_buffer),NI_NUMERICHOST);
printf("%s %s \n",address_buffer,service_buffer);
这里解释一下getnameinfo函数:
c
int getnameinfo(const struct sockaddr *restrict addr, socklen_t addrlen,
char host[_Nullable restrict .hostlen],
socklen_t hostlen,
char serv[_Nullable restrict .servlen],
socklen_t servlen,
int flags);
- addr: socket的地址信息,从我们刚刚得到的peer_address->ai_addr来提供,
- addrlen: socket地址的长度,peer_address->ai_addrlen
- host: 用于存放主机名称的字符数组
- hostlen: 主机名对应的长度
- serv: 用于存放端口的字符数组
- servlen: 端口对应的长度
- flags: 是用来指定getnameinfo的行为的,如指定为NI_NUMERICHOST就是返回数字形式的主机名称。如93.184.216.34
创建Socket
利用配置好的远程socket信息来创建socket。
c
printf("Creating socket...\n");
int socket_peer;
socket_peer = socket(peer_address->ai_family,peer_address->ai_socktype,peer_address->ai_protocol);
if(socket_peer < 0) {
fprintf(stderr,"socket() failed. (%d)\n",errno);
return 1;
}
socket()函数的原型:
c
int socket(int domain, int type, int protocol);
- domain就是域,简单就是socket要用的协议簇,再简单点来说,就是要用IPv4还是IPv6,这个在前面配置socket信息时,已经准备好了,所以直接用peer_address->ai_family来赋值。
- type: 指定socket的类型,因为我们要的是TCP连接,那么这里的类型就是SOCK_STREAM,即在前面配置的类型信息,直接赋peer_address->ai_socktype,即可。
- protocol:指定socket通信使用的协议,SOCK_STREAM使用的就是TCP。getaddrinfo里已经帮忙配置好了,直接使用peer_address->ai_protocol即可。
如果创建成功,socket函数会返回对应的socket文件描述符,否则返回-1.
连接socket
c
printf("Connecting...\n");
if(connect(socket_peer,peer_address->ai_addr,peer_address->ai_addrlen)){
fprintf(stderr,"connect() failed. (%d)\n",errno);
return 1;
}
freeaddrinfo(peer_address);
connect函数原型:
c
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
- sockfd:socket文件描述符,当我们拿到socket文件描述符时,意味着我们的系统一定有资源(如内存)来处理这个socket。
- addr: socket地址,这地址会负责接收我们通过socket发送的数据。
- addrlen: socket地址的长度
如果连接成功,函数返回0,否则返回-1.
在连接成功后,我们用freeaddrinfo(peer_address)释放掉peer_address的内存。成功就意味着这些内存不需要再使用到。
进入循环等待
c
while(1){
fd_set reads;
FD_ZERO(&reads);
FD_SET(socket_peer,&reads);
FD_SET(0,&reads);
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 100000;
if(select(socket_peer+1,&reads,0,0,&timeout) < 0){
fprintf(stderr,"select() failed.(%d).\n",errno);
return 1;
}
if(FD_ISSET(socket_peer,&reads)){
char read[4096];
int bytes_received = recv(socket_peer,read,4096,0);
if(bytes_received < 1){
printf("Connection closed by peer.\n");
break;
}
printf("Received (%d bytes): %.*s",bytes_received,bytes_received,read);
}
if(FD_ISSET(0,&reads)){
char read[4096];
if(!fgets(read,4096,stdin)) break;
printf("Sending: %s",read);
int bytes_sent = send(socket_peer,read,strlen(read),0);
printf("Sent %d bytes.\n",bytes_sent);
}
}
while(1)就让我们的程序进入循环了。这样的我们的程序才不会退出。我们在循环中会使用select()函数。我们来简单描述一下为会要用select()函数。使用select()函数的目的是为了解决多个TCP连接,虽然我们现在实现的是TCP客户端,连接的数量会比TCP服务端要少很多,也容易控制很多,甚至在这里都可以不用select(),但是我们使用好的实践总不会差。所以这里我用服务端的例子来解释为会要用select()函数,这样大家会比较好理解。
首先,TCP服务端的socket连接不会少于一个,甚至更多。在服务端accept函数是一个阻塞的函数,意味着没有新连接来的时候,它就卡在accept函数调用那里。这是其中一个原因。另外当我们使用recv()读取数据时,我们的程序也会阻塞在recv()这里,直到数据准备好。作为一个服务端当然不能只接受一个连接,也不能只在连接那一会读取数据。所以对于一个服务端来说,在一般情况下,是不应该阻塞I/O的。它必须为多个客户端服务。如果我们的服务端正阻塞在recv()调用这里,那么其他客户端尝试连接到服务端这边明显必须等待,这显然也是不可接受的。为了解决这些问题,我们有几个办法:
- 方法一:轮询,不断地一个接一个地检查socket的状态,有数据要处理的就马上处理,否则直接忽略。这样虽然实现了同时处理socket的目的,但是在大多数据时间里都在浪费计算资源。而且还可能使用程序在一定程度上变得复杂。比如说处理recv()返回的数据就会比阻塞式的socket来复杂。
- 方法二:启动多进程或多线程来处理非阻塞的socket, 这种情况下,阻塞是可接受的,因为socket的阻塞都发生在各自的进程或线程里,不会阻塞其他的socket。这种方法虽然是个不错的选择,但是它也有一个比较明显的缺点,如线程的使用,当它们需要共享一些状态时,就很容易出错,维护这些状态是不容易的。而且在不同平台上的进程或线程机制也不尽相同,这给可移植性带来挑战。比如说在类Unix系统上,创建一个新进程是很容易的,fork()就搞掂了,我们用下面的例子来说明:
c
while(1) {
int socket_client = accept(sock_listen,&client,&len);
int pid = fork()
if(pid == 0) {// 孩子进程
close(sock_listen);
recv(socket_client,...);
send(socket_client,...);
close(socket_client);
exit(0);
}
// 父进程
close(socket_client);
}
accept()建立了一个到客户端的新连接。然后马上调用fork()创建一个新的进程,新创建的进程也父进程是一模一样的,所以当CPU执行到子程时,它会判断pid的值,如果是0,就意味着是自己,也就是这个子进程里,那么它就要把监听socket(sock_listen)关掉,因为在它自己的进程里,它唯一关心的是自己,所以它马上执行recv()和send()函数做自己的事,如果这些调用出现了阻塞,也无所谓,反正在自己家。在创建这个子进程的父进程,它得到fork()的返回值就是子进程的id,对于父进程,它要做的事就是把创建出来的客户端socket关掉,因为这个socket不需要在父进程里工程,它已经在子进程中去做它自己的事了。
各位编程的小伙伴,在理父进程与子进程的关系,尤其是那些实例的使用时,不要想着它们在共享着同一个对象,那是在高级编程里的想法,在这里要认识到这是一个内存的变化,使它们独立了,是内核帮助完成这种分离的。
上面就是在类Unix系统上创建进程的,在windows上这就会变得复杂起来。所以这种方法也不怎么推荐用来解决多个socket连接的场景
- 方法三:select()函数,这个就是我们推荐用来解决我们多socket连接场景的方法。我们可以给select()一个socket的集合,select()就会告诉我们哪一个socket已经准备好了。select()不仅没有计算资源的浪费,而且在类Unix和Windows系统上都可以使用,因为select()都支持Berkeley socket和WinSock,所以移植问题就就迎刃而解了。顺便说一下关于socket的一些事,我们都知道在系统间通信的端点就是socket,我们的应用程序收发各种来自网络的数据都是通过socket。其实存在好一些socket应用编程接口。最出名的就是Berkeley sockets,它在1983发布,Berkeley sockets的API很快就被业界接受,它做了一些小改动后便被接纳为POSIX标准。Linux和MacOS上提供的socket API都是Berkeley sockets的一个实现。Berkeley sockets、BSD sockets、Unix sockets、Portable Operating System Interface (POSIX)sockets这些术语都可以互相使用,它们都指的是Berkeley sockets。Windows上的socket API叫Winsock。
回归我们的主题。循环里我们用select()来解决同步多路复用的问题。我们给它一个socket的集合,它就会阻塞在select()调用处,直到当中有一个socket已准备好可以被读,我们也可以配置它返回有socket准备好被写入或遇到了错误。我们也可以配置在一个指定的时间,如果没有任务事件,就返回。其实,我们就可以复用这样的超时去读取我们terminal的输入。select()函数原型:
c
int select(int nfds, fd_set *_Nullable restrict readfds,
fd_set *_Nullable restrict writefds,
fd_set *_Nullable restrict exceptfds,
struct timeval *_Nullable restrict timeout);
- nfds: 这个值是三个文件描述符集合参数中最大文件描述加1(文件描述符是一个整型)。
- fd_set是文件描述符集合的结构体,在类Unix系统中,几乎所有操作都与文件描述符有关,无论操作的对象是外部设备还是文件,这也是为什么说在类Unix系统中,一切皆文件的原因。
- readfds:在这个集合中的文件描述符会被观察,看它们是否为读操作准备好了。如果一个读操作没有阻塞,那么文件描述符就准备好读了。如果到达了文件尾,就是到了文件结束的位置,那么文件描述符也是准备好读的状态。在select()返回后,readfds就只剩下准备好读的文件描述符,其他的文件描述符都被清掉。
- writefds:在这个集合中的文件描述符会被观察,看它们是否为写操作准备好了。如果一个写操作没有阻塞,那么文件描述符就处于准备好的状态。然而,对一个大量写的操作仍然会阻塞,即使用文件描述符已是可写的状态。在select()返回后,writefds就只剩下为写操作准备好的文件描述符,其他的文件描述符都被清掉。
- exceptfds: 在这个集合中的文件描述符会被观察,看它们是否发生了异常。在select()返回后,exceptfds就只剩下发生了异常的文件描述符,其他的文件描述符都被清掉。
- timeout: 指定一个时间间隔(超时时间),这个时间是给select()等待文件描述符变成准备好用的。当一个文件描述符变成准备好的状态,那么select()返回;或者被信号处理中断select()的调用;或者超时,select()返回。如果这个超时值是0,那么select()调用就会马上返回。如果这个超时值设置为NULL,则select()将会 无限期地阻塞,直到等到一个文件描述符变成准备好了。
如果select()调用失败会返回-1,如果成员则返回文件描述符的数量,如果超时则返回0 。
上面讲完了select()函数的原型,还有三个函数经常一起用的:
- void FD_ZERO(fd_set *set); 这个函数用于清空文件描述符集合,常在初始化文件描述符集合时使用
- void FD_SET(int fd, fd_set *set);添加文件描述符到集合中,如果已存在,则不做任何操作,也不会产生错误。
- int FD_ISSET(int fd, fd_set *set);用于测试给定的文件描述符是否在集合中。
创建文件描述符集合
在我们的循环中,创建fd_set集合:
c
fd_set reads;
FD_ZERO(&reads);
FD_SET(socket_peer,&reads);
FD_SET(0,&reads);
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 100000;
if(select(socket_peer+1,&reads,0,0,&timeout) < 0){
fprintf(stderr,"select() failed.(%d).\n",errno);
return 1;
}
我们将远程socket加入集合中:
c
FD_SET(socket_peer,&reads);
同时我们也把terminal的输入加入集合,terminal的输入的文件描述符是0,即stdin标准输入:
c
FD_SET(0,&reads);
在我们的这个实例中,我们只观察读,所以代码写和异常的两个文件描述符集合的参数我们传0即可。
检查远程socket是否有响应
只要有变化,select()就会返回,那么我们可以用FD_ISSET测试,我们想要处理的socket是否在已准备好的文件描述符列表中,有,则可以做进一步操作。
c
if(FD_ISSET(socket_peer,&reads)){
char read[4096];
int bytes_received = recv(socket_peer,read,4096,0);
if(bytes_received < 1){
printf("Connection closed by peer.\n");
break;
}
printf("Received (%d bytes): %.*s",bytes_received,bytes_received,read);
}
recv()函数原型:
c
ssize_t recv(int sockfd, void buf[.len], size_t len,
int flags);
- sockfd:有数据输入的文件描述符
- buf:用于存储输入的数据的数组
- len:数组的长度
- flags: 设置0即可,直接读入数据,否则会根据相应的flags做相应处理。
termineral的输入
c
if(FD_ISSET(0,&reads)){
char read[4096];
if(!fgets(read,4096,stdin)) break;
printf("Sending: %s",read);
int bytes_sent = send(socket_peer,read,strlen(read),0);
printf("Sent %d bytes.\n",bytes_sent);
}
fgets函数原型:
c
char *fgets(char *restrict s, int n, FILE *restrict stream);
- s:接收字符的指针,传字符数组名即可
- n:字符数组的大小即可。这个是决定一次最大可以接收最大的字符长度
- stream: 这个是数据源。
我们用这个例子说清楚了一些我们想交流的内容。
完整代码
到此就把整个TCP客户端的一个简单例子讲完了。下面是完成的代码:
c
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
int main(int argc,char *argv[]){
if(argc < 3) {
fprintf(stderr,"usage: client hostname port\n");
return 1;
}
struct addrinfo hints;
memset(&hints,0,sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
struct addrinfo *peer_address;
if(getaddrinfo(argv[1],argv[2],&hints,&peer_address)){
fprintf(stderr,"getaddrinfo() failed. (%d) \n",errno);
return 1;
}
printf("Remote address is :\n");
char address_buffer[100];
char service_buffer[100];
getnameinfo(peer_address->ai_addr,peer_address->ai_addrlen,address_buffer,sizeof(address_buffer),service_buffer,sizeof(service_buffer),NI_NUMERICHOST);
printf("%s %s \n",address_buffer,service_buffer);
printf("Creating socket...\n");
int socket_peer;
socket_peer = socket(peer_address->ai_family,peer_address->ai_socktype,peer_address->ai_protocol);
if(socket_peer < 0) {
fprintf(stderr,"socket() failed. (%d)\n",errno);
return 1;
}
printf("Connecting...\n");
if(connect(socket_peer,peer_address->ai_addr,peer_address->ai_addrlen)){
fprintf(stderr,"connect() failed. (%d)\n",errno);
return 1;
}
freeaddrinfo(peer_address);
printf("Connected.\n");
printf("To send data,enter text followed by enter.\n");
while(1){
fd_set reads;
FD_ZERO(&reads);
FD_SET(socket_peer,&reads);
FD_SET(0,&reads);
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 100000;
if(select(socket_peer+1,&reads,0,0,&timeout) < 0){
fprintf(stderr,"select() failed.(%d).\n",errno);
return 1;
}
if(FD_ISSET(socket_peer,&reads)){
char read[4096];
int bytes_received = recv(socket_peer,read,4096,0);
if(bytes_received < 1){
printf("Connection closed by peer.\n");
break;
}
printf("Received (%d bytes): %.*s",bytes_received,bytes_received,read);
}
if(FD_ISSET(0,&reads)){
char read[4096];
if(!fgets(read,4096,stdin)) break;
printf("Sending: %s",read);
int bytes_sent = send(socket_peer,read,strlen(read),0);
printf("Sent %d bytes.\n",bytes_sent);
}
}
printf("Closing socket...\n");
close(socket_peer);
printf("Finished.\n");
return 0;
}
编译:
c
chat % gcc client.c -o client
运行例子:
c
chat % ./client example.com 80
Remote address is :
93.184.216.34 http
Creating socket...
Connecting...
Connected.
To send data,enter text followed by enter.
Hello
Sending: Hello
Sent 6 bytes.
Received (516 bytes): HTTP/1.0 501 Not Implemented
Content-Type: text/html
Content-Length: 357
Connection: close
Date: Sun, 17 Mar 2024 10:46:24 GMT
Server: ECSF (sac/2512)
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>501 - Not Implemented</title>
</head>
<body>
<h1>501 - Not Implemented</h1>
</body>
</html>
Connection closed by peer.
Closing socket...
Finished.
chat %