现在的位置: 首页 > 综合 > 正文

VC++深入详解(15):简单聊天工具的实现

2013年08月21日 ⁄ 综合 ⁄ 共 9614字 ⁄ 字号 评论关闭

我们看一个综合例子:使用MFC来实现一个网络聊天软件。看到这个例子有人可能觉得奇怪,前面网络编程时讲到过一个类似的控制台应用程序的例子,为什么要放在这里?原因在于,前面的那个例子,我们必须是一个人说完就得等另一个人说,不能自己连续说,这是由它的实现代码决定的;而我们这里想实现的是“自由”的对话,可以在任意时间发送或者接收数据。这就需要我们这一小节的知识来帮忙了:我们可以利用一个线程来实现接收消息。
下面我们一步步开始完成,先设计程序的外观:首先使用一个对话框应用程序,然后将对话框上的默认按钮“确认”和“取消”都删除。然后为它加上下面的控件:

控件名称	ID		作用
组框		IDC_STATIC	标识:接收数据
接收编辑框	IDC_EDIT_RECV	显示所有聊天数据
发送组框	IDC_STATIC	标识:发送数据
IP地址空间	IDC_IPADDRESS1	允许用户输入点分十进制IP
发送编辑框	IDC_EDIT_SEND	允许用户输入发送的内容
发送按钮	IDC_BTN_SEND	点击后把信息发送到指定IP上

完成了外观设计之后,我们就要开始编写网络程序了,回忆一下基于UDP的socket编写的步骤:
服务器端:
1.加载套接字库,协商版本号
2.创建套接字。
3.绑定端口
4.发送/接收数据
5.关闭套接字
6.释放资源
客户端:
1.加载套接字库,协商版本号
2.创建套接字
3.发送/接收消息
4.关闭套接字
5.释放资源

MFC为我们提供了AfxSocketInit函数来实现加载套接字,协商版本号的功能,由MSDN可知,我们应该把它放在我的自己应用程序类的InitInstance中:

BOOL CCH_15_CHATApp::InitInstance()  
{  
    if(!AfxSocketInit)  
    {  
        AfxMessageBox("加载套接字失败");  
        return FALSE;  
    }  
//其他代码省略  
}

注意如果要使用这个函数,必须包含Afxsock.h这个头文件。我们应该把这头文件包含在stdafx.h中。stdafx.h是一个预编译头文件,里面包含了MFC应用程序所需的一些必要的头文件。

接着,为从CDialog派生下来的两个类中的那个不是CAboutDlg的类(CAboutDlg是用来产生关于对话框的这里没有用)增加一个私有SOCKET类型的成员变量m_socket和BOOL类型的成员函数InitSocket,用来初始化套接字:

BOOL CCH_15_CHATDlg::InitSocket()  
{  
    m_socket = socket(AF_INET,SOCK_DGRAM,0);  
    if(INVALID_SOCKET == m_socket)  
    {  
        MessageBox("创建套接字失败");  
        return FALSE;  
    }  
    SOCKADDR_IN addrSock;  
    addrSock.sin_family = AF_INET;  
    addrSock.sin_port = htons(6000);  
    addrSock.sin_addr.S_un.S_addr = htonl(INADDR_ANY);  
      
    int retval;  
    retval = bind(m_socket,(SOCKADDR*)&addrSock,sizeof(SOCKADDR));  
    if(SOCKET_ERROR == retval)  
    {  
        closesocket(m_socket);  
        MessageBox("绑定失败!");  
        return FALSE;  
    }  
  
    return TRUE;  
}  

我们这个程序既要接收又要发送,对于接收程序来说,需要指定使用那个端口接收,接收什么IP的消息。我们应该在OnInitDialog中调用这个函数,完成套接字的初始化。
下面实现接收功能。如果没有数据到来,recvfrom函数会阻塞,从而导致程序暂停运行。所以我们可将接收数据的操作放置在一个单独的线程中完成,并给这个线程传递两个参数,一个是套接字,一个是对话框的句柄,这样,当接收导数据后,可以将该数据传给对话框,经过处理之后显示在接收编辑框控件上。但是,我们知道CreateThread函数中只有第4个参数可以用来给创建的线程传递参数,这该怎么办呢?我们发现第4个参数是一个LPVOID,而他的实际类型是(void *)也就是一个空类型的指针,所以我们可以定义一个结构体,这个结构体中包含了前面说的2个参数,然后把这个结构体的地址传给函数即可。
我们先在这个类的头文件中定义一个结构体:

