【计算机网络】Socket聊天室程序

计算机网络第一次实验报告

实验名称:Socket聊天室程序

实验内容

使用流式Socket设计聊天协议,聊天信息带有时间标签和类型标签,本报告中将说明交互消息的类型、语法、语义、时序等具体的消息处理方式。对聊天程序进行设计,本报告将给出模块划分、模块的功能、具体核心函数的展示和模块的流程图。在Windows系统下,利用C++对设计的程序进行实现,对实现的程序进行测试,发现可以实现聊天室的功能。最后对实验过程中遇到的问题和产生的思考进行总结。

协议设计

整体流程

在本次的实验中主要就是需要捋清楚客户端和服务器端分别做了什么,由于本实验实现的是多客户端的群聊,所以:

服务器接收多个客户端的连接,多线程来解决多客户端通信问题

对于服务器,首先应加载和初始化socket,然后创建socket,绑定服务器端的socket和IP地址,开始监听等待客户端的连接。如果有客户端的连接,则启动一个线程来和这个客户端通信,并且在接收客户端的连接后创建一个新的socket,这一点通过全局的socket类型的数组来实现。

在线程内部,实现发送消息和接收消息,具体的收发方式在下面部分的“消息的发送接收”中解释。首先接收客户端发来的数据,然后将数据广播给当前所有的连接到服务器的客户端,这一点通过for循环来实现。最后关闭socket并清理环境。

对于客户端,首先应加载和初始化socket,然后创建socket,绑定socket和IP地址,然后连接服务器。发送消息是在主函数中完成向服务器发送消息,接收消息时需要创建线程来接收消息,这样做可以避免在主函数接收消息而导致的主线程阻塞的情况。最后关闭socket并清理环境。

消息的类型

在协议中规定消息的类型为:

enum type {
	CHAT,
	EXIT
};

CHAT为普通的客户端与客户端或客户端与服务器端的聊天消息。

EXIT为客户端断开连接离开聊天室的消息。

消息的语法

定义消息的语法如下,包含type,time,content三个字段,每个字段通过’\n’分割,在“各模块功能”部分将对message结构体向string类型消息的转换的函数进行详细介绍。

struct message
{
	type type;
	string time;
	string content;
};
消息的语义

字段中信息代表的具体含义如下:

**type:**表示消息的类型,有CHAT和EXIT两种;

**time:**表示消息发送/接收的时间,格式为——年-月-日 时:秒:分;

**content:**表示要发送的消息的文本内容。

消息的发送接收

消息的发送:

1.在客户端输入要发送的消息,回车代表一条消息内容的结束。

2.将消息通过加上时间戳、判断和增加消息类型等方法后以message结构体类型进行保存。

3.再通过转换函数将message结构体转换为字符串类型,并放入字符型数组中存储和发送

4.以字符型数组形式发送消息后,判断消息类型,对于EXIT类型的消息则打印日志后将该客户端断开连接;对于CHAT类型的普通消息则以规范格式进行输出打印。

消息的接收:

1.收到了以字符型数组发送的消息,将其存储为字符串形式

2.通过转换函数将字符串转换为message结构体类型保存。

3.判断消息类型,对于EXIT类型的消息则打印日志后将该客户端断开连接;对于CHAT类型的普通消息则以规范格式进行输出打印。

各模块功能

Sever主线程循环接收客户端的连接

在服务器端,完成加载和初始化socket,然后创建socket,绑定服务器端的socket和IP地址,开始监听等待客户端的连接等基本任务后,就进入了主线程循环接收客户端连接的过程。对不超过聊天室最大容量 MaxClientNum的情况进行循环,直到超出容量则打印聊天室已满的日志。对于正常的客户端连接情况,需要用accept函数完成对客户端连接请求的接收,并对聊天室当前人数进行更新,然后判别连接是否正常。对于正常的连接,则创建线程完成消息的发送和接收

	int i = 0;
	while (i < MaxClientNum)
	{
		//接收客户端的连接请求的socket
		sockaddr_in addrClient{};
		int len = sizeof addrClient;
		ClientSocket[i] = accept(sockSrv, (SOCKADDR*)&addrClient, &len);
		CNUM++;
		if (ClientSocket[i] == SOCKET_ERROR)
		{
			cout << "[INFO]: wrong client" << endl;
			closesocket(sockSrv);
			WSACleanup();
		}
		cout << "[INFO]: ACCEPT SOCKET SUCCEED  " << "client" << i << " join in" << endl;
		cout << "---------------------------------------------------------------------" << endl;
		CloseHandle( CreateThread(NULL, NULL,handlerRequest, (LPVOID)i, NULL, NULL));
		i++;
	}
	cout << "[INFO]: the chatroom is full!" << endl;
