2020-11-21计网实验:Socket 编程
点此下载实验代码
1. 实验目的
- 掌握 TCP 和 UDP 协议主要特点和工作原理
- 理解 socket 的基本概念和工作原理
- 编程实现 socket 网络通信(C/Python/Java)
2. 实验代码实现
2.1 代码组织结构
本次实验实现的 3 个项目(6 个程序)的代码分别为:
server_reg.c和client_reg.c:简单的网络注册服务器和客户端server_check.c和client_check.c:简易的网络签到服务器和客户端server_chat.c和client_chat.c:UDP
聊天室服务器和客户端
其中这些代码需要依赖以下的自己写的函数库:
csutil.c:Client & Server Utility
Function,客户端和服务器间通信所需的函数,封装了很多对
socket 的操作stuutil.c:Student Utility
Function,建立、查询、写入学生数据库所需函数的封装chatutil.c:Chatting Utility
Function,基于 UDP 的自定义聊天应用层协议的实现
也包括了这三个函数库的头文件:csutil.h、stuutil.h和chatutil.h。
为了方便一起编译和链接数量众多的代码文件,同时写了
Makefile。其中本次实验的 6
个程序分别编译输出为server_reg、client_reg、server_check、client_check、server_chat、client_chat。
2.2
csutil.c中的 socket 操作封装
由于本次实验都围绕 socket
编程展开,因此可以把常规操作写进csutil.c一些函数里。这样之后有重复的功能使用时,只需添加#include "csutil.h",即可复用这些代码。
2.2.1 异常处理函数
使用 socket 时的很多函数都可能会产生错误。由 UNIX
程序设计可知,当使用 UNIX
提供的函数出现异常时,异常代码会保存在extern的全局变量errno中,需要包含errno.h头文件。而要获取该错误代码对应的字符串信息,可使用perror函数,需要包含string.h头文件。
这里由于程序逻辑简单,因此一旦遇到错误,则打印是哪个函数出错,以及错误信息,并退出程序,返回值为
1:
// 异常处理。如果有异常,打印错误信息,直接退出
// funcname: 出错的函数名称
void throw_exception(char *funcname)
{
perror(funcname);
exit(1);
}
2.2.2
IP:port和sockaddr结构体的转换函数
在我们使用 UNIX
的bind、connect和sendto函数时,需要传入sockaddr结构体。而通常我们只是手动输入
IP 和端口号。因此编写了get_addr函数进行
IP+端口号到sockaddr_in结构体的转换:
// 将 IPv4:Port 转换为 `struct sockaddr_in`
// ip: 待转换 IP 地址
// port: 待转换端口号
// addr: 存储结果的结构体指针
void get_addr(char *ip, int port, struct sockaddr_in *addr)
{
int ip_net, port_net;
if (inet_pton(AF_INET, ip, &ip_net) < 0)
throw_exception("inet_pton");
port_net = htons(port);
addr->sin_family = AF_INET;
addr->sin_addr.s_addr = ip_net;
addr->sin_port = port_net;
bzero(addr->sin_zero, sizeof(addr->sin_zero));
}
同时,在使用 UNIX
的accept和recvfrom函数时,我们需要获取是哪个
IP
的哪个端口上的客户端向我们发来数据。因此也需要将从这两个函数中获得的sockaddr结构体转换为
IP+端口号:
// 将 `struct sockaddr_in` 转换为 IPv4:Port
// ip: 存储 IP 地址指针
// port: 存储端口号的指针
// addr: 待转换的结构体指针
void get_ip_port(char *ip, int *port, struct sockaddr_in *addr)
{
if (inet_ntop(AF_INET, &addr->sin_addr.s_addr, ip, INET_ADDRSTRLEN) == NULL)
throw_exception("inet_ntop");
*port = ntohs(addr->sin_port);
}
这里的转换函数没有使用实验 PPT
上提供的inet_addr、inet_ntoa等函数,因为在《UNIX
环境高级编程》一书的第 16
章中提到,这两个函数没有这里使用的inet_pton、inet_ntop有更好的兼容性
这里的转换都只是考虑 IPv4
地址字符串,且不支持输入域名再解析 IP 地址。如果是 IPv6
地址,应将sockaddr_in结构体换成sockaddr_in6,且将AF_INET换成AF_INET6。
2.2.2 服务器的建立函数
2.2.2.1 函数原型
由于建立服务器的代码在本实验中也经常复用,因此将建立服务器的过程封装成make_server函数。函数原型如下:
// 建立一个运行在 `sip`:`sport` 的服务器
// sip: 服务器 IP
// sport: 服务器端口
// protocol: 应用层协议,可以是 TCP 或 UDP
// handler: 处理数据收发函数
// block: 是否阻塞连接(是否为迭代模式)
void make_server(char *sip, int sport, int protocol, void (*handler)(int), bool block);
sip和sport:该函数在sip:sport上运行服务器。下文中出现的变量名,前缀带有s的意为服务端的信息,前缀带有c的意为客户端的信息
protocol:服务器的传输层协议由protocol指定,可以是TCP或UDP。这两个宏常量在csutil.h里分别被定义为SOCK_STREAM和SOCK_DGRAM
handler:服务器遇到用户连接时的处理函数。该函数需要接受一个参数,为用于该连接的(套接字)文件描述符fd,也即accept函数的返回值(对于
TCP 协议)或socket函数的返回值(对于 UDP
协议)
block:服务器是否在处理用户连接的过程中阻塞。即服务器能否多进程同时处理并发请求
block参数只适用于 TCP 的情况。对于 UDP
聊天室而言,由于 UDP
是无连接的,因此不会发生和客户端连接时的阻塞情况,该参数可以任意设置
该函数具体实现如下。首先,进行固定的socket、bind操作:
char cip[INET_ADDRSTRLEN];
int cport, sfd, cfd, caddr_len;
struct sockaddr_in saddr, caddr;
pid_t pid;
if ((sfd = socket(AF_INET, protocol, 0)) < 0)
throw_exception("socket");
get_addr(sip, sport, &saddr);
if (bind(sfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
throw_exception("bind");
printf("Serving at %s:%d\n", sip, sport);
这里用于存储客户端 IP
地址的cip数组长度设置为了 IPv4
地址的最大长度INET_ADDRSTRLEN,即
16(包括最后的\0)。这一常量在arpa/inet.h头文件里定义。
2.2.2.2 迭代 TCP 服务器
之后,针对不同的选项进行不同的操作。首先是迭代的 TCP
服务器:
// TCP 迭代
if (protocol == TCP && block)
{
if (listen(sfd, SOMAXCONN) < 0)
throw_exception("listen");
while (1)
{
if ((cfd = accept(sfd, (struct sockaddr *)&caddr, &caddr_len)) < 0)
throw_exception("accept");
get_ip_port(ip, &port, &caddr);
printf("\nAccepted connection from %s:%d\n", ip, port);
handler(cfd);
close(cfd);
}
}
需要先listen,之后只需一个循环处理一次accept即可。并在accept之后友好的打印客户端的地址信息,再调用handler函数实现自定义的套接字读写操作与客户端通信。
这里的listen的第二个参数并没有像实验 PPT
那样设置为 0。根据 UNIX 的文档,TCP
的最大连接数在sys/socket.h头文件中有SOMAXCONN的宏定义。在本地的
Linux 系统上为 128
2.2.2.3 并发 TCP 服务器
其次是并发的 TCP 服务器:
// TCP 并发
else if (protocol == TCP && !block)
{
if (listen(sfd, SOMAXCONN) < 0)
throw_exception("listen");
signal(SIGCHLD, SIG_IGN); // 避免僵尸进程
while (1)
{
if ((cfd = accept(sfd, (struct sockaddr *)&caddr, &caddr_len)) < 0)
throw_exception("accept");
get_ip_port(ip, &port, &caddr);
printf("\nAccepted connection from %s:%d\n", ip, port);
if ((pid = fork()) < 0)
throw_exception("fork");
else if (pid == 0)
{
close(sfd);
handler(cfd);
close(cfd);
exit(0);
}
close(cfd);
}
}
与迭代 TCP
服务器不同的是,程序在accept之后,立即调用fork创建子进程,并在子进程中处理这一新建的连接,而父进程继续循环等待其他的连接。
注意到一些细节问题。在 UNIX
系统中,如果在子进程结束之前,父进程没有来得及调用sys.wait.h中的一系列函数获取子进程的结束信息,则子进程会变成僵尸进程,占用系统资源。但是我们这里不可以在父进程中的while循环中使用wait,因为一旦在循环中wait后,我们的父进程就也被阻塞了,成为了迭代式的
TCP 服务器。因此有两种避免的方法:
- 一种是
fork两次,在子进程的子进程中处理请求,并提前结束子进程,使用wait获取子进程信息。这样子进程的子进程就会被init进程接管,避免了僵尸进程的产生 - 另外是通过信号处理函数。当子进程终止时,父进程会收到
SIGCHLD信号。因此如果我们在收到该信号时进行wait,或者直接忽略该信号,让子进程的信息直接从系统的进程表项中删除,都可以避免僵尸进程的产生。这里使用和实验
PPT
上一样的忽略该信号的做法:在父进程中调用signal(SIGCHLD, SIG_IGN),需要包含signal.h头文件
同时,为了进一步节约资源,当一个进程fork时,其文件表项也会被复制一份,就像调用了dup函数一样。但是这里在子进程中,我们不需要父进程的sfd(套接字文件描述符),在父进程中我们不需要子进程的cfd(针对一个
TCP 连接的文件描述符),因此可以在各自的分支中关闭。
2.2.2.4 UDP 服务器
由于 UDP
是无连接的,因此其实现最简单,既不要listen也不要accept,直接循环调用handler进行数据收发即可:
// UDP
else if (protocol == UDP)
{
while (1)
handler(sfd);
}
2.2.3
客户发起与服务器的通信函数
客户端与服务器发起通信也是一个在本实验中经常复用的函数。这里和建立服务器类似,函数需要提供服务器
IP、服务器端口号、运输层协议、以及handler函数这几个参数。之后函数通过connect与服务器建立连接,获取到用于该连接的文件描述符cfd,之后调用handler,传入cfd参数进行数据的收发处理。具体实现如下:
// 向 `sip`:`sport` 处的服务器发起连接
// sip: 服务器 IP
// sport: 服务器端口
// protocol: 应用层协议,可以是 TCP 或 UDP
// handler: 处理数据收发函数
void contact_server(char *sip, int sport, int protocol, void (*handler)(int))
{
int fd;
struct sockaddr_in saddr;
if ((fd = socket(AF_INET, protocol, 0)) < 0)
throw_exception("socket");
get_addr(sip, sport, &saddr);
if (connect(fd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
// UDP works too
throw_exception("connect");
handler(fd);
close(fd);
}
注意到这里的connect并没有排除 UDP
协议!这是因为根据《UNIX 环境高级编程》第 16 章 16.4
节,UDP
协议下也可调用connect函数。这样做的效果是每次使用send发送数据时,会默认向connect函数指定的地址发送。且调用recv函数时,也只会从connect函数指定的地址接收数据报。不需要我们每次使用sendto和recvfrom函数指明地址。
2.2.4 数据收发函数
由于 UNIX
提供的收发函数仍然较为底层,且没有异常处理,因此针对recv、send、recvfrom、sendto这几个函数编写了更友好的封装:recv_data、send_data、recv_data_from、send_data_to,并添加了异常处理。以recv_data_from为例:
// 封装的更方便的`recvfrom`
// fd: socket 文件描述符
// buf: 缓冲区
// ip: 存放发送方 IP 的指针
// port: 存放发送方端口的指针
void recv_data_from(int fd, void *buf, char *ip, int *port)
{
struct sockaddr_in caddr;
int caddr_len = sizeof(caddr);
bzero(buf, MAXLINE);
again:
if (recvfrom(fd, buf, MAXLINE, 0, (struct sockaddr *)&caddr, &caddr_len) < 0)
{
if (errno == EINTR)
goto again; // 防止 interruped system call
throw_exception("recvfrom");
}
get_ip_port(ip, port, &caddr);
}
可见其不需要传入sockaddr结构体,而直接传入接受
IP
和端口号的地址即可。并且不需要指定缓冲区长度,因为在csutil.h里,统一将发送方和接收方的缓冲区的长度设置为MAXLINE,8192。也不需要每次在接收前都手动对缓冲区进行
0 初始化,因为函数中已经帮我们通过bzero进行
0 填充。
注意到该函数中使用了goto,这里是避免recv系列函数在执行的过程中被打断。因为后续的程序中有用到信号处理函数,当程序接收到信号时,对于普通的系统调用,会让它执行完再执行信号处理函数,但是对于慢的系统调用,如网络套接字上的读写,当收到信号时,该系统调用会被中断,并返回错误码EINTR。因此,如果这个时候直接报错退出,是无法实现程序所需功能的。解决方法是,一旦遇到这个错误,就重新进行一遍当前的系统调用,而最简便的解决方法就是利用goto。
这也是《UNIX 环境高级编程》一书在第 10
章中的示例做法
同样的,以send_data函数为例:
// 封装的更方便的`send`
// fd: socket 文件描述符
// buf: 缓冲区
// len: 发送的数据部分的长度,为 0 则自动计算
void send_data(int fd, void *buf, int len)
{
if (len == 0)
len = strlen(buf) + 1;
if (send(fd, buf, len, 0) < 0)
throw_exception("send");
}
可见其提供了一个选项,如果将长度设置为
0,则不需要我们手动计算需要发送数据的长度。这对发送字符串常量时非常友好。
2.3
简单的网络注册服务(迭代)
2.3.1
stuutil.c学生数据库
为了实现学生的信息存储、添加、查询,因此编写了stuutil.c。这里采用文本文件形式存储学生信息:第一行为学生姓名,第二行为学生的学号。每个学生信息之间也用一行隔开。文本文件的位置为stuutil.h里定义的DB_PATH,即服务端程序运行目录下的student.txt。
这里需要用到add_student函数,向数据库中添加学生信息,即直接在该文本文件后追加信息:
// 向数据库中添加学生
// name: 姓名
// number: 学号
void add_student(char *name, char *number)
{
FILE *fp;
fp = fopen(DB_PATH, "at");
fputs(name, fp);
fputs("\n", fp);
fputs(number, fp);
fputs("\n", fp);
fclose(fp);
}
2.3.2 服务端程序
有了stuutil.h和之前的csutil.h后,接下来的写代码过程就异常轻松。因为我们只要从命令行中获取
IP
和端口,指定协议后,自行编写handler函数即可。例如,服务端的完整代码如下:
/* 注册服务器 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "csutil.h"
#include "stuutil.h"
// 与客户端的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf_name[MAXLINE], buf_number[MAXLINE];
while (1)
{
// 第一轮收发
send_data(cfd, "Please input student name:", 0);
recv_data(cfd, buf_name);
if (strcmp(buf_name, "bye") == 0)
return;
printf("Student name:\t%s\n", buf_name);
// 第二轮收发
send_data(cfd, "Please input student number:", 0);
recv_data(cfd, buf_number);
printf("Student number:\t%s\n", buf_number);
add_student(buf_name, buf_number);
}
}
int main(int argc, char *argv[])
{
char *ip;
int port;
if (argc != 3)
{
printf("Usage: ./server_reg server_ip server_port\n");
return 0;
}
ip = argv[1];
port = atoi(argv[2]);
make_server(ip, port, TCP, handler, true); // 建立迭代 TCP 服务器
return 0;
}
其中,在make_server函数中,设置协议为
TCP,并设置为迭代模式。而在处理客户端请求的handler函数中,每次循环进行两轮收发,分别对应姓名和学号。直到输入的姓名为bye时退出。同时,针对接收到的姓名和学号,在服务端的屏幕上打印,并接着通过
2.3.1 中提到的add_student函数存储。
2.3.3 客户端程序
而对于客户端,其结构大致类似。只需将main函数的make_server换成
2.2.3
中提到的contact_server,并且实现处理和服务器连接handler即可。客户端的handler函数实现如下:
// 与服务器的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf[MAXLINE];
while (1)
{
// 第一轮收发,输入姓名
recv_data(cfd, buf);
printf("[Server] %s\n[Client] ", buf);
fgets(buf, MAXLINE, stdin);
buf[strlen(buf) - 1] = '\0';
send_data(cfd, buf, 0);
if (strcmp(buf, "bye") == 0)
break;
// 第二轮收发,输入学号
recv_data(cfd, buf);
printf("[Server] %s\n[Client] ", buf);
fgets(buf, MAXLINE, stdin);
buf[strlen(buf) - 1] = '\0';
send_data(cfd, buf, 0);
}
}
同样进行两轮的收发。并在输入为bye的情况下退出。
2.4
简易的网络签到服务(并发)
2.4.1
stuutil.c学生数据库
和 2.3.1
节同样,在stuutil.c中实现简单的学生查询函数has_student:
// 数据库中是否存在该学生
// name: 姓名
// return: true 为存在,false 为不存在
bool has_student(char *name)
{
char buf[MAXLINE];
int len;
FILE *fp;
fp = fopen(DB_PATH, "rt");
while (!feof(fp))
{
fgets(buf, MAXLINE, fp);
buf[strlen(buf) - 1] = '\0';
if (strcmp(buf, name) == 0)
{
fclose(fp);
return true;
}
fgets(buf, MAXLINE, fp);
buf[strlen(buf) - 1] = '\0';
}
fclose(fp);
return false;
}
由于签到只考虑姓名,因此如果学生姓名存在,则返回true。
2.4.2 服务端程序
服务端程序的主函数只需将make_server中的最后一个参数由
2.3.2
中的true改为false,即建立并发式的
TCP 服务器:
make_server(ip, port, TCP, handler, false);
服务端程序的handler函数如下:
// 与客户端的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf[MAXLINE];
send_data(cfd, "Please input student name:", 0);
while (1)
{
recv_data(cfd, buf);
if (strcmp(buf, "bye") == 0) // bye 的情况
return;
if (has_student(buf)) // 查找学生,存在的情况
{
send_data(cfd, "Successfully checked in! Next name:", 0);
printf("%s checked in\n", buf);
}
else // 查找学生,不存在的情况
send_data(cfd, "No such student! Next name:", 0);
}
}
每次循环只进行一轮收发。如果收到的学生姓名存在,则也同时输出在服务端的屏幕上(方便老师后台查看签到情况)。如果不存在也会向客户端发送错误信息。如果接收到bye,则退出。
2.4.3 客户端程序
客户端的handler更为简单,每次循环进行一轮收发,如果输入bye则退出:
// 与服务器的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf[MAXLINE];
while (1)
{
// 接收服务器提示
recv_data(cfd, buf);
printf("[Server] %s\n[Client] ", buf);
// 向服务器发送姓名
fgets(buf, MAXLINE, stdin);
buf[strlen(buf) - 1] = '\0';
send_data(cfd, buf, 0);
if (strcmp(buf, "bye") == 0) // 如果输入 bye,退出循环
break;
}
}
2.5 基于 UDP socket
的聊天室
2.5.1
chatutil.c应用层协议
2.5.1.1 协议报文格式
为了在 UDP
协议的基础上实现聊天室用户注册、客户端向服务器的消息传送、服务端对消息的广播,因此在chatutil.h里定义了自己设计的应用层协议(后文暂且称为简单聊天协议)的报文格式。头部部分结构体为struct heaer:
#pragma once
#pragma pack(push, 1)
struct header // 应用层报文头部
{
uint16_t id;
uint8_t flags;
uint8_t len;
};
#pragma pack(pop)
采用#pragma pack(1)强制一字节对齐。其对应如下:
| 客户端 ID 高 8 位 | 客户端 ID 低 8 位 |
| 标志位 | 数据部分长度 |
头部之后紧接着就是数据部分
2.5.1.2 协议确认机制
为了确保客户端能及时检测到服务器挂了,而不是一直接收,不知道是没有人发送消息还是服务器挂了,因此针对客户端向服务器的通信引入了确认机制:
- 服务器会对客户端发送的 REG 报文进行 REG ACK 或 REG
NAK 确认
- 服务器会对客户端发送的 SND 报文进行 SND ACK
确认
- 服务器会对客户端发送的 FIN 报文进行 FIN ACK
确认
- 如果一段时间内客户端没有收到确认,则认为服务器已经挂了。超时时间在
chatutil.h里定义为TIMEOUT,为
8 秒
2.5.1.2 应用层的收发函数
为了处理自己设计的应用层协议,还需设计针对该协议的收发函数。而服务器的收发和客户端的收发又有所不同,因为
UDP
的无连接性质,服务器需要知道另一方的地址,即要使用sendto和recvfrom。以服务器的发送函数举例:
// 服务器发送简单聊天协议报文
// cfd: socket 文件描述符
// cip: 接收方客户端 IP 地址
// cport: 接收方客户端端口号
// id: 接收方客户端 ID
// flags: 协议报文标志位
// data: 协议报文数据部分
void server_send_chat(int cfd, char *cip, int cport, int id, int flags, char *data)
{
int len = strlen(data) + 1;
struct header hd;
char buf[sizeof(hd) + len];
hd.id = id;
hd.flags = flags;
hd.len = len;
memcpy(buf, &hd, sizeof(hd));
memcpy(buf + sizeof(hd), data, len);
send_data_to(cfd, buf, cip, cport, sizeof(hd) + len);
}
需要知道要发送的客户端的 ID、IP
地址、端口号,以及需要指明的标志。之后该函数通过这些信息先构建简单聊天协议的头部,再将头部和数据部分拼接起来,复制到buf中,再使用csutil.c中的send_data_to函数发送。
再以客户端的接收函数举例。由于在 2.2.3
中,客户端事先已经通过connect函数指明服务器的地址,因此客户端不需要在收发函数中再指明服务器的地址:
// 客户端接收简单聊天协议报文
// cfd: socket 文件描述符
// flags: 协议报文标志位
// data: 协议报文数据部分
// timeout: 等待 ACK 的时间,超过此时间则认为服务器挂了
// return: 发送方客户端 ID
int client_recv_chat(int cfd, int *flags, char *data, int timeout)
{
struct timeval tv;
struct header hd;
char buf[MAXLINE];
tv.tv_usec = 0;
tv.tv_sec = timeout;
if (setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) // 设置接收的等待时间
throw_exception("setsockopt");
recv_data(cfd, buf);
tv.tv_sec = 0;
if (setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) // 恢复接收的等待时间
throw_exception("setsockopt");
memcpy(&hd, buf, sizeof(hd));
memcpy(data, buf + sizeof(hd), hd.len);
*flags = (int)hd.flags;
return hd.id;
}
同样,客户端收到数据后,先存放到buf中,之后依次复制给header头部结构体和data数据部分。再将header中的flags字段给flags指针指向的变量,并返回header中的客户端
ID。
为了考虑服务器突然掉线的情况,本函数接收timeout参数,单位秒。通过setsockopt函数设置了recv系列函数的超时时间。如果超时,则recv会产生
Resource not avaliable 的错误信息,并推出程序。
2.5.2 服务端程序
2.5.2.1 主函数部分
与 2.3.2 和 2.4.2
的服务端程序类似,主函数中只需将make_server的协议参数改为
UDP 即可:
make_server(ip, port, UDP, handler, false);
2.5.2.2 客户端信息存储
定义client结构体存放单个用户信息,包括
IP、端口号、名称,以及是否存活:
// 客户端信息结构体
struct client
{
bool alive; // 是否存活
char ip[INET_ADDRSTRLEN]; // 客户端 IP
char name[MAXNAME]; // 客户端名称
int port; // 客户端端口号
};
这里引入是否存活字段alive的机理是,初始化时,所有的alive都为false。当客户端连接时,该客户端
ID
对应的alive变为true。当客户端向服务器发送
FIN 时,服务器就将该客户端 ID
对应的alive设为false。待新用户加入时,如果发现有
ID 对应的内容为false,即可将该 ID
分配给新的用户,直接覆盖原有数据。这省去了数组元素的添加、删除操作。
之后,建立clis数组,数组下标即为客户端
ID。这样只需通过 ID
一次寻址即可获得客户信息,而不用写个循环进行查找:
struct client clis[MAXCLIENT];
为了在用户注册时查找是否有名称重复,因此编写了has_name函数:
// 当前已连接的客户端中是否存在该名称
// name: 待查找的名称
// return: true 为存在,false 为不存在
bool has_name(char *name)
{
for (int i = 1; i < MAXCLIENT; i++)
if (clis[i].alive && strcmp(clis[i].name, name) == 0)
return true;
return false;
}
在同意用户注册后,需要给该用户分配一个 ID,并且在该
ID
对应的结构体中存放用户信息。因此编写了cli_alloc函数:
// 为刚加入的客户端分配 ID
// name: 客户端姓名
// ip: 客户端 IP 地址
// port: 客户端端口号
// return: 分配到的客户端 ID,0 表示已满
int cli_alloc(char *name, char *ip, int port)
{
for (int i = 1; i < MAXCLIENT; i++)
if (!clis[i].alive)
{
clis[i].alive = true;
strcpy(clis[i].name, name);
strcpy(clis[i].ip, ip);
clis[i].port = port;
return i;
}
return 0;
}
从下标 1
开始检测到第一个alive字段为false的客户端,分配给新的客户端使用。如果客户端已满,则返回
0。
2.5.2.3 应用层报文处理
在应用层的handler函数中,首先调用chatutil.c中的server_recv_chat接受一个客户端发来的一个简单聊天协议的应用层报文,并打印该报文信息:
char cip[INET_ADDRSTRLEN], data[MAXDATALEN], appended_data[MAXDATALEN]; // appended_data 为服务器修改后的发送内容
int cport, id, flags;
// 打印服务器收到的应用层报文信息
id = server_recv_chat(sfd, cip, &cport, &flags, data);
printf("%s:%d, id=%d, flags=%d, data=%s\n", cip, cport, id, flags, data);
之后,根据该报文的标志位不同,做不同的处理。首先是
REG 标志位,处理用户注册的情况:
// 收到注册报文情况
if (flags & F_REG)
{
if (has_name(data)) // 名称冲突,发送 NAK 报文反馈
server_send_chat(sfd, cip, cport, id, F_REG | F_NAK, "Name has already used");
else
{
id = cli_alloc(data, cip, cport);
if (id != 0)
{
server_send_chat(sfd, cip, cport, id, F_REG | F_ACK, ""); // 注册成功,发送 ACK 报文反馈
sprintf(appended_data, "%s (%s:%d) joins the conversation", clis[id].name, clis[id].ip, clis[id].port);
for (int i = 1; i < MAXCLIENT; i++) // 向其他客户端广播该成员已经加入
if (clis[i].alive)
server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}
else // 服务器已满,发送 NAK 报文反馈
server_send_chat(sfd, cip, cport, id, F_REG | F_NAK, "Server is full");
}
}
当用户注册成功时,服务器会像所有的客户端(包括注册的客户端)广播该客户加入聊天的信息。
接下来考虑 SND 标志位:
// 收到发送报文情况
else if (flags & F_SND)
{
server_send_chat(sfd, cip, cport, id, F_SND | F_ACK, ""); // 发送 ACK 报文反馈
sprintf(appended_data, "[%s] %s", clis[id].name, data);
for (int i = 1; i < MAXCLIENT; i++) // 向其他客户端广播该客户端发送的信息
if (clis[i].alive && i != id)
server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}
服务器首先向发送方回复
ACK,然后将从客户端收到的聊天信息转发给其他的客户端。
最后考虑 FIN 标志位:
// 收到结束报文情况
else if (flags & F_FIN)
{
server_send_chat(sfd, cip, cport, id, F_FIN | F_ACK, ""); // 发送 ACK 报文反馈
clis[id].alive = false;
sprintf(appended_data, "%s (%s:%d) leaves the conversation", clis[id].name, clis[id].ip, clis[id].port);
for (int i = 1; i < MAXCLIENT; i++) // 向其他客户端广播该成员已经离开
if (clis[i].alive && i != id)
server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}
服务器也是先回复
ACK,然后将该客户都标志位置为false,最后向其他成员广播该成员离开的信息。
2.5.3 客户端程序
2.5.3.1 主函数部分
因为普通的程序输入和输出只能依次进行,就像之前的注册和签到服务一样,各自进行一轮收发。但是聊天室的用户应该想发就发。因此主函数部分首先注册
Ctrl+C
信号的处理函数switch_mode,用于客户端可以在按下
Ctrl+C 后立即切换到输入模式。
而连接服务器部分只需稍微修改contact_server的参数变成
UDP 即可:
signal(SIGINT, switch_mode); // 注册 Ctrl+C 信号的处理程序。在聊天过程中,Ctrl+C 为切换到输入模式
...
contact_server(ip, port, UDP, handler);
之后的注册和接受消息环节靠handler实现,而发送消息环节靠信号处理实现。
2.5.3.2
注册和接收消息环节
首先是注册环节:
// 注册环节
while (1)
{
printf("Please input your name: ");
fgets(name, MAXNAME, stdin);
name[strlen(name) - 1] = '\0';
client_send_chat(cfd, 0, F_REG, name);
while (1) // 等待服务端的 F_ACK,如果超时则直接报错退出
{
id = client_recv_chat(cfd, &flags, data, TIMEOUT);
if ((flags & F_REG) && (flags & (F_ACK | F_NAK)))
break;
}
if (flags & F_ACK) // 如果服务器同意注册,则退出循环,否则重新起名字
break;
printf("%s\n", data);
}
可见,在注册环节中也引入了超时机制。如果超时未收到
ACK 或 NAK 回复,则认为服务器挂了,退出。
接收消息环节很简单,只需一直循环接收并打印即可:
// 接收消息环节
while (1)
{
client_recv_chat(cfd, &flags, data, 0);
if (flags & F_BRD)
printf("%s\n", data);
}
注意这里的超时时限被设置为
0,即无限长,因为可能存在大家都潜水一直不说话的情况。因此若要检测服务器是否正常运作,需要靠接下来的发送消息环节的确认实现。
2.5.3.4 发送消息环节
以下是switch_mode的部分代码:
// 切换到输入消息模式(按下 Ctrl+C 触发)
void switch_mode()
{
...
// 打印自己的名称,提示输入,获取输入
printf("\r[%s] ", name);
fgets(data, MAXDATALEN, stdin);
data[strlen(data) - 1] = '\0';
...
// 输入为其他聊天信息的情况
client_send_chat(cfd, id, F_SND, data);
while (1) // 等待服务端的 F_ACK,如果超时则直接报错退出
{
client_recv_chat(cfd, &flags, data, TIMEOUT);
if ((flags & F_SND) && (flags & F_ACK))
break;
else if (flags & F_BRD)
printf("%s\n", data);
}
}
可以看到,按下 Ctrl+C
后,首先屏幕上会打印出自己的名字提示输入,之后按下回车,客户端就将该输入信息发送给服务端,并启动超时等待服务端
ACK
报文。如果超时未收到该报文,则认为服务器挂了,结束。如果没有先收到
ACK,而是收到 BRD 广播消息,则也正常打印该消息。
3. 代码运行和探究
使用make命令,借助之前写的 Makefile
进行自动化编译,之后输入./程序名 ip 端口即可运行客户端或服务器。
3.1
简单的网络注册服务(迭代)
3.1.1 常规运行效果
打开 1 个服务器(右中),4
个客户端(左边和右上角),以及一个查看netstat命令的终端。效果如下。
步骤一:开启服务器,运行在本地 6666
端口。然后按照左上、左中、左下、右上的顺序开启客户端。之后可见左上的服务端优先和服务器通信,其他的则进行等待。由netstat可知,所有的连接均处于ESTABLISHED状态,即三次握手完成。并且由服务端输出的信息可知,当前是端口号
36656 的进程在和服务器通信。如下图所示:

步骤二:
在左上输入bye,之后左中的客户端立即和服务器开始通信。同时由netstat,原来
6666 到 36656 的连接关闭,而 36656 到 6666
端口的连接处于关闭前的TIME_WAIT状态。这是因为如课本
5-29 图所示,服务器收到客户的 FIN 和 FIN ACK
后,就关闭了连接,而客户端要在收到服务器的 FIN ACK
后,等待 2MSL
再关闭连接。且由服务器输出信息可知,当前客户端端口号为
36658。如下图所示:

步骤三:在左中输入bye,左下角立即开始连接。可见其是按发起连接的先后顺序来的。此时的netstat中
56658 到 6666
端口也处于TIME_WAIT状态,而右上角的可怜的客户端仍在等待中。如下图所示:

步骤四:在左下输入bye,右上和服务器通信,输入完后再输入bye。等待一段时间后(让这段时间超过
2MSL),再使用netstat查看,则以上的连接全部释放。如下图所示:

可见其表现符合预期。
上文提到,这里的 TCP
连接的backlog设置为了SOMAXCONN,在本地系统上是
128,因此这几个客户端的连接均被接受,且一直在等待中
3.1.2 TCP
连接数限制情况
之前在csutil.c的make_server函数中,将
TCP
连接数(即listen的backlog参数)设置为SOMAXCONN,为
128,既不是 0 也不是 1。若将其改为
0,重复上述步骤,如下图所示:

可以发现,右下角用netstat的时候,端口号最高的那个客户端
36690(左下)、36692(右上)对 6666
的连接为SYN_SENT状态,只完成一次握手。并且不久之后,这两个就出现了
Connection timed out
的错误,可见他们并没有成功和服务器建立连接。这是因为由课本图
5-28 所示,TCP 建立连接前要进行三次握手。而由于 TCP
最大连接数的限制,后两个客户端发送 SYN
后,服务端没有响应 SYN
ACK,导致只完成一次握手,并且因为握手超时而结束链接
现在将其改为 1,重复上述步骤,如下图所示:

可见这次多了一个客户端的连接容量。据此推测最大可连接的客户端数可能是backlog + 2。
3.1.3
客户机绑定固定端口情况
为此,在csutil.c的contact_server函数中插入以下代码,将客户端绑定在本地的
5555 端口:
struct sockaddr_in caddr;
get_addr("127.0.0.1", 5555, &caddr);
if (bind(fd, (struct sockaddr *)&caddr, sizeof(caddr)) < 0)
throw_exception("bind");
之后,使用make重新编译,先让客户端给服务器通信,然后关闭,再给服务器通信。第一次通信时一切正常,但如果结束后,立即通信,客户端程序会被提示
Address already in use,说明之前绑定的 5555
端口号已被占据,如下图所示:

可以看到,之前建立的连接仍处于TIME_WAIT状态。因此如果在连接完全释放之前再用同样的端口绑定,则会因为之前的端口号还在使用中而无法绑定。但是如果等待一段时间,经过
2MSL 后,再发起连接,则又可以通信:

因此,不建议客户端绑定同样的端口号,是因为客户端关闭连接后,连接需要等待一段时间完全释放,因此端口号不能立即复用,使得在一段时间内无法建立连接。
3.1.4 IP
地址和端口的字节序转换
由于inet_ntop和inet_pton两个函数自带网络字节序转换。为此,只能演示端口的网络字节序不转换的情况。将csutil.c中的get_addr函数中的
port_net = htons(port);
改为
port_net = port;
使用make编译后,仍然在 6666
端口运行服务器:

可以看到,仍然能够正常通信,这是因为客户端和服务器都使用到get_addr函数,因此名为
6666 端口,实际均当作了 2586
端口。但是在实际应用上,这种错误是不允许的。
接下来分析一下原因。将 6666 转成 16
位二进制数,分成两个字节为 6666_{10}=0001\;1010\quad0000\;1010_2 而将这两个字节反序,而字节内的顺序不变,再转回
10 进制,就是 2586: 0000\;1010\quad0001\,1010_2=2586_{10}
可见在网络和在本地机器上使用了不同的端序来存放数据,因此转换是必要的。
3.2
简易的网络签到服务(并发)
3.2.1 常规运行效果
打开 1 个服务器(右下),也是运行在 6666 端口;5
个并发客户端(左边和右上、右中)。效果如下:
图
1:五个客户端均可同时与服务器建立连接,服务器也展示了 5
个客户端连入的信息。

图 2:5
个客户端可以同时发送姓名给服务器签到,服务器打印签到信息。对于不存在的姓名,也能提示错误:

可见其表现符合预期。
3.2.2
父进程中是否关闭cfd的影响
在上述的代码中,服务器通过socket获取的描述符为sfd,而通过accept获取的面向单个连接的描述符为cfd。上述代码在父进程中关闭了cfd,其解释已经在
2.2.2.3 中提到一些。
以下左侧的 6666
端口是父进程关闭cfd的情况,右侧的 8888
端口是父进程不关闭cfd的情况,在通信时没有差异,但是当客户端退出时,两侧的netstat结果不同:

左侧的正常进入了TIME_WAIT,不久后连接被完全释放。而右侧的服务器处于CLOSE_WAIT状态,客户端处于FIN_WAIT2状态,这说明只完成了
FIN 和 ACK
的两次握手。也就是说服务器的应用进程并没有关闭该连接,没有主动发起
FIN ACK。
这可以通过 UNIX
的文件描述符复制来解释。当进程被fork后,子进程中的文件描述符就像是被dup函数过了一样,即父进程和子进程的这两个文件描述符都指向一个
file
table,其中包含了文件状态,当前偏移量等文件信息。而只有当指向某个
file table 的文件描述符为 0
时,该文件会真正关闭,否则只是对某个进程“关闭”,从进程中的
process table
中移除。因此,如果只在子进程中调用close,则其虽然在子进程中的
process table 被移除了,但是父进程中并没有,因此该连接的
file table
仍然存在,也就意味着连接并未关闭。因此,需要在父进程中关闭cfd,否则浪费系统资源,也让客户端不能完全释放连接。
3.3 基于 UDP socket
的聊天室
3.3.1 运行效果
也是在本地的 6666
端口开一个服务器(右下),之后五个客户端进行聊天。默认是接收信息,如果要发送,则需要按
Ctrl+C:

可见,左下角的在注册姓名时,尝试使用重复的姓名,导致注册失败。并且服务器会发送用户加入和退出的提示及其地址。服务器端也会记录日志,保留每一次的客户端发来的简单聊天协议应用层报文。
可以看到,在注册输入姓名时,服务器收到的报文的id为
0,flags为 4,代表
REG。而发送消息时,服务器收到各自用户的id,且flags为
8,代表 SND。用户退出时,flag为 16,代表
FIN。
3.3.2 异常捕捉
紧接着上述聊天。如果服务器突然挂了(按 Ctrl+C
结束),则客户端再向其发消息的时候,就会报错。这里因为端口已经关闭,而不是服务器无响应,因此会立即有
Connection refused 错误:

如果我们乱输一个公网的地址,则可能该报文永远不会得到回应,这时候之前引入的超时机制就起作用了。会在超时
8 秒之后报错退出,错误内容是 Resources unavailable:

3.3.3 真实环境:UDP
的不可靠传输
接下来,在自己的服务器 f5soft.site 的
6666 端口(公网地址为
39.97.114.106:6666)部署该服务器,同时在自己的电脑上启动
4
个客户端,连接公网服务器。可以发现,绝大部分功能均正常运行。但是由于
UDP
的不可靠传输,且处于公网环境,其下层也是不可靠的(不像本地网络的下层是可靠的),因此在公网的环境中出现了
UDP 数据报的丢失!

可见 UDP
确实不可靠。如果要彻底解决这一问题,需要在我们的应用层(简单聊天协议)实现可靠传输,因此除了之前的超时机制外,还要引入重传机制等。
4. 实验小结
本次实验,体会并掌握了
- 通过合理的代码复用降低程序的代码量
- UNIX 网络套接字编程基础
- UNIX 多进程并行程序设计
- Socket 网络地址的获取与转换
- TCP、UDP 服务器的建立
- TCP、UDP 客户端与服务器的通信
- TCP
建立连接时的三次握手过程和释放连接时的四次握手过程
- 使用
netstat命令查看本机的连接和端口状况 - 实现自己的应用层协议——简单聊天协议
- 生产环境和本地环境的区别及 UDP 的不可靠传输验证
5. 相关文件记录
- 代码文件位于
src文件夹中,其中server_reg.c和client_reg.c:简单的网络注册服务器和客户端server_check.c和client_check.c:简易的网络签到服务器和客户端server_chat.c和client_chat.c:UDP
聊天室服务器和客户端csutil.c和csutil.h:Client
& Server Utility
Function,客户端和服务器间通信所需的函数,封装了很多对
socket 的操作stuutil.c和stuutil.h:Student
Utility
Function,建立、查询、写入学生数据库所需函数的封装chatutil.c和chatutil.h:Chatting
Utility Function,基于 UDP
的自定义聊天应用层协议的实现Makefile:Makefile 自动化编译文件
若要编译运行,在 Linux
环境下,在src目录下输入make,即可生成编译后的文件
点此下载实验代码