欢迎前往我的个人站点查看更多精彩内容:酷编程
一、程序演示
虽然最开始是打算写个局域网就好了的,但其实如果你有云服务器,可以向微信、QQ一样与相隔甚远的朋友聊天,只需要将客户端IP修改为云服务器的IP,并将服务器程序运行到云服务器上,端口可自行确定。
因为我原本就租了一个云服务器,所以项目里也有我已经改好了的Linux服务器代码,在Ubuntu
上可正常运行。
注意: 本文只详解介绍各个功能模块代码, 如果你想要一步一步从头写出该软件, 可以看我的这篇文章:MFG开发多人聊天室
该项目使用
WTL
界面库以及boost asio
网络库进行开发,是本文的升级版本,服务器代码完全跨平台,客户端最终生成的可执行文件只有160kb
注意本项目可能存在的问题:
由于linux系统默认采用的
utf-8
编码,而windows
系统采用的一般为GB2312
或GBK
编码,为了客户端能够简单方便的处理两种平台服务器的信息,我便将我的linux
系统编码调整到了GBK
,所以想要直接使用我编译好的这个linux
服务器,需要你调整你的linux
系统编码为GBK
或GB2312
,否则应该是启动不了的。又或者你可以重新将该linux
服务器源码在你的linux
上编译一次,应该就好了,这个问题比较复杂,我并没有打算去修补。
二、项目介绍
项目下载点这里
或者到本文章的最后 , 扫码进入微信公众号, 回复LANChat
, 即可免费下载:
文件解压后:
文件介绍:
- LANClient:客户端源代码
- LANSever:Windows服务端源代码
- Sever:Linux服务端源代码
- LANChat.sln :项目文件,用vs打开即可
- LANClient.exe:客户端程序
- LANSever.exe:windows服务端程序
- Sever.out:Linux服务端程序
因我使用的当前最新版本VS2022,如果你为低版本,编译可能会出现部分问题,如vs2019需进行以下设置:
三、代码详解
因考虑到初次学习网络编程的同学,所以源代码并没有进行任何封装,只是按着逻辑一步一步写的。
最简单的一个网络程序,点这里查看
服务器
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include<iostream>
#include<WinSock2.h>
#include<map>
#include<thread>
#include<WS2tcpip.h>
#pragma comment(lib,"ws2_32.lib")
using namespace std;
map<SOCKET*, string> m_clients; //存储socket和名称的映射关系
unsigned __stdcall RecvMSG(void* param) {
SOCKET* cli = (SOCKET*)param;
//通知所有客户端
for (auto i : m_clients) {
if (i.first == cli) continue;
string tm = "1:";
tm += (m_clients[cli] + ":加入聊天室");
send(*i.first, tm.data(), tm.size(), 0);
}
//向新客户端发送已有用户
string tn = "4:";
for (auto i : m_clients) {
if (i.first == cli) continue;
tn +=(i.second+":");
}
send(*cli, tn.data(), tn.size(), 0);
for (auto i : m_clients) {
if (i.first == cli) continue;
int len = send(*cli, i.second.data(), i.second.size(), 0);
if (len != i.second.size()) {
cout << i.second << ":发送出错" << endl;
}
}
char msg[0xFF];
while (1) {
int len = recv(*cli, msg, sizeof(msg), 0);
//正常接收,转发消息
if (len > 0) {
for (auto i : m_clients) {
if (i.first == cli) continue;
string tm = "3:"+m_clients[cli] + ':';
tm += msg;
send(*i.first, tm.data(),tm.size(), 0);
}
continue;
}
//客户端断连,通知
for (auto i : m_clients) {
if (i.first == cli) continue;
string exitMsg = "2:";
exitMsg+= (m_clients[cli] + ":退出聊天室");
send(*i.first, exitMsg.data(), exitMsg.size(), 0);
}
cout << m_clients[cli]<< ":退出聊天室" << endl;
m_clients.erase(cli);
closesocket(*cli);
delete[] cli;
break;
}
return 0;
}
int main() {
WSADATA wsadata;
int sta = WSAStartup(MAKEWORD(2, 2), &wsadata);
if (sta != 0) {
cout << "创建协议栈失败!";
return 0;
}
SOCKET sockSev = socket(AF_INET, SOCK_STREAM, 0);
SOCKADDR_IN addrSev;
addrSev.sin_family = AF_INET;
addrSev.sin_port = htons(9999);
addrSev.sin_addr.S_un.S_addr = INADDR_ANY;
bind(sockSev, (sockaddr*)&addrSev, sizeof(addrSev));
listen(sockSev, 5);
cout << "服务器启动成功!" << endl;
while (true) {
SOCKADDR_IN addrCli;
int len = sizeof(addrCli);
SOCKET* sockCli = new SOCKET;
*sockCli = accept(sockSev, (sockaddr*)&addrCli, &len);
if (*sockCli == INVALID_SOCKET) {
cout << inet_ntoa(addrCli.sin_addr) << ":连接失败!" << endl;
continue;
}
char msg[20];
len = recv(*sockCli, msg, 20, 0);
if (len <= 0) {
closesocket(*sockCli);
delete sockCli;
cout << inet_ntoa(addrCli.sin_addr) << ":接收数据失败!" << endl;
}
m_clients.insert(pair<SOCKET*, string>(sockCli, msg));
cout << msg << ":进入聊天室!" << endl;
//为新客户端开启线程接收信息
_beginthreadex(0, 0, RecvMSG, sockCli, 0, 0);
}
closesocket(sockSev);
}
拿到源码,还是直接看main函数
对于windows服务器来说,网络编程需要固定的以下几步骤:
- 网络环境初始化:WSAStartup
- 创建服务器套接字:socket
- 绑定本机IP和端口:bind
- 监听客户端:listen
- 等待客户端连接:accept
- 发送消息:send
- 接收消息:recv
基本函数使用方法已经在另一篇文章中有过说明
这里只对核心代码和主要逻辑进行必要性说明:
- 初始化网络环境,创建服务器socket,绑定端口与IP地址,进入while死循环
- 在while循环中,等待客户端连接
- 当有客户端连接成功时,等待客户端发送昵称,插入全局map类型变量中,并单独为此客户端开启一个线程,然后进行下一次循环
- 线程中首先向当前所有在线用户通知新成员上线,并向新用户发送当前已在线的成员
- 接着进入while循环等待客户端发送来的消息,并根据消息内容进行不同的处理
主要特点是通过接受到的每个字符串第一个数字决定要执行的命令,比如1:代表有新人加入,2代表有人退出,等等
这里用到了一个map数据结构,用途就是将昵称和SOCKET进行绑定,便于发送消息等
遍历map结构我使用到了for(auto i : map),这是一种较新的遍历方法,auto代表自动推断类型,因为该数据类型实在太长了
遍历到的i,主要有两个成员,first和second,如其名,first代表第一个变量,second代表第二个变量
客户端
客户端采用了MFC框架进行简单开发
界面:
主要成员变量:
注意,这里的控件变量是通过MFC提供的工具自动绑定的,右键要绑定变量的控件,添加变量
然后按需求选择与填写即可
后面就可以通过该变量名直接操控控件或控件内容
连接按钮代码:
if (isCon) { //判断当前是否连接,入如果已经连接,则断开连接
closesocket(m_client);
m_client = -1;
isCon = false;
AfxMessageBox(L"成功断开连接!");
SetDlgItemText(IDC_BTN_CNT, _T("连接"));
m_Member.DeleteAllItems();
return;
}
if (m_client == -1) {
m_client = socket(AF_INET, SOCK_STREAM, 0);
}
UpdateData(); //更新控件中的数据到变量中
if (m_name.IsEmpty()) {
AfxMessageBox(L"请输入昵称!");
return;
}
SOCKADDR_IN addrSev;
addrSev.sin_family = AF_INET;
addrSev.sin_port = htons(GetDlgItemInt(IDC_ET_PORT));
DWORD ip;
m_ip.GetAddress(ip);
addrSev.sin_addr.S_un.S_addr = htonl(ip);
int res = connect(m_client, (sockaddr*)&addrSev, sizeof(addrSev)); //连接
if (res == -1) {
AfxMessageBox(L"连接服务器失败!");
return;
}
m_Member.InsertItem(0, m_name); //加入当前在线成员列表
SetDlgItemText(IDC_BTN_CNT, _T("连接成功!"));
_beginthreadex(0, 0, RecvMsg, &m_client, 0, 0); //开启一个线程接收来自服务器的消息
Sleep(500);
SetDlgItemText(IDC_BTN_CNT, _T("断开连接!"));
isCon = true;
std::string na = WtoA(m_name); //将宽字符转化为窄字符
send(m_client, na.data(), na.size(), 0); //发送昵称
发送消息按钮:
if (m_client == -1) { //还未连接服务器
SetDlgItemText(IDC_BTN_SEND, _T("网络错误!"));
Sleep(500);
SetDlgItemText(IDC_BTN_SEND, _T("发送"));
return;
}
CString msg;
GetDlgItemText(IDC_ET_MSG, msg);
if (msg.IsEmpty()) {
SetDlgItemText(IDC_BTN_SEND, _T("消息为空!"));
Sleep(500);
SetDlgItemText(IDC_BTN_SEND, _T("发送"));
return;
}
UpdateData(); //将控件数据更新到变量中
std::string str = WtoA(msg);
int len = send(m_client, str.data(), str.size(), 0); //发送消息
if (len == str.size()) {
msg = _T("@你:") + msg + _T("\r\n");
m_et_Msg.Append(msg);
SetDlgItemText(IDC_ET_MSG, _T(""));
UpdateData(false);
}
m_showMSg.LineScroll(m_showMSg.GetLineCount() - 10); //滚动历史消息,保证显示最新消息
接收消息的线程:
unsigned __stdcall CLANClientDlg::RecvMsg(void* param)
{
SOCKET* cli = (SOCKET*)param;
while (1) {
char* buf = new char[0xFF]{};
int len = recv(*cli, buf, 0xFF, 0);
if (len <= 0) {
::PostMessageW(hwnd, UM_MODIUSER, 0, (LPARAM)buf); //接收消息错误,发出退出消息
break;
}
::PostMessageW(hwnd, UM_MODIUSER, 1, (LPARAM)buf); //成功接收消息
}
return 0;
}
这里为自定义消息UM_MODIUSER
,将该消息发送到主线程的处理函数中进行处理
自定义消息处理函数:
if (!wParam) { //接受消息发送错误
char* msg = (char*)lParam;
delete[] msg;
UpdateData();
m_et_Msg.Append(_T("你已经断线!\r\n"));
UpdateData(false);
m_Member.DeleteAllItems();
return -1;
}
char* msg = (char*)lParam;
if (msg[0] == '1' && msg[1] == ':') { //1:有新成员加入
USES_CONVERSION;
CString s = A2W(&msg[2]);
UpdateData();
m_et_Msg.Append(s + L"\r\n");
UpdateData(false);
int index = s.Find(L':');
s.GetBuffer()[index] = L'\0';
m_Member.InsertItem(0,s);
}
else if (msg[0] == '2' && msg[1] == ':') { //2:有成员退出
USES_CONVERSION;
CString s = A2W(&msg[2]);
UpdateData();
m_et_Msg.Append(s + L"\r\n");
UpdateData(false);
int index = s.Find(L':');
s.GetBuffer()[index] = L'\0';
for (int i = 0; i < m_Member.GetItemCount(); i++) {
if (m_Member.GetItemText(i, 0)==s) {
m_Member.DeleteItem(i);
break;
}
}
}
else if (msg[0] == '3' && msg[1] == ':') { //3:正常接收消息
USES_CONVERSION;
CString s = A2W(&msg[2]);
UpdateData();
m_et_Msg.Append(s + L"\r\n");
UpdateData(false);
}
else if (msg[0] == '4' && msg[1] == ':') { //4:更新已有的成员
USES_CONVERSION;
CString s = A2W(&msg[2]);
int index = 0;
for (int i = 0; i < s.GetLength(); i++) {
if (s[i] == L':') {
s.GetBuffer()[i] = L'\0';
m_Member.InsertItem(0, &s.GetBuffer()[index]);
index = i + 1;
}
}
}
m_showMSg.LineScroll(m_showMSg.GetLineCount() - 10); //滚动消息列表到最新
delete[] msg; //删除分配的内存,避免内存泄露
return 0;
设置按钮:
if (isSet)
{
RECT rect;
GetWindowRect(&rect);
rect.right += 360;
MoveWindow(&rect);
}
else
{
RECT rect;
GetWindowRect(&rect);
rect.right -= 360;
MoveWindow(&rect);
}
isSet = !isSet;
该按钮作用就是隐藏或展示右边的内容