5. 手写一个聊天室(单聊、群聊、文件传输)

一、前言

鉴于很多人都想给聊天室添加单聊的功能,所以这里就再出一篇功能更全的聊天室

一直啃旧东西也没意思,所以本篇文章再给大家介绍一个新的窗口框架:WTL

本文的聊天室客户端将基于WTL进行开发,主要有三个程序:windows客户端windows服务器以及linux服务器

由于有两个不同平台的服务器,分别开发就太麻烦了,因此本文选择使用boost中的asio库进行跨平台开发。

二、前置知识点

1.相关文章

在学习本文前,你需要至少阅读以下四篇文章:

  1. 模板
  2. Boost asio库使用详解
  3. C++开源项目配置(vcpkg)
  4. Linux系统入门

其中第一点,是WTL的底层实现逻辑,不懂的话,后面用起来你可能就会觉得非常奇怪。

然后是第二点,由于我们的服务器必须能运行到windowslinux双平台,如果分别进行开发就太麻烦了,所以这里采用C++准标准库中的asio库进行跨平台开发。

即我们只需要写一份代码,就可以在多个平台直接编译运行

然后是第三点,因为我们需要用到第三方库,所以就必须得学会如何在自己的项目中使用第三方库。

但本文实际上并不需要,因为我直接提供了完整的项目解决方案,包括用到的WTL与boost第三方库,我也直接放在了项目中。

至于最后一点,则是为了让我们能够直接在VS中编译运行linux服务器。

2.WTL的基本使用流程

MFC一样,WTL也是一个图形框架,是由微软员工写出来的。

之所以出现WTL,主要还是因为MFC很笨重,不跨平台,闭源,而且似乎已经很多年没有更新过了

一个最简单的MFC程序,默认生成就有数兆大小,一旦使用MFC出现bug,排错就会非常麻烦,因为没有源码。

WTL就可以很好的解决这些问题,因为WTL是直接给我们提供框架源代码的,你可以任意修改、扩展里面的内容。

而且因为其小巧,其生成的文件非常小,本文完成的这个聊天室,最终生成的可执行文件只有160k

但缺点肯定也是有的,就是不跨平台,使用起来也较为麻烦。

不过我已经总结出了一套基本使用流程,所以不必过于担心,只要你会MFCWTL也能很快学会。

WTL很多函数接口都是直接模仿的MFC,所以如果你对WTL中的某个控件不知道怎么用,你就可以直接查询MFC的文档,可以点击这里查看官方MFC文档。

首先,我们需要建立一个桌面窗口空项目:

随便取个名字:

在这里插入图片描述

然后选择桌面应用程序,以及勾选空项目:

在这里插入图片描述

如果用的vcpkg,那么下面两个配置的步骤就不需要了。

这样我们的客户端项目就创建好了,现在你就可以将WTL源代码复制进这个项目文件夹中:

在这里插入图片描述

然后将这个文件添加到项目的包含目录中即可,不再赘述:

在这里插入图片描述

现在,我们就可以正式写代码了。

首先新建两个文件:stdafx.hmain.cpp

在这里插入图片描述

然后在stdafx.h头文件中包含如下头文件:

#pragma once
#include<atlbase.h> //使用ATL的基本文件,必须包含
#include<atlapp.h> //使用WTL的基本文件
#include<atlwin.h>  //ATL封装的窗口
#include<atlframe.h> //WTL封装的窗口框架类
#include<atlmisc.h>  //WTL封装的工具类
#include<atlctrls.h> //WTL封装的控件类
#include<atlcrack.h> //WTL封装的增强消息宏
#include<atldlgs.h> //WTL封装的各种对话框类
#include"resource.h" //资源头文件,当使用到了资源,会自动生成该头文件

也就是说,这个头文件是用来包含库的头文件的,方便我们以后使用。

这里写出的便是我们所需要的所有头文件了,注意最好不要更改各个头文件之间的顺序,以后其它文件直接包含这个stdafx.h头文件就行了。

然后我们就可以像MFC那样,在资源视图中,新建一个对话框窗口资源:

在这里插入图片描述

注意这里的对话框资源ID,可以按 F4或者右键跳转到属性界面进行修改,后面要用到,比如这里我就将其修改成为了IDD_DLG_MAIN,即用这个窗口资源作为我们的主对话框。

完成了这一步之后,我们还需要为它绑定一个类。