struct RECVPARAM  
{  
    SOCKET sock;  
    HWND hwnd;  
};

然后在OnInitDialog中完成对结构体的赋值并创建线程:

RECVPARAM *pRecvParam = new RECVPARAM;  
pRecvParam->sock = m_socket;  
pRecvParam->hwnd = m_hWnd;  
  
HANDLE hThread = CreateThread(NULL,0,RecvProc,(LVOID)pRecvParam,0,NULL);  
CloseHandle(hThread); 

其中的线程函数RecvProc我们还没有编写,应该如何编写呢?我们可以写一个全局函数,但是有时候处于面向对象的考虑,不允许写全局函数时,应该怎么办呢?我们不能简单的把它写为类成员函数。因为类成员函数是通过类的对象调用的,而我们这里并没有对象。有一种方法可以解决这个问题,就是使用静态成员函数。

DWORD  WINAPI CCH_15_CHATDlg::RecvProc(LPVOID lpParameter)  
{  
    //从参数中获取套接字和窗口句柄  
    SOCKET socket = ((RECVPARAM*)lpParameter)->sock;  
    HWND hwnd =  ((RECVPARAM*)lpParameter)->hwnd;  
    //释放内存  
    delete lpParameter;  
    SOCKADDR_IN addrFrom;  
    int len = sizeof(SOCKADDR);  
      
    char recvBuf[200];  
    char tempBuf[300];  
    int retval;  
    //始终处于接收状态  
    while(TRUE)  
    {  
        //接收数据  
        retval = recvfrom(socket,recvBuf,200,0,(SOCKADDR*)&addrFrom,&len);  
        if(SOCKET_ERROR  == retval)  
            break;  
        //将接受到的数据格式化到内存中  
        sprintf(tempBuf,"%s说:%s",inet_ntoa(addrFrom.sin_addr),recvBuf);  
        //投递消息  
        ::PostMessage(hwnd,WM_RECVDATA,0,(LPARAM)tempBuf);  
    }  
    return 0;  
} 

其中WM_RECVDATA是我们自定义的消息。我们应该在头文件中定义:

#define WM_RECVDATA WM_USER+1 

下来,便是对这个消息进行相应了,还是3步:响应函数的声明:

afx_msg void OnRecvData(WPARAM wParam, LPARAM lParam);  

消息映射:

ON_MESSAGE(WM_RECVDATA,OnRecvData)

函数的定义:

void CCH_15_CHATDlg::OnRecvData(WPARAM wParam, LPARAM lParam)  
{  
    CString str = (char*)lParam;  
    CString strTemp;  
    //把原来的消息放在strTemp里  
    GetDlgItemText(IDC_EDIT_RECV,strTemp);  
    str +="\r\n";  
    //str装的是原信息和现在的信息  
    str += strTemp;  
    //一并输出它们  
    setDlgItem(IDC_EDIT_RECV,str);  
} 

下面我们实现发送功能。首先当用户点击“发送”按钮时,就应该发送,我们为其添加消息响应函数:

void CCH_15_CHATDlg::OnBtnSend()   
{  
    // TODO: Add your control notification handler code here  
    //获取发送对象的IP  
    DWORD dwIP;  
    ((CIPAddressCtrl*)GetDlgItem(IDC_IPADDRESS1))->GetAddress(dwIP);  
    SOCKADDR_IN addrTo;  
    addrTo.sin_addr.S_un.S_addr = htonl(dwIP);  
    addrTo.sin_family = AF_INET;  
    addrTo.sin_port = htons(6000);  
      
    //获取待发送的文字  
    CString strSend;  
    GetDlgItemText(IDC_EDIT_SEND,strSend);  
      
    //发送消息  
    sendto(m_socket,strSend,strSend.GetLength()+1,0,  
        (SOCKADDR*)&addrTo,sizeof(SOCKADDR));  
  
    //清空发送消息框  
    SetDlgItemText(IDC_EDIT_SEND,"");  
}  

然后我们把我们的显示消息的编辑框控件设为支持多行,并将发送按钮设为默认按钮,就OK了!

