尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++网络编程实战:从零构建简易聊天室,掌握Socket与多线程核心技术

C++网络编程实战:从零构建简易聊天室,掌握Socket与多线程核心技术 1. 项目概述为什么从“简易聊天室”开始你的C网络编程之旅如果你正在学习C并且已经掌握了基础语法和面向对象的概念那么恭喜你你已经具备了挑战一个真正“有感觉”的项目的资格。很多朋友学C总觉得在控制台里打印“Hello World”或者算个斐波那契数列离实际应用差了十万八千里。而一个“简易聊天室”项目恰恰是填补这道鸿沟的绝佳桥梁。它不像大型游戏引擎那样庞杂也不像操作系统内核那样深奥但它麻雀虽小五脏俱全几乎涵盖了C中级阶段所有核心技能点的实战应用。这个项目能做什么简单说就是让你亲手打造一个可以多人实时文字聊天的程序。它通常包含一个服务器端Server和多个客户端Client。服务器负责中转所有聊天消息客户端则负责界面展示和用户输入。通过完成它你将不再是语法规则的复读机而是一个能解决实际问题的开发者。你会发现之前学的类、继承、多态、模板、STL容器突然都有了用武之地之前觉得抽象的“网络编程”、“多线程”、“Socket”也变得清晰具体。更重要的是当你看到自己写的程序能让不同电脑上的朋友互相发消息时那种成就感是无可替代的。这个项目适合所有希望将C知识体系化的学习者尤其是那些觉得自学枯燥、缺乏方向的朋友。2. 核心架构与设计思路拆解2.1 技术选型为什么是Socket 多线程要实现一个聊天室核心是解决“网络通信”和“并发处理”两个问题。在C的世界里最经典、最底层、也最能体现功力的方案就是Berkeley Socket套接字配合多线程Multithreading。为什么不选现成的网络库如Boost.Asio、POCO对于学习而言从Socket入手能让你透彻理解网络通信的底层机制比如TCP的三次握手、数据流的收发、端口的监听与连接。这就像学开车先学手动挡虽然初期麻烦但你对车辆的理解会更深。掌握Socket之后你再去看那些高级封装库就会明白它们到底在帮你做什么用起来也会得心应手。为什么必须用多线程想象一下服务器同时连接了10个客户端。如果只有一个线程那么它在一个时刻只能处理一个客户端的消息要么在接收A的消息要么在给B发送消息。当它在处理A时B、C、D等其他客户端发来的消息就只能干等着用户体验就是“卡顿”和“延迟”。多线程的精髓在于“同时”处理。我们可以为每一个新连接的客户端创建一个独立的线程。这个线程只负责和对应的客户端通信接收该客户端发来的消息并将需要转发给该客户端的消息发送出去。这样多个客户端之间互不阻塞实现了真正的并发聊天。2.2 整体架构设计图逻辑层面在开始写代码前我们需要在脑子里把数据流画清楚服务器端Server主线程持续监听listen某个端口如8888等待客户端连接。当有客户端连接accept成功时主线程会创建一个新的Socket来代表这个连接并立即创建一个新的工作线程Worker Thread来处理这个Socket。工作线程在一个循环里通过这个专属Socket读取recv对应客户端发来的消息。收到消息后需要将消息广播给当前在线的所有其他客户端。这里就涉及到共享资源——在线客户端列表。这个列表需要被所有工作线程访问和修改比如有新客户端加入或退出因此必须引入**线程同步机制如互斥锁mutex**来保证数据安全。服务器还需要一个机制来管理客户端列表并在客户端断开连接时清理对应的线程和Socket资源。客户端Client主线程负责连接服务器、提供用户输入界面、并发送send用户输入的消息。同时客户端也必须创建一个单独的接收线程。这个线程在一个循环里持续从服务器Socket读取recv数据即其他用户发来的消息并实时显示在聊天界面上。如果只用主线程那么在等待用户输入时就无法接收到服务器发来的新消息。这个“服务端监听/接受多客户端线程”、“客户端双线程发送/接收”的模型是这类即时通信工具最经典和高效的架构。3. 核心细节解析与实操要点3.1 Socket编程基础与关键API在Windows下我们使用Winsock API在Linux/macOS下使用BSD Socket API。两者概念完全一致只是头文件和少量函数名有差异。下面以跨平台的通用思想进行讲解并注明关键区别。核心步骤与函数初始化仅Windows需要// Windows (Winsock) 特有步骤 WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); // 初始化Winsock库 // 程序结束时需要调用 WSACleanup();Linux/Unix系统无需此步骤。创建Socket// 通用概念创建了一个通信端点 SOCKET server_socket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // AF_INET: IPv4地址族 // SOCK_STREAM: 流式Socket对应TCP协议可靠、有序、面向连接 // IPPROTO_TCP: 使用TCP协议。当第二个参数为SOCK_STREAM时此参数可简写为0。这里选择SOCK_STREAM而非SOCK_DGRAM是因为聊天消息需要可靠、按序送达TCP协议的特性丢包重传、流量控制正好满足。UDP虽然快但可能丢消息或乱序不适合聊天室。绑定地址与端口服务器端sockaddr_in server_addr; server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr INADDR_ANY; // 绑定到本机所有IP地址 server_addr.sin_port htons(8888); // 绑定到8888端口htons将主机字节序转为网络字节序 bind(server_socket, (sockaddr*)server_addr, sizeof(server_addr));INADDR_ANY是一个特殊值表示服务器愿意接受来自任何本地网络接口的连接。htons()函数至关重要它解决了不同CPU架构大端/小端在存储多字节数据如端口号时的顺序问题确保网络传输的统一性。监听与接受连接服务器端listen(server_socket, SOMAXCONN); // 开始监听SOMAXCONN是系统允许的最大连接队列长度 sockaddr_in client_addr; int addr_len sizeof(client_addr); SOCKET client_socket accept(server_socket, (sockaddr*)client_addr, addr_len); // accept是阻塞调用没有新连接时程序会停在这里等待。 // 一旦有连接accept返回一个新的Socketclient_socket专门用于和这个客户端通信。连接服务器客户端sockaddr_in server_addr; server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr inet_addr(127.0.0.1); // 服务器IP本地测试用回环地址 server_addr.sin_port htons(8888); connect(client_socket, (sockaddr*)server_addr, sizeof(server_addr));发送与接收数据// 发送 char send_buf[1024] Hello, ChatRoom!; int bytes_sent send(target_socket, send_buf, strlen(send_buf), 0); // 接收 char recv_buf[1024] {0}; int bytes_recv recv(source_socket, recv_buf, sizeof(recv_buf) - 1, 0); // 留一位给字符串结束符\0 recv_buf[bytes_recv] \0; // 确保字符串正确终止send和recv的返回值需要仔细处理。它们返回实际发送/接收的字节数。返回值可能小于你请求的长度对于send可能因为网络缓冲区满对于recv可能因为对方发送的数据还没完全到达。返回值等于0通常表示连接已正常关闭小于0则表示出错。3.2 多线程管理与线程安全线程创建 C11提供了标准的线程库thread这比平台相关的pthread或CreateThread更推荐。#include thread #include vector std::vectorstd::thread worker_threads; // 用于保存所有工作线程便于后续管理 void client_handler(SOCKET client_sock) { // 处理单个客户端通信的逻辑 } // 在主线程监听循环中 while (true) { SOCKET client_sock accept(...); worker_threads.emplace_back(client_handler, client_sock); // 创建新线程并传入客户端socket worker_threads.back().detach(); // 分离线程让其独立运行。主线程不等待它结束。 }这里使用了detach()意味着主线程不会等待这个工作线程结束。工作线程在client_handler函数返回后会自动结束并释放资源。另一种方式是join()但那样主线程会阻塞等待不适合服务器场景。共享资源与互斥锁 所有工作线程都需要访问“在线客户端列表”可能是一个std::vectorSOCKET或std::map。多个线程同时修改一个容器是灾难性的会导致程序崩溃或数据错乱。必须使用互斥锁Mutex进行保护。#include mutex #include list std::listSOCKET client_sockets_list; // 共享资源所有客户端Socket列表 std::mutex list_mutex; // 用于保护上述列表的互斥锁 // 当有新客户端连接时需要将其Socket加入列表 { std::lock_guardstd::mutex lock(list_mutex); // 构造时加锁析构时自动解锁 client_sockets_list.push_back(new_client_socket); } // lock_guard超出作用域锁自动释放 // 当需要广播消息时遍历列表 void broadcast_message(const char* msg, SOCKET exclude_sock INVALID_SOCKET) { std::lock_guardstd::mutex lock(list_mutex); for (auto it client_sockets_list.begin(); it ! client_sockets_list.end(); ) { SOCKET sock *it; if (sock ! exclude_sock) { if (send(sock, msg, strlen(msg), 0) 0) { // 发送失败说明该客户端已断开 closesocket(sock); it client_sockets_list.erase(it); // 从列表中移除 continue; } } it; } }注意std::lock_guard是RAII资源获取即初始化思想的典型应用。它保证在退出作用域时锁一定会被释放即使中间发生了异常。这比手动lock()和unlock()安全得多避免了忘记解锁导致的死锁。3.3 协议设计如何让消息变得“有意义”直接发送原始字符串会遇到问题客户端怎么知道一条消息从哪里开始、到哪里结束如果一次recv收到了“你好世界”这到底是一条消息还是“你好”和“世界”两条消息粘在一起了这就是TCP的“粘包”问题。因此我们需要一个简单的应用层协议来界定消息边界。常用方法一定长消息头在每条实际消息前先发送一个固定长度的“头”比如4个字节这个头里存储了后面实际消息的长度。// 发送端 std::string message 用户A说大家好; uint32_t msg_len static_castuint32_t(message.size()); // 获取消息长度 uint32_t net_len htonl(msg_len); // 转换字节序 send(sock, (char*)net_len, sizeof(net_len), 0); // 先发送4字节的长度头 send(sock, message.c_str(), msg_len, 0); // 再发送实际消息 // 接收端 uint32_t net_len 0; recv(sock, (char*)net_len, sizeof(net_len), MSG_WAITALL); // 先收满4个字节的长度头 uint32_t msg_len ntohl(net_len); // 转换回主机字节序 std::vectorchar buffer(msg_len 1); recv(sock, buffer.data(), msg_len, MSG_WAITALL); // 再根据长度收满实际消息 buffer[msg_len] \0; std::string received_msg(buffer.data());MSG_WAITALL标志会要求recv函数收满指定字节数后才返回这简化了处理逻辑。但需要注意在网络异常时它可能导致永久阻塞。常用方法二特殊分隔符为每条消息末尾添加一个特殊的、不会在正常消息中出现的分隔符比如\n或\0。接收方持续读取直到遇到分隔符。// 发送端 std::string message 用户A说大家好\n; // 添加\n作为结束符 send(sock, message.c_str(), message.size(), 0); // 接收端需要循环读取并拼接直到遇到\n std::string buffer; char ch; while (recv(sock, ch, 1, 0) 0) { buffer ch; if (ch \n) { // 遇到结束符 process_message(buffer); // 处理完整消息 buffer.clear(); } }这种方法实现简单但要求消息内容本身不能包含分隔符且效率较低每次只读一个字节。对于我们的简易聊天室定长消息头是更可靠和专业的选择。它清晰地定义了消息边界能有效处理任意内容的二进制或文本数据。4. 实操过程与核心环节实现4.1 服务器端Server.cpp核心实现下面我们分块实现服务器端的关键代码。为了清晰省略了部分错误处理实际开发中务必对每个Socket API的返回值进行检查。// Server.cpp #include iostream #include winsock2.h // Windows网络头文件Linux下为sys/socket.h等 #include thread #include mutex #include list #include vector #include string #pragma comment(lib, ws2_32.lib) // 链接Winsock库Linux无需 std::listSOCKET g_client_sockets; std::mutex g_list_mutex; // 处理单个客户端连接的线程函数 void handle_client(SOCKET client_sock) { char buffer[1024]; int bytes_received; // 首先可以接收客户端发来的用户名按你的协议设计 // 这里假设第一条消息是用户名 bytes_received recv(client_sock, buffer, sizeof(buffer) - 1, 0); if (bytes_received 0) { closesocket(client_sock); return; } buffer[bytes_received] \0; std::string client_name(buffer); { std::lock_guardstd::mutex lock(g_list_mutex); std::cout [ client_name ] 加入了聊天室。当前在线: g_client_sockets.size() 1 std::endl; } // 广播某某加入的消息此处省略广播函数实现 // 主循环持续接收该客户端的消息并广播 while (true) { bytes_received recv(client_sock, buffer, sizeof(buffer) - 1, 0); if (bytes_received 0) { // 连接断开 break; } buffer[bytes_received] \0; std::string message std::string([) client_name ]说: buffer; // 广播这条消息给其他所有客户端 broadcast_message(message.c_str(), client_sock); // 排除自己 } // 客户端断开处理 { std::lock_guardstd::mutex lock(g_list_mutex); g_client_sockets.remove(client_sock); std::cout [ client_name ] 离开了聊天室。当前在线: g_client_sockets.size() std::endl; } closesocket(client_sock); // 广播某某离开的消息 } // 广播消息给所有客户端排除发送者 void broadcast_message(const char* message, SOCKET exclude_sock) { std::lock_guardstd::mutex lock(g_list_mutex); for (auto it g_client_sockets.begin(); it ! g_client_sockets.end(); ) { SOCKET sock *it; if (sock ! exclude_sock) { if (send(sock, message, strlen(message), 0) 0) { // 发送失败认为客户端已断开 closesocket(sock); it g_client_sockets.erase(it); continue; } } it; } } int main() { // 1. 初始化Winsock (Windows) WSADATA wsa; if (WSAStartup(MAKEWORD(2,2), wsa) ! 0) { std::cerr WSAStartup failed! std::endl; return 1; } // 2. 创建服务器Socket SOCKET server_socket socket(AF_INET, SOCK_STREAM, 0); if (server_socket INVALID_SOCKET) { std::cerr Could not create socket! std::endl; WSACleanup(); return 1; } // 3. 绑定地址和端口 sockaddr_in server_addr; server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr INADDR_ANY; server_addr.sin_port htons(8888); if (bind(server_socket, (sockaddr*)server_addr, sizeof(server_addr)) SOCKET_ERROR) { std::cerr Bind failed! std::endl; closesocket(server_socket); WSACleanup(); return 1; } // 4. 开始监听 if (listen(server_socket, SOMAXCONN) SOCKET_ERROR) { std::cerr Listen failed! std::endl; closesocket(server_socket); WSACleanup(); return 1; } std::cout 聊天室服务器启动监听端口 8888 ... std::endl; // 5. 主循环接受客户端连接 while (true) { sockaddr_in client_addr; int addr_len sizeof(client_addr); SOCKET client_socket accept(server_socket, (sockaddr*)client_addr, addr_len); if (client_socket INVALID_SOCKET) { std::cerr Accept failed! std::endl; continue; // 接受失败继续等待下一个连接 } // 将新客户端Socket加入全局列表 { std::lock_guardstd::mutex lock(g_list_mutex); g_client_sockets.push_back(client_socket); } // 创建新线程处理这个客户端 std::thread client_thread(handle_client, client_socket); client_thread.detach(); // 分离线程使其独立运行 } // 清理实际上上面的循环是无限的这里只是为了完整性 closesocket(server_socket); WSACleanup(); return 0; }4.2 客户端Client.cpp核心实现客户端需要两个线程一个用于发送用户输入主线程一个用于接收服务器消息后台线程。// Client.cpp #include iostream #include winsock2.h #include thread #include string #include atomic #pragma comment(lib, ws2_32.lib) std::atomicbool g_is_running(true); // 原子布尔用于安全地控制线程退出 // 接收消息的线程函数 void receive_messages(SOCKET sock) { char buffer[1024]; int bytes_received; while (g_is_running) { bytes_received recv(sock, buffer, sizeof(buffer) - 1, 0); if (bytes_received 0) { buffer[bytes_received] \0; std::cout \n buffer std::endl; // 显示收到的消息 std::cout 你: std::flush; // 重新打印提示符并立即刷新输出 } else if (bytes_received 0) { std::cout \n与服务器的连接已断开。 std::endl; break; } else { std::cerr \n接收消息出错 std::endl; break; } } } int main() { // 1. 初始化Winsock WSADATA wsa; WSAStartup(MAKEWORD(2,2), wsa); // 2. 创建客户端Socket SOCKET client_socket socket(AF_INET, SOCK_STREAM, 0); // 3. 连接服务器 sockaddr_in server_addr; server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr inet_addr(127.0.0.1); // 服务器IP如果是远程改为服务器公网IP server_addr.sin_port htons(8888); if (connect(client_socket, (sockaddr*)server_addr, sizeof(server_addr)) 0) { std::cerr 连接服务器失败 std::endl; return 1; } std::cout 已连接到聊天室服务器 std::endl; // 4. 输入用户名并发送给服务器 std::string username; std::cout 请输入你的昵称: ; std::getline(std::cin, username); send(client_socket, username.c_str(), username.size(), 0); // 5. 启动接收消息线程 std::thread recv_thread(receive_messages, client_socket); // 6. 主线程循环读取用户输入并发送 std::string input; std::cout 你: ; while (g_is_running std::getline(std::cin, input)) { if (input /quit) { // 定义一个退出命令 g_is_running false; break; } if (send(client_socket, input.c_str(), input.size(), 0) 0) { std::cerr 发送失败 std::endl; break; } // 发送后接收线程会异步打印其他人的回复所以这里不需要再打印 // 但为了界面友好可以等待接收线程的输出 } // 7. 清理 g_is_running false; closesocket(client_socket); // 关闭Socket会使得接收线程的recv返回错误或0从而退出 recv_thread.join(); // 等待接收线程结束 WSACleanup(); std::cout 客户端已退出。 std::endl; return 0; }4.3 编译与运行指南在Windows下使用Visual Studio或MinGW创建项目在VS中创建空项目将上述Server.cpp和Client.cpp分别添加到两个项目中或一个项目两个源文件分别生成。配置链接器在项目属性 - 链接器 - 输入 - 附加依赖项中添加ws2_32.lib。编译分别编译生成Server.exe和Client.exe。运行先在一个命令行窗口运行Server.exe。再打开多个命令行窗口分别运行Client.exe输入不同昵称即可开始聊天。在Linux/macOS下使用g修改头文件将#include winsock2.h和#pragma comment行删除。替换为#include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h // 用于close()修改Socket类型和函数将SOCKET类型改为int。将closesocket()改为close()。删除WSAStartup和WSACleanup。inet_addr可以继续使用或改用inet_pton。编译g -stdc11 -pthread Server.cpp -o Server g -stdc11 -pthread Client.cpp -o Client-pthread参数用于链接线程库。运行与Windows类似先运行./Server再多个终端运行./Client。5. 常见问题与排查技巧实录即使按照教程一步步来你也可能会遇到各种“坑”。下面是我在多次教学和实践中总结的常见问题及解决方法。5.1 连接失败类问题问题现象可能原因排查步骤与解决方案bind failed: Address already in use端口被占用。可能是之前的服务器程序没有正确关闭或者有其他程序占用了8888端口。1.等待TCP连接关闭后端口会进入TIME_WAIT状态等待1-4分钟才能重用。可以换个端口试试。2.设置Socket选项在bind之前调用setsockopt设置SO_REUSEADDR选项允许端口立即重用。int opt 1; setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, (char*)opt, sizeof(opt));connect failed: Connection refused连接被拒绝。1.检查服务器是否运行确保Server.exe已经启动。2.检查IP和端口确认客户端代码中的服务器IP和端口与服务器监听的完全一致。3.检查防火墙Windows防火墙或第三方安全软件可能阻止了连接。尝试暂时关闭防火墙测试或在防火墙中为你的程序添加出入站规则。connect failed: No connection could be made because the target machine actively refused it.目标机器主动拒绝。除了上述原因还可能是因为服务器listen的队列已满虽然SOMAXCONN通常很大。这种情况在瞬间有大量连接尝试时可能出现对于学习项目概率极低。5.2 运行时数据异常类问题问题现象可能原因排查步骤与解决方案消息乱码或显示异常字符字符编码问题。Windows控制台默认编码可能是GBK而代码中字符串是UTF-8或无明确编码。1.统一编码在代码中明确使用std::string处理ASCII/GBK或使用std::wstring配合宽字符函数。对于简单聊天室可先全部使用英文测试。2.控制台设置在Windows命令行输入chcp 65001可以临时将控制台代码页改为UTF-8但需要配合支持UTF-8的字体。消息“粘”在一起粘包没有处理消息边界。TCP是流式协议recv一次可能收到多条消息的一部分也可能只收到一条消息的一部分。必须实现应用层协议。采用上文介绍的“定长消息头”方法是标准解决方案。确保发送方先发长度接收方先读长度再根据长度读取确切的消息体。服务器或客户端崩溃提示“vector/socket list iterator not incrementable”等多线程数据竞争。一个线程在遍历客户端列表进行广播时另一个线程可能正在从列表中删除一个已断开的客户端Socket导致迭代器失效。确保所有对共享容器g_client_sockets的访问包括遍历都必须在互斥锁g_list_mutex的保护下进行。参考上面broadcast_message函数的实现它在锁的保护下进行遍历和删除。客户端退出后服务器对应线程不结束CPU占用高客户端Socket关闭后服务器端的recv可能不会立即返回错误。如果使用阻塞模式且没有超时设置线程可能永远卡在recv上。1.检查recv返回值如示例代码当recv返回0或负数时应跳出循环结束线程。2.使用非阻塞Socket或设置超时可以通过setsockopt设置SO_RCVTIMEO来给recv设置一个超时时间超时后返回错误线程可以检查退出标志。结合我们使用的原子变量g_is_running是更好的方法。5.3 性能与资源管理类问题问题现象可能原因排查步骤与解决方案连接数稍多如上百后服务器性能急剧下降或崩溃。“一个连接一个线程”的模型有上限。线程的创建、销毁、上下文切换开销很大。这是经典C10K问题。对于学习项目几十个连接没问题。要支持更高并发需要转向I/O多路复用模型如使用select、poll或epoll(Linux)/kqueue(BSD)/IOCP(Windows)。这将是你的下一个进阶课题。内存缓慢增长内存泄漏。1. 连接断开后线程资源未完全清理。2. 动态分配的内存没有释放。1.确保线程分离或正确join示例中使用了detach()线程结束后系统自动回收资源。如果使用join()必须在合适的地方调用。2.检查所有new/malloc都有对应的delete/free。在这个简单示例中我们主要使用栈对象和STL容器它们会自动管理内存。但如果你在项目中动态创建了对象务必注意。send或recv返回WSAEWOULDBLOCK非阻塞模式下。你已将Socket设置为非阻塞模式但当前操作如发送缓冲区满或接收缓冲区空无法立即完成。对于初学者建议全部使用阻塞模式默认逻辑更简单。如果使用非阻塞模式你需要用select等函数来监控Socket的可读/可写状态或者循环重试直到操作完成。5.4 一个关键的实操心得关于网络字节序我踩过的一个坑是直接传递了一个int型的变量比如消息长度而没有进行字节序转换。在本地测试客户端和服务器都在同一台x86电脑上时一切正常。但一旦客户端和服务器运行在不同架构比如一个x86一个ARM的机器上消息长度解析就会完全错误导致程序崩溃或收到乱码。教训任何多字节的整型数据如int,uint16_t,uint32_t在通过网络从一台主机发送到另一台主机之前必须使用htons/htonl主机到网络转换接收后必须使用ntohs/ntohl网络到主机转换回来。字符串char数组本身是单字节的不需要转换。但如果你把长度信息放在字符串里比如“LEN:100|MESSAGE:...”那么解析“100”这个数字时仍然是在本地进行不存在字节序问题。只有将二进制整型直接写入Socket缓冲区时才必须转换。这个项目虽然“简易”但它像一把钥匙为你打开了C应用于实际系统开发的大门。从Socket通信、多线程同步到简单的协议设计每一个环节都是后端开发、游戏网络、分布式系统等领域的基石。当你成功运行起自己的聊天室并邀请朋友一起测试时你会真切感受到代码创造价值的快乐。接下来你可以尝试为它添加更多功能私聊、发送文件、表情符号、甚至图形化界面用Qt或ImGui让这个“简易”的聊天室成为你技术栈中一个坚实的起点。
返回列表