除了一个一个文件的添加,我们这里还可以直接添加类的:

在这里插入图片描述

然后取名为CMainDlg

在这里插入图片描述

然后在该类的头文件中修改为如下代码:

#include"stdafx.h"
class CMainDlg :public CDialogImpl<CMainDlg> {
public:
	enum {
		IDD = IDD_DLG_MAIN
	};

	BEGIN_MSG_MAP(CMainDlg)
	END_MSG_MAP();
};

这里用到的就是C++模板实现多态的特性,我们继承自模板类CDialogImpl,并传入了自己的类名。

接着写一个枚举变量等于要绑定的窗口资源ID即可:

	enum {
		IDD = IDD_DLG_MAIN
	};

最后就是消息映射,因为windows是消息驱动的,MFC里面同样也是用消息映射来处理事件,这里同样如此:

	BEGIN_MSG_MAP(CMainDlg)
	END_MSG_MAP();

注意BEGIN_MSG_MAP后面需要填写的是本类类名,也就是给本类添加消息映射。

至此,我们就完成了类与窗口资源的绑定工作。

最后,我们还需要在main.cpp中写下我们的启动代码:

#include"stdafx.h"
#include"CMainDlg.h"

CAppModule app; //保存主线程id和消息循环的实例
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd) {
	app.Init(NULL, hInstance);

	CMainDlg dlg;
	dlg.DoModal();

	app.Term();
}

WinMain函数是窗口程序的入口函数,直接复制即可,这里主要是多出了CAppModule ,如果你对MFC熟悉的话,应该知道MFC程序中也有一个CWinApp类代表着当前整个应用程序实例。

在这里,我们只需要用它声明一个全局变量,然后在代码中初始化它与销毁它即可:

app.Init(NULL, hInstance);
//代码
app.Term();

然后在这两行代码中间,填入我们想要执行的代码,所以这里只需要声明一个我们上面那个窗口类,然后调用其DoModal函数即可,

现在运行程序,你就发现可以正常运行了。

上面所有的步骤都是最基本且必要的步骤。

如果要深究原理的话,实际步骤会比上面的内容还要复杂的多,这里用到的是我自己总结出的一套能够快速用WTL库进行开发的方法,只要你熟悉MFC,就能很快上手。

但你会发现,运行出来的窗口是无法关闭的,那时因为我们没有响应对应的关闭消息。

这时,我们只需要来到资源对话框窗口,按F4进入其属性页面,点击上方的消息,添加对窗口关闭消息的处理:

在这里插入图片描述

这就和MFC非常像了,在生成的代码中,写一个EndDialog(0)函数即可退出当前的对话框:

在这里插入图片描述

这个EndDialog函数便是我们继承下来的,专门用于退出当前对话框窗口。

如果你想要看我们继承下来了哪些函数,可以使用this->,VS就会帮我们列举出来。

除了关闭消息,我们最常使用的还有初始化消息,方法与上面一样:

在这里插入图片描述

还有按钮,你也可以直接双击它们,添加点击事件处理函数,也可以选择它们按F4,选择其它可用的事件:

在这里插入图片描述

添加控件的方法和MFC一模一样,在工具箱中拖出需要的控件,放在对话框上面即可。

至此,我们就过了一遍WTL开发程序的基本步骤,来总结一下。

  1. 新建stdafx.h头文件,用于存放WTL库的头文件。
  2. 添加一个资源对话框窗口。
  3. 添加一个类,通过其枚举IDD绑定窗口的ID,并添加消息映射宏。
  4. main.cpp中启动整个程序。
  5. 可以在窗口视图中,选择对应的窗口、控件,按F4进入属性页面,添加对应的消息、事件处理函数。

然后之后的开发,就和MFC基本相同了。

不过还有一点,我们想要在代码中直接操作控件,MFC中曾经提到过数种方法,其中使用ID的方法仍然是可以的。

即,用GetDlgItem与控件id来获取控件实例,进而调用相应的函数:

GetDlgItem(IDOK).EnableWindow(false);

但每次都这样弄就比较麻烦,其实我们也可以将其绑定在一个变量上:

首先添加一个对应的类型(WTL中的控件类名与MFC中的基本保持一致):

在这里插入图片描述

然后在窗口的初始化函数中调用Attach方法,直接将这个控件与变量进行绑定:

m_btn.Attach(GetDlgItem(IDOK));

