2020-11-21

计网实验:Socket 编程

点此下载实验代码

1. 实验目的

  • 掌握 TCP 和 UDP 协议主要特点和工作原理
  • 理解 socket 的基本概念和工作原理
  • 编程实现 socket 网络通信(C/Python/Java)

2. 实验代码实现

2.1 代码组织结构

本次实验实现的 3 个项目(6 个程序)的代码分别为:

  • server_reg.cclient_reg.c:简单的网络注册服务器和客户端
  • server_check.cclient_check.c:简易的网络签到服务器和客户端
  • server_chat.cclient_chat.c:UDP 聊天室服务器和客户端

其中这些代码需要依赖以下的自己写的函数库:

  • csutil.c:Client & Server Utility Function,客户端和服务器间通信所需的函数,封装了很多对 socket 的操作
  • stuutil.c:Student Utility Function,建立、查询、写入学生数据库所需函数的封装
  • chatutil.c:Chatting Utility Function,基于 UDP 的自定义聊天应用层协议的实现

也包括了这三个函数库的头文件:csutil.hstuutil.hchatutil.h

为了方便一起编译和链接数量众多的代码文件,同时写了 Makefile。其中本次实验的 6 个程序分别编译输出为server_regclient_regserver_checkclient_checkserver_chatclient_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:

1
2
3
4
5
6
7
// 异常处理。如果有异常,打印错误信息,直接退出
// funcname: 出错的函数名称
void throw_exception(char *funcname)
{
    perror(funcname);
    exit(1);
}

2.2.2 IP:portsockaddr结构体的转换函数

在我们使用 UNIX 的bindconnectsendto函数时,需要传入sockaddr结构体。而通常我们只是手动输入 IP 和端口号。因此编写了get_addr函数进行 IP+端口号到sockaddr_in结构体的转换:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 将 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 的acceptrecvfrom函数时,我们需要获取是哪个 IP 的哪个端口上的客户端向我们发来数据。因此也需要将从这两个函数中获得的sockaddr结构体转换为 IP+端口号:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 将 `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_addrinet_ntoa等函数,因为在《UNIX 环境高级编程》一书的第 16 章中提到,这两个函数没有这里使用的inet_ptoninet_ntop有更好的兼容性

这里的转换都只是考虑 IPv4 地址字符串,且不支持输入域名再解析 IP 地址。如果是 IPv6 地址,应将sockaddr_in结构体换成sockaddr_in6,且将AF_INET换成AF_INET6

2.2.2 服务器的建立函数

2.2.2.1 函数原型

由于建立服务器的代码在本实验中也经常复用,因此将建立服务器的过程封装成make_server函数。函数原型如下:

1
2
3
4
5
6
7
// 建立一个运行在 `sip`:`sport` 的服务器
// sip:      服务器 IP
// sport:    服务器端口
// protocol: 应用层协议,可以是 TCP 或 UDP
// handler:  处理数据收发函数
// block:    是否阻塞连接(是否为迭代模式)
void make_server(char *sip, int sport, int protocol, void (*handler)(int), bool block);
  • sipsport:该函数在sip:sport上运行服务器。下文中出现的变量名,前缀带有s的意为服务端的信息,前缀带有c的意为客户端的信息

  • protocol:服务器的传输层协议由protocol指定,可以是TCPUDP。这两个宏常量在csutil.h里分别被定义为SOCK_STREAMSOCK_DGRAM

  • handler:服务器遇到用户连接时的处理函数。该函数需要接受一个参数,为用于该连接的(套接字)文件描述符fd,也即accept函数的返回值(对于 TCP 协议)或socket函数的返回值(对于 UDP 协议)

  • block:服务器是否在处理用户连接的过程中阻塞。即服务器能否多进程同时处理并发请求

block参数只适用于 TCP 的情况。对于 UDP 聊天室而言,由于 UDP 是无连接的,因此不会发生和客户端连接时的阻塞情况,该参数可以任意设置

该函数具体实现如下。首先,进行固定的socketbind操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 服务器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 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 服务器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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进行数据收发即可:

1
2
3
4
5
6
// UDP
else if (protocol == UDP)
{
    while (1)
        handler(sfd);
}

2.2.3 客户发起与服务器的通信函数

客户端与服务器发起通信也是一个在本实验中经常复用的函数。这里和建立服务器类似,函数需要提供服务器 IP、服务器端口号、运输层协议、以及handler函数这几个参数。之后函数通过connect与服务器建立连接,获取到用于该连接的文件描述符cfd,之后调用handler,传入cfd参数进行数据的收发处理。具体实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 向 `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函数指定的地址接收数据报。不需要我们每次使用sendtorecvfrom函数指明地址。

2.2.4 数据收发函数

由于 UNIX 提供的收发函数仍然较为底层,且没有异常处理,因此针对recvsendrecvfromsendto这几个函数编写了更友好的封装:recv_datasend_datarecv_data_fromsend_data_to,并添加了异常处理。以recv_data_from为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 封装的更方便的`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函数为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 封装的更方便的`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函数,向数据库中添加学生信息,即直接在该文本文件后追加信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 向数据库中添加学生
// 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函数即可。例如,服务端的完整代码如下:

 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
/* 注册服务器 */

#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函数实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 与服务器的数据收发处理
// 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

 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
// 数据库中是否存在该学生
// 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 服务器:

1
make_server(ip, port, TCP, handler, false);

服务端程序的handler函数如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 与客户端的数据收发处理
// 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则退出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 与服务器的数据收发处理
// 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

