1.本章说明
前面一章我们已经学会了如何用C/C++语言进行网络编程,但那却仅限于最简单的一对一聊天,和我们平时用的QQ、微信的功能也差太多了。
所以本章将结合前面学到的MFC界面编程的知识,带领大家开发一个聊天软件,它的主要功能就是群聊。
本文项目源码的链接放在了文章末,需要的可以自取,如果觉得困难的,可以直接参照源码对照本文学习即可。
如果需要添加更多强大的功能,比如单聊、文件传输等等,可以参考另外两篇文章:
2.聊天室服务器
2.1 建立项目
聊天最重要的是什么?那当然是信息正确性,即对方是否收到了正确的信息,为了这个目的,那当然就是选择TCP协议了,因为它的特点之一就是可靠性!
而UDP协议主要运用于视频传输中,比如打微信视频,网不好的时候就会出现卡顿、丢帧,但这也并不是太影响整体的交流过程。
其次就是服务器形式,服务器仅仅只是用于转发信息而已,那自然是用不着图形化界面的,建立一个控制台项目即可。
过程相信大家都会了,那我也就不再演示如何建立一个项目了,我这里新建的一个解决方案名字为LANChat
,然后服务器项目的名称为LANServer
,客户端的先不用管,后面我再进行讲解:
然后服务器里面只有两个文件,首先是server.cpp
文件,这是这个服务器主要且唯一的源文件,main
函数入口在里面。
然后还有一个头文件名字为CNetServer.h
,这个用于我们封装类,便于使用,下面就要开始进行讲解。
2.2 封装套接字
上一章使用了TCP来进行服务器与客户端进行聊天的时候,我们发现很多步骤其实都是固定的,但每次却又不得不写,会相当的麻烦,比如初始化网络环境这一步骤。
所以为了更加有效率的开发软件,以及理解类的作用,首先我们要开始进行封装。
考虑到服务器端是需要两种套接字的:
- 监听套接字:等待客户端的连接。
- 收发数据的套接字:由
accept
函数返回,用于与特定客户端收发数据。
所以在服务器端,我们需要封装两个类,就分别命名为:CTcpListen
、CTcpOfClient
CTcpListen
:用于监听等待客户端连接。CTcpOfClient
:用于与连接上来的客户端进行收发信息。
注意,这两个类我都写在了头文件CNetServer.h
中,之所以我要这样写,主要有两个原因:
- 这两个类中的代码并不多,并且两者联系也很紧密。
- 便于大家查看,因为如果两个类分开写,同时再将代码分离出来一个
.cpp
的源文件,一下子出现4个文件,大家可能就真不太好观察。
所以我这里将其写在了一个文件中,且代码并未分离,但你可以将其分离到对应的.cpp
文件中。
首先需要的头文件以及库文件写好:
#include<WinSock2.h>
#pragma comment(lib,"ws2_32.lib")
#include<string>
using std::string;
然后就先来写 CTcpOfClient
吧,因为这个类会在CTcpListen
接受客户端连接的时候用到。
首先是成员变量,这里我添加了两个成员变量:
m_sock
:用于和客户端通信的套接字。m_ip
:用于保存客户端的ip地址。
套接字就是accept
的返回值嘛,可以用于通信,而客户端的ip地址其实也返回到了accept
的地址参数中,可通过特定函数获取,后面再提。
紧接着就是构造函数与析构函数。
构造函数用于将套接字置为无效,而析构函数则用于关闭套接字,通过析构函数,我们就可以省去关闭套接字的步骤。
紧接着就是设置这两个成员变量的函数:
这个没啥可说的,就是将accept
函数返回的信息,赋值给我们的成员变量。
然后还有一个获取ip的函数,以及最重要的两个函数收发数据函数:
都很简单,就是调用一下最基本的收发数据函数,然后返回它们的返回值而已,但我们却可以少用两个参数。
因为这两个函数的最后一个参数都固定为0
,第一个参数固定为成员变量m_sock
,就不需要外面传递了。
至此,我们的收发数据的客户端套接字就封装好了,完整代码如下:
class CTcpOfClient
{
private:
string m_ip; //客户端ip地址
SOCKET m_sock; //与客户进行通信的套接字
public:
CTcpOfClient() {
m_sock = INVALID_SOCKET;
}
~CTcpOfClient()
{
if (m_sock != INVALID_SOCKET) {
closesocket(m_sock);
}
}
/**
* @brief 设置连接上来的用户信息
* @param ip 客户端的ip地址
* @param sock 与客户端通信的套接字
*/
void SetSocket(string ip, SOCKET sock) {
m_sock = sock;
m_ip = ip;
}
//获取该用户的ip地址
string GetIp() {
return m_ip;
}
/**
* @brief 像该客户端发送信息
* @param msg 信息缓存区
* @param len 信息长度
* @return 发送成功的字节数
*/
int Send(const char* msg, int len) {
return send(m_sock, msg, len, 0);
}
/**
* @brief 接收该客户端的信息
* @param buf 接收信息的缓存区
* @param size 该缓存区的大小
* @return 接收到的字节数
*/
int Recv(char* buf, int size) {
return recv(m_sock, buf, size, 0);
}
};
封装完收发数据的套接字,紧接着就来到最重要的监听套接字封装了:
首先还是看成员变量,就一个用于监听客户端的套接字,然后是构造函数与析构函数:
紧接着是是监听套接字的创建、绑定、监听以及等待客户端连接:
这些函数的实现过程都和前面章节差不多,不再细讲,这里主要注意一下这些函数的返回值的写法。
因为返回值都是bool
类型,所以我们就可以通过一个比较符号的结果来返回值。
比如上面我基本都是使用不等于符号,如果函数返回结果等于无效值,那结果就是真,返回也为真,这样写可以避免再写if
语句来进行判断之类的。
注意一下bind
函数:
bool Bind(int port, string ip = "0.0.0.0")
第二个参数我采用的是默认参数,即如果不填,就默认为0.0.0.0
,这个IP地址很特殊,作为bind
函数而言,它的意思是监听当前计算机的所有ip地址。
比如本机回环网络ip:127.0.0.1
,以及当前你连接的wifi
的局域网ip
等等,都可以连接上来。
这里主要需要讲解一下Accept
函数:
因为accept
函数在接收到客户端连接时,同时还会把客户端的网络信息保存到第二个参数中,比如ip地址,端口号等,这里我只想要ip地址,就可以通过函数inet_ntoa
将数字形式的ip,转化为字符串ip。
然后就可以调用CTcpOfClient
的函数SetSocket
用于设置信息。
最后考虑到我们还需要初始化网络环境,但初始化网络环境并不依赖于任何一个具体的对象,所以我将其写为了静态函数:
完整代码:
class CTcpListen {
private:
SOCKET m_sock;
public:
CTcpListen() {
m_sock = INVALID_SOCKET;
}
~CTcpListen()
{
if (m_sock != INVALID_SOCKET) {
closesocket(m_sock);
}
}
public:
/**
* @brief 创建socket
* @return 成功返回true,失败返回false
*/
bool Create() {
m_sock = socket(AF_INET, SOCK_STREAM, 0);
return m_sock != INVALID_SOCKET;
}
/**
* @brief 绑定本机网络
* @param port 端口
* @param ip ip地址
* @return 成功返回true,失败返回false
*/
bool Bind(int port, string ip = "0.0.0.0") {
SOCKADDR_IN addrSev;
addrSev.sin_family = AF_INET;
addrSev.sin_port = htons(port);
addrSev.sin_addr.S_un.S_addr = inet_addr(ip.data());
return -1 != bind(m_sock, (sockaddr*)&addrSev, sizeof(addrSev));
}
/**
* @brief 监听
* @return 成功返回true,失败返回false
*/
bool Listen() {
return -1 != listen(m_sock, 5);
}
/**
* @brief 接收客户端连接
* @param cli 返回连接上来的客户端
* @return 成功返回true,否则返回false
*/
bool Accept(CTcpOfClient& cli) {
SOCKADDR_IN addrCli;
int len = sizeof(addrCli);
SOCKET sockCli = accept(m_sock, (sockaddr*)&addrCli, &len);
string ip = inet_ntoa(addrCli.sin_addr); //将数字ip值转化为字符串ip
cli.SetSocket(ip, sockCli); //设置客户端的信息
return sockCli != INVALID_SOCKET;
}
/**
* @brief 初始化网络环境
* @return 成功返回true,否则返回false
*/
static bool InitNet() {
WSADATA wsadata;
return 0 == WSAStartup(MAKEWORD(2, 2), &wsadata);
}
/**
* @brief 清理网络环境
*/
static void ClearNet() {
WSACleanup();
}
};
2.3 编写Tcp服务器
封装完两个套接字,我们就可以开始正式编写服务器了,也正是因为封装了,所以我们写起来就会简单很多。
下面代码均位于server.cpp
文件。
首先,包含我们需要的头文件:
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include<iostream>
#include<map>
#include"CNetServer.h"
using std::map;
using std::cout;
using std::endl;
using std::pair;
除了基本的输入输出头文件,以及我们刚才封装的套接字类的头文件,我们还包含了map
映射标准库,以及使用了pair
来向map
中添加映射元素元素。
之所以我们需要这样一个映射表,是因为我们现在写的是聊天室,登录到我们服务器中的用户至少得有个昵称吧!
但我们服务器可不是通过名称来锁定客户端的,而是通过套接字,因为只有套接字才能给对应的客户端发送数据。
所以我们就需要建立套接字与用户名称之间的映射,通过套接字,就能立即找到对应的用户名称,方便使用。
因此,我们就需要定义一个全局的变量,用于存储用户的套接字与名称之间的关系:
map<CTcpOfClient*, string> m_clients; //存储客户端套接字和名称的映射关系
这里使用的是我们封装的套接字的指针,这是为了便于传递给线程,同时避免复制拷贝。
什么是线程?不清楚的可以参考这篇文章:进程与线程
使用线程是因为目前我们还没有办法让一个主线程既要收发所有客户端的消息,还要处理客户端连接上来。
因为这些函数都是阻塞的,例如accept
函数,只有当客户端连接上来这个函数才会返回,否则主线程将卡在这里。
完成了这些,我们就可以正式写服务器的代码了:
初始化过程很简单,因为我们提前封装好了的,只是调用一下而已,Bind
函数我只填了第一个参数,所以第二个参数ip地址将采用默认值0.0.0.0
,即将监听当前设备所有ip。
然后就可以进入我们的重要环节,接收客户端连接:
既然是群聊,那必然不可能只是一个客户端,所以我们就需要使用while
循环来不断等待客户端连接上来。
每一次循环,我们都要在堆中申请一个存放客户端套接字的内存,然后再将这个对象传入Accept
函数,用于得到连接上来的客户端套接字:
CTcpOfClient *cli=new CTcpOfClient; //由于处于while循环内部,需要用new在堆中申请空间,否则下一个循环该变量就会被销毁(生命周期)
//等待客户端的连接
if (!sockServer.Accept(*cli)) {
cout <<cli->GetIp() << ":连接失败!" << endl;
continue;
}
如果失败了,就可以打印一下,如果成功连接上来了客户端,就可以进行下一步,接收客户端发送自己的名称:
char msg[20]{};
int len=cli->Recv(msg,20); //首先接收这位客户端的名称
if (len <= 0) {
cout << cli->GetIp() << ":接收数据失败!已经断开" << endl;
delete cli;
continue;
}
当然了,这一步是我们自己要求的,即当我们后面写客户端程序的时候,当连接服务器成功之后,也必须立刻发送自己的昵称给服务器,这样才不至于让服务器在这里堵住。
如果返回值小于等于0,就说明连接有问题,就可以直接断开了!然后删除占用的内存,避免内存泄漏。
如果接收成功了之后,就可以将这个客户端套接字与对应的名字,组合成pair
,然后插入map
中:
m_clients.insert(pair<CTcpOfClient*, string>(cli, msg));
cout << msg << ":进入聊天室!" << endl;
//为新客户端单独开启一个线程用于接收处理该客户端的信息
CreateThread(0, 0, RecvMSG, cli, 0, 0);
并且还要开启一个线程,使用win api函数CreateThread
创建,其第三个参数为线程函数,第四个参数为传递给线程函数的参数,也就是这个线程需要处理的客户端套接字cli
。
这里还没有写这个线程函数,下面马上讲解。
最后就可以清理一下网络环境:
//清理网络环境
CTcpListen::ClearNet();
然后下面就是线程入口函数的讲解:
首先进入线程第一件事就是将参数强制转化为其原本的类型:
CTcpOfClient* cli = (CTcpOfClient*)param; //得到传入的客户端实例
因为我们是聊天室,所以得向其它客户端发送有新用户上线的消息:
//通知以前的所有客户端,有新人加入
for (auto& i : m_clients) {
if (i.first == cli) continue; //新人本人,就不用发送了
string tm = "1:"; //命令序号,1代表新人加入聊天室
tm += (m_clients[cli] + ":加入聊天室");
i.first->Send(tm.data(), tm.size()); //给在群聊中的每一个人都发送新成员加入的消息
}
这里使用了新型的遍历方法,前面提到过,这是遍历C/C++标准库的通用遍历方法,只是我这里使用的auto
。
这个关键字前面好像也提到过,这是一种自动推断类型,它可以帮我们自动推断这个变量的类型,这可以极大方便我们编程。
因为C++模板库很多都是一长串的东西,写起来很费劲,所以就有了这个自动推断类型,可以简化我们的代码量。
因为我们这个类型是pair<CTcpOfClient*, string>
,即成员first
代表前一个套接字,second
代表后一个该套接字所代表的名称,所以如果该套接字等于本身线程所管理的套接字,就可以直接跳过。
还有上面的代码中,不加大括号的写法在C/C++
中也很常见,但并不是特别推荐,因为不便于观察:
if() 语句1;
同时由于没有大括号,所以if语句后面只能执行一条代码;优点当然也有,就是在这里这种情况下,一行就写好了,看起来舒服。
重新回到代码逻辑上来,不是本身的话,我们就要构造字符串发送给对方
string tm = "1:"; //命令序号,1代表新人加入聊天室
但由于客户都安收到的都是一堆字符串,怎么知道我们的服务器要它干嘛呢?
所以我们就得在字符串前加上命令号,比如这里用数字1表示新人加入,后面还通过一个:
用于分割正文内容。
然后就是通过昵称拼接字符串,发送给其它客户端,由于我们是用的映射处理,所以取出该套接字的昵称就非常简单,通过m_clients[cli]
即可取出对应的昵称。
然后调用其它用户的Send
函数发送新人进入的消息即可!
改进点:这里其实应该将构造字符串的地点放在循环外的,因为该字符串一直没有变,放在循环里面就会多次构造一模一样的字符串,会浪费资源,不过我这里就不再更改了。
上面是对所有老用户通知新用户的到来,但我们也得向新用户通知一下以前有哪些老用户吧!
//向新客户端发送以前的用户
string tn = "4:"; //命令序号,4代表向新人发送当前在线人员
for (auto& i : m_clients) { //遍历所有用户,拼接用户名称
if (i.first == cli) continue;
tn += (i.second + ":");
}
cli->Send(tn.data(), tn.size()); //向新用户发送所有在线用户
所以这里就用4
来表示通知新用户,有哪些老用户在这个群聊里。
之所以用4,是因为我后面写完了才想起来这回事,这里也就不再更改了。