Sever发送和接收消息

在线程内部实现和客户端的通信,对于接收数据,在while(1)循环中不断使用recv函数接收消息并判断消息是否接收成功。对没有接收成功的情况分为两部分进行处理,如果错误编号为10054即client关闭则也自动退出,其他情况则打印错误日志并break。如果接收成功,则判断消息类型,对于EXIT类型的消息则完成日志打印并关闭该socket,对于CHAT类型的消息则按照格式输出。

在发送阶段,遍历此时的所有在线的客户端,给每一个非消息发送者的客户端转发该条消息,并打印出消息发送者的名字。然后判断消息是否发送成功,对没有发送成功的情况分为两部分进行处理,如果错误编号为10054即client关闭则也自动退出,其他情况则打印错误日志并break,如果发送成功则打印日志即可。

DWORD WINAPI handlerRequest(LPVOID lparam)
{
	int i = (int)lparam;
	int recvflag;
	//和客户端通信,发送接收数据
	char Buf[MaxBufNum];
	char SBuf[MaxBufNum];
	char SendBuf[MaxBufNum];
	char tmp[MaxBufNum];
	char Send_content[MaxBufNum];
	while (1)
	{
		memset(Buf, 0, MaxBufNum);
		memset(SendBuf, 0, MaxBufNum);
		memset(tmp, 0, MaxBufNum);
		//接收
		recvflag = recv(ClientSocket[i], Buf, MaxBufNum, 0);
		if (recvflag != SOCKET_ERROR)
		{
			//退出标志是exit
			string sss = Buf;
			message mess_server_recv = stringtomessage(sss);
			if (mess_server_recv.type == EXIT)
			{
				cout << "[INFO]: CLIENT EXIT  " << "client" << i << " leave" << endl;
				cout << "---------------------------------------------------" << endl;
				send(ClientSocket[i], R"([INFO]: exit succeed)", MaxBufNum, 0);
				ClientSocket[i] = NULL;
				closesocket(ClientSocket[i]);
				break;
			}
			cout << mess_server_recv.time << "[CHAT]: " << "receive from client" << i << ":" << mess_server_recv.content << endl;
			//发送
			for (int j = 0; j <CNUM; j++)
			{
				int sendflag=0;
				if (j != i&&ClientSocket[j]!=NULL)
				{
					string s = "[RECV]:  from client";
					char ch;
					ch = i + 48;
					s.push_back(ch);
					strncpy_s(tmp, s.c_str(), s.length());
					send(ClientSocket[j], tmp, MaxBufNum, 0);
					//sendflag = send(ClientSocket[j], Bufcon, sizeof(Buf), 0);
					sendflag = send(ClientSocket[j], Buf, MaxBufNum, 0);
					if (sendflag == SOCKET_ERROR)
					{
						if (WSAGetLastError() == 10054)
						{
							cout << "[CLIENT EXIT]: " << "client" << j << " leave" << endl;
							//CNUM--;
							closesocket(ClientSocket[j]);
						}
						else
						{
							cout << "[INFO]: fail to send message" << endl;
						}
					}
					else
						cout << "[INFO]: send succeed" << endl;
				}
			}
		}
		else
		{
			//如果client关闭则也自动退出
			if (WSAGetLastError() == 10054)
			{
				cout << "[INFO]: CLIENT EXIT  " << "client" << i << " leave" << endl;
				closesocket(ClientSocket[i]);
				//CNUM--;
				break;
			}
			else
			{
				cout << "[INFO]: fail to receive message " << endl;
				break;
			}
		}
	}
	return 0;
}
Client发送消息

在客户端中,消息的发送是在主线程中进行的,首先在客户端中通过connet函数连接到服务器,然后创建线程进行消息的接收。对于消息的发送,为保证可以多条消息连续发送,在while(1)循环中进行,输入发送内容后,通过localtime函数获取时间戳并判断消息类型后封装进message结构体并转化为字符型数组完成发送。对发送不成功的情况则打印日志并退出,对发送成功的情况判断消息类型并完成输出。