在这里插入图片描述

这样以后,我们就可以直接通过操作这个变量来看控制控件了。

三、项目下载与文件介绍

由于这个项目比较复杂,所以我这里将直接提供源代码,后面也直接对这个源代码中的内容进行讲解。

只要你阅读过本站《C++实战入门到精通》的内容,我相信你是能够看得懂的。

唯一的难点可能是对这些库的使用不熟练。

本链接采用阿里云分享,点击这里跳转下载,下载后是一个可执行文件,直接点击运行就可以解压。

解压后,得到下面目录:

在这里插入图片描述

  • ChatClient:客户端代码。

  • include:本项目所用到的库文件,但由于客户端、服务器的网络部分都用到了boost库,所以我这里直接将其放在了外面,而不是在三个项目里面都放置一份。

  • LChatServer:linux服务器。

  • WChatServer:windows服务器。

  • ChatClient:生成的客户端程序

  • ChatRoom.sln:解决方案管理文件,用VS打开这个文件即可打开整个解决方案。

  • LChatServer.out:生成的linux服务器程序

  • WChatServer.exe :生成的windows服务器程序

对于Windows平台的项目,可以直接编译运行,而对于linux服务器,你就还需要配置你的linux平台,在本文最开始就提到过,不再赘述。

然后,还需要将boost库复制到该项目的include文件夹中才能正常编译:

在这里插入图片描述

这些文件夹不用你生成,只需要配置好环境后,在VS中点击编译就会自动在linux平台生成。

生成完毕后,再将你的boost文件夹复制过去,存放在一个include文件夹中即可。

我们的d盘、c盘之类的,只可以在子系统目录:/mnt下找到的,所以你可以直接使用linux复制文件命令将windows系统上的文件复制到你的子系统内。

这样就能正常编译了。

四、服务器代码讲解

服务器共有六个函数、一个类NetPacket

在这里插入图片描述

老规矩,我们还是从main函数说起:

在这里插入图片描述

首先是io_context类,这个是asio库中最基础的类,所有的io操作类都需要它作为第一个参数。

然后是acceptor类,专门用于tcp服务器来等待客户端的连接。

因为需要读网络套接字,所以它的第一个参数就是上面声明的io_context对象,然后第二个参数就是我们要监听的端口类。

endpoint类聚合了地址与端口,所以我们可以这样构造,第一个参数意思就是监听本机所有的ipv4地址,然后第二个参数就是我们要监听的端口号。

接着进入循环,注意SPSock是我自己定义的一个类型,其原型是:

shared_ptr<tcp::socket> SPSock

这部分内容如果不熟悉,可以参考本站的另一篇文章:智能指针

定义在了头文件stdafx.h中:

typedef	shared_ptr<tcp::socket> SPSock; //socket共享智能指针

之所以要用它,原因就是我们想要使用指针(便于传送),但我们不想要操心内存。

实际用到的是socket类,其主要用途就是作为一个可以和客户端通信的套接字。

接着就是等待客户端连接上来,一旦连接上来,这个sock里面就存放着与这位客户端通信的能力:

acptr.accept(*sock);

紧接着是两个变量:

vector<char> msg;
msg_id id;

这里之所以用vector<char>而不是string,主要原因就是string默认以\0作为结束,而网络传输的数据中没人能保证里面的数据中没有\0,尤其是我们想要传输文件,文件数据中里面肯定有\0

如果继续使用string,一遇到数据中的\0就会自动截断数据,导致数据丢失,这肯定不行。

vector就不存在这个问题,也就是说,这里的msg就是用来接收客户端发送来的消息的。

而这里的msg_id 是一个枚举类型,定义在了头文件stdafx.h中:

//消息包id
enum class msg_id {
	name = 0,//数据内容为客户端名称
	newuser = 1, //数据内容为新用户名称
	alluser = 2, //数据内容为当前所有在线用户名称
	exit = 4, //数据内容为当前退出聊天室的成员名称
	normal = 8, //数据内容为聊天消息
	single = 16, //数据内容为 昵称:消息
	file = 32, //文件发送,数据内容为文件数据,以文件名开头 \0 与后面的文件数据分割
	single_file = 16 | 32 //给单用户发送文件,相当于 single 与 file的组合
};

这是用来标识客户端给我们服务器发送的消息类型,本项目共用到了8种消息类型。

