socket通信之九:使用完成端口实现的一个聊天室

基本上windows平台下的几种IO模型都实现了一遍,还有两个没有实现,但是它们一个需要基于windows消息,一个和重叠IO中的事件通知模型比较类似,并且不能实现真正的异步,所以就不列出来了。


这一篇介绍如何实现一个聊天室。前面介绍的几种模型中除了基本的socket模型和阻塞版本的模型之外都可以用来实现聊天室,因为这两个模型还不能实现支持多个客户端。对于其它的几个模型,我们需要做的只是在原来模型的基本上维护一个socket连接的列表即可。这里使用完成端口来实现聊天室。使用其它的模型基本上也是这个思路,之所以使用这个是因为它不像多线程那样耗费资源,也不像selelct和重叠IO中基于事件通知的模型那样有连接限制,相比与重叠IO中的完成例程模型,不需要考虑负载均衡的问题。所以就是它了。


1.程序运行流程


先插入几张程序运行的截图。


1.1.启动服务器端的程序和客户端的程序


下图左侧是服务器端程序的启动界面,右图是客户端启动的界面。

对服务器端而言:

  1. 第一行代表的是本机的ip地址,也就是服务器端的ip地址,客户端中服务器ip地址这一栏需要填上这个地址;
  2. 端口号表示服务器端绑定的端口号,这个端口号可以自己指定。客户端中对应的端口号需要填上服务器端绑定的这个端口号,这样客户端才可以连上上服务器端。

对客户端而言:

  1. 服务器ip地址填上服务器端指定的ip地址,端口填上服务器端绑定的端口号;
  2. 如果客户端和服务器端在同一台机器上启动,此时的客户端中填写的服务器ip地址是正确的,否则需要根据服务器端的ip地址更改;
  3. 客户端需要指定一个昵称,用来标示不同的客户端,这个标示不能为空;
  4. 这里我允许有重复的昵称,如果不允许,只需要加上一定的判断就可以了。

socket通信之九:使用完成端口实现的一个聊天室_第1张图片



1.2.点击服务器端的开启服务器


点击了开启服务器后,服务器才会启动,此时在下面的“信息”栏中会显示“创建启动线程成功!”,“服务器端已启动......”等字样。

socket通信之九:使用完成端口实现的一个聊天室_第2张图片



1.3.客户端连接服务器


服务器端启动以后客户端就可以连接服务器了,现在“昵称”后面的编辑框中输入一个昵称,接着点击“连接服务器”按钮即可。

客户端会提示“连接服务器成功!”,“allanxia成功登陆!”这两行文字。

服务器端会提示“客户端连接:10.11.163.113:707”,“allanxia 登陆成功!”,其中第一条消息指示的是连接的客户端的ip地址和端口号,第二条信息指示的是客户端的昵称。

socket通信之九:使用完成端口实现的一个聊天室_第3张图片


如果又有一个客户端连接,如下图:

会发生下面的情况:

服务端会受到Bill Gates的登陆信息;

已经连接的客户端(这里只有allanxia)也会收到他的登陆信息;

自身也会收到登陆消息。



接着再连接一个客户端:和上面同样的原理。




1.4.客户端通信


现在allanxia这个客户端发送一条消息“大家好,我是allanxia”,所有的客户端就都收到这个消息了。

socket通信之九:使用完成端口实现的一个聊天室_第4张图片


现在大家就可以畅所欲言了。这就是基本的模型了,如果有客户端退出,服务器端会收到通知,但是客户端就不会收到通知了,这就是聊天室的基本模型了。




2.程序分析


这里我使用的是完成端口的模型,原理上讲其它几个支持多客户端的模型都可以用来实现这个聊天室。


这里我使用MFC+vs2008开发。


2.1客户端分析


首先看客户端的代码,因为客户端的代码比较简单,但是和控制台的相比也要做一些更改,之前没想这么多,结果调试和好一会儿。


根据上面程序运行的截图可以发现,点击“连接服务器”这个按钮后,客户端会和服务器连接。所以之前在控制台中创建客户端socket的代码需要放到这个按钮的响应函数中来。

并且我们可能会在其它响应函数里操作这个代码,所以这个socket需要定义成类的成员变量,这个对话框类叫CChatRoomClientDlg。


socket连接创建后,就需要将昵称发送给服务器端了,这里使用send将消息发送过去就可以,和之前的控制台程序相同。

下面就要特别注意了!!!

客户端的接收流程和之前控制台的有很大的区别,之前控制台的是发送一条数据,再接收一条数据。但是在聊天室中发送数据是我们可以控制的,但是接收数据客户端就不了解的,它只需要从这个socket连接中不断接收数据并显示在客户端的当前信息栏中就可以了。所以这里我们需要启动一个新的线程,这个线程专门用来负责从这个socket连接中接收数据并显示。否则将循环放在主窗口函数中会出现阻塞。