1
2
3
4
5
6
7
8
9
#pragma once
#pragma pack(push, 1)
struct header // 应用层报文头部
{
    uint16_t id;
    uint8_t flags;
    uint8_t len;
};
#pragma pack(pop)

采用#pragma pack(1)强制一字节对齐。其对应如下:

0-7bit 8-15bit
客户端 ID 高 8 位 客户端 ID 低 8 位
标志位 数据部分长度

头部之后紧接着就是数据部分

  • 客户端 ID:每个连接到 UDP 聊天室服务器的客户端都有一个唯一的客户端 ID,用于标识客户端的身份。如果一个客户端刚进入聊天室,但还没有注册,则使用默认 ID,数值为 0。由于 ID 有 16 位,因此本 UDP 服务器最大可支持 65535 个客户端同时聊天

  • 标志位:用于标志该报文的作用。具体 8 个 bit 由高位到低位的分布为:

0x80 0x40 0x20 0x10 0x08 0x04 0x02 0x01
保留 保留 BRD FIN SND REG NAK ACK
  • ACK:服务器同意注册的确认报文,以及对客户端发送的消息应答的确认报文
  • NAK:服务器不同意注册的否定报文(名称重复、当前在线客户端数已满)
  • REG:客户端的注册请求报文
  • SND:客户端向服务器发送的聊天消息报文
  • FIN:客户端退出聊天室报文
  • BRD:服务器向客户端广播聊天消息及其他通知信息报文

  • 数据部分长度:按字节计算的数据部分的长度

2.5.1.2 协议确认机制

为了确保客户端能及时检测到服务器挂了,而不是一直接收,不知道是没有人发送消息还是服务器挂了,因此针对客户端向服务器的通信引入了确认机制:

  • 服务器会对客户端发送的 REG 报文进行 REG ACK 或 REG NAK 确认
  • 服务器会对客户端发送的 SND 报文进行 SND ACK 确认
  • 服务器会对客户端发送的 FIN 报文进行 FIN ACK 确认
  • 如果一段时间内客户端没有收到确认,则认为服务器已经挂了。超时时间在chatutil.h里定义为TIMEOUT,为 8 秒
2.5.1.2 应用层的收发函数

为了处理自己设计的应用层协议,还需设计针对该协议的收发函数。而服务器的收发和客户端的收发又有所不同,因为 UDP 的无连接性质,服务器需要知道另一方的地址,即要使用sendtorecvfrom。以服务器的发送函数举例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 服务器发送简单聊天协议报文
// 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函数指明服务器的地址,因此客户端不需要在收发函数中再指明服务器的地址:

 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
// 客户端接收简单聊天协议报文
// 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 即可:

1
make_server(ip, port, UDP, handler, false);
2.5.2.2 客户端信息存储

定义client结构体存放单个用户信息,包括 IP、端口号、名称,以及是否存活:

1
2
3
4
5
6
7
8
// 客户端信息结构体
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 一次寻址即可获得客户信息,而不用写个循环进行查找:

1
struct client clis[MAXCLIENT];

为了在用户注册时查找是否有名称重复,因此编写了has_name函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 当前已连接的客户端中是否存在该名称
// 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函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 为刚加入的客户端分配 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接受一个客户端发来的一个简单聊天协议的应用层报文,并打印该报文信息:

1
2
3
4
5
6
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 标志位,处理用户注册的情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 收到注册报文情况
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 标志位:

1
2
3
4
5
6
7
8
9
// 收到发送报文情况
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 标志位:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 收到结束报文情况
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 即可:

1
2
3
signal(SIGINT, switch_mode); // 注册 Ctrl+C 信号的处理程序。在聊天过程中,Ctrl+C 为切换到输入模式
...
contact_server(ip, port, UDP, handler);

之后的注册和接受消息环节靠handler实现,而发送消息环节靠信号处理实现。

2.5.3.2 注册和接收消息环节

首先是注册环节:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 注册环节
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 回复,则认为服务器挂了,退出。

接收消息环节很简单,只需一直循环接收并打印即可:

1
2
3
4
5
6
7
// 接收消息环节
while (1)
{
    client_recv_chat(cfd, &flags, data, 0);
    if (flags & F_BRD)
        printf("%s\n", data);
}

注意这里的超时时限被设置为 0,即无限长,因为可能存在大家都潜水一直不说话的情况。因此若要检测服务器是否正常运作,需要靠接下来的发送消息环节的确认实现。

2.5.3.4 发送消息环节

以下是switch_mode的部分代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 切换到输入消息模式(按下 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.cmake_server函数中,将 TCP 连接数(即listenbacklog参数)设置为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.ccontact_server函数中插入以下代码,将客户端绑定在本地的 5555 端口:

1
2
3
4
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_ntopinet_pton两个函数自带网络字节序转换。为此,只能演示端口的网络字节序不转换的情况。将csutil.c中的get_addr函数中的

1
port_net = htons(port);

改为

1
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.cclient_reg.c:简单的网络注册服务器和客户端
  • server_check.cclient_check.c:简易的网络签到服务器和客户端
  • server_chat.cclient_chat.c:UDP 聊天室服务器和客户端
  • csutil.ccsutil.h:Client & Server Utility Function,客户端和服务器间通信所需的函数,封装了很多对 socket 的操作
  • stuutil.cstuutil.h:Student Utility Function,建立、查询、写入学生数据库所需函数的封装
  • chatutil.cchatutil.h:Chatting Utility Function,基于 UDP 的自定义聊天应用层协议的实现
  • Makefile:Makefile 自动化编译文件

若要编译运行,在 Linux 环境下,在src目录下输入make,即可生成编译后的文件

点此下载实验代码