8. WTL框架实现聊天室开发(支持单聊、群聊与文件传输)

1 前置知识

本文是一篇功能更加全面的聊天室功能,在群聊、文件传输基础上,还加上了单聊,并且使用的是新的窗口框架:WTL

WTL是一个比较久远的框架了,windows系统专用,如今并不太流行,所以如何你对单独开发非常小巧的window应用程序兴趣不大的话,不推荐学这个,我这里只是给大家添加一种选择。

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

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

在学习后面的内容前,你需要至少阅读以下四篇文章:

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

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

然后asio库,由于我们的服务器必须能运行到windowslinux双平台,如果分别进行开发就太麻烦了,所以这里采用C++准标准库中的asio库进行跨平台开发,也就是我们只需要写一份代码,就可以在多个平台直接编译运行

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

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

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

1.1 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));

在这里插入图片描述

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

2 服务器代码讲解

下面介绍的代码可以从本文的文末直接下载。

服务器共有六个函数、一个类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,则说明不存在。

2.1 NetPacket类

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

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