int conflag=connect(sockCli, (SOCKADDR*)&addrCli, sizeof(addrCli));
	if (conflag == -1)
		cout << "[INFO]: fail to connect" << endl;
	else
	{
		cout << "[INFO]: CONNECT succeed" << endl;
		cout << "---------------------------------------------------" << endl;
	}
	CreateThread(NULL, 0, &receivemessage, LPVOID(sockCli), NULL, NULL);
	char SBuf[MaxBufNum] = {};
	memset(SBuf, 0, MaxBufNum);
	char Send_content[MaxBufNum] = {};
	memset(Send_content, 0, MaxBufNum);
	message mess_client_send;
	while (1)
	{
		//发送
		cin.getline(Send_content, MaxBufNum);
		mess_client_send.content = Send_content;
		//获取时间
		char timestamp[100] = { 0 };
		time_t t = time(0);
		strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", localtime(&t));
		mess_client_send.time = timestamp;

		//判断消息类型
		if (mess_client_send.content== "exit")
			mess_client_send.type = EXIT;
		else
			mess_client_send.type = CHAT;

		strcpy_s(SBuf, messagetostring(mess_client_send).c_str());
		int sendflag = send(sockCli, SBuf, MaxBufNum, 0);
		if (sendflag ==SOCKET_ERROR)
		{
			cout << "[INFO]: fail to send" << endl;
			cout << "---------------------------------------------------" << endl;
		}
		else
		{
			if (mess_client_send.type == EXIT)
			{
				cout << "[INFO]: exit" << endl;
				cout << "---------------------------------------------------" << endl;
				break;
			}
			else if(mess_client_send.type==CHAT)
			{
				cout << "[INFO]: send succeed" << endl;
				cout << "---------------------------------------------------" << endl;
			}
		}
	}
Client接收消息

为了避免在主函数中接收消息而导致的主线程阻塞的情况,客户端在接收消息时需要创建线程来接收消息。线程内部,在while(1)循环中用recv函数进行消息的接收,对接收成功的情况完成message类型的转换并判断消息类型,对不同的消息类型以不同格式输出。

DWORD WINAPI receivemessage(LPVOID IpParameter)
{
	SOCKET sockCli = (SOCKET)(LPVOID)IpParameter;
	char RBuf[MaxBufNum] = {};
	memset(RBuf, 0, MaxBufNum);
	int recvflag = 0;
	while (1)
	{
		recvflag = recv(sockCli, RBuf, MaxBufNum, 0);
		if (recvflag != SOCKET_ERROR)
		{
			string ss = RBuf;
			message mess_client_recv = stringtomessage(ss);
			if (mess_client_recv.type == CHAT)
			{
				cout << mess_client_recv.time << " " << "[CHAT]: " << mess_client_recv.content << endl;
			}
			else
			{
				cout << mess_client_recv.time << " " << "[EXIT]" << endl;
				break;
			}
		}
		else break;
	}
	return 0;
}
消息处理函数

由于对于消息的结构体格式需要在传输过程中修改为字符数组格式进行接收和发送,比较重要的内容是对结构体message、字符串、字符型数组进行转换。对于messgae类型转换为字符串,只需将不同消息类型以不同标号代替并放在字符串首个字符,然后将时间字符串和消息内容字符串连接在后面,三者之间以换行符‘\n’分割。对于字符串转换为message类型,需要首先识别字符串的首个字符确定消息类型,然后以换行符为分割将字符串切割开,依次赋给时间和消息内容部分。

//消息类型转换为字符串
string messagetostring(message mes)
{
	string ss;
	if (mes.type == CHAT)
		ss = '1';
	else if (mes.type == EXIT)
		ss = '2';
	ss += '\n' + mes.time + '\n' + mes.content + '\n' ;
	return ss;
}

//字符串转换为消息类型
message stringtomessage(string ss)
{
	message mes;
	int i = 2;
	int timelen = 0;
	if (ss[0] == '1')
		mes.type = CHAT;
	else if (ss[0] == '2')
		mes.type = EXIT;
	while (ss[i] != '\n')
	{
		timelen++;
		i++;
	}
	mes.time = ss.substr(2, timelen);
	mes.content = ss.substr(3 + static_cast<std::basic_string<char, std::char_traits<char>, std::allocator<char>>::size_type>(timelen), sizeof(ss) - timelen - 1);
	return mes;
}