同时注意,这里用的都是2的倍数,这就是为了方便各个消息进行组合。

比如前四个id转换为二进制后,就是下面这样:

0b0000 0b0001 0b0010 0b0100 0b1000

只需要用或运算,我们就可以将这些消息任意组合,比如第二、第三个消息进行或运算:

0b0001 | 0b0010 = 0b0011

最后在判断一个消息id中有没有某个消息,就可以通过与运算:

0b0011 & 0b0001 =1

只要运算结果不为0,那就存在,如果为0,则说明不存在。

1.NetPacket类

然后我们就来到接收客户端消息了:

if (!NetPacket::RecvMsg(sock, id, msg)) {
	continue;
}

这是我封装的一个消息包类。

为什么我要封装一个类来发送消息,而不是直接发送呢?

因为我需要让服务器与客户端知道彼此每次互相发送的数据是什么类型,所以我就需要在每条数据前面添加一个消息id,用来标识这个数据的类型。

方法就是在实际数据前面添加一些内容来描述其后紧跟的数据类型,也常被称为数据包头,而消息种类很多,这个过程非常繁琐,所以我们将其封装成函数便于后面使用。

下面我们就来介绍一下我封装的这个网络数据包:

在这里插入图片描述

这个类非常简单,里面只有一个结构体、两个函数。

这里的结构体便是刚才说的数据包头,而两个函数则分别用于收发一个数据包。

注意:其作用是收发一个完整的数据包,而不是一小块数据,只要这个数据包接收、发送的不完整,那么就直接返回false

比如我要发送一个1M大小的文件,因为网络原因,可能就发送了0.5M,那么这两个函数就直接返回错误,不进行接受。

所以现在就可以肯定的认为,只要这两个函数返回true,那么就必然发送、接收了一个完整的数据包,这便于我们后面进行解析。

也正因如此,本程序并不适合大文件传输,那会导致程序一直卡在这里收发数据,导致卡死,对于大文件传输,那就需要分包,比如一个1G文件,那么我就可以将其分为1024份,每一份作为一个单独的数据包来发送,最后接收方再将这些分开发送的数据重新整合。

首先我们来看这个结构体,实际上就是我们刚提到的数据包头:

struct Header {
	short head; //头部标志,为0xFEFF
	char id; //消息id
	char sum; //校验和
	size_t len; //数据长度

	Header() :head(short(0xFEFF)), id(0), sum(0), len(0) {} //默认初始化
};

其中共有四个成员变量

  • head:用来标识一个数据包的头部,因为网络数据传输就和一根管道一样,很多包数据都在其中排队,所以我们需要识别出每个包的头部,这里取值为0xFEFF的原因是这个数据在数据包中出现的频率较低。

  • id:用来标识这个数据包的类型。

  • sum:校验和,这是为了防止数据传输出错,所以我们在发送数据时要计算这个值,然后接收方接收到数据之后,也要计算一遍,和这个值进行比较,如果相等,则说明数据无误。

  • len:紧跟在数据包后面的数据长度,长度不可能为复数,所以使用unsigned in,也被定义为size_t

最后还给这个结构体写了一个构造函数,赋予其各个变量默认值,使用的是初始化列表写法。

看完结构体,我们就可以来看看收发数据函数了。

首先是接收数据函数:

	static bool SendMsg(const SPSock& so, msg_id id, const vector<char>& msg) {
		Header h;
		h.id = (char)id;
		for (auto i : msg) { //得到校验和,方便对方校验接收到的数据是否有误
			h.sum += i;
		}
		h.len = msg.size();

		try {
			so->send(buffer(&h, sizeof(Header))); //先发送头部
			so->send(buffer(msg.data(), msg.size())); //再发送数据
		}
		catch (std::exception& e) {
			cout << e.what() << endl;
			return false;
		}
		return true;
	}

先看其参数,第一个是要进行发送的套接字(使用的智能指针),然后第二个是消息id,第三个为要发送的数据。

各个步骤都很简单,总结来说就是为数据包头赋对应的值,然后发送数据包头、数据。

而所谓的检验和就是将所有要发送的数据全部相加

结果肯定会超出一个char的大小,但无所谓,溢出的数据舍弃即可,我们需要的仅仅是一个能够标识所有数据的信息,而不是这些数据真正的和。

在发送的时候,采用了异常的写法:

try{
}catch(){
}