这个程序还有1点我不太满意的地方:我看不到我之前给它发送的是什么?怎么修改呢?有了前面的程序,这个问题可以照猫画虎的解决:

void CCH_15_CHATDlg::OnBtnSend()   
{  
    // TODO: Add your control notification handler code here  
    //获取发送对象的IP  
    DWORD dwIP;  
    ((CIPAddressCtrl*)GetDlgItem(IDC_IPADDRESS1))->GetAddress(dwIP);  
    SOCKADDR_IN addrTo;  
    addrTo.sin_addr.S_un.S_addr = htonl(dwIP);  
    addrTo.sin_family = AF_INET;  
    addrTo.sin_port = htons(6000);  
      
    //获取待发送的文字  
    char sendBuf[300];  
    char tempBuf[300];  
    GetDlgItemText(IDC_EDIT_SEND,sendBuf,300);  
    //发送消息  
    sendto(m_socket,sendBuf,strlen(sendBuf)+1,0,  
        (SOCKADDR*)&addrTo,sizeof(SOCKADDR));  
    sprintf(tempBuf,"我说:%s",sendBuf);  
    ::PostMessage(m_hWnd,WM_RECVDATA,0,(LPARAM)tempBuf);  
    //清空发送消息框  
    SetDlgItemText(IDC_EDIT_SEND,"");  
} 

这样就大功告成了!其实,还有更加简单的办法,就是直接获取和设置文本的内容,我们再下面会用到。

其实,Windows套接字在两种模式下执行I/O操作:阻塞模式和非阻塞模式。在阻塞模式下,在I/O操作完成之前,执行操作的Winsock函数会一直等待下去,不会立即返回。我们程序中的recvfrom函数就是这样,如果没有获取数据,就会阻塞起来。当然,由于我们把接收数据的函数写在了一个线程里,所以它的阻塞并不影响其他线程的运行。
在非阻塞模式下,Winsock会立即返回,在函数执行的操作完成后,系统会将操作结果通知线程,而线程会根据通知信息来判断该操作是否正常。

由于阻塞方式会影响系统的性能,所以有时需要使用非阻塞方式实现。WindowsSocket为了支持Windows消息驱动机制,对网络事件采取了基于消息的异步存取策略。具体的说,当WSAAsyncSelect函数所登记的网络事件发生时,Windows应用程序相应的窗口函数将接受到一个消息,消息中指示了发生的网络事件,以及与该事件相关的一些信息。
我们看看相关的函数:
WSAAsyncSelect:为指定的套接字请求基于Windows消息的网络事件通知,并将该套接字设为非阻塞模式。
WSAEnumProtocols:获取系统安装的网络协议的相关信息。
WSAStartup:初始化进程使用的WS2_32.DLL
WSASocket:创建套接字
WSARecvFrom:接收数据报类型的数据,并保存数据发送方的地址。
WSASendTo:发送数据到指定的目标

下面我们看看如何使用这些函数。我们还是编写之前的那个网络聊天室程序。
外观部分的设计与前面的一样,这里就不在重复了。大家也不要忘记使用在连接器中增加ws2_32.lib。
下面看如何加载套接字。我们之前使用的是AfxSocketInit函数,但这个函数只能加载1.1版本的套接字库,本程序需要使用2.0版本的一些函数,因此应该调用WSAStartup来手动加载。同样的,加载函数也应该放在InitInstance中来实现:

	//加载套接字库
	WORD wVersionRequested;
	WSADATA wsaData;
	int err;
	
	wVersionRequested = MAKEWORD( 2, 2 );
	
	err = WSAStartup( wVersionRequested, &wsaData );
	if ( err != 0 ) 
	{
		return FALSE;
	}

	
	if ( LOBYTE( wsaData.wVersion ) != 2 ||
        HIBYTE( wsaData.wVersion ) != 2 ) 
	{
		WSACleanup( );
		return FALSE; 
	}

同样的,需在 stdafx.h中包含:#include <winsock2.h>

下来的任务是创建并初始化套接字:为我们的对话框类增加一个SOCKET类型的私有成员变量m_socket,和一个BOOL类型的成员函数InitSocket

