目录
一、前言
今天我们不学习其他的知识点,主要是复习之前学习过的TCP网络通信和多线程以及线程同步互斥,然后结合这以上知识点设计实现一个小的项目,主要仿照qq群聊的服务器可客户端的实现,下面我将会说明一下设计需求,以下是整个设计示意图。
二、设计需求
1.服务器需求
需求一:对于每一个上线连接的客户端,服务端会起一个线程去维护。
需求二:将服务器受到的消息转发给全部的客户端。例如:服务器接收客户端A的消息后,将立即发送给客户端A,B,C...
需求三:当某个客户端断开(下线),需要处理断开的链接。
2.客户端需求
需求一:请求连接上线,
需求二:发消息给服务器。
需求三:客户端等待服务端的消息。
需求四:等待用户自己的关闭(下线)。
三、服务端设计
1.项目准备
在创建项目后,引入一些必需的头文件以及创建项目需要的宏,例如:允许客户端连接的最大数量,接收文件字节的大小,客户端连接的个数等等。
#include <stdio.h> #include <windows.h> #include <process.h> #include <iostream> #pragma comment(lib, "ws2_32.lib") #define MAX_CLEN 256 // 最大连接数量 #define MAX_BUF_SIZE 1024 // 接收文件大小 SOCKET clnSockets[MAX_CLEN]; // 所有的连接客户端的socket int clnCnt = 0; // 客户端连接的个数 // 互斥的句柄 HANDLE hMutex;
2.初始化网络库
WSAStartup初始化Winsock,这个函数用于初始化网络环境,都是固定写法,必须要有的,直接复制粘贴即可。
// 1. 初始化库 WSADATA wsaData; int stu = WSAStartup(MAKEWORD(2, 2), &wsaData); if (stu != 0) { std::cout << "WSAStartup 错误:" << stu << std::endl; return 0; }
3.SOCKET创建服务器套接字
这和我们之前学的windwos网络一样都是固定写法,重点时查看函数原型以及它的参数,代码如下:
// 2. socket 创建套接字 SOCKET sockSrv = socket(AF_INET, SOCK_STREAM, 0); if (sockSrv == INVALID_SOCKET) { std::cout << "socket failed!" << GetLastError() << std::endl; WSACleanup(); //释放Winsock库资源 return 1; }
4. bind 绑定套接字
这个流程主要是绑定服务器的IP地址,端口号,以及协议版本。
// 3 bind 绑定套接字 SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY); // 地址 IP地址any addrSrv.sin_family = AF_INET; // ipv4协议 addrSrv.sin_port = htons(6000); // 端口号 if ( SOCKET_ERROR == bind(sockSrv, (sockaddr*)&addrSrv, sizeof(SOCKADDR))) { std::cout << "bind failed!" << GetLastError() << std::endl; WSACleanup(); //释放Winsock库资源 return 1; }
5. listen监听套接字
listen函数最重要的是理解它的第二个参数,为等待连接的最大队列长度 ,这个解释我有专门出过一篇文章windows网络进阶之listen参数含义。
// 4. 监听 if (listen(sockSrv, 5) == SOCKET_ERROR) // 5 是指最大的监听数目,执行到listen { printf("listen error = %d\n", GetLastError()); return -1; }
6. accept接受客户端连接
对于每一个被接受的连接请求,accept
函数都会创建一个新的套接字,用于与该客户端的后续通信。也都是固定流程,后面互斥和多线程就比较难理解了。
// 5. accept接受客户端连接 SOCKADDR_IN addrCli; int len = sizeof(SOCKADDR); while (true) { // 接受客户端的连接 SOCKET sockCon = accept(sockSrv, (sockaddr*)&addrCli, &len); }
7.建立套接字数组
将accept生成的套接字放入全局套接字数组中,同时加上互斥锁。
//创建一个互斥对象 hMutex = CreateMutex(NULL, false, NULL); while (true) { // 接受客户端的连接 SOCKET sockCon = accept(sockSrv, (sockaddr*)&addrCli, &len); // 全局变量要加锁 WaitForSingleObject(hMutex, INFINITE); // 将连接放到数组里面 clnSockets[clnCnt++] = sockCon; // 解锁 ReleaseMutex(hMutex); } closesocket(sockSrv); CloseHandle(hMutex); WSACleanup(); return 0;
8. 建立多线程与客户端通信
每通过accept
函数返回的新创建的套接字,就建立一个线程去维护。
//创建一个互斥对象 hMutex = CreateMutex(NULL, false, NULL); while (true) { // 接受客户端的连接 SOCKET sockCon = accept(sockSrv, (sockaddr*)&addrCli, &len); // 全局变量要加锁 WaitForSingleObject(hMutex, INFINITE); // 将连接放到数组里面 clnSockets[clnCnt++] = sockCon; // 解锁 ReleaseMutex(hMutex); // 每接收一个客户端的连接,都安排一个线程去维护 hThread = (HANDLE)_beginthreadex(NULL, 0, &handleCln, (void*)&sockCon, 0, NULL); printf("Connect client IP = %s\n, Num = %d \n", inet_ntoa(addrCli.sin_addr), clnCnt); } closesocket(sockSrv); CloseHandle(hMutex); WSACleanup(); return 0;
9. 处理线程函数,收消息
上个步骤我们对每一个接受连接的套接字都创建了线程,现在我们开始来写线程函数中的逻辑代码,主要有三个部分:收到客户端的消息,将收到的消息再发给所有客户端,处理断开的客户端。
下面我们开始完成第一个部分: 收到客户端的消息。
因为客户端发消息会不止一个,所以我们要建立while循环,通关判断接收到的消息来判断,如果为0就退出循环。
// 处理线程函数, 收发消息 unsigned WINAPI handleCln(void *arg) { SOCKET hClnSock = *((SOCKET *)arg); int iLen = 0; char recvBuff[MAX_BUF_SIZE] = { 0 }; while (1) { // iLen 成功时返回接收的字节数(收到EOF时为0),失败时返回SOCKETERROR。 iLen = recv(hClnSock, recvBuff, MAX_BUF_SIZE, 0); // if (iLen >= 0) { // 将收到的消息转发给所有客户端 SendMsg(recvBuff,iLen); } else { break; } }
10. 发消息给客户端
完成第二个部分: 将收到的消息再发给所有客户端。
因为是仿照qq的小demo,所以服务器一旦收到消息,就要再发送给所有的客户端。这段逻辑写在SendMsg 函数中,同时还需要注意因为在多线程中,所以要避免多个线程同时访问共享资源时产生数据不一致的问题,需要加互斥锁和解锁。
// 将收到的消息转发给所有客户端 void SendMsg(char* msg, int len) { int i; WaitForSingleObject(hMutex, INFINITE); for (i = 0; i < clnCnt; i++) { send(clnSockets[i], msg, len, 0); } ReleaseMutex(hMutex); }
11.处理断开的客户端
完成第三个部分: 处理断开的客户端。
这里也是通过 for 循环遍历 socket 数组,通过匹配每一项,如果相匹配,就然后断开连接。同时 socket 数组 中的数量减 1。
// 处理消息, 收发消息 unsigned WINAPI handleCln(void *arg) { SOCKET hClnSock = *((SOCKET *)arg); int iLen = 0; char recvBuff[MAX_BUF_SIZE] = { 0 }; while (1) { // iLen 成功时返回接收的字节数(收到EOF时为0),失败时返回SOCKETERROR。 iLen = recv(hClnSock, recvBuff, MAX_BUF_SIZE, 0); // if (iLen >= 0) { // 将收到的消息转发给所有客户端 SendMsg(recvBuff,iLen); } else { break; } } printf("此时连接的客户端数量 = %d\n", clnCnt); WaitForSingleObject(hMutex, INFINITE); for (int i = 0; i < clnCnt; i++) { // 找到哪个连接下线的,移除这个连接 if (hClnSock == clnSockets[i]) { while (i++ < clnCnt) { clnSockets[i] = clnSockets[i + 1]; } break; } } // 断开连接减 1 clnCnt--; printf("断开连接后连接的客户端数量 = %d\n", clnCnt); ReleaseMutex(hMutex); // 断开连接 closesocket(hClnSock); return 0; }
四、客户端设计
1.项目准备
客户端设计和服务器端其实差别不大,代码有些基本都相同,逻辑也大多一致,所以有些代码不在过多赘述。
项目准备代码:
#include <stdio.h> #include <windows.h> #include <process.h> #include <iostream> #pragma comment(lib, "ws2_32.lib") #define NAME_SIZE 256 #define MAX_BUF_SIZE 1024 char szName[NAME_SIZE] = "[DEFAULT]"; // 默认的昵称 char szMsg[MAX_BUF_SIZE]; // 收发数据的大小
2. 处理main函数参数
项目为仿qq群聊,所以我用main函数中的命令行参数作为我们输入的每一个客户端的名字,项目启动在终端开始启动,否则就退出程序。
int main(int argc, char* argv[]) { if (argc != 2) { printf("必须输入两个参数,包括昵称\n"); printf("例如: WXS\n"); system("pause"); return -1; } sprintf_s(szName, "[%s]", argv[1]); printf("this is Client"); }
3.初始化网络库
和服务器端代码一样。
// 初始化库 WSADATA wsaData; int stu = WSAStartup(MAKEWORD(2, 2), &wsaData); if (stu != 0) { std::cout << "WSAStartup 错误:" << stu << std::endl; return 0; }
4.SOCKET创建客户端套接字
以服务器类似。
SOCKET sockCli = socket(AF_INET, SOCK_STREAM, 0); if (sockCli == INVALID_SOCKET) { std::cout << "socket failed!" << GetLastError() << std::endl; WSACleanup(); //释放Winsock库资源 return 1; }
5. 配置IP地址和端口号,连接服务器
也是基本固定写法。
// 配置IP地址 和 端口号 SOCKADDR_IN addrSrv; addrSrv.sin_family = AF_INET; // ipv4协议 addrSrv.sin_addr.S_un.S_addr = inet_addr("192.168.1.7"); // 地址 IP地址any addrSrv.sin_port = htons(6000); // 端口号 // 连接服务器 int res = connect(sockCli, (sockaddr*)&addrSrv, sizeof(sockaddr));
6.创建两线程,发送和接收
这里我们创建了两个线程,分别处理发送消息给客户端同时接收消息。同时这个函数WaitForSingleObject 会阻塞主进程代码,直到子进程结束。
// 定义两个线程 HANDLE hSendThread, hRecvThread; // 发送消息 hSendThread = (HANDLE)_beginthreadex(NULL, 0, &SendMsg, (void*)&sockCli, 0, NULL); // 接收消息 hRecvThread = (HANDLE)_beginthreadex(NULL, 0, &RecvMsg, (void*)&sockCli, 0, NULL); // 阻塞代码,处理子线程执行完后再执行 WaitForSingleObject(hSendThread,INFINITE); WaitForSingleObject(hRecvThread, INFINITE);
7.处理发送消息线程函数
我们客户端发送消息是通过控制台程序进行发送的,所以要用到用户输入。同时发送的时候带上自己的名字前缀,也要处理快捷键客户端下线的逻辑,不能一致发送消息。
unsigned WINAPI SendMsg(void* arg) { SOCKET hClnSock = *((SOCKET*)arg); char szNameMsg[NAME_SIZE + MAX_BUF_SIZE] = { 0 }; // 昵称和消息 while (1) { memset(szMsg, 0, MAX_BUF_SIZE); // 阻塞这一句,等待控制台的消息 //fgets(szMsg, MAX_BUF_SIZE, stdin); // 第二种写法 std::cin >> szMsg; if (!strcmp(szMsg, "Q\n") || !strcmp(szMsg, "q\n")) { // 处理下线 closesocket(hClnSock); exit(0); } // 拼接 名字和字符串一起发送 sprintf_s(szNameMsg, "%s %s", szName, szMsg); send(hClnSock, szNameMsg, strlen(szNameMsg) + 1, 0); } }
7.处理接收消息线程函数
这里接收消息比较简单,和正常接收客户端消息的逻辑差不多,代码如下:
unsigned WINAPI RecvMsg(void* arg) { SOCKET hClnSock = *((SOCKET*)arg); char szNameMsg[NAME_SIZE + MAX_BUF_SIZE] = { 0 }; // 昵称和消息 int len; while (1) { len = recv(hClnSock, szNameMsg, sizeof(szNameMsg), 0); if (len <= 0) { break; return -2; } szNameMsg[len] = 0; std::cout << szNameMsg << std::endl; // fputs(szNameMsg, stdout); } }
五、项目运行
以上我们分别讲解了服务器和客户端代码的实现逻辑,现在我们来进行步骤验证我们的操作结果。
1.编译生成可执行文件
如图所示:
2.运行可执行程序
这里要注意服务器直接运行exe文件即可,而客户端要通过命令行输入运行。
服务器端:
客户端运行需要打开终端,输入exe文件的路径,以及名字。另外进行通讯还需要打开多个客户端。
3.进行通讯
结果展示为:
六、总代码展示
1.服务端代码:
如下所示:
// 1. 对于每一个上线的客户端,服务端会起一个线程去维护 // 2. 将受到的消息转发给全部的客户端 // 3. 当某个客户端断开(下线),需要处理断开的链接。怎么处理呢? #include <stdio.h> #include <windows.h> #include <process.h> #include <iostream> #pragma comment(lib, "ws2_32.lib") #define MAX_CLEN 256 #define MAX_BUF_SIZE 1024 SOCKET clnSockets[MAX_CLEN]; // 所有的连接客户端的socket int clnCnt = 0; // 客户端连接的个数 HANDLE hMutex; // 将收到的消息转发给所有客户端 void SendMsg(char* msg, int len) { int i; WaitForSingleObject(hMutex, INFINITE); for (i = 0; i < clnCnt; i++) { send(clnSockets[i], msg, len, 0); } ReleaseMutex(hMutex); } // 处理消息, 收发消息 unsigned WINAPI handleCln(void *arg) { SOCKET hClnSock = *((SOCKET *)arg); int iLen = 0; char recvBuff[MAX_BUF_SIZE] = { 0 }; while (1) { // iLen 成功时返回接收的字节数(收到EOF时为0),失败时返回SOCKETERROR。 iLen = recv(hClnSock, recvBuff, MAX_BUF_SIZE, 0); // if (iLen >= 0) { // 将收到的消息转发给所有客户端 SendMsg(recvBuff,iLen); } else { break; } } printf("此时连接的客户端数量 = %d\n", clnCnt); WaitForSingleObject(hMutex, INFINITE); for (int i = 0; i < clnCnt; i++) { // 找到哪个连接下线的,移除这个连接 if (hClnSock == clnSockets[i]) { while (i++ < clnCnt) { clnSockets[i] = clnSockets[i + 1]; } break; } } // 断开连接减 1 clnCnt--; printf("断开连接后连接的客户端数量 = %d\n", clnCnt); ReleaseMutex(hMutex); // 断开连接 closesocket(hClnSock); return 0; } int main(int argc, char* argv[]) { printf("this is Server\n"); //0. 初始化网络 #if 1 // 0 初始化网络库 // 初始化库 WSADATA wsaData; int stu = WSAStartup(MAKEWORD(2, 2), &wsaData); if (stu != 0) { std::cout << "WSAStartup 错误:" << stu << std::endl; return 0; } #endif HANDLE hThread; // 1. 创建一个互斥对象 hMutex = CreateMutex(NULL, false, NULL); // 2. socket 创建套接字 SOCKET sockSrv = socket(AF_INET, SOCK_STREAM, 0); if (sockSrv == INVALID_SOCKET) { std::cout << "socket failed!" << GetLastError() << std::endl; WSACleanup(); //释放Winsock库资源 return 1; } // 3 bind 绑定套接字 SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY); // 地址 IP地址any addrSrv.sin_family = AF_INET; // ipv4协议 addrSrv.sin_port = htons(6000); // 端口号 if ( SOCKET_ERROR == bind(sockSrv, (sockaddr*)&addrSrv, sizeof(SOCKADDR))) { std::cout << "bind failed!" << GetLastError() << std::endl; WSACleanup(); //释放Winsock库资源 return 1; } // 4. 监听 if (listen(sockSrv, 5) == SOCKET_ERROR) // 5 是指最大的监听数目,执行到listen { printf("listen error = %d\n", GetLastError()); return -1; } // 5 SOCKADDR_IN addrCli; int len = sizeof(SOCKADDR); while (true) { // 接受客户端的连接 SOCKET sockCon = accept(sockSrv, (sockaddr*)&addrCli, &len); // 全局变量要加锁 WaitForSingleObject(hMutex, INFINITE); // 将连接放到数组里面 clnSockets[clnCnt++] = sockCon; // 解锁 ReleaseMutex(hMutex); // 每接收一个客户端的连接,都安排一个线程去维护 hThread = (HANDLE)_beginthreadex(NULL, 0, &handleCln, (void*)&sockCon, 0, NULL); printf("Connect client IP = %s\n, Num = %d \n", inet_ntoa(addrCli.sin_addr), clnCnt); } closesocket(sockSrv); CloseHandle(hMutex); WSACleanup(); return 0; }
2.客户端代码:
如下所示:
// 客户端做的事情: //1 请求连接上线, //2 发消息 //3 客户端等待服务端的消息 //4 等待用户自己的关闭(下线) #include <stdio.h> #include <windows.h> #include <process.h> #include <iostream> #pragma comment(lib, "ws2_32.lib") #define NAME_SIZE 256 #define MAX_BUF_SIZE 1024 char szName[NAME_SIZE] = "[DEFAULT]"; // 默认的昵称 char szMsg[MAX_BUF_SIZE]; // 收发数据的大小 unsigned WINAPI SendMsg(void* arg) { SOCKET hClnSock = *((SOCKET*)arg); char szNameMsg[NAME_SIZE + MAX_BUF_SIZE] = { 0 }; // 昵称和消息 while (1) { memset(szMsg, 0, MAX_BUF_SIZE); // 阻塞这一句,等待控制台的消息 //fgets(szMsg, MAX_BUF_SIZE, stdin); std::cin >> szMsg; if (!strcmp(szMsg, "Q\n") || !strcmp(szMsg, "q\n")) { // 处理下线 closesocket(hClnSock); exit(0); } // 拼接 名字和字符串一起发送 sprintf_s(szNameMsg, "%s %s", szName, szMsg); send(hClnSock, szNameMsg, strlen(szNameMsg) + 1, 0); } } unsigned WINAPI RecvMsg(void* arg) { SOCKET hClnSock = *((SOCKET*)arg); char szNameMsg[NAME_SIZE + MAX_BUF_SIZE] = { 0 }; // 昵称和消息 int len; while (1) { len = recv(hClnSock, szNameMsg, sizeof(szNameMsg), 0); if (len <= 0) { break; return -2; } szNameMsg[len] = 0; std::cout << szNameMsg << std::endl; // fputs(szNameMsg, stdout); } } int main(int argc, char* argv[]) { if (argc != 2) { printf("必须输入两个参数,包括昵称\n"); printf("例如: WXS\n"); system("pause"); return -1; } sprintf_s(szName, "[%s]", argv[1]); printf("this is Client"); //0. 初始化网络 #if 1 // 0 初始化网络库 // 初始化库 WSADATA wsaData; int stu = WSAStartup(MAKEWORD(2, 2), &wsaData); if (stu != 0) { std::cout << "WSAStartup 错误:" << stu << std::endl; return 0; } #endif // 定义两个线程 HANDLE hSendThread, hRecvThread; // 1. 建立 socket SOCKET sockCli = socket(AF_INET, SOCK_STREAM, 0); if (sockCli == INVALID_SOCKET) { std::cout << "socket failed!" << GetLastError() << std::endl; WSACleanup(); //释放Winsock库资源 return 1; } // 2, 配置IP地址 和 端口号 SOCKADDR_IN addrSrv; addrSrv.sin_family = AF_INET; // ipv4协议 addrSrv.sin_addr.S_un.S_addr = inet_addr("192.168.1.7"); // 地址 IP地址any addrSrv.sin_port = htons(6000); // 端口号 // 3. 连接服务器 int res = connect(sockCli, (sockaddr*)&addrSrv, sizeof(sockaddr)); // 4. 发送服务器消息,启动线程 hSendThread = (HANDLE)_beginthreadex(NULL, 0, &SendMsg, (void*)&sockCli, 0, NULL); // 5. 等待 hRecvThread = (HANDLE)_beginthreadex(NULL, 0, &RecvMsg, (void*)&sockCli, 0, NULL); WaitForSingleObject(hSendThread,INFINITE); WaitForSingleObject(hRecvThread, INFINITE); closesocket(sockCli); WSACleanup(); return 0; }
七、最后
制作不易,熬夜肝的,还请多多点赞,拯救下秃头的博主吧!!