草庐IT

C/C++实现聊天室(详解版)

余识- 2023-10-25 原文

文章目录

一、程序演示


虽然最开始是打算写个局域网就好了的,但其实如果你有云服务器,可以向微信、QQ一样与相隔甚远的朋友聊天,只需要将客户端IP修改为云服务器的IP,并将服务器程序运行到云服务器上,端口可自行确定。

因为我原本就租了一个云服务器,所以项目里也有我已经改好了的Linux服务器代码,在Ubuntu上可正常运行。

注意: 本文只详解介绍各个功能模块代码, 如果你想要一步一步从头写出该软件, 可以看我的这篇文章:MFG开发多人聊天室

进阶项目:C++ 实现聊天室(单聊、群聊、文件传输)

该项目使用WTL界面库以及boost asio网络库进行开发,是本文的升级版本,服务器代码完全跨平台,客户端最终生成的可执行文件只有160kb

注意本项目可能存在的问题:

由于linux系统默认采用的utf-8编码,而windows系统采用的一般为GB2312GBK编码,为了客户端能够简单方便的处理两种平台服务器的信息,我便将我的linux系统编码调整到了GBK,所以想要直接使用我编译好的这个linux服务器,需要你调整你的linux系统编码为GBKGB2312,否则应该是启动不了的。又或者你可以重新将该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服务器来说,网络编程需要固定的以下几步骤:

  1. 网络环境初始化:WSAStartup
  2. 创建服务器套接字:socket
  3. 绑定本机IP和端口:bind
  4. 监听客户端:listen
  5. 等待客户端连接:accept
  6. 发送消息:send
  7. 接收消息: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;


该按钮作用就是隐藏或展示右边的内容

有关C/C++实现聊天室(详解版)的更多相关文章

  1. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  2. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  3. 基于C#实现简易绘图工具【100010177】 - 2

    C#实现简易绘图工具一.引言实验目的:通过制作窗体应用程序(C#画图软件),熟悉基本的窗体设计过程以及控件设计,事件处理等,熟悉使用C#的winform窗体进行绘图的基本步骤,对于面向对象编程有更加深刻的体会.Tutorial任务设计一个具有基本功能的画图软件**·包括简单的新建文件,保存,重新绘图等功能**·实现一些基本图形的绘制,包括铅笔和基本形状等,学习橡皮工具的创建**·设计一个合理舒适的UI界面**注明:你可能需要先了解一些关于winform窗体应用程序绘图的基本知识,以及关于GDI+类和结构的知识二.实验环境Windows系统下的visualstudio2017C#窗体应用程序三.

  4. MIMO-OFDM无线通信技术及MATLAB实现(1)无线信道:传播和衰落 - 2

     MIMO技术的优缺点优点通过下面三个增益来总体概括:阵列增益。阵列增益是指由于接收机通过对接收信号的相干合并而活得的平均SNR的提高。在发射机不知道信道信息的情况下,MIMO系统可以获得的阵列增益与接收天线数成正比复用增益。在采用空间复用方案的MIMO系统中,可以获得复用增益,即信道容量成倍增加。信道容量的增加与min(Nt,Nr)成正比分集增益。在采用空间分集方案的MIMO系统中,可以获得分集增益,即可靠性性能的改善。分集增益用独立衰落支路数来描述,即分集指数。在使用了空时编码的MIMO系统中,由于接收天线或发射天线之间的间距较远,可认为它们各自的大尺度衰落是相互独立的,因此分布式MIMO

  5. 【Java入门】使用Java实现文件夹的遍历 - 2

    遍历文件夹我们通常是使用递归进行操作,这种方式比较简单,也比较容易理解。本文为大家介绍另一种不使用递归的方式,由于没有使用递归,只用到了循环和集合,所以效率更高一些!一、使用递归遍历文件夹整体思路1、使用File封装初始目录,2、打印这个目录3、获取这个目录下所有的子文件和子目录的数组。4、遍历这个数组,取出每个File对象4-1、如果File是否是一个文件,打印4-2、否则就是一个目录,递归调用代码实现publicclassSearchFile{publicstaticvoidmain(String[]args){//初始目录Filedir=newFile("d:/Dev");Datebeg

  6. ruby - Arrays Sets 和 SortedSets 在 Ruby 中是如何实现的 - 2

    通常,数组被实现为内存块,集合被实现为HashMap,有序集合被实现为跳跃列表。在Ruby中也是如此吗?我正在尝试从性能和内存占用方面评估Ruby中不同容器的使用情况 最佳答案 数组是Ruby核心库的一部分。每个Ruby实现都有自己的数组实现。Ruby语言规范只规定了Ruby数组的行为,并没有规定任何特定的实现策略。它甚至没有指定任何会强制或至少建议特定实现策略的性能约束。然而,大多数Rubyist对数组的性能特征有一些期望,这会迫使不符合它们的实现变得默默无闻,因为实际上没有人会使用它:插入、前置或追加以及删除元素的最坏情况步骤复

  7. ruby - "public/protected/private"方法是如何实现的,我该如何模拟它? - 2

    在ruby中,你可以这样做:classThingpublicdeff1puts"f1"endprivatedeff2puts"f2"endpublicdeff3puts"f3"endprivatedeff4puts"f4"endend现在f1和f3是公共(public)的,f2和f4是私有(private)的。内部发生了什么,允许您调用一个类方法,然后更改方法定义?我怎样才能实现相同的功能(表面上是创建我自己的java之类的注释)例如...classThingfundeff1puts"hey"endnotfundeff2puts"hey"endendfun和notfun将更改以下函数定

  8. ruby - 实现k最近邻需要哪些数据? - 2

    我目前有一个reddit克隆类型的网站。我正在尝试根据我的用户之前喜欢的帖子推荐帖子。看起来K最近邻或k均值是执行此操作的最佳方法。我似乎无法理解如何实际实现它。我看过一些数学公式(例如k表示维基百科页面),但它们对我来说并没有真正意义。有人可以推荐一些伪代码,或者可以查看的地方,以便我更好地了解如何执行此操作吗? 最佳答案 K最近邻(又名KNN)是一种分类算法。基本上,您采用包含N个项目的训练组并对它们进行分类。如何对它们进行分类完全取决于您的数据,以及您认为该数据的重要分类特征是什么。在您的示例中,这可能是帖子类别、谁发布了该项

  9. ruby-on-rails - 使用 Ruby 正确处理 Stripe 错误和异常以实现一次性收费 - 2

    我查看了Stripedocumentationonerrors,但我仍然无法正确处理/重定向这些错误。基本上无论发生什么,我都希望他们返回到edit操作(通过edit_profile_path)并向他们显示一条消息(无论成功与否)。我在edit操作上有一个表单,它可以POST到update操作。使用有效的信用卡可以正常工作(费用在Stripe仪表板中)。我正在使用Stripe.js。classExtrasController5000,#amountincents:currency=>"usd",:card=>token,:description=>current_user.email)

  10. ruby - Ruby 1.8 的 Shellwords.shellescape 实现 - 2

    虽然1.8.7的构建我似乎有一个向后移植的Shellwords::shellescape版本,但我知道该方法是1.9的一个特性,在1.8的早期版本中绝对不支持.有谁知道我在哪里可以找到(以Gem形式或仅作为片段)针对Ruby转义的Bourne-shell命令的强大独立实现? 最佳答案 您也可以从shellwords.rb中复制您想要的内容。在Ruby的颠覆存储库的主干中(即GPLv2'd):defshellescape(str)#Anemptyargumentwillbeskipped,soreturnemptyquotes.ret

随机推荐