我们看一个综合例子:使用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);
这样就差不多了。