【C语言系统编程】【第三部分:网络编程】3.3 实践与案例分析

3.3 实践与案例分析

在本章节中,我们将通过一些具体的案例来展示如何在实际项目中进行网络编程。这些案例不仅能帮助你理解各个概念,还能提升你的实践技能,并为你将来的项目提供参考。

3.3.1 案例分析:简单的聊天室

聊天室是网络编程中的经典案例,通过这一案例,您将学习如何构建一个基于TCP协议的服务器/客户端模型,实现消息广播机制,并处理多线程/进程并发问题。

3.3.1.1 TCP 服务器/客户端模型

在这个部分,我们将介绍如何使用TCP协议建立服务器和客户端,管理连接,并传输数据。

  • 服务器端代码示例
多线程TCP服务器示例

此示例程序展示了如何使用多线程通过TCP协议实现简单的服务器。服务器能够接受多个客户端连接并为每个连接创建一个线程来处理通信。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h> // 用于线程 [1]

#define PORT 8080
#define MAX_CLIENTS 10

void *handle_client(void *arg); // 处理客户端连接的函数声明

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    pthread_t tid; // 线程标识符 [2]

    // 创建套接字 [3]
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项,允许端口重用 [4]
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 填写服务器地址信息 [5]
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY; // 允许来自任何IP的连接
    address.sin_port = htons(PORT); // 转换端口为网络字节顺序

    // 将套接字绑定到地址 [6]
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }
    
    // 监听传入的连接 [7]
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 接受客户连接并创建线程处理 [8]
    while ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) >= 0) {
        if (pthread_create(&tid, NULL, handle_client, (void *)&new_socket) != 0) {
            perror("pthread_create");
            close(new_socket);
        }
    }

    if (new_socket < 0) {
        perror("accept");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    return 0;
}

void *handle_client(void *arg) {
    int sock = *(int *)arg; // 客户端的套接字描述符 [9]
    char buffer[1024] = {0};
    int valread;

    // 读取并处理来自客户端的数据 [10]
    while ((valread = read(sock, buffer, 1024)) > 0) {
        buffer[valread] = '\0';
        // 目前简单地打印接收到的消息
        printf("Received: %s\n", buffer);
    }

    // 关闭客户端套接字并退出线程
    close(sock);
    printf("Client disconnected\n");
    pthread_exit(NULL); // 退出线程 [11]
}
  • [1] 用于线程#include <pthread.h> 引入了多线程编程的头文件,允许使用pthread库来处理并发任务。
  • [2] 线程标识符pthread_t tid 是一个线程标识符,用于引用新创建的线程。
  • [3] 创建套接字:调用 socket() 函数创建一个套接字,通过标记 AF_INETSOCK_STREAM 来创建基于TCP的网络连接。
  • [4] 设置套接字选项:通过 setsockopt() 函数允许端口重用,以便在程序重启时能立即重新绑定现有端口。
  • [5] 填写服务器地址信息:通过 struct sockaddr_in 结构填入IP和端口信息。
  • [6] 套接字绑定:通过 bind() 函数将套接字与服务器地址绑定,使套接字与网络地址相关联。
  • [7] 监听传入的连接:通过 listen() 函数使套接字进入监听状态,准备接受传入的客户端连接。
  • [8] 创建线程处理连接:使用 accept() 接受连接,然后通过 pthread_create() 创建一个新线程为该连接处理通信。
  • [9] 客户端的套接字描述符int sock = *(int *)arg 将传递过来的套接字描述符参数转换回整型引用。
  • [10] 读取和处理数据:在 handle_client() 函数中使用 read() 循环读取客户端发送过来的数据,并显示接收到的内容。
  • [11] 退出线程:通过 pthread_exit() 使处理完的线程正常退出和释放资源。
  • 客户端代码示例
  • 示例代码解析
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define PORT 8080 // 定义服务器端口号 [1]