在“发送按钮“的响应函数中填写向这个socket连接发送数据的代码就可以了,这个比较简单,因为一次只会发送一条数据,不会向接收数据这样会出现循环,所以需要使用一个新的线程,不然会出现阻塞。


基本的流程如下图:

socket通信之九:使用完成端口实现的一个聊天室_第5张图片



下面是一些关键的代码:

点击”连接服务器“和”发送“按钮的响应函数:

//点击连接服务器时的处理
void CChatRoomClientDlg::OnBnClickedConn()
{
	UpdateData(true);

	CString nickNameStr=getNickName();
	if (nickNameStr.IsEmpty()||nickNameStr.GetLength()==0)
	{
		AfxMessageBox("请输入一个昵称!");
	}
	else//输入昵称不为空时才和服务端相连
	{
		CString info;

		WSADATA wsaData;
		int err;

		//1.首先执行初始化Windows Socket库
		err=WSAStartup(MAKEWORD(1,1),&wsaData);
		if (err!=0)
		{
			info.Format("初始化socket失败!\n");
			appendString(info);
			return ;
		}

		//2.创建Socket
		sockClient=socket(AF_INET,SOCK_STREAM,0);

		struct sockaddr_in addrServer;
		DWORD dwip;
		m_ip.GetAddress(dwip);
//		addrServer.sin_addr.s_addr=inet_addr(IP_ADDRESS);
		addrServer.sin_addr.s_addr=htonl(dwip);
		addrServer.sin_family=AF_INET;
		addrServer.sin_port=htons(m_port);

		//3.连接Socket,第一个参数为客户端socket,第二个参数为服务器端地址
		err=connect(sockClient,(struct sockaddr *)&addrServer,sizeof(addrServer));
		if (err!=0)
		{
			info.Format("连接服务器失败!\n");
			appendString(info);
			return ;
		}else
		{
			info.Format("连接服务器成功!\n");
			appendString(info);
		}

		CString nick=getNickName();
		char * sendBuff=nick.GetBuffer(nick.GetLength());
		nick.ReleaseBuffer();
		char recvBuf[MAX_PATH];

		send(sockClient,sendBuff,strlen(sendBuff)+1,0);	//第三个参数加上1是为了将字符串结束符'\0'也发送过去

		//创建接收线程
		CreateThread(NULL,0,receiveThread,this,0,NULL);
		
	}
}

//发送数据
void CChatRoomClientDlg::OnBnClickedOk()
{
	UpdateData(true);

	CString output=m_output;
	output=output+"\n";
	char * sendBuff=output.GetBuffer(output.GetLength());
	output.ReleaseBuffer();
	send(sockClient,sendBuff,strlen(sendBuff)+1,0);	//第三个参数加上1是为了将字符串结束符'\0'也发送过去
	m_output="";
	UpdateData(false);

}

下面是接收线程的代码:

//客户端的接收线程,持续从socket连接中接受数据
DWORD WINAPI receiveThread(LPVOID lpParam)
{

	//将对话框指针作为参数传递给这个变量
	CChatRoomClientDlg * chatRoom=(CChatRoomClientDlg*)lpParam;
	char recvBuf[MAX_PATH];
	CString info;
	while(true)
	{
		memset(recvBuf,0,MAX_PATH);
		recv(chatRoom->sockClient,recvBuf,MAX_PATH,0);
		info.Format("%s\n",recvBuf);
		chatRoom->appendString(info);
	}
	delete[] recvBuf;
}


2.2服务端分析

服务端也是在原来控制台程序的基本上进行的更改,主要进行了下面的更改:

  1. 此时需要维护一个socket的集合;
  2. 服务器每次收到客户端的消息后,需要遍历这个socket集合并将信息发送给连接的每一个socket

并且由于服务器端需要持续监听有没有客户端连接服务器,所以还需要一个新的线程startThread,点击”启动服务器“后启动这个线程。在这个线程中执行创建socket,绑定,监听,然后在一个while循环中接收客户端的连接,并且完成与IO完成端口相关的一些操作。

这里由于我加入了一个昵称,所以需要维护socket连接和昵称之间的对应关系,这里使用map来存在socket和昵称之间的对应关系,并且由于第一次发送数据时发送的是昵称,所以我们可以根据这个map中是否存在这个socket来判断这次处理的连接请求是否是第一次连接,如果是第一次连接,将socket和昵称存储起来并将信息发送出去,如果不是,直接将信息发送出去。