实现效果及说明

懒得上传图片了,以后再说
开启阶段:
发送和接收消息:

实现群聊功能:

可以多条信息连续发送:

服务器端日志信息:

退出后无法再发送和接收消息:

遇到的问题及解决

**Q1:**刚开始的设计对消息的发送格式和存储格式等的转化不清晰

**solution1:**需要明确的是在发送和接收消息的时候必须使用的是字符型数组,但是设计的存储消息的message结构体并不是字符型数组格式的。所以需要对这点进行转换,先把message结构体通过确定方式转换为字符串类型,这两者的转换直接封装成函数了,返回再把字符串和字符型数组进行转换即可。

**Q2:**在服务器端给客户端发送消息的时候条件判断不明确

**solution2:**首先需要知道,服务器端要给所有的非消息来源的客户端发送消息,也就是如果服务器收到的消息来源于客户端i,则在发送时需要给除了i的所有在线客户端发送消息,这里不能自己再给自己发了。然后需要对ClientSocket这个socket数组进行判断,对于非空的情况发送消息,因为如果数组为空意味着给socket已经没有了,这个客户端已经断开连接了,不能再向这个客户端发消息了。

**Q3:**对于全局变量CNUM的定义不清楚

**solution3:**本来对CNUM的定义是当前在服务器中的客户端数目,但是由于客户端的编号是一直增加的,所以即使有客户端退出了,CNUM的值也不应当减,只有这样才可以保证在服务器端发送消息的时候把所有的客户端都发送到了而没有漏掉一些客户端。

**Q4:**addrSrv.sin_addr.s_addr = inet_addr(“127.0.0.1”);的使用报错

**solution4:**改为

inet_pton(AF_INET, "127.0.0.1", &addrSrv.sin_addr.s_addr);
	addrSrv.sin_family = AF_INET;
	addrSrv.sin_port = htons(6666);

**Q5:**报错问题:

bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR));
	//绑定服务器端的socket和地址
	if (bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR))==-1)
	{
		cout << "[INFO]: BIND wrong" <<endl;
	}

**solution5:**这里相当于bind进行了两遍,所以是会报错的,应该借用一个标志来判断,改成如下:

int bindflag=bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR));
	//绑定服务器端的socket和地址
	if (bindflag==-1)
	{
		cout << "[INFO]: BIND wrong" <<endl;
	}

**Q6:**对sockaddr&sockaddr_in的区别和关系搞不清楚

solution6:

sockaddr缺陷是sa_data把目标地址和端口信息混在一起了

struct sockaddr {

​ sa_family_t sin_family;//地址族

​ char sa_data[14]; //14字节,包含套接字中的目标地址和端口信息

};

sockaddr_in:

typedef struct sockaddr_in {
short sin_family; //地址族
u_short sin_port; //16位TCP/UDP端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用
} SOCKADDR_IN, *PSOCKADDR_IN, *LPSOCKADDR_IN;

其中的结构体in_addr也有相应的定义,用来存放32位IP地址

总结:

二者长度一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。sockaddr常用于bind、connect、recvfrom、sendto等函数的参数,指明地址信息,是一种通用的套接字地址。
sockaddr_in 是internet环境下套接字的地址形式。所以在网络编程中我们会对sockaddr_in结构体进行操作,使用sockaddr_in来建立所需的信息,最后使用类型转化就可以了。一般先把sockaddr_in变量赋值后,强制类型转换后传入用sockaddr做参数的函数。

sin_addr; //32位IP地址
char sin_zero[8]; //不使用
} SOCKADDR_IN, *PSOCKADDR_IN, *LPSOCKADDR_IN;

其中的结构体in_addr也有相应的定义,用来存放32位IP地址

总结:

二者长度一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。sockaddr常用于bind、connect、recvfrom、sendto等函数的参数,指明地址信息,是一种通用的套接字地址。
sockaddr_in 是internet环境下套接字的地址形式。所以在网络编程中我们会对sockaddr_in结构体进行操作,使用sockaddr_in来建立所需的信息,最后使用类型转化就可以了。一般先把sockaddr_in变量赋值后,强制类型转换后传入用sockaddr做参数的函数。

sockaddr_in用于socket定义和赋值;sockaddr用于函数参数。

  • 1
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值