int main() {
    int sock = 0;
    struct sockaddr_in serv_addr; // 定义服务器地址结构 [2]
    char *message = "Hello from client"; // 客户端发送的消息 [3]
    char buffer[1024] = {0}; // 缓冲区,用于接收服务器响应 [4]

    // 创建套接字并检查是否成功
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        printf("\n Socket creation error \n");
        return -1;
    }

    serv_addr.sin_family = AF_INET; // 设置地址族为IPv4 [5]
    serv_addr.sin_port = htons(PORT); // 设置端口号,并进行网络字节序转换 [6]

    // 将IP地址从点分十进制转换为二进制格式
    if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {
        printf("\nInvalid address/ Address not supported \n");
        return -1;
    }

    // 建立与服务器的连接
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        printf("\nConnection Failed \n");
        return -1;
    }

    // 发送数据到服务器
    send(sock, message, strlen(message), 0);
    printf("Message sent\n");
    
    // 从服务器接收数据
    int valread = read(sock, buffer, 1024); // 读取服务器回应存入缓冲区 [7]
    printf("%s\n", buffer); // 打印服务器响应 [8]
    
    // 关闭套接字
    close(sock);
    return 0;
}
  • [1] 服务器端口号:这里定义的 PORT 8080 是客户端希望连接的服务器的端口号。
  • [2] 服务器地址结构struct sockaddr_in 是一个包含地址信息的结构体,用于存储服务器的IP地址和端口。
  • [3] 客户端消息"Hello from client" 是客户端发送给服务器的消息内容。
  • [4] 缓冲区char buffer[1024] 用于存储从服务器接收到的数据。
  • [5] 地址族AF_INET 表示使用IPv4协议族。
  • [6] 端口设置htons(PORT) 将主机字节序转换为网络字节序,这是网络通讯的标准格式。
  • [7] 读取响应read() 用于从服务器读取数据,并存入 buffer 中,buffer 的最大大小为1024字节。
  • [8] 打印服务器响应printf("%s\n", buffer) 输出来自服务器的响应内容。

这段代码示例展示了一个简单的客户端程序,它通过TCP协议与一个本地服务器(假设运行在127.0.0.1端口8080)通信,发送消息并接收服务器的响应。

3.3.1.2 消息广播机制

消息广播是聊天室设计中的一个重要部分,需要服务器将接收到的消息转发给所有已连接的客户端。

  • 示例代码

要实现消息广播,可以修改handle_client函数,使其能够将接收到的消息发送给所有客户端:

  • 示例代码解析
#include <pthread.h>

#define MAX_CLIENTS 10

int client_sockets[MAX_CLIENTS];  // 保存客户端套接字 [1]
pthread_mutex_t clients_mutex = PTHREAD_MUTEX_INITIALIZER;  // 互斥锁初始化 [2]

// 处理客户端连接的线程函数
void *handle_client(void *arg) {
    int sock = *(int *)arg;  // 客户端套接字 [3]
    char buffer[1024] = {0}; // 缓冲区 [4]
    int valread;

    pthread_mutex_lock(&clients_mutex);  // 加锁:修改共享资源 [5]
    for (int i = 0; i < MAX_CLIENTS; i++) {
        if (client_sockets[i] == 0) {
            client_sockets[i] = sock; // 存储新连接的客户端 [6]
            break;
        }
    }
    pthread_mutex_unlock(&clients_mutex);  // 解锁:完成修改 [7]

    // 接收和广播数据
    while ((valread = read(sock, buffer, 1024)) > 0) {
        buffer[valread] = '\0'; // 添加字符串结束符 [8]
        printf("Received: %s\n", buffer);

        pthread_mutex_lock(&clients_mutex);  // 加锁:广播消息 [9]
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (client_sockets[i] != 0) {
                send(client_sockets[i], buffer, strlen(buffer), 0); // 向所有客户端发送数据 [10]
            }
        }
        pthread_mutex_unlock(&clients_mutex);  // 解锁:广播完成 [11]
    }

    close(sock);  // 关闭客户端套接字 [12]
    pthread_mutex_lock(&clients_mutex);  // 加锁:移除客户端 [13]
    for (int i = 0; i < MAX_CLIENTS; i++) {
        if (client_sockets[i] == sock) {
            client_sockets[i] = 0; // 清除套接字记录 [14]
            break;
        }
    }
    pthread_mutex_unlock(&clients_mutex);  // 解锁:移除完成 [15]
    printf("Client disconnected\n");
    pthread_exit(NULL);  // 线程退出 [16]
}
  • [1] 保存客户端套接字int client_sockets[MAX_CLIENTS] 是一个数组,用来保存连接的客户端的套接字。
  • [2] 互斥锁初始化pthread_mutex_t clients_mutex 定义了一个互斥锁,用于线程同步访问共享资源。
  • [3] 客户端套接字:通过传入的参数 arg 提取客户端的套接字描述符。
  • [4] 缓冲区char buffer[1024] 用于存储从客户端接收到的数据。
  • [5] 加锁:修改共享资源:在添加新客户端记录之前加锁,防止其他线程同时修改 client_sockets
  • [6] 存储新连接的客户端:在共享数组 client_sockets 中记录当前客户端的套接字。
  • [7] 解锁:完成修改:解锁以允许其他线程访问该共享资源。
  • [8] 添加字符串结束符:在缓冲区的结尾加上字符串结束符,确保其是个有效的C字符串。
  • [9] 加锁:广播消息:在向所有客户端发送数据前加锁,确保数据一致性。
  • [10] 向所有客户端发送数据:利用 send() 函数广播消息到每个连接的客户端。
  • [11] 解锁:广播完成:解锁以让其他线程可以访问该共享资源。
  • [12] 关闭客户端套接字:在完成数据读取或连接断开后,关闭当前客户端的网络连接。
  • [13] 加锁:移除客户端:在从 client_sockets 移除客户端套接字前加锁。
  • [14] 清除套接字记录:移除已断开连接的客户端记录。
  • [15] 解锁:移除完成:解锁以允许其他线程相继访问。
  • [16] 线程退出:使用 pthread_exit() 结束处理客户端连接的线程。