总结起来就是:客户端改变的是接收和发送的模式,此时客户端的结构其实都已经有了一些变化,所以上面画了张图。但是服务器端只是一些一些数据传输上的变化,整个程序的框架还是没有发送变化,所以图就不画了。


特别需要注意的是MFC对话框和其它线程之间如何传输数据这个问题。可以将对话框的指针作为参数传递进线程中。


还有一些与ip控件处理相关的代码就不列举了。

下面是一些关键的代码:
//点击连接服务器的响应函数,启动一个新的线程
void CChatRoomServerDlg::OnBnClickedBegin()
{
		UpdateData(true);
		HANDLE hThread=CreateThread(NULL,0,startThread,this,0,NULL);
		if (hThread==NULL)
		{
			appendString(CString(_T("创建启动线程失败!\n")));
		}
		else
		{
			appendString(CString(_T("创建启动线程成功!\n")));
		}
		CloseHandle(hThread);
}


struct PerSocketData
{
	WSAOVERLAPPED overlap;//每一个socket连接需要关联一个WSAOVERLAPPED对象
	WSABUF buffer;//与WSAOVERLAPPED对象绑定的缓冲区
	char          szMessage[MSGSIZE];//初始化buffer的缓冲区
	DWORD          NumberOfBytesRecvd;//指定接收到的字符的数目
	DWORD          flags;

};

struct TransferData
{
		CChatRoomServerDlg * _chatRoom;//对话框指针
		HANDLE _completionPort;//完成端口
};

//使用这个工作线程来通过重叠IO的方式与客户端通信
DWORD WINAPI workThread(LPVOID lpParam)
{
	TransferData * transData=(TransferData *)lpParam;
	HANDLE completionPort=transData->_completionPort;
	CChatRoomServerDlg * chatRoom=transData->_chatRoom;
	DWORD dwBytesTransfered;
	SOCKET clientSocket;
	PerSocketData * lpIOdata=NULL;

	while(true)
	{
		GetQueuedCompletionStatus(
			completionPort,
			&dwBytesTransfered,
			(LPDWORD)&clientSocket,
			(LPOVERLAPPED*)&lpIOdata,
			INFINITE);

		if (dwBytesTransfered==0xFFFFFFFF)
		{
			return 0;
		}

		if (dwBytesTransfered==0)
		{
			string message=chatRoom->socketMap[clientSocket];
			message=message+string(" 退出聊天室!\n");
			chatRoom->appendString(CString(message.c_str()));
			closesocket(clientSocket);
			HeapFree(GetProcessHeap(),0,lpIOdata);
		}
		else
		{
			//如果这个socket连接已经被加入map中,此时传送的是信息
			if (chatRoom->socketMap.count(clientSocket))
			{
				string message=lpIOdata->szMessage;
				map<SOCKET,string>::iterator iter;
				string nickName=chatRoom->socketMap[clientSocket];

				string info=nickName+string(" 发送了一条消息:")+string(message);
				chatRoom->appendString(CString(info.c_str()));

				message=nickName+string(":")+string("\n")+message;

				//遍历每一个socket连接并将信息发送出去
				for (iter=chatRoom->socketMap.begin();iter!=chatRoom->socketMap.end();iter++)
				{
					send(iter->first,message.c_str(),message.size()+1,0);//多发送一个字符,将字符串结束符也发送过去
				}
			}
			else//第一次连接,此时收到的是用户昵称
			{
				//将这个新的socket连接和昵称放入集合中
					string message=lpIOdata->szMessage;//获取昵称
					chatRoom->socketMap[clientSocket]=message;//将昵称和socket绑定到一起

					//显示登陆信息在服务器端
					string login=chatRoom->socketMap[clientSocket];
					login=login+string(" 登陆成功!\n");
					chatRoom->appendString(CString(login.c_str()));

					//组装好发送给客户端的数据
					map<SOCKET,string>::iterator iter;
					string info=" 成功登陆!";
					message=message+info;

					//遍历每一个socket连接并将信息发送出去
					for (iter=chatRoom->socketMap.begin();iter!=chatRoom->socketMap.end();iter++)
					{		
						send(iter->first,message.c_str(),message.size()+1,0);//多发送一个字符,将字符串结束符也发送过去
					}	
			}

		//	cout<<lpIOdata->szMessage<<endl;

		//	send(clientSocket,lpIOdata->szMessage,dwBytesTransfered+1,0);//多发送一个字符,将字符串结束符也发送过去
			memset(lpIOdata,0,sizeof(PerSocketData));
			lpIOdata->buffer.len=MSGSIZE;
			lpIOdata->buffer.buf=lpIOdata->szMessage;

			WSARecv(clientSocket,
				&lpIOdata->buffer,
				1,
				&lpIOdata->NumberOfBytesRecvd,
				&lpIOdata->flags,
				&lpIOdata->overlap,
				NULL);
		}

	}

	return 0;
}


