十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

VC++聊天室课设:Winsock与select模型实现多客户端通信

VC++聊天室课设:Winsock与select模型实现多客户端通信 简介一份基于VC与MFC的简单聊天室课程设计文档面向需要完成网络编程类课设的高校学生系统讲解基于TCP/IP协议、SOCKET接口实现客户端/服务器架构聊天程序的完整过程。文档内容覆盖课程设计任务书、摘要、引言、可行性分析、需求分析、总体设计、服务器与客户端模块实现、数据库设计、测试与调试、文档编写等环节并结合MS SQL 2000、Windows 98/2000/XP平台梳理了网络通讯机制、消息转发、私聊功能、CSocket类使用等关键知识点可直接参考其报告框架和代码思路。压缩包共1个文件为287KB的doc文档内含课程设计报告正文及源程序说明。已有207人学习浏览适合初次接触MFC网络编程、需要快速搭建课设思路与撰写文档结构的学习者。1. 用VC做聊天室课程设计先定三个决策拿到这个题目大多数人第一反应是翻出CSDN的旧源码但课程设计挂掉或答辩被问住的也往往是照搬源码却说不清原理的人。用VC设计聊天室本质是两件事一是用Winsock把TCP协议栈跑通二是处理多客户端并发时的资源竞争。VC在这里的优势不是语言本身多现代而是Windows平台下MFC和Win32 API的调试工具链成熟断点、监视窗口、输出窗口都顺手。以我做过网络课设的经验动手前先做三个决策比写代码更要紧确认使用Win32 API还是MFC、确认服务端用select还是多线程、确认UI刷新用PostMessage还是直接跨线程调用。这三个决策定了剩下的就是填空。下面按服务端、客户端、排错、验收的顺序展开全程用VS环境代码可以直接抄进课设报告。2. 服务端用Winsock初始化到select模型实现多客户端接入2.1 先初始化Winsock和WSAStartup的版本参数服务端是聊天室的地基所有客户端连接都挂在它身上。第一步不是创建socket而是调用WSAStartup加载Winsock库这一步在课程设计里最容易写错版本号。WSAStartup的第一个参数是MAKEWORD(2,2)表示请求加载Winsock 2.2版本返回值是0才算加载成功。#include winsock2.h #include stdio.h #pragma comment(lib, ws2_32.lib) int main() { WSADATA wsa; int err WSAStartup(MAKEWORD(2, 2), wsa); if (err ! 0) { printf(WSAStartup失败: %d\n, err); return -1; } // 检查实际协商出来的版本是否大于2.0 if (LOBYTE(wsa.wVersion) 2) { printf(Winsock版本过低\n); WSACleanup(); return -1; }这里检查LOBYTE(wsa.wVersion)是因为wsa结构体里wVersion返回的是双方协商后的版本号有些老系统只支持1.1直接创建IPv4套接字会报10047地址族不支持错误。加载库之后紧接着创建监听套接字SOCKET listen_sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listen_sock INVALID_SOCKET) { printf(socket创建失败: %d\n, WSAGetLastError()); return -1; } SOCKADDR_IN addr; addr.sin_family AF_INET; addr.sin_port htons(8888); addr.sin_addr.S_un.S_addr htonl(INADDR_ANY); // 地址重用解决关闭服务端后立即重启报10048的问题 BOOL opt TRUE; setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, (const char*)opt, sizeof(opt)); if (bind(listen_sock, (SOCKADDR*)addr, sizeof(addr)) SOCKET_ERROR) { printf(bind失败: %d\n, WSAGetLastError()); return -1; } if (listen(listen_sock, 5) SOCKET_ERROR) { printf(listen失败: %d\n, WSAGetLastError()); return -1; } printf(聊天室服务端启动监听端口 8888\n);bind时的htons与htonl两个函数分别把端口号和IP地址转成网络字节序这是TCP/IP课程里一定会被问到的知识点。INADDR_ANY表示监听本机所有网卡地址课程设计用这个最省事如果写成127.0.0.1其他机器就连不上。listen的第二个参数是backlog表示等待accept的连接队列长度写5够用。SO_REUSEADDR这个选项很多人忽略但调试聊天室时服务端频繁重启没有它就会遇到10048端口被占用错误。2.2 用select模型管理多客户端避免大量线程服务端的核心问题是如何同时知道多个客户端有没有发来消息。最简单的做法是每个连接开一个recv线程但聊天室课设里客户端数量一般在10个以内用线程反而要处理锁竞争和线程退出。select模型更合适它把监听套接字和所有客户端套接字放进一个fd_set集合交给select函数统一监督某个套接字有数据可读时select就返回。SOCKET clients[64]; int client_count 0; fd_set read_set; char buf[1024]; timeval tv; while (true) { FD_ZERO(read_set); FD_SET(listen_sock, read_set); for (int i 0; i client_count; i) { FD_SET(clients[i], read_set); } tv.tv_sec 1; tv.tv_usec 0; int ret select(0, read_set, NULL, NULL, tv); if (ret 0) { // 超时或出错回到循环头部重新设置集合 continue; } if (FD_ISSET(listen_sock, read_set)) { SOCKADDR_IN client_addr; int len sizeof(client_addr); SOCKET new_client accept(listen_sock, (SOCKADDR*)client_addr, len); if (new_client ! INVALID_SOCKET) { clients[client_count] new_client; client_count; printf(客户端 %s:%d 加入\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); } } for (int i 0; i client_count; i) { if (!FD_ISSET(clients[i], read_set)) { continue; } int n recv(clients[i], buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; // 广播给其他客户端自己发的消息不回显 for (int j 0; j client_count; j) { if (j ! i) { send(clients[j], buf, n, 0); } } } else if (n 0) { printf(客户端 %d 正常断开\n, i); closesocket(clients[i]); clients[i] clients[client_count - 1]; client_count--; i--; } else { int code WSAGetLastError(); if (code WSAECONNRESET) { printf(客户端 %d 异常断开\n, i); } else { printf(recv错误: %d\n, code); } closesocket(clients[i]); clients[i] clients[client_count - 1]; client_count--; i--; } } }select的返回值有三种情况大于0说明有套接字可读等于0说明在timeval时间内什么都没发生SOCKET_ERROR则是出错。timeval设成1秒用于在每秒钟回圈里打印心跳日志如果设成NULL则会无限阻塞程序只能永远卡在select里。留意客户端数组的删除方式聊天的客户端连接没有顺序要求所以直接把最后一个元素移动到待删除的位置复杂度O(1)。如果你保留一个空洞后面每次遍历都要判断有效位课设报告里可以比较这两种写法的优劣。另外注意广播时send(clients[j], buf, n, 0)发送的是原始字节长度n不是strlen(buf)因为recv读取的字节数可能小于缓冲区大小而且聊天内容允许包含换行符。2.3 消息格式与简单协议设计上面代码里的消息协议就是纯文本每个客户端发送一行字符串服务端原样转发。课设设计报告里必须有协议说明建议加上消息类型头例如用“MSG|内容”“LOGIN|昵称”“QUIT|原因”三种格式这样服务端收到数据后先按竖线切分做相应分发也方便后续扩展成一对一私聊。// 简单协议拆分示例 char msg_type[16], content[1024]; if (sscanf(buf, %15[^|]|%1023[^|], msg_type, content) 2) { if (strcmp(msg_type, LOGIN) 0) { // 记录昵称返回当前在线人数 char ack[128]; sprintf(ack, MSG|欢迎%s加入当前在线%d人\n, content, client_count); send(clients[i], ack, (int)strlen(ack), 0); } else if (strcmp(msg_type, MSG) 0) { // 前面广播逻辑但需要带上发送者昵称 } else if (strcmp(msg_type, QUIT) 0) { closesocket(clients[i]); } }协议字段用竖线分隔是因为聊天内容很少包含竖线符解析成本低。如果想做得严谨可以用长度内容的方式比如“00012|MSG|你好hello”但课程设计阶段定长头会消耗不少时间在边界处理上竖线分隔足以应付演示。注意sscanf的%15[^|]写法是读最多15个非竖线字符防止超长昵称溢出缓冲区。3. 客户端MFC对话框程序与接收线程的UI更新3.1 用MFC对话框模板创建客户端界面客户端不推荐用控制台聊天室课设评分往往看重交互界面。MFC对话框程序是最合适的选择VS2013及之后的版本都有MFC向导生成的代码框架里包含CWinApp和CDialogEx两个核心类。界面布局按“上为聊天记录、中为输入框、下方为发送按钮”排列为三个控件分别加上IDC_LIST_MSG、IDC_EDIT_INPUT、IDC_BTN_SEND。在对话框的OnInitDialog里完成socket初始化BOOL CClientDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 创建socket并连接到服务端 int err WSAStartup(MAKEWORD(2, 2), wsaData); if (err ! 0) { AfxMessageBox(_T(Winsock初始化失败)); return FALSE; } m_sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_sock INVALID_SOCKET) { AfxMessageBox(_T(socket创建失败)); return FALSE; } SOCKADDR_IN addr; addr.sin_family AF_INET; addr.sin_port htons(8888); // inet_addr把点分IP字符串转为二进制用127.0.0.1做本机测试 addr.sin_addr.S_un.S_addr inet_addr(127.0.0.1); if (connect(m_sock, (SOCKADDR*)addr, sizeof(addr)) SOCKET_ERROR) { AfxMessageBox(_T(连接失败确认服务端已启动)); return FALSE; } // 连接成功后启动接收线程 m_is_running TRUE; CWinThread* recv_thread AfxBeginThread(RecvThreadProc, this); m_recv_thread recv_thread; return TRUE; }connect是阻塞调用课程设计环境下服务端在本机或局域网内基本都是毫秒级返回。注意MFC的向导默认隐藏了inet_addr函数的过时警告正确写法是InetPton(AF_INET, strIp, addr.sin_addr.s_addr)但课设中用inet_addr最简洁。客户端最重要的一点是connect成功后不能直接在OnInitDialog里调用recv因为recv阻塞会卡死消息循环导致窗口假死必须开一个线程专门收数据。3.2 接收线程与PostMessage的跨线程安全更新MFC中跨线程直接操作控件是课程设计最常见的崩溃原因。UI控件句柄关联的对话框窗口运行在主线程上工作线程如果调用SetDlgItemTextWindows消息泵可能正在处理其他消息两个线程同时访问控件内部缓存结果是随机崩溃或者显示错乱。标准做法是接收线程把收到的字符串通过PostMessage发给主线程由主线程在消息响应函数里更新控件。// 在对话框类中定义自定义消息注意消息号要大于WM_USER #define WM_NET_MSG (WM_USER 100) UINT CClientDlg::RecvThreadProc(LPVOID param) { CClientDlg* dlg (CClientDlg*)param; char buf[2048]; while (dlg-m_is_running) { int n recv(dlg-m_sock, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; // 把char*转成CString用_tcsdup复制一份堆内存传过去 CString* pMsg new CString(CA2T(buf)); ::PostMessage(dlg-m_hWnd, WM_NET_MSG, (WPARAM)0, (LPARAM)pMsg); } else if (n 0) { // 服务端断开 break; } else { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK) { break; } } } dlg-m_is_running FALSE; return 0; }PostMessage传的是指针接收消息后要手动delete否则内存泄漏。在对话框类里增加消息映射BEGIN_MESSAGE_MAP(CClientDlg, CDialogEx) ON_MESSAGE(WM_NET_MSG, CClientDlg::OnNetMsg) ON_BN_CLICKED(IDC_BTN_SEND, CClientDlg::OnBtnSend) END_MESSAGE_MAP() LRESULT CClientDlg::OnNetMsg(WPARAM wParam, LPARAM lParam) { CString* pMsg (CString*)lParam; if (pMsg ::IsWindow(m_hWnd)) { int count m_list_msg.GetCount(); m_list_msg.InsertString(count, *pMsg); // 自动滚动到最后一行否则消息多时看不见最新内容 m_list_msg.SetCurSel(count); delete pMsg; } return 0; }IsWindow(m_hWnd)用于对话框销毁后仍有残留消息到达时防止野指针。列表框用InsertString追加SetCurSel把选中行移动到最新消息实现自动滚动。这里CA2T把窄字符转为Unicode保证VS2013及后续版本默认Unicode字符集下能正常显示中文。3.3 发送按钮的事件响应与回车快捷键发送按钮的消息处理函数比较简单但有两处边界多字节输入法合成时不能发送半截内容以及空消息要过滤。void CClientDlg::OnBtnSend() { UpdateData(TRUE); CString text; m_edit_input.GetWindowText(text); text.Trim(); if (text.IsEmpty()) { return; } // 加上协议头格式 MSG|聊天内容 CString full _T(MSG|) text; CT2A ascii(full, CP_UTF8); int len (int)strlen(ascii); int ret send(m_sock, ascii, len, 0); if (ret SOCKET_ERROR) { int err WSAGetLastError(); AfxMessageBox(_T(发送失败)); } else { m_edit_input.SetWindowText(_T()); // 自己的消息回显到列表里 m_list_msg.AddString(full); int count m_list_msg.GetCount(); m_list_msg.SetCurSel(count - 1); } UpdateData(FALSE); }CT2A将CString转成多字节字符串在这里小心一个坑CP_UTF8可能会导致字节数变化send的第三参应该使用转换后的字符数而不是strlen因为如果内容含有中文字符strlen返回的是字节数打开聊天记录时中文会自动按UTF-8解码。敲回车发送是另一个加分项。在对话框类里重写PreTranslateMessage判断虚拟键码VK_RETURN。4. 并发与排错临界区保护、常见错误码与DLL调试4.1 用临界区保护online列表和昵称缓存服务端的clients数组在select循环里是单线程访问所以不需要加锁。但如果扩展成“每个连接一个独立线程”的模型或者客户端在非UI线程里直接修改控件就必须引入同步。Windows下C做线程同步最轻量的是临界区CRITICAL_SECTION它适用于同一进程内多个线程互斥访问共享数据。CRITICAL_SECTION g_cs; // 初始化 InitializeCriticalSection(g_cs); // 进入临界区前加锁任何访问clients数组的路径都必须成对出现 EnterCriticalSection(g_cs); for (int j 0; j client_count; j) { SendMessage(clientWinList[j], WM_UPDATE, 0, 0); } LeaveCriticalSection(g_cs);临界区的特点是用户态同步没有内核对象开销比互斥量快但缺点是它只保护代码段不是保护数据本身。课设里如果在多个地方访问共享链表一定要把EnterCriticalSection和LeaveCriticalSection配对在六个进入点里漏一个都会造成偶发崩溃。还有一个细节LeavelCriticalSection的时机越早越好不要把一个耗时的文件写入或recv调用放进临界区否则其他线程全部卡死。4.2 常见错误码10048、10054、10061的含义聊天室程序运行中会反复出现几个Winsock错误码答辩老师几乎必问。把它们整理成一张表直接写进课设报告能撑起不少篇幅错误码名称含义常见场景解决办法10048WSAEADDRINUSE端口被占用服务端重启后bind失败setsockopt设置SO_REUSEADDR10054WSAECONNRESET对端强制关闭连接客户端直接关掉窗口用try-catch或检查recv返回值10061WSAECONNREFUSED连接被拒绝客户端连了未启动的端口先启动服务端再启动客户端10047WSAEAFNOSUPPORT地址族不支持MAKEWORD(1,1)后使用AF_INET使用MAKEWORD(2,2)10022WSAEINVAL无效参数send第二个参数传了0检查send参数长度客户端直接关闭窗口时服务端端的recv会收到0字节这是正常断开如果客户端进程被强制结束服务端会收到10054。很多同学把10054当成错误写在错误日志里显得不专业其实它就是TCP的RST包到达时Winsock的反馈。在调试聊天室时一定要在客户端关闭窗口的OnDestroy消息里主动调用closesocket并通知服务端否则服务端要等一个TCP超时周期大约2分钟才能把这条连接清掉。4.3 vc如何调试DLL与多线程断点技巧聊天室里把wdows核心逻辑放在DLL里的做法不算多但很多课设会把协议解析单独封装成解析库然后以动态加载方式调用。VC调试DLL的核心是启动调试时把宿主程序指定为调用方EXE。在VS项目的“调试”配置页把“命令”设置成宿主EXE路径例如$(OutDir)\ChatClient.exe“工作目录”设置成包含那个EXE的目录按F5后断点才能命中DLL内的代码。还需要注意如果DLL的文件与栈上加载的路径不一致设置“使用默认符号设置”之外还要在“调试器类型”里选“混合”模式否则原生断点不会被触发。多线程断点调试有两个实用的IDE技巧线程窗口里右键线程可以冻结或解除冻结在怀疑两个线程竞争共享数据时先冻结其中一个线程再单步另一个复现崩溃的几率远大于直接运行输出窗口中可以查看“线程 0x2c30 已退出代码为 0xc0000135”之类的信息0xc0000135意味着DLL缺失说明机器上没有安装对应版本的VC运行时库。5. 课程设计交付版自测清单与验收前打磨5.1 单机五步自测方法课设交付前随便点点按钮发现不崩是不够的需要有一套可复现的验证步骤。我一般用“两台虚拟机 一个本机实例”的方式测试没有虚拟机的话单机开三个客户端实例也能覆盖大部分场景。启动服务端保证控制台输出“聊天室服务端启动监听端口 8888”重复启动一次确认SO_REUSEADDR生效后没有10048报错。启动第一个客户端连接后发送一条中文消息确认服务端窗口打印接收日志且无乱码。启动第二个客户端发送消息确认第一个客户端能收到而第二个客户端不会把消息重复显示两次自己的回显。直接点击客户端窗口右上角的叉号关闭进程服务端应在数秒内打印客户端断开日志而不是等到进程后遗症超时。全部客户端关闭后服务端CtrlC退出再立刻重启确认监听端口恢复。如果第4步出现问题说明客户端没有在OnDestroy里主动发送QUIT协议或调用closesocket。不管用不用得上在服务端入口加一个socket错误日志到文件总比在控制台输出强文件里记录错误码和时间排错时能分辨出是连接被重置还是正常退出。5.2 从课程设计角度补充的关键条项答辩和报告评审很看重代码之外的工程习惯与测试意识这一点在课设报告中的体现如下设计决策备选方案推荐理由传输层使用TCPUDP聊天室需要保证消息顺序与可靠TCP天然带ACK服务端用select每连接一线程在线人数少时select更简单无需加锁UI更新用PostMessage直接调SetDlgItemText跨线程访问控件随机崩溃字符集使用Unicode工程多字节VS2013后的默认设置支持中文聊天更稳定“提供VC例程”这个需求建议把完整工程压缩包内附一份README说明必须把服务端和客户端分别编译成两个不同目录的exe因为课设中常有人在同一个解决方案里同时启动两个项目导致bind失败。Demo执行顺序注明先跑ChatServer.exe再跑ChatClient.exe界面输入127.0.0.1连接。把这几点写清楚整个课设的完整度就和一个模板工程划清了界限。往深里做还可以扩展登录昵称校验、离线消息缓存甚至把select换成IOCP但那是另一个量级的题目了。本文还有配套的精品资源点击获取
返回列表