3.3.1.3 多线程/进程并发处理

为了支持多个客户端的并发连接,可以使用多线程或多进程模型。在上面的服务器代码中,我们已经使用了多线程模型(pthread_create)来处理并发连接。

  • 关于多线程的注意事项

在使用多线程时,需要注意同步问题。例如在广播消息时,访问客户端数组是一个临界区,需要使用pthread_mutex进行同步。

  • 多进程模型的替换

多进程模型的实现也类似,可以在接受到新连接时创建新的子进程来处理。用fork替换pthread_create即可:

  • 示例代码解析
while ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) >= 0) {
    if (fork() == 0) {  // 子进程执行 [1]
        close(server_fd);                // 关闭服务器文件描述符 [2]
        handle_client((void *)&new_socket); // 处理客户端请求 [3]
        exit(0);                            // 子进程退出 [4]
    } else {  // 父进程继续监听 [5]
        close(new_socket);                  // 关闭新连接的 socket [6]
    }
}
  • [1] 子进程执行: fork() 函数创建一个新的子进程。比较 fork() 的返回值,如果为 0,则意味着在子进程中执行后续代码。
  • [2] 关闭服务器文件描述符: 在子进程中,通过 close(server_fd); 关闭父进程的服务器文件描述符 server_fd,因为子进程不需要监听新的连接。
  • [3] 处理客户端请求: 调用 handle_client() 函数处理新接入的客户端请求,传入 new_socket 的地址作为参数。
  • [4] 子进程退出: 处理完客户端请求后,通过 exit(0); 退出子进程,防止子进程执行不必要的父进程代码。
  • [5] 父进程继续监听: 如果 fork() 返回值大于 0,则表示处于父进程逻辑中,父进程将继续监听其他即将到来的客户端连接。
  • [6] 关闭新连接的 socket: 在父进程中,通过 close(new_socket); 关闭为当前客户端生成的 socket,因为此连接将由子进程处理,父进程无需保留该连接。

这样子进程会处理每一个新的客户端连接,而父进程将继续监听新的连接请求。

通过以上案例分析和代码示例,相信你对如何建立一个简单的聊天室有了一定的了解。这包括了如何使用TCP协议创建服务器和客户端、如何实现消息广播机制,以及如何处理多线程和多进程并发问题。在理解这些基础上,你可以进一步扩展和完善这个项目,例如添加用户认证、改进消息格式等。

3.3.2 案例分析:文件传输程序
3.3.2.1 TCP 连接管理