DWORD WINAPI startThread(LPVOID lpParam)
{

	//将对话框指针作为参数传递给这个成员变量
	CChatRoomServerDlg * chatRoom=(CChatRoomServerDlg*)lpParam;
	
	WSADATA wsaData;
	int err;

	CString str;

	//1.加载套接字库
	err=WSAStartup(MAKEWORD(1,1),&wsaData);
	if (err!=0)
	{
		str.Format("Init Windows Socket Failed::%d\n",GetLastError());
		chatRoom->appendString(str);
		return 0;
	}

	//下面执行一些使用完成端口需要进行的步骤
	//创建一个完成端口
	HANDLE completionPort=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,0,0);
	PerSocketData * sockData;
	SYSTEM_INFO systeminfo;
	GetSystemInfo(&systeminfo);
	DWORD dwThreadId;
	TransferData trans;
	trans._chatRoom=chatRoom;
	trans._completionPort=completionPort;
	for (int i=0;i<systeminfo.dwNumberOfProcessors;i++)
	{
		CreateThread(NULL,0,workThread,&trans,0,&dwThreadId);
	}

	//2.创建socket
	//套接字描述符,SOCKET实际上是unsigned int
	SOCKET serverSocket;
	serverSocket=socket(AF_INET,SOCK_STREAM,0);
	if (serverSocket==INVALID_SOCKET)
	{
		str.Format("Create Socket Failed::%d\n",GetLastError());
		chatRoom->appendString(str);
		return 0;
	}


	//服务器端的地址和端口号,使用本地ip地址和用户指定的端口号
	struct sockaddr_in serverAddr,clientAdd;
	DWORD dwip;
	chatRoom->m_ip.GetAddress(dwip);
	//serverAddr.sin_addr.s_addr=inet_addr(IP_ADDRESS);
	serverAddr.sin_addr.s_addr=htonl(dwip);
	serverAddr.sin_family=AF_INET;
	serverAddr.sin_port=htons(chatRoom->m_port);


	//3.绑定Socket,将Socket与某个协议的某个地址绑定
	err=bind(serverSocket,(struct sockaddr*)&serverAddr,sizeof(serverAddr));
	if (err!=0)
	{
		str.Format("Bind Socket Failed::%d\n",GetLastError());
		chatRoom->appendString(str);
		return 0;
	}


	//4.监听,将套接字由默认的主动套接字转换成被动套接字
	err=listen(serverSocket,10);
	if (err!=0)
	{
		str.Format("listen Socket Failed::%d\n",GetLastError());
		chatRoom->appendString(str);
		return 0;
	}

	chatRoom->appendString("服务器端已启动......\n");

	int addrLen=sizeof(clientAdd);
	SOCKET  sockConn;
	while(true)
	{
		//5.接收请求,当收到请求后,会将客户端的信息存入clientAdd这个结构体中,并返回描述这个TCP连接的Socket
		sockConn=accept(serverSocket,(struct sockaddr*)&clientAdd,&addrLen);
		if (sockConn==INVALID_SOCKET)
		{
			str.Format("Accpet Failed::%d\n",GetLastError());
			chatRoom->appendString(str);
			return 0;
		}

		str.Format("客户端连接:%s : %d\n",inet_ntoa(clientAdd.sin_addr),clientAdd.sin_port);
		chatRoom->appendString(str);
		
		//将之前的第6步替换成了下面的操作
		CreateIoCompletionPort((HANDLE)sockConn,completionPort,(DWORD)sockConn,0);

		sockData=(PerSocketData*)HeapAlloc(
			GetProcessHeap(),
			HEAP_ZERO_MEMORY,
			sizeof(PerSocketData));
		sockData->buffer.len=MSGSIZE;
		sockData->buffer.buf=sockData->szMessage;

		WSARecv(
			sockConn,
			&sockData->buffer,
			1,
			&sockData->NumberOfBytesRecvd,
			&sockData->flags,
			&sockData->overlap,
			NULL);

	}

	PostQueuedCompletionStatus(completionPort,0xFFFFFFFF,0,NULL);
	CloseHandle(completionPort);
	closesocket(serverSocket);
	//7.清理Windows Socket库
	WSACleanup();
}

可执行程序可以在 这里下载,工程文件可以在 这里下载。

关于完成端口的实现,可以参考之前的文章。



你可能感兴趣的:(socket通信之九:使用完成端口实现的一个聊天室)