我們看一個綜合例子:使用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 hereDWORD 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);
這樣就差不多了。