在文件传输程序中,首先需要保证客户端和服务器之间的连接。TCP是面向连接的协议,它允许建立可靠的双向通信链路。下面展示了一个基本的客户端和服务器的TCP连接管理代码:

  • 服务器端代码解析
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define PORT 8080

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int addrlen = sizeof(address);

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 配置地址和端口
    address.sin_family = AF_INET;             // IPv4协议 [1]
    address.sin_addr.s_addr = INADDR_ANY;     // 监听所有接口 [2]
    address.sin_port = htons(PORT);           // 端口设置 [3]

    // 绑定套接字
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 监听
    if (listen(server_fd, 3) < 0) {           // 监听队列长度为3 [4]
        perror("listen");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 接受连接
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
        perror("accept");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    printf("连接成功\n");
    close(new_socket);                        // 关闭客户端套接字 [5]
    close(server_fd);                         // 关闭服务器套接字 [6]
    return 0;
}
  • [1] IPv4协议address.sin_family = AF_INET 表示使用IPv4地址族。

  • [2] 监听所有接口INADDR_ANY 让服务器监听所有可用的网络接口(例如,eth0、lo等)。

  • [3] 端口设置htons(PORT) 将端口号转换为网络字节序,这是网络协议所需的字节顺序。

  • [4] 监听队列长度listen(server_fd, 3) 设置了服务器的监听队列长度为3,表示最多可积压3个未处理的连接请求。

  • [5] 关闭客户端套接字:用 close(new_socket) 关闭为客户端创建的套接字。

  • [6] 关闭服务器套接字:服务器不再需要监听其他连接时,调用 close(server_fd) 关闭服务器套接字。

  • 示例代码解析

// 客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define PORT 8080  // 定义端口号 [1]

