我们这个TCP客户端将从命令行接收两个参数,一个是IP地址或域名,另一个是端口,并尝试连接在这个IP地址的TCP服务端。

TCP端的创建流程:

  1. 判断命令行参数个数,够不够
  2. 利用getaddrinfo()函数和命令行传递的参数来配置远程地址
  3. 创建socket
  4. 连接socket
  5. 进入循环等待本地terminal或socket来的新数据
    5.1 创建fd_set集合
    5.2 调用select(), 阻塞在这里,直到有socket已准备好
    5.3 在select()返回,检查远程socket是否有数据回来
    5.4 在select()返回,检查terminal输入

判断命令的参数对不对

因为加上命令本身所以,main函数接收到参数个数是3个,小于3个,我们就认为不对。

	if(argc < 3) {
		fprintf(stderr,"usage: client hostname port\n");
		return 1;
	}

配置远程地址

我们用getaddrinfo()函数来完成远程地址的配置。

	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的函数原型:

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类型参数,我们构造了一个给它:

	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中。

打印配置好的远程套接字信息(可选)

	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函数:

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。

	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()函数的原型:

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

	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函数原型:

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的内存。成功就意味着这些内存不需要再使用到。

进入循环等待

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 = 10000;
		
		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()就搞掂了,我们用下面的例子来说明:
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()函数原型:

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集合:

		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加入集合中:

FD_SET(socket_peer,&reads);

同时我们也把terminal的输入加入集合,terminal的输入的文件描述符是0,即stdin标准输入:

FD_SET(0,&reads);

在我们的这个实例中,我们只观察读,所以代码写和异常的两个文件描述符集合的参数我们传0即可。

检查远程socket是否有响应

只要有变化,select()就会返回,那么我们可以用FD_ISSET测试,我们想要处理的socket是否在已准备好的文件描述符列表中,有,则可以做进一步操作。

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()函数原型:

ssize_t recv(int sockfd, void buf[.len], size_t len,
                        int flags);
  • sockfd:有数据输入的文件描述符
  • buf:用于存储输入的数据的数组
  • len:数组的长度
  • flags: 设置0即可,直接读入数据,否则会根据相应的flags做相应处理。

termineral的输入

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函数原型:

char *fgets(char *restrict s, int n, FILE *restrict stream);
  • s:接收字符的指针,传字符数组名即可
  • n:字符数组的大小即可。这个是决定一次最大可以接收最大的字符长度
  • stream: 这个是数据源。

我们用这个例子说清楚了一些我们想交流的内容。

完整代码

到此就把整个TCP客户端的一个简单例子讲完了。下面是完成的代码:

#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;
}

编译:

chat % gcc client.c -o client

运行例子:

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 % 
Logo

欢迎加入西安开发者社区!我们致力于为西安地区的开发者提供学习、合作和成长的机会。参与我们的活动,与专家分享最新技术趋势,解决挑战,探索创新。加入我们,共同打造技术社区!

更多推荐