1 聊天室
前面一章我们已经学会了如何用C/C++语言进行网络编程,但那却仅限于最简单的一对一聊天,和我们平时用的QQ、微信的功能也差太多了。
所以本章将结合前面学到的MFC界面编程的知识,带领大家开发一个聊天软件,它的主要功能就是群聊。
本文项目源码的链接放在了文章末,需要的可以自取,如果觉得困难的,可以直接参照源码对照本文学习即可。
如果需要添加更多强大的功能,比如单聊、文件传输等等,可以参考另外两篇文章:
2 聊天室服务器
聊天最重要的是什么?那当然是信息正确性,即对方是否收到了正确的信息,为了这个目的,我们自然选择TCP协议,因为它的特点之一就是可靠性。
其次就是服务器形式,服务器仅仅只是用于转发信息而已,那自然是用不着图形化界面的,建立一个控制台项目即可。
过程相信大家都会了,那我也就不再演示如何建立一个项目了,我这里新建的一个解决方案名字为LANChat,然后服务器项目的名称为LANServer,客户端的先不用管,后面我再进行讲解:

然后服务器里面只有两个文件,首先是server.cpp文件,这是这个服务器主要且唯一的源文件,main函数入口在里面。
然后还有一个头文件名字为CNetServer.h,这个用于我们封装类,便于使用,下面就要开始进行讲解。
2.1 封装套接字
上一章使用了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.2 编写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); //首先接收这位客户端的名称