int main() {
    int sock = 0;
    struct sockaddr_in serv_addr;

    // 创建套接字
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {  // [2]
        printf("\nSocket creation error \n");
        return -1;
    }

    serv_addr.sin_family = AF_INET;  // 使用IPv4协议 [3]
    serv_addr.sin_port = htons(PORT);  // 设置端口号并转换字节序 [4]
    
    // 转换IP地址
    if(inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {  // [5]
        printf("\nInvalid address/ Address not supported \n");
        return -1;
    }

    // 连接到服务器
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {  // [6]
        printf("\nConnection Failed \n");
        return -1;
    }

    printf("连接成功\n");  // 输出连接成功信息 [7]
    close(sock);  // 关闭套接字 [8]
    return 0;
}
  • [1] 定义端口号#define PORT 8080 定义了用于连接的端口号,这里是与服务器通信的重要参数。
  • [2] 创建套接字socket() 函数用于创建一个套接字,AF_INET 指定使用IPv4,SOCK_STREAM 表示使用面向连接的TCP协议。
  • [3] 使用IPv4协议serv_addr.sin_family = AF_INET; 设置地址族为 AF_INET,表示使用IPv4协议。
  • [4] 设置端口号并转换字节序htons() 函数用于将主机字节序转换为网络字节序,确保端口号的安全传输。
  • [5] 转换IP地址inet_pton() 函数将点分十进制的IP地址转换为网络字节序的形式,若转换失败返回错误信息。
  • [6] 连接到服务器connect() 函数用于将客户端的套接字连接到指定的服务器地址。
  • [7] 输出连接成功信息:如果连接成功,客户端输出“连接成功”这行为标识。
  • [8] 关闭套接字:使用 close(sock); 关闭客户端的套接字释放资源。
3.3.2.2 数据封包与文件分块传输

在传输大文件时,可以将文件分成若干小块进行传输。为了确保数据的完整性和有效性,可以使用自定义协议对数据进行封包与拆包:

  • 示例代码解析
#define CHUNK_SIZE 1024  // 定义数据块大小为1024字节 [1]

void send_file(FILE *file, int sock) {
    char buffer[CHUNK_SIZE];  // 用于存储文件数据的缓冲区 [2]
    int bytes_read;

    // 读取文件并通过套接字发送
    while ((bytes_read = fread(buffer, sizeof(char), CHUNK_SIZE, file)) > 0) {  // 从文件中读取数据 [3]
        if (send(sock, buffer, bytes_read, 0) < 0) {  // 通过套接字发送数据 [4]
            perror("send");  // 发送失败,输出错误信息 [5]
            break;           // 终止循环
        }
    }
}

void receive_file(FILE *file, int sock) {
    char buffer[CHUNK_SIZE];  // 用于接收套接字数据的缓冲区 [6]
    int bytes_received;

    // 从套接字接收数据并写入文件
    while ((bytes_received = recv(sock, buffer, CHUNK_SIZE, 0)) > 0) {  // 从套接字接收数据 [7]
        fwrite(buffer, sizeof(char), bytes_received, file);             // 将接收到的数据写入文件 [8]
    }
}
  • [1] 数据块大小#define CHUNK_SIZE 1024 定义一个常量 CHUNK_SIZE,用来指定每次传输的数据块大小为1024字节。这个大小可以根据网络和文件情况调整,以达到更优的传输效率。
  • [2] 发送缓冲区:在 send_file 函数中,char buffer[CHUNK_SIZE] 用来暂存从文件读取的数据,准备发送。
  • [3] 从文件中读取数据fread() 函数以块的方式读取数据,其中每次最多读取 CHUNK_SIZE 字节的内容。
  • [4] 通过套接字发送数据send() 函数用于将读取到的文件数据通过网络套接字进行发送,数据长度为 bytes_read
  • [5] 发送失败处理:若 send() 返回值小于0,表示发送失败,使用 perror() 打印错误信息,并中断传输循环。
  • [6] 接收缓冲区:在 receive_file 函数中,char buffer[CHUNK_SIZE] 用来暂存从网络接收到的数据。
  • [7] 从套接字接收数据recv() 函数用于通过网络套接字接收数据,每次接收至多 CHUNK_SIZE 字节。
  • [8] 将接收到的数据写入文件fwrite() 将从套接字接收到的数据写入至指定文件,长度为 bytes_received

总体而言,这段代码实现了从文件读取至缓冲区,再通过套接字发送文件数据的功能,以及从套接字读取数据并写入文件的接收功能。这是一个简单的文件传输机制。

3.3.2.3 进度显示与传输校验

在文件传输过程中,实时显示传输进度可以提高用户体验。同时,通过简单的校验(如校验和)来确保数据的完整性:

代码解析

此代码片段展示了两个主要的功能:一个是通过网络发送文件内容,并实时显示其传输进度,另一个是计算文件的简单校验和。

函数:send_file_with_progress
void send_file_with_progress(FILE *file, int sock) {
    char buffer[CHUNK_SIZE];
    int bytes_read;
    int total_bytes_sent = 0;

    fseek(file, 0, SEEK_END);
    int file_size = ftell(file);
    fseek(file, 0, SEEK_SET);

    while ((bytes_read = fread(buffer, sizeof(char), CHUNK_SIZE, file)) > 0) {
        if (send(sock, buffer, bytes_read, 0) < 0) {
            perror("send");
            break;
        }
        total_bytes_sent += bytes_read;
        printf("进度: %.2f%%\n", (total_bytes_sent / (float)file_size) * 100);
    }
}
  • 作用:发送文件内容,并显示发送进度。
  • 参数file 是待发送的文件指针,sock 是用于发送数据的套接字描述符。

关键知识点讲解

  • [1] 缓冲区初始化char buffer[CHUNK_SIZE]; 定义了一个缓冲区,用来暂存每次从文件读取的部分数据。
  • [2] 获取文件大小
    • 使用fseek(file, 0, SEEK_END);将文件指针移动到文件末尾。
    • int file_size = ftell(file);利用ftell()获取当前文件指针的位置,即文件大小。
    • fseek(file, 0, SEEK_SET);将文件指针复位到文件开头位置。
  • [3] 循环读取与发送
    • 使用fread(buffer, sizeof(char), CHUNK_SIZE, file)循环读取文件数据到缓冲区。
    • 使用send(sock, buffer, bytes_read, 0)发送读取到的字节。
    • 记录并计算已发送的数据量,以展示传输进度。
  • [4] 显示进度
    • printf("进度: %.2f%%\n", (total_bytes_sent / (float)file_size) * 100); 利用总数据量与文件大小之比,实时显示传输进度。
函数:calculate_checksum
unsigned long calculate_checksum(FILE *file) {
    unsigned long checksum = 0;
    char buffer[CHUNK_SIZE];
    int bytes_read;

    rewind(file);
    while ((bytes_read = fread(buffer, sizeof(char), CHUNK_SIZE, file)) > 0) {
        for (int i = 0; i < bytes_read; ++i) {
            checksum += buffer[i];
        }
    }
    rewind(file);
    return checksum;
}
  • 作用:计算文件数据的简单校验和。
  • 参数file是一个指向待计算校验和的文件的指针。

关键知识点讲解

  • [5] 初始化与重置unsigned long checksum = 0; 初始化校验和变量,rewind(file); 确保从文件开始位置进行计算。
  • [6] 校验和计算逻辑
    • fread() 读取的数据块上执行循环,累加字节到checksum
    • 这是一个简单的字节加总算法,不太适合高安全需求场景,但用于简单验证或错误检测是足够的。
  • [7] 文件指针复位:在计算完成后再次rewind(file);,使得文件指针回到开头,以方便后续其他操作。

在使用这些函数时,务必确保文件已正确打开,套接字已正常连接,以避免潜在错误。

在文件传输前后,计算文件的校验和并进行比对,可确保数据无误传输:

FILE *file = fopen("example_file", "rb"); // 打开文件以二进制只读模式 [1]
if (!file) {
    perror("fopen"); // 打印错误信息 [2]
    return;
}

unsigned long checksum = calculate_checksum(file); // 计算文件校验和 [3]
send(sock, &checksum, sizeof(checksum), 0); // 发送校验和 [4]

send_file_with_progress(file, sock); // 发送文件并显示进度 [5]

fclose(file); // 关闭文件 [6]
  • [1] 打开文件以二进制只读模式:使用 fopen() 函数以二进制模式 ("rb") 打开名为 “example_file” 的文件。函数返回一个 FILE* 指针。如果文件成功打开,返回指向该文件的指针;否则返回 NULL
  • [2] 打印错误信息:如果 fopen() 返回 NULL,意味着文件打开失败,perror() 将打印出对应的错误信息到标准错误输出,并返回函数。
  • [3] 计算文件校验和:调用 calculate_checksum(file) 函数计算文件的校验和,这通常用于验证文件内容的完整性。
  • [4] 发送校验和:使用 send() 函数通过 sock 套接字发送计算出的校验和数据。第三个参数表示要发送的数据的大小。
  • [5] 发送文件并显示进度send_file_with_progress(file, sock) 函数用于通过 sock 套接字发送文件内容,并在发送过程中显示进度。具体的实现细节取决于该函数的定义。
  • [6] 关闭文件:使用 fclose() 函数关闭先前打开的文件,释放相关资源。

在接收端也计算和验证:

unsigned long received_checksum;
recv(sock, &received_checksum, sizeof(received_checksum), 0); // 接收校验和 [1]

FILE *file = fopen("received_file", "wb"); // 打开文件以写入模式 [2]
if (!file) {
    perror("fopen");
    return;
}

receive_file(file, sock); // 接收文件数据 [3]

unsigned long calculated_checksum = calculate_checksum(file); // 计算接收数据的校验和 [4]
if (received_checksum == calculated_checksum) {
    printf("文件传输成功,校验和匹配。\n"); // 校验和匹配 [5]
} else {
    printf("文件校验和不匹配,传输可能损坏。\n"); // 校验和不匹配 [6]
}

fclose(file); // 关闭文件 [7]
  • [1] 接收校验和:使用 recv() 函数从套接字 sock 接收一个 unsigned long 类型的校验和,并将其存储在 received_checksum 中。

  • [2] 打开文件以写入模式fopen() 函数以二进制写入模式 (“wb”) 打开(或创建)文件 "received_file"。如果文件无法打开或创建,使用 perror("fopen") 打印错误信息,并返回以终止函数。

  • [3] 接收文件数据:调用 receive_file() 函数,从套接字 sock 接收文件数据并写入到打开的 file 指针所指向的文件中。该函数具体实现不在此示例中,但它的职责是在传输过程中将数据流接收并保存。

  • [4] 计算接收数据的校验和:通过调用 calculate_checksum(file) 函数来计算所接收的文件数据的校验和。该函数应该能够重新从头读取文件并得出与发送方协议一致的校验和。

  • [5] 校验和匹配:如果接收的校验和 received_checksum 与计算出的校验和 calculated_checksum 相等,则打印确认消息表明文件传输成功且完整。

  • [6] 校验和不匹配:如果校验和不匹配,意味着文件数据有可能在传输中损坏或丢失,打印警告消息。

  • [7] 关闭文件fclose() 函数用于关闭打开的文件释放系统资源。

此代码块涉及数据接收和完整性验证的基本过程,在实现文件传输时,校验和机制可以有效检测传输错误以确保数据完整性。

这样一个简易的文件传输程序就基本完成了。通过实践,读者可以更好地理解网络编程的概念和技巧。

3.3.3 案例分析:HTTP客户端
3.3.3.1 基于TCP的HTTP请求与响应

在构建一个HTTP客户端时,首先要了解HTTP协议是基于TCP协议建立连接的。因此,我们需要先创建一个TCP套接字,并通过这个套接字与服务器进行通信。

  • 步骤介绍:
    1. 创建TCP套接字:使用socket函数创建一个流套接字。
    2. 连接服务器:使用connect连接到指定的服务器和端口。
    3. 发送HTTP请求:通过send函数发送HTTP请求。
    4. 接收HTTP响应:使用recv函数接收服务器的响应。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int sock;
    struct sockaddr_in server_addr;
    char request[] = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
    char response[4096];

    // 创建套接字
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(80); // HTTP默认端口 [1]

    // 将IP地址转换为二进制形式
    if (inet_pton(AF_INET, "93.184.216.34", &server_addr.sin_addr) <= 0) { // example.com的IP地址 [2]
        perror("Invalid address/ Address not supported");
        exit(EXIT_FAILURE);
    }

    // 连接服务器
    if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("Connection failed");
        close(sock);
        exit(EXIT_FAILURE);
    }

    // 发送HTTP请求
    send(sock, request, strlen(request), 0); // 发送请求数据 [3]

    // 接收HTTP响应
    recv(sock, response, sizeof(response), 0); // 接收响应数据 [4]

    // 输出响应内容
    printf("HTTP Response:\n%s\n", response);

    // 关闭套接字
    close(sock);
    return 0;
}
  • [1] HTTP默认端口htons(80) 将端口号80转换为网络字节序,这个端口号是HTTP协议的默认端口。
  • [2] IP地址转换inet_pton() 函数将IP地址(“93.184.216.34”)从文本形式转换为二进制形式并存储在 server_addr.sin_addr 中。
  • [3] 发送请求数据send() 函数用于将HTTP请求的字符串通过套接字发送到服务器。
  • [4] 接收响应数据recv() 函数用于接收服务器的响应数据并存储在 response 缓冲中。