BOOL CCH_16_CHATDlg::InitSocket()
{
	//创建套接字
	m_socket = WSASocket( AF_INET,		//地址族
						  SOCK_DGRAM,	//服务类型:数据报
						  0,			//协议类型,根据服务类型自动选择
						  NULL,			//使用前三个参数决定创建的socket的特性
						  0,			//保留
						  0 );			//没有属性
	
	if(INVALID_SOCKET== m_socket)
	{
		MessageBox("创建套接字失败!");
		return FALSE;
	}


	//绑定套接字
	SOCKADDR_IN addrSock;
	addrSock.sin_addr.S_un.S_addr =htonl(INADDR_ANY);
	addrSock.sin_family = AF_INET;
	addrSock.sin_port = htons(6000);

	if(SOCKET_ERROR == bind(m_socket,(SOCKADDR*)&addrSock,sizeof(SOCKADDR))
	{
		MessageBox("绑定失败");
		return FALSE;
	}


	//注册网络事件
	if(SOCKET_ERROR == WSAAsyncSelect(m_socket,	//标识请求网络事件通知的套接字描述符
									  m_hWnd,	//网络事件发生时接收消息的窗口句柄
									  UM_SOCK,	//指定网络事件发生时窗口接收到的消息
									  FD_READ))	//感兴趣的网络事件
	{
		MessageBox("注册网络读取事件失败");
		return FALSE;
	}

	return TRUE;
}

注意:使用WSAAsyncSelect函数后,自定义消息的wParam指定的是哪个套接字,而lParam的低字节指定了网络事件,而高字节指定了错误代码。

我们可以在OnInitDialog中调用这个函数。同时,不要忘记在我们的对话框类的头文件中增加UM_SOCK的定义:

#define UM_SOCK WM_USER+1

下面我们看接收功能的实现。当注册的事件发生以后,操作系统会向调用进程发送响应消息,并将该事件的相应的信息一起传送给调用进程,是写信息可以通过消息的参数传递。我们现在写UM_SOCK 消息响应函数:

void CCH_16_CHATDlg::OnSock(WPARAM wParam, LPARAM lParam)
{
	switch(LOWORD(lParam))
	{
	case FD_READ:
		//存数据的地址
		WSABUF wsabuf;
		wsabuf.buf = new char[200];
		wsabuf.len = 200;

		DWORD dwRead;
		DWORD dwFlag = 0;
		SOCKADDR_IN addrFrom;
		int len = sizeof(SOCKADDR);
		CString str;
		CString strTemp;

		//接收数据
		if(SOCK_ERROR == WSARecvFrom(m_socket,				//套接字
									 &wsabuf,				//接收地址
									 1,						//1个地址
									 &dwRead,				//实际接受了多少
									 &dwFlag,				//没有使用
									 (SOCKADDR*)&addrFrom,	//存放源地址的缓冲区
									 &len,					//缓冲区大小
									 NULL,					//非重叠字忽略
									 NULL))					//非重叠字忽略
		{
			MessageBox("数据接收失败");
			delete[] wsabuf.buf;
			return ;
		}

		str.Format("%s 说: %s",inet_ntoa(addrFrom.sin_addr),wsabuf.buf);
		str += "\r\n";
		GetDlgItemText(IDC_EDIT_RECV,strTemp);
		str += strTemp;
		SetDlgItemText(IDC_RECV,str);
		delete[] wsabuf.buf;
		break;
	}
}

最后是点击发送按钮实现发送:

void CCH_16_CHATDlg::OnBtnSend() 
{
	// TODO: Add your control notification handler code here
	DWORD dwSend;
	CString strSend;
	GetDlgItemText(IDC_EDIT_SEND,strSend);
	
	int len = strSend.GetLength();
	WSABUF wsabuf;
	wsabuf.buf = strSend.GetBuffer(len);
	wsabuf.len = len + 1;
	
	DWORD dwIP;
	((CIPAddressCtrl*)GetDlgItem(IDC_IPADDRESS1))->GetAddress(dwIP);

	SOCKADDR_IN addrTo;
	addrTo.sin_addr.S_un.S_addr = htonl(dwIP);
	addrTo.sin_family = AF_INET;
	addrTo.sin_port = htons(6000);
	if(SOCKET_ERROR == WSASendTo(m_socket,				//套接字
								 &wsabuf,				//发送数据
								 1,						//1个地址
								 &dwSend,				//实际发送的数目
								 0,						//填0即可
								 (SOCKADDR*)&addrTo,	//发送目标的地址
								 sizeof(SOCKADDR),		//地址大小
								 NULL,					//没有使用
								 NULL))					//没有使用
	{
		MessageBox("发送失败!");
		return ;
	}

	//清空发送区域
	SetDlgItemText(IDC_EDIT_SEND,"");
}

最后,在我们App类的析构函数中终止套接字库的使用:

CCH_16_CHATApp::~CCH_16_CHATApp()
{
	WSACleanup();
}

在我们对话空类中终止套接字的使用:

CCH_16_CHATDlg::~CCH_16_CHATDlg()
{
	if(m_socket)
		closesocket(m_socket);
}

接下来,我们换个花样,因为IP地址是在不好记忆,能否通过主机名P来进行通信呢?其实回忆我们的程序,当要发送数据时,始终需要填充的是SOCKADDR_IN 类中的IP地址。所以,只要我们能够搞定主机名到IP地址的转化,就OK了。
这可以通过gethostbyname来实现。这个函数的返回值是hostent结构体:

struct hostent {
  char FAR *       h_name;
  char FAR * FAR * h_aliases;
  short            h_addrtype;
  short            h_length;
  char FAR * FAR * h_addr_list;
};

其中的最后一个元素是一个指针数组,数组其中的每个元素都是IP地址的结构体(多个网卡的电脑可能有多个IP地址)。
分析完以后,我们看具体的操作:
我们为对话框资源添加一个组框,名字改为主机名,然后在上面覆盖一个编辑框,ID改为IDC_EDIT_HOSTNAME。
然后对发送程序稍作修改:

	SOCKADDR_IN addrTo;
	DWORD dwIP;	
	CString strHostname;
	HOSTENT* pHost;
	//如果没有获取主机名,使用IP地址,否则将主机名转化为IP地址
	if(GetDlgItemText(IDC_EDIT_HOSTNAME,strHostname),strHostname == "")
	{
		((CIPAddressCtrl*)GetDlgItem(IDC_IPADDRESS1))->GetAddress(dwIP);
		addrTo.sin_addr.S_un.S_addr = htonl(dwIP);
	}
	else
	{
		pHost = gethostbyname(strHostname);
		//h_addr_list[0]是一个指向IP的指针,但是IP是DWORD*型的,所以要先转化,然后取内容
		addrTo.sin_addr.S_un.S_addr = *((DWORD*)pHost->h_addr_list[0]);
	}

这样,的确可以使用了,但是在接收消息的地方,还是显示的是IP地址,能否可以在显示消息时将发送方也改成主机名呢?也是可以的,使用gethostbyaddr函数来实现。我们只需要在OnSock中稍作修改即可:

		HOSTENT *pHost;
		pHost = gethostbyaddr((char*)&addrFrom.sin_addr.S_un.S_addr,4,AF_INET);
		if(pHost)
		{
			str.Format("%s 说: %s",pHost->h_name,wsabuf.buf);
		}
		else
		{
			str.Format("%s 说: %s",inet_ntoa(addrFrom.sin_addr),wsabuf.buf);
		}

下面同样的问题摆在了我们面前:如何能够当点击发送以后,让接收消息对话框也能显示我们发送的消息。先回忆那个阻塞版本是如何显示消息的:在那个版本中,如果收到消息,就会发出一条自定义消息,消息的参数可以传递发送的内容;而点击发送时,同样也可以发送一条消息,消息的参数携带发送内容。在消息响应函数中,专门处理将内容排版合理的显示出来。
其实这个方法是受到了,前面的影响,我们完全可以直接的在OnBtnSend中进行获取和设置控件内容的操作:

	CString strShow;
	GetDlgItemText(IDC_EDIT_RECV,strShow);
	strShow += "我说:";
	strShow += strSend;
	strShow += "\r\n";
	SetDlgItemText(IDC_EDIT_RECV,strShow);

但是,需要在OnSock中稍微调整一下字符的顺序,把原先编辑框中的内容插入到接收内容的前面:

		//str是接收的内容
		str += "\r\n";
		//strTemp是编辑框中已有的内容
		GetDlgItemText(IDC_EDIT_RECV,strTemp);

		str.Insert(0,strTemp.GetBuffer(200));
		SetDlgItemText(IDC_EDIT_RECV,str);

这样就差不多了。

抱歉!评论已关闭.