上次说了基于UDP聊天软件的服务器实现,还有一点忘记说了,那便是关闭套接字。这个功能自然是要写到析构函数里,如下:
------------------------------------------------------------
Myusock::USockServer::~USockServer()
{
std::string msg = "服务器已关闭!";
SendToAllClient(MESSAGE_SYSTEM_ERROR, msg);
closesocket(m_servSock);
WSACleanup();
}
在析构函数中,除了关闭套接字,还应该通知所有连接进来的客户端服务器已关闭,客户端可以根据此消息来做相应的操作,这个消息的类型被定义为MESSAGE_SYSTEM_ERROR。
这里,当客户端收到消息类型为MESSAGE_SYSTEM_ERROR的消息时,我们可以设置发送和发送给所有人键为不可点击状态,让连接服务器处理可点击状态,并提示“服务器已关闭”。
现在来看客户端,服务器已实现好了,客户端便相对的简单一些。客户端定义如下:
#include
#include //正则表达式类,用于验证IP地址的合法性
#include //智能指针
#include
#pragma comment(lib, "ws2_32.lib")
#pragma warning(disable:4996)
namespace Myusock {
class USockClient;//前向声明
//这个枚举类型用于是验证IP地址与端口的返回值
enum {
INVALID_ADDRESS,//无效的IP地址
VALID_PORT,//有效的端口号
VALID_ADDRESS_PORT//有效的IP地址和端口号
};
//消息标志,和服务器的是相对应的
enum MESSAGE_TYPE {
MESSAGE_MIN,//消息最小边界
MESSAGE_LOGIN,//登录消息
MESSAGE_LOGOUT,//退出消息
MESSAGE_CHAT_FROM_ME,//我发送指定用户的消息
MESSAGE_CHAT_TO_OTHER,//指定用户收我发的消息
MESSAGE_CHATALL,//发送给所有用户
MESSAGE_CHATALL_FROM_ME,//我发送给所有人
MESSAGE_CHATALL_TO_OTHER,//所有人收我的消息
MESSAGE_CHAT,//聊天消息
MESSAGE_SYSTEM_ERROR,//系统返回错误消息
MESSAGE_SYSTEM,//系统通知消息
MESSAGE_MAX//消息最大边界
};
const unsigned long MAX_BUF = 40960;
const unsigned int MSG_TYPE_LEN = 4;
//用户信息结构,客户端并不需要保存地址信息,所以只保存下自己的名称
typedef struct {
std::string userName;//用户名
}UserInfo;
//这是客户端界面的句柄与控件信息
typedef struct {
HWND hWnd;
CListBox* cListBox;
CButton* cButtonConnect;
CButton* cButtonSend;
CButton* cButtonSendToAll;
}OP_CONTROL;
class USockClient
{
public:
USockClient();
~USockClient();
void Connect(OP_CONTROL opControl, const std::string address,
const unsigned short port, const std::string name);//连接服务器
void SendUserInfo();//发送个人信息
void ConnectedSocket() const;//已连接Socket
void SendToOther(const std::string userName, std::string msg);//向目标用户发送消息
void SendToAll(std::string msg);//发送给所有用户
void RecvFromServer();//接收消息
private:
int IsValidAddressAndPort(const std::string address,//判断IP地址和端口号是否有效
const unsigned short port) const;
void init(const std::string address, const unsigned short port);//初始化套接字相关信息
std::string DealMessage(const char* msg, int& type) const;//处理消息结构
void FormatMsg(MESSAGE_TYPE msgType, std::string& msg);//格式化使消息为带消息类型的消息
void SetDlgNewMessage(std::string msg);
void SendToServer(const std::string msg) const;//发送消息
private:
WSADATA m_wsaData;
SOCKET m_sock;
SOCKADDR_IN m_servAddr;
std::string m_userName;
CString m_cstrMsg;
std::vector > m_vAllUserInfo;//所有用户信息
OP_CONTROL m_opControl;//指向要操作的控件
};
}
客户端的很多操作其实和服务器是一样的,只是服务器多了个"分配功能"。
我们先从验证IP地址和端口地址来说,它的函数如下:
-----------------------------------------------
int Myusock::USockClient::IsValidAddressAndPort(const std::string address, const unsigned short port) const
{
std::regex reg("^(1\\d|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
"(1\\d|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
"(1\\d|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
"(1\\d|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$");
if (!std::regex_match(address, reg))
return INVALID_ADDRESS;
if (port >= 1024 && port
return VALID_PORT;
return VALID_ADDRESS_PORT;
}
在验证IP地址时使用了C++的正则表达式,因为使用别的方法我们很难来验证IP地址。std::regex是C++的正则类,里面提供了一些正则操作,它的头文件是。^表示正则表达式的开头,$表示正则表达式的结尾,中间是4段匹配IP地址的正则规则,根据这个规则,利用std::regex_macth来和传进来的address进行匹配,若是匹配失败,我们返回定义的枚举INVALID_ADDRESS,表式无效的IP地址。
关于正则的详细操作后面会总结一篇文章,这里便不详细解释了。
下面是端口的匹配,因为它的规则并不复杂,则无需使用正则了。
接下来来看初始操作,也很简单。
------------------------------------
void Myusock::USockClient::init(const std::string address, const unsigned short port)
{
int iRet = IsValidAddressAndPort(address, port);
if (INVALID_ADDRESS == iRet)
throw "IP地址错误!";
else if (iRet != VALID_PORT)
throw "端口错误";
if (WSAStartup(MAKEWORD(2, 2), &m_wsaData) != 0)
throw "WSAStartup() error!";
m_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (INVALID_SOCKET == m_sock)
throw "socket() error!";
//设置为非阻塞状态
/*u_long nonblocking = 1;
ioctlsocket(m_sock, FIONBIO, &nonblocking);*/
memset(&m_servAddr, 0, sizeof(m_servAddr));
m_servAddr.sin_family = AF_INET;
m_servAddr.sin_addr.S_un.S_addr = inet_addr(address.c_str());
m_servAddr.sin_port = htons(port);
std::string servInfo = "Server IP:" + address + " Port:";
char tmp[6] = { 0 };
sprintf(tmp, "%d", port);
servInfo += tmp;
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, CA2W(servInfo.c_str()));
}
可以设置一个已连接套接字函数,供用户自由调用,关于这个上篇有说:
void Myusock::USockClient::ConnectedSocket() const
{
//已连接的UDP socket
connect(m_sock, (SOCKADDR*)&m_servAddr, sizeof(m_servAddr));
}
现在,便可以实现Connect函数,说是Connect函数,其实并不是连接服务器,只是调用了init函数初始化了自己的信息:
---------------------------------------------------------------------------------
void Myusock::USockClient::Connect(OP_CONTROL opControl, const std::string address,
const unsigned short port, const std::string name)
{
if (name.empty())
throw "用户名不能为空!";
//这些是控件
m_userName = name;
m_opControl.hWnd = opControl.hWnd;
m_opControl.cListBox = opControl.cListBox;
m_opControl.cButtonConnect = opControl.cButtonConnect;
m_opControl.cButtonSend = opControl.cButtonSend;
m_opControl.cButtonSendToAll = opControl.cButtonSendToAll;
init(address, port);
}
那么,客户端是如何向服务器注册信息的呢?这就是SendUserInfo所做的事:
-----------------------------------------------------------------------
void Myusock::USockClient::SendUserInfo()
{
std::string userName = m_userName;
FormatMsg(MESSAGE_LOGIN, userName);
//std::cout
SendToServer(userName);//将用户名发送到服务器
}
其中,FormatMsg和服务器的版本实现是一样的,只是方便将消息格式化为带消息类型的消息。我们在刚初始化完客户端消息后,第一次的发的消息便是用户的个人登录信息MESSAGE_LOGIN,这样,就把用户添加到服务器列表中了。可以对比着看服务器的MESSAGE_LOGIN是如何处理的。
SendToServer()的实现很简单,就是封装了下sendto,使只需传入个string便可发送,方便我们调用:
-----------------------------------------------
void Myusock::USockClient::SendToServer(const std::string msg) const
{
sendto(m_sock, msg.c_str(), msg.size(), 0, (SOCKADDR*)&m_servAddr, sizeof(m_servAddr));
}
现在来看SendToOther()函数,这个函数是向指定用户发送消息,即私聊:
-------------------------------------------------------
void Myusock::USockClient::SendToOther(const std::string userName, std::string msg)
{
std::string sendMsg = m_userName + "_" + userName + "_" + msg;//发送人_收信人_消息体
FormatMsg(MESSAGE_CHAT, sendMsg);//消息类型:发送人_收信人_消息体
SendToServer(sendMsg);//将组装好消息发送给服务器
}
实现起来也是如此的简单,只要把消息重组为:消息类型:发送人_收信人_消息体这样的格式就好了。在服务器的MESSAGE_CHAT消息便是用来解析这种消息类型并实现分发的,具体也可再去看看服务器的实现。
再来看群聊消息:
-----------------------------------------
void Myusock::USockClient::SendToAll(std::string msg)
{
if (m_vAllUserInfo.empty())
throw "当前用户列表为空";
std::string sendMsg = m_userName + "_" + msg;//发送人_消息
FormatMsg(MESSAGE_CHATALL, sendMsg);//消息类型:发送人_消息
SendToServer(sendMsg);
}
还是依旧的简单,这都因为有了之前的消息类型逻辑处理,使我们发送消息只需组装为指定类型便可轻松搞定。这里,发送给所有人的格式定义为:消息类型:发送人_消息体。因为是向所有在线的用户发,便无需发送接收人了。服务器对应的处理消息类型为MESSAGE_CHATALL,也可对应服务器来看服务器是如何解析的。
客户端最重要的也是接收函数,因为上面这些操作的简单都是建立在接收函数稍微复杂的处理上的。
------------------------------------------------------
void Myusock::USockClient::RecvFromServer()
{
char recvMsg[MAXBYTE] = { 0 };
int servAddrSize = sizeof(m_servAddr);
recvfrom(m_sock, recvMsg, MAXBYTE, 0, (SOCKADDR*)&m_servAddr, &servAddrSize);
int msg_type;
//若接收的消息为空,则表示和服务器的地址和开放的端口不一致,或是服务器未开启,便设置相应键的状态,并抛出错误提示。
if (recvMsg[0] == '\0')
{
m_opControl.cButtonConnect->EnableWindow(TRUE);
m_opControl.cButtonSend->EnableWindow(FALSE);
m_opControl.cButtonSendToAll->EnableWindow(FALSE);
throw "与服务器建立连接失败,请检查端口或IP地址是否正确,或者确认是否打开了服务器!";
}
//DealMessage函数和服务器的处理是一样的,返回解析的消息体,消息类型通过引用保存在msg_type中
std::string check_msg = DealMessage(recvMsg, msg_type);//msg_type:消息类型 check_msg:消息体
if (!check_msg.compare("error"))
{
throw "不正确的消息类型.";
}
switch (msg_type)
{
//登录消息,为何客户端也需要处理这个消息呢?
//这便是用户信息同步的功能实现。服务器在收到了SendUserInfo的登录消息后,会有个同步消息,将自己的信息同步给其它用户,同时将其他人的信息同步给自己。同步给自己的信息,即其他用户的信息,服务器就是通过MESSAGE_LOGIN消息类型发送给客户端的。
case MESSAGE_LOGIN:
{
std::shared_ptr newUserInfo = std::make_shared();
newUserInfo->userName = check_msg;
m_vAllUserInfo.push_back(newUserInfo);//加入新用户的信息
//std::cout userName
m_opControl.cListBox->AddString(CA2W(newUserInfo->userName.c_str()));//将新用户加入到CList框中
std::string new_user = newUserInfo->userName + "加入了聊天室.";
//SetDlgNewMessage函数也和服务器的一样,用于获取聊天框中的消息,并将新消息加入,存于成员函数m_cstrMsg中,即此函数影响m_cstrMsg中的值。
SetDlgNewMessage(new_user);//设置m_cstrMsg中为新消息
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, m_cstrMsg);
break;
}
//当有用户退出时,服务器继续同步用户信息,并将用户退出的信息通过MESSAGE_LOGOUT消息类型发送给客户端。这样,客户端便可以随时知道用户是否在线并设置用户列表框中的数据。
case MESSAGE_LOGOUT:
{
auto pos = std::find_if(m_vAllUserInfo.begin(), m_vAllUserInfo.end(),
[&](std::shared_ptr user) {
return user->userName == check_msg;
});
m_vAllUserInfo.erase(pos);//删除此用户信息
//std::cout
int index = m_opControl.cListBox->FindString(-1, CA2W(check_msg.c_str()));
if(index != -1)
m_opControl.cListBox->DeleteString(index);//CList列表中删除此用户
std::string exit_user = check_msg + "退出了聊天室.";
SetDlgNewMessage(exit_user);//设置m_cstrMsg中为新消息
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, m_cstrMsg);
break;
}
//这两个同时处理,因为前面的解析操作都是一样的,避免写两次。再后面再用if判断就好了。
//这是我们说的私聊功能,分别是别人对我说的和我对其他人说的。根据类型的不同设置聊天框中显示的不同。
case MESSAGE_CHAT_FROM_ME:
case MESSAGE_CHAT_TO_OTHER:
{
int pos = check_msg.find('_');//名称标记位
if (pos == std::string::npos)
break;
std::string sendName = check_msg.substr(0, pos);//发送人名称
check_msg = check_msg.substr(pos + 1);
pos = check_msg.find('_');
if (pos == std::string::npos)
break;
std::string recvName = check_msg.substr(0, pos);//接收人名称
check_msg = check_msg.substr(pos + 1);//发送内容
if (msg_type == MESSAGE_CHAT_FROM_ME)
{
check_msg = "我对" + recvName + "説:" + check_msg;
SetDlgNewMessage(check_msg);//设置m_cstrMsg中为新消息
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, m_cstrMsg);
}
else
{
check_msg = sendName + "对我:" + check_msg;
SetDlgNewMessage(check_msg);//设置m_cstrMsg中为新消息
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, m_cstrMsg);
}
break;
}
//这里是群聊功能的返回数据
//分别是我对其他所有人说的和其他人广播给我的,操作和上面的一样。
case MESSAGE_CHATALL_FROM_ME:
case MESSAGE_CHATALL_TO_OTHER:
{
int pos = check_msg.find('_');//名称标记位
if (pos == std::string::npos)
break;
std::string sendName = check_msg.substr(0, pos);//发送人名称
check_msg = check_msg.substr(pos + 1);//发送内容
if (msg_type == MESSAGE_CHATALL_FROM_ME)
{
check_msg = "我説:" + check_msg;
SetDlgNewMessage(check_msg);//设置m_cstrMsg中为新消息
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, m_cstrMsg);
}
else
{
check_msg = sendName + "説:" + check_msg;
SetDlgNewMessage(check_msg);//设置m_cstrMsg中为新消息
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, m_cstrMsg);
}
break;
}
//从系统返回来的错误信息
//像是服务器退出消息或是用户名重复消息。因为在未登录时,本地并无其他用户的信息,无法在本地完成判断,所以由服务器判断
case MESSAGE_SYSTEM_ERROR:
{
m_opControl.cButtonConnect->EnableWindow(TRUE);//设置连接键可点击
m_opControl.cButtonSend->EnableWindow(FALSE);//设置发送键不可点击
m_opControl.cButtonSendToAll->EnableWindow(FALSE);//设置发送所有人键不可点击
::MessageBox(m_opControl.hWnd, CA2W(check_msg.c_str()), L"错误提示", MB_ICONERROR);
break;
}
//这是系统消息
//即系统对所有用户发的消息。
case MESSAGE_SYSTEM:
{
check_msg = "系统通知:" + check_msg;
SetDlgNewMessage(check_msg);
SetDlgItemText(m_opControl.hWnd, IDC_CHAT_CONTENT, m_cstrMsg);
break;
}
}
}
同样,在析构函数中向服务器发送下线通知:
-----------------------------------------------------------
Myusock::USockClient::~USockClient()
{
std::string userName = m_userName;
FormatMsg(MESSAGE_LOGOUT, userName);
SendToServer(userName);//发送退出消息
closesocket(m_sock);
WSACleanup();
}
这里,接收函数也是需要循环接收的,所以自然也需要开个线程:
------------------------------------------------------------------
void RecvMsg(HWND hWnd)
{
while (true)
{
std::mutex mtx;
std::lock_guard lock(mtx);
try {
usockClient.RecvFromServer();
}
catch(const char *e){
::MessageBox(hWnd, CA2W(e), L"错误提示", MB_ICONERROR);
}
}
}
这里直接写在了MFC的窗口类中,所以不需要加static了,我们在里面调用:
------------------------------------------------------------
void CUdpSockClientDlg::OnBnClickedBtnConnect()
{
CString cstrAddress, cstrPort, cstrName;
GetDlgItemText(IDC_IPADDRESS1, cstrAddress);//获取输入的IP
GetDlgItemText(IDC_PORT, cstrPort);//获取输入的端口
GetDlgItemText(IDC_NICKNAME, cstrName);//获取用户昵称
if (cstrAddress.IsEmpty() || cstrPort.IsEmpty() || cstrName.IsEmpty())
{
AfxMessageBox(L"请输入IP和端口及昵称");
return;
}
if (!cstrName.Compare(L"系统消息"))
{
AfxMessageBox(L"不能使用系统名称!");
return;
}
USES_CONVERSION;
std::string strAddress = W2CA(cstrAddress);//转换后的IP
cstrPort.TrimLeft();
cstrPort.TrimRight();
unsigned int usPort = _ttoi(cstrPort);//转换后的端口
std::string strName = W2CA(cstrName);//转换后的昵称
try {
Myusock::OP_CONTROL opControl;
opControl.hWnd = GetSafeHwnd();
opControl.cListBox = &m_userList;
opControl.cButtonConnect = &m_btnConnect;
opControl.cButtonSend = &m_btnSend;
opControl.cButtonSendToAll = &m_btnSendToAll;
usockClient.Connect(opControl, strAddress, usPort, strName);
usockClient.ConnectedSocket();
usockClient.SendUserInfo();
m_btnSend.EnableWindow(TRUE);//设置发送键可点击
m_btnSendToAll.EnableWindow(TRUE);//设置发送给所有人键可用
m_btnConnect.EnableWindow(FALSE);//设置连接键不可点击
std::thread recvThread(RecvMsg, GetSafeHwnd());//开始接收线程
recvThread.detach();
}
catch (const char *e)
{
::MessageBox(GetSafeHwnd(), CA2W(e), L"错误提示", MB_ICONERROR);
}
}
发送按钮只需调用SendToOther()便可以了,发送人的名称是通过点击列表框中的用户名获取的,这是MFC的操作,便不说了。
------------------------------------------------------------
void CUdpSockClientDlg::OnBnClickedBtnSend()
{
int index = m_userList.GetCurSel();
if (index == -1)
{
AfxMessageBox(L"请选择要发送的用户");
return;
}
CString cstrName, cstrMsg;
m_userList.GetText(index, cstrName);//获取选择目标用户昵称
GetDlgItemText(IDC_EDIT_MSG, cstrMsg);//获取要发送的消息
if (cstrMsg.IsEmpty())
{
AfxMessageBox(L"请输入要发送的消息.");
return;
}
USES_CONVERSION;
std::string strName(W2CA(cstrName));
std::string strMsg(W2CA(cstrMsg));
try {
usockClient.SendToOther(strName, strMsg);
}
catch (const char *e)
{
::MessageBox(GetSafeHwnd(), CA2W(e), L"错误提示", MB_ICONERROR);
}
}
发送给所有人也只用调用SendToAll函数便好了,这里不用获取发送目标的名称了:
--------------------------------------------------------
void CUdpSockClientDlg::OnBnClickedChatToAll()
{
CString cstrMsg;
GetDlgItemText(IDC_EDIT_MSG, cstrMsg);
USES_CONVERSION;
std::string strMsg(W2CA(cstrMsg));
try {
usockClient.SendToAll(strMsg);
}
catch (const char *e) {
::MessageBox(GetSafeHwnd(), CA2W(e), L"错误提示", MB_ICONERROR);
}
}
好了,这便是基于UDP的聊天客户端的实现。
领取专属 10元无门槛券
私享最新 技术干货