该程序演示了一个简单的HTTP客户端,使用套接字连接到指定的服务器地址并发送GET请求。收到响应后,程序输出响应内容并关闭套接字。

3.3.3.2 URL解析与请求构造

URL解析是HTTP客户端的重要一步。我们需要将URL分解为主机名和路径,以便在构造HTTP请求时使用。

  • 步骤介绍:
    1. 解析URL:将URL拆解为协议、主机、端口和路径。
    2. 构造HTTP请求:根据解析出的信息生成完整的HTTP请求字符串。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 入门级URL解析函数示例
void parse_url(const char *url, char *host, char *path) {
    // 查找 "://"
    const char *host_start = strstr(url, "://");
    if (host_start) {
        host_start += 3;  // 跳过协议部分 "://" [1]
    } else {
        host_start = url; // 如果没有找到,则从url开头解析 [2]
    }

    // 查找路径开始位置
    const char *path_start = strchr(host_start, '/');
    if (path_start) {
        strncpy(host, host_start, path_start - host_start); // 提取主机名 [3]
        strcpy(path, path_start);                           // 提取路径 [4]
    } else {
        strcpy(host, host_start); // 如果没有路径,默认为"/" [5]
        strcpy(path, "/");
    }
}

// 构造HTTP GET请求
void construct_http_request(const char *host, const char *path, char *request) {
    // 使用sprintf构建HTTP请求字符串
    sprintf(request, "GET %s HTTP/1.1\r\nHost: %s\r\n\r\n", path, host); // 格式化为HTTP请求 [6]
}

int main() {
    // 定义URL以及存储主机名、路径和请求的缓冲区
    char url[] = "http://example.com/path/to/resource";
    char host[256], path[256], request[512];

    // 调用URL解析
    parse_url(url, host, path);
    // 构造HTTP GET请求
    construct_http_request(host, path, request);

    // 输出解析结果和HTTP请求
    printf("Host: %s\nPath: %s\nRequest:\n%s\n", host, path, request);

    return 0;
}
  • [1] 跳过协议部分:如果 url 中存在协议(如 “http://”),strstr 函数用于找到 "://" 的位置,然后指针 host_start 移动过这三个字符,以便定位到主机名的开始位置。
  • [2] 如果没有找到协议:如果 url 中不包含协议部分,host_start 将指向 url 的开头,假设整个 url 都是主机部分。
  • [3] 提取主机名:使用 strncpy 复制从 host_startpath_start 位置的字符串,这些字符组成主机名。
  • [4] 提取路径:用 strcpy 将路径部分从 path_start 开始的字符复制到 path,形成路径字符串。
  • [5] 主机名和默认路径:当找不到路径(不存在 /)时,将整个字符串作为主机名,并将路径设定为默认值 “/”。
  • [6] 格式化为HTTP请求sprintf 用于构建 HTTP GET 请求字符串,将解析出的 pathhost 置入适当位置,构成一个完整的 HTTP 1.1 请求格式。

这段代码展示了如何简单解析一个 URL 并使用解析结果生成一个 HTTP GET 请求。这在构建基本 HTTP 客户端或学习网络协议时非常有用。

3.3.3.3 响应解析与数据处理

接收到HTTP响应后,我们需要解析响应头和响应体,以便提取有用的信息,如状态码、内容类型和实际数据。

  • 步骤介绍:
    1. 解析响应头:提取状态码、响应头字段和值。
    2. 处理响应体:根据内容类型进行适当的数据处理。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 分析HTTP响应示例
void parse_http_response(const char *response) {
    // 查找响应头和响应体的分界点 [1]
    const char *header_end = strstr(response, "\r\n\r\n");
    if (header_end) {
        char headers[512];  // 用于存储HTTP头信息 [2]
        char body[4096];    // 用于存储HTTP响应体 [3]

        // 解析响应头
        strncpy(headers, response, header_end - response); // 复制头部信息到headers [4]
        headers[header_end - response] = '\0';  // 添加字符串结束符 [5]
        printf("HTTP Headers:\n%s\n", headers);

        // 提取响应体
        strcpy(body, header_end + 4);  // 复制响应体到body [6]
        printf("HTTP Body:\n%s\n", body);
    } else {
        printf("Invalid HTTP response.\n");  // 如果没有找到分界点则说明响应无效 [7]
    }
}

int main() {
    // 虚拟HTTP响应示例 [8]
    char dummy_response[] = 
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html\r\n"
        "Content-Length: 13\r\n"
        "\r\n"
        "<h1>Hello</h1>";

    parse_http_response(dummy_response);  // 调用解析函数 [9]

    return 0;
}
  • [1] 查找响应头和响应体的分界点:使用 strstr() 来查找相应的 CRLFCRLF 字符串 \r\n\r\n,表示HTTP头与主体之间的分界。
  • [2] 用于存储HTTP头信息headers 数组用于暂存解析后的HTTP头。
  • [3] 用于存储HTTP响应体body 数组用于保存解析后的HTTP响应体数据。
  • [4] 复制头部信息到 headers:使用 strncpy() 函数从响应中提取头部信息到 headers 中。
  • [5] 添加字符串结束符:确保 headers 以空字符结尾,以便正确形成C风格字符串。
  • [6] 复制响应体到 body:使用 strcpy() 从响应中抽取主体部分。
  • [7] 如果没有找到分界点则说明响应无效:如果找不到 \r\n\r\n,表明响应格式不正确,打印错误信息。
  • [8] 虚拟HTTP响应示例dummy_response 是一个模拟的HTTP响应字符串,用于测试。
  • [9] 调用解析函数parse_http_response() 函数进行解析,打印头信息和主体。

上述代码展示了如何解析HTTP响应,提取响应头和响应体,并进行基本的打印输出。

通过上述步骤,构建一个基本的HTTP客户端,能够完整地从URL解析、构造HTTP请求,并解析返回的HTTP响应,不仅可以深入理解HTTP协议,还能提升网络编程能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值