VC++深入詳解(15):簡單聊天工具的實現

來源:互聯網
上載者:User

我們看一個綜合例子:使用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);

這樣就差不多了。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.