网络编程领域的HelloWorld程序

引言

Echo客户端/服务端程序应该是网络编程领域的入门首选,可以视为网络编程领域的HelloWorld程序。

为了深入学习网络编程,我写了这样一个程序,姑且叫它Simplest_Socket。这确实是最简单的socket通信程序。与一般的Echo服务器不同,Simplest_Socket会把客户端传来的英文字符串转换为大写再返回给客户端;而不像Echo服务器那样原样返回。

这样设计的目的在于体现服务器的“服务”功能,尽管只是把小写转为大写,但这确实是一项服务。

思路

TCP套接字通信由一个四元组确定一个端到端通信,即:

(客户端IP地址,客户端端口号,服务端IP地址,服务端端口号)

整体的时序图如下:

上图中数据传输采用了read/write函数,套接字是一种文件类型,所以用这两个文件IO来读写套接字描述符也可以;但是套接字与普通文件又有些不同,在<sys/socket.h>中声明了专门用于socket的IO函数:send函数和recv函数。Simplest_Socket采用这两个函数来读写套接字。

客户端

指定服务端套接字地址结构

客户端作为连接的主动发起方,它需要知道它要连到哪个服务器的哪个端口,所以在客户端程序中,首先要定义服务端的套接字地址结构,在IPv4因特网中,套接字地址结构由结构体sockaddr_in定义,它声明在头文件<arpa/inet.h>中。在这个结构体中:

  • sin_family用于指定使用的网络层协议IPv4,取值为AF_INET;
  • in_port_t被定义为uint16_t,sin_port指定端口号,由于是无符号16位整型,所以其取值范围为0~65535;
  • sin_addr用于指定IPv4地址,它也是一个结构体in_addr,in_addr_t被定义为uint32_t,即无符号32位整型,正好与IPv4地址的大小对应。
1
2
3
4
5
6
7
8
9
#include <arpa/inet.h>
struct in_addr{
	in_addr_t s_addr; //IPv4地址
};
struct sockaddr_in{
	sa_family_t    sin_family; //协议族
	in_port_t      sin_port;  //端口号
	struct in_addr sin_addr;  //IPv4地址
}

我们所定义的存放IP地址和端口号的变量均已主机字节序存放在我们的本地主机上,当进行网络通信时,需要将它们转化为网络字节序,这些工作由头文件<arpa/inet.h>中声明的htons函数和inet_pton函数来完成:

  • htons函数用于把将无符号16位整型数据由主机字节序转为网络字节序,端口号正好适用;
  • inet_pton函数用于将主机字节序的点分十进制IP地址转换为网络字节序的二进制IP地址,IPv4地址和IPv6地址均可用该函数。
1
2
3
4
5
#include <arpa/inet.h>
uint16_t htons(uint16_t hostint16);
int inet_pton(int domain,
              const char *restrict str,
              void *restrict addr);

所以对于我们的客户端,我们可以定义如下套接字地址结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <arpa/inet.h>
#include <string.h>
const char* server_ip = "127.0.0.1"; //服务端IP地址
const uint16_t SERVER_PORT = 2021; //服务端监听端口号

struct sockaddr_in server_sockaddr;
memset(&server_sockaddr, 0, sizeof(server_sockaddr));
server_sockaddr.sin_family = AF_INET; //指定IPv4
server_sockaddr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, server_ip, &server_sockaddr.sin_addr);

声明在<string.h>中的memset函数可以用于初始化新申请的空间,将其置为指定值。

创建套接字

既然要使用套接字,当然第一件事就是调用socket函数创建套接字。

1
2
3
#include <sys/socket.h>
int socket(int family, int type, int protocol);
//成功返回非负套接字描述符,出错返回-1

对于我们的客户端:

  • family:设置协议域为AF_INET,即IPv4;
  • type:设置套接字类型为SOCK_STREAM,即字节流套接字;
  • protocol:设置该参数为0,表示选择根据family和type组合系统提供的默认传输层协议。对于AF_INET和SOCK_STREAM组合,默认协议为TCP。

使用perror函数将错误原因输出到标准错误(stderr):

1
2
3
#include <stdio.h>
void perror(const char *str);
//输出格式为"str:错误原因",错误原因依照全局变量errno的值来决定要输出的字符串

所以在客户端中这样创建客户端的套接字:

1
2
3
4
5
6
7
8
#include <sys/socket.h>
#include <stdio.h>

int client_fd = socket(AF_INET, SOCK_STREAM, 0); //指定TCP协议
if(client_fd < 0){
	perror("socket");
	exit(1);
}

发起连接

创建完套接字之后,作为主动方的客户端要做的就是发起连接,这由connect函数完成:

1
2
3
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
//成功返回0;出错返回-1
  • sockfd参数即客户端socket函数返回的套接字描述符;
  • servaddr参数指向一个指明了服务端IP地址和端口号的套接字地址结构,即我们之前创建的server_sockaddr;
  • addrlen参数是servaddr参数指向的地址结构的大小,可由sizeof()运算得到。

所以,在客户端中这样发起连接:

1
2
3
4
5
6
7
#include <sys/socket.h>
#include <stdio.h>

if(connect(client_fd, (struct sockaddr*) &server_sockaddr, sizeof(server_sockaddr)) < 0){
	perror("connect");
	exit(1);
}

调用connect函数将激发TCP的三路握手过程,详情以后分析TCP状态机的时候再讲。

数据传输

客户端发起连接之后,服务端接收连接,双方完成TCP的三路握手之后,通信链路就建立起来了,客户端可以传输数据了。

在传输数据之前,我们定义了一个发送缓冲区sendbuf和一个接收缓冲区recvbuf。

对于Simplest_Socket来说,客户端传输的数据就是用户输入的英文字符串,所以while函数的条件我们设置为fgets函数,fgets函数是一个声明在<stdio.h>的标准IO库函数:

1
2
3
#include <stdio.h>
char *fgets(char *restrict buf, int n, FILE *restrict fp);
//成功返回buf;若已到达文件尾或出错返回NULL
  • fgets函数从指定的流fp读取字符送到长度为n的缓冲区buf,一直读到下一个换行符为止,但不会超过n-1个字符。

fgets函数:缓冲区buf以null字节结尾。如果该行(包括换行符)的字符数超过n-1,则fgets只返回一个不完整的行,但是buf总是以null结尾。对fgets的下一次调用会继续读该行。

我们从标准输入(stdin)读取用户输入的数据到sendbuf。然后就可以使用send函数把sendbuf中的数据通过客户端的套接字传输给服务端,

1
2
3
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
//成功返回发送的字节数;出错返回-1
  • send函数将buf中的nbytes个字节的数据通过套接字描述符sockfd发送给服务端,flags参数一般置0。

我们可以用strlen函数获取sendbuf中实际数据的长度:

1
size_t strlen(const char* str);
  • strlen函数从字符串的开头位置依次向后计数,直到遇见\0,然后返回计时器的值。最终统计的字符串长度不包括\0

我们定义如果用户输入的是Q\n,表示退出。这个用strcmp函数来完成:

1
2
#include <string.h>
int strcmp(const char *str1, const char *str2)
  • strcmp函数返回值为0则表示参与比较的两个字符串相等。

发送完数据之后要做的就是要接收服务端返回的数据,这由recv函数来完成:

1
2
3
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
//返回值:返回数据的字节长度;若无可用数据或对等方已经按序结束,返回0;出错返回-1
  • recv函数通过套接字描述符sockfd接收数据至buf,nbytes参数指定buf的大小,flags参数一般置0。

接收完数据后,通过fputs函数把recvbuf的内容输出到标准输出(stdout)。

1
2
#include <stdio.h>
int fputs(const char *restrict str, FILE *restrict fp);
  • fputs函数将一个以null字节终止的字符串str写到指定的流fp,尾端的终止符null不写出。

在准备发送和接收下一次的新数据之前,我们用memset函数将sendbuf和recvbuf的空间全部置0:

1
2
#include <string.h>
void *memset(void *str, int c, size_t n)
  • memset函数将参数 str 所指向的字符串的前 n 个字符置为值c。

所以在客户端这样写数据传输部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <sys/socket.h>
#include <string.h>

const int BUFFER_SIZE = 1024; //定义缓冲区大小

char sendbuf[BUFFER_SIZE];
char recvbuf[BUFFER_SIZE];
while(fgets(sendbuf, sizeof(sendbuf), stdin)){ //从stdin读入待传输数据至sendbuf
	send(client_fd, sendbuf, strlen(sendbuf), 0); //将sendbuf中的数据通过client_fd套接字传输
    if(strcmp(sendbuf, "Q\n") == 0) //输入Q表示退出
		break;
	recv(client_fd, recvbuf, sizeof(recvbuf), 0); //从client_fd套接字接收数据,保存至recvbuf
	fputs(recvbuf, stdout);//将recvbuf中的数据输出至stdout
	memset(sendbuf, 0, sizeof(sendbuf));//将sendbuf的内存值置0
	memset(recvbuf, 0, sizeof(recvbuf));//将recvbuf的内存值置0
}

关闭连接

当用户输入Q\n,表示要退出连接。

因为套接字也是一种文件类型,所以我们可以像关闭普通的文件描述符一样,用close函数来关闭它。

1
2
3
#include <unistd.h>
int close(int fd);
//成功返回0;出错返回-1

所以在客户端这样写关闭连接部分:

1
2
3
#include <unistd.h>

close(client_fd);

服务端

服务端部分内容与客户端一致,重复部分不再赘述。

定义服务端套接字地址结构

这一部分与客户端相同,也是定义服务端的套接字地址结构。

创建监听套接字

服务端作为被动接收连接的一方,需要创建一个监听套接字,这个也跟客户端创建套接字一样。

绑定

对于服务端,我们一般还会指定一个固定的端口号,并且这个端口号还应该让想用这个服务器的客户端知道,也就是服务端的监听套接字要绑定一个固定的套接字地址结构,这样客户端在想要连接到这个服务端时,才可以知道我应该连接到哪个套接字地址结构,如果你的端口号一直变,那客户端就比较难受了。

这个绑定工作由bind函数完成。

1
2
3
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
//成功返回0;出错返回-1
  • bind函数将套接字sockfd绑定到套接字地址结构myaddr,addrlen为myaddr的长度,可由sizeof()得到。

转化为被动套接字

由socket函数创建的套接字是一个主动套接字,即它是一个会调用connect函数发起连接的客户端套接字。

那问题就出来了,我服务端的套接字可不是要主动发起连接的,而是要被动接受连接的。那么就需要把socket函数创建的主动套接字转化为被动套接字,这个工作由listen函数来完成:

1
2
3
#include <sys/socket.h>
int listen(int sockfd, int backlog);
//成功返回0;出错返回-1
  • listen函数把主动套接字sockfd转化为被动套接字,指示内核应该接受指向该套接字的连接请求。
  • backlog参数规定了内核应该为sockfd套接字排队的最大连接个数。

调用listen函数会使TCP服务器状态由ClOSED转为LISTEN。

接收请求

接下来就是接收客户端的连接请求,该工作由accept函数完成。

1
2
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
  • accept函数接收监听套接字描述符,并返回一个已连接套接字描述符。
  • cliaddr参数和addrlen用于返回客户的套接字地址结构及其大小,若不关心客户端的身份,将二者设为NULL即可。

数据传输

数据传输过程也与客户端没有太大不同。

关闭连接

关闭连接也与客户端相同,只是服务端要关闭两个套接字:

  • 监听套接字
  • 已连接套接字

源程序

客户端

 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
43
44
45
46
47
48
49
#include <sys/socket.h> //socket系列函数头文件
#include <arpa/inet.h> //sockaddr_in结构体、inet_pton函数和htons函数头文件
#include <string.h> //strlen函数、strcmp函数和memset函数头文件
#include <unistd.h> //close函数头文件
#include <stdlib.h> //exit函数头文件
#include <stdio.h> //fgets函数、fputs函数、perror函数头文件

const int BUFFER_SIZE = 1024; //定义缓冲区大小
const char* server_ip = "127.0.0.1"; //服务端IP地址
const uint16_t SERVER_PORT = 2021; //服务端监听端口号

int main(){
	//定义服务端套接字地址结构
	struct sockaddr_in server_sockaddr;
	memset(&server_sockaddr, 0, sizeof(server_sockaddr));
	server_sockaddr.sin_family = AF_INET; //指定IPv4
	server_sockaddr.sin_port = htons(SERVER_PORT);
	inet_pton(AF_INET, server_ip, &server_sockaddr.sin_addr);

	//创建客户端套接字描述符
	int client_fd = socket(AF_INET, SOCK_STREAM, 0); //指定TCP协议
	if(client_fd < 0){
		perror("socket");
		exit(1);
	}

	//发起连接
	if(connect(client_fd, (struct sockaddr*) &server_sockaddr, sizeof(server_sockaddr)) < 0){
		perror("connect");
		exit(1);
	}

	//开始数据传输
	char sendbuf[BUFFER_SIZE];
	char recvbuf[BUFFER_SIZE];
	while(fgets(sendbuf, sizeof(sendbuf), stdin)){ //从stdin读入待传输数据至sendbuf
		send(client_fd, sendbuf, strlen(sendbuf), 0); //将sendbuf中的数据通过client_fd套接字传输
        if(strcmp(sendbuf, "Q\n") == 0) //输入Q表示退出
			break;
		recv(client_fd, recvbuf, sizeof(recvbuf), 0); //从client_fd套接字接收数据,保存至recvbuf
		fputs(recvbuf, stdout);//将recvbuf中的数据输出至stdout
		memset(sendbuf, 0, sizeof(sendbuf));//将sendbuf的内存值置0
		memset(recvbuf, 0, sizeof(recvbuf));//将recvbuf的内存值置0
	}

	//关闭连接
	close(client_fd);
	return 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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <sys/socket.h> //socket系列函数头文件
#include <arpa/inet.h> //sockaddr_in结构体、inet_pton函数、htons函数头文件
#include <string.h> //strlen函数、strcmp函数和memset函数头文件
#include <unistd.h> //close函数头文件
#include <stdlib.h> //exit函数头文件
#include <stdio.h> //perror函数头文件
#include <ctype.h> //toupper函数头文件

const char* server_ip = "127.0.0.1"; //指定服务端IP地址
const uint16_t SERVER_PORT = 2021;//指定监听端口号
const int QUEUE = 1024; //用于listen函数第二个参数,指定内核应为相应套接字排队的最大连接数
const int BUFFER_SIZE = 1024;//指定缓冲区大小

int main(){
	//定义服务端套接字地址结构并赋值
	struct sockaddr_in server_sockaddr;
	memset(&server_sockaddr, 0, sizeof(server_sockaddr));
	server_sockaddr.sin_family = AF_INET; //指定IPv4
	server_sockaddr.sin_port = htons(SERVER_PORT);
	inet_pton(AF_INET, server_ip, &server_sockaddr.sin_addr);

	//创建一个监听套接字描述符
	int listen_fd = socket(AF_INET, SOCK_STREAM, 0); //指定TCP协议
	if(listen_fd < 0){
		perror("socket");
		exit(1);
	}

	//将监听套接字描述符绑定到套接字地址结构
	if(bind(listen_fd, (struct sockaddr*) &server_sockaddr, sizeof(server_sockaddr)) < 0){
		perror("bind");
		exit(1);
	}

	//开始监听
	if(listen(listen_fd, QUEUE) < 0){
		perror("listen");
		exit(1);
	}

	//接受连接请求,创建 已连接套接字描述符
	int conn_fd = accept(listen_fd, NULL, NULL);
	if(conn_fd < 0){
		perror("accept");
		exit(1);
	}

	//开始数据传输
	char sendbuf[BUFFER_SIZE];
	char recvbuf[BUFFER_SIZE];
	while(1){
		memset(recvbuf, 0, sizeof(recvbuf));
		memset(sendbuf, 0, sizeof(sendbuf));
		
		recv(conn_fd, recvbuf, sizeof(recvbuf), 0); //从conn_fd套接字接收数据,保存至recvbuf
		if(strcmp(recvbuf, "Q\n") == 0) //如果收到的数据是Q,表示退出
			break;
		fputs(recvbuf, stdout); //将收到的数据原样输出至stdout
	
		//将recvbuf中的小写字母转为大写,新数据保存至sendbuf
		for(int i = 0; i < strlen(recvbuf); ++i){
			if(islower(recvbuf[i]))
				sendbuf[i] = toupper(recvbuf[i]);
			else
				sendbuf[i] = recvbuf[i];
		}

		send(conn_fd, sendbuf, strlen(sendbuf), 0); //将sendbuf中的数据通过conn_fd套接字传输
		
	}

	//关闭连接及监听描述符
	close(conn_fd);
	close(listen_fd);
	return 0;
}

数据流通图

Simplest_Socket

Built with Hugo
Theme Stack designed by Jimmy