本博客由Rcchio原創,轉載請告知作者
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
前言:聊天程序是生活中經常使用到的程序,典型的代表便是騰訊的QQ、微信。實現一個簡易的聊天程序,可以很好使TCP/IP協議編程的知識得到很好的運用,同時加深我們對c/s模式的理解。
本文講述了一個基於UDP協議的簡單的控制台聊天程序的實現。程序雖然簡易,但卻具有很好的擴展性,從功能上看,可以進一步豐富該聊天程序的功能,如增添添加好友和群,傳輸文件,視頻通話;從形式上看,如嘗試學習一些
c++GUI庫來為程序添加圖形界面,如學習使用數據庫的使用來為服務器存儲用戶信息等。所以說,聊天程序練習編程的一個不錯的選擇。作者已將完整代碼的鏈接和可執行程序的鏈接放在文章末尾,有需要的同學可以自取。話不多說,
下面來看一下這個簡單的程序是如何實現的吧~
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
一、聊天程序的功能
1.登錄賬號、注冊新賬號
2.進行群聊
3.進行私聊
注:該程序對傳統的聊天程序進行了簡化:
1.服務器中只有一個群
2.新注冊的賬號,默認已經添加到該群中
3.私聊對象范圍為該群的在線用戶
二、程序的實現語言
c++
三、聊天程序的架構
該程序采用經典的c/s架構,即采用客戶端/服務器架構。
1.服務器的功能:
- 接收發送器的消息請求,並根據消息類型進行不同的處理
- 通過文件存儲用戶的用戶名和密碼
2.客戶端的功能:
- 發送器:注冊新賬號,登錄已有賬號,發送群聊消息和私聊消息
- 接收器:接收服務器轉發的群聊消息和私聊消息
三、具體實現
對於聊天程序,最主要的過程就是服務器與客戶端程序之間的通信,本文已經默認讀者已經具備了最基本的網絡編程知識,某些具體細節便不再詳述。
考慮到服務器程序是整個聊天程序的核心,因此重點講述服務器程序的實現。其實,服務器程序實現以后,客戶端的編程也就十分簡單了。
1.服務器程序
服務器程序由一個server類實現,類的聲明代碼如下,各成員函數的功能也已詳細注釋好。
class server { public: bool Startup(); //檢測是否滿足服務器運行的環境 bool SetServerSocket(); //設置服務器用來監聽信息的socket套接字 bool Checktxt(); //檢測存儲文件是否存在,若不存在,創建一個 void work(); //服務器運行的主函數 void SendMessage(string message, struct sockaddr_in x); //發送信息的函數 void Sendonlinelist(); //向客戶端發送好友在線列表 bool TestUsernameAndPassword(string username, string password); //測試用戶名和密碼是否正確 bool TestDuplicateLogin(string username); //測試是否重復登錄 bool TestDuplicateRigister(string username); //測試是否重復注冊 string Getusername(string ip,int port); //根據ip和端口號獲得用戶名 int Getuserindex(string username); //根據用戶名獲得用戶在在線用戶表的索引號 void extractLoginuserinfor(string userinfor, string &username, string &password, string &receiverport); //提取登錄請求中的用戶名密碼和顯示器端口號 void extractRegisteruserinfor(string userinfor, string&username,string&password); //提取注冊請求中的用戶名和密碼 void extactPersonalMessageReceivername(string &message,string &receivername); //提取私聊消息中的接收者的姓名 private: WSADATA wsaData; SOCKET sSocket; //用來接收消息的套接字 struct sockaddr_in ser; //服務器地址 struct sockaddr_in cli; //客戶地址 int cli_length=sizeof(cli); //客戶地址長度 char recv_buf[BUFFER_LENGTH]; //接收數據的緩沖區 vector<user> usertable; //在線用戶表 string sendmessage,printmessage; //存儲服務器轉發、打印用的字符串 int iSend, iRecv; //存儲服務器發送和接收的字符串的長度 };
下面具體講一下這些成員函數的實現
首先服務器運行需要檢測運行環境是否得到滿足,這里使用成員函數Startup(),服務器程序可以運行返回布爾值true,否則返回布爾值false,函數實現如下:
bool server::Startup() { if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { cout << "Failed to load Winsock." << endl; return false; } return true; }
服務器的套接字sSocket是用來接收客戶端發送的各種類型的消息的。設置sSocket時除了要用socket()函數創建,還要用bind() 函數為服務器綁定一個地址,這里的地址是指服務器的ip地址和端口號,是客戶端默認知道的。本程序中服務
器使用的端口號為 5055,已用用宏定義設置好
#define DEFAULT_PORT 5055
設置套接字sSocket的代碼實現為
bool server::SetServerSocket() { //產生服務器端套接口 sSocket = socket(AF_INET, SOCK_DGRAM, 0); if (sSocket == INVALID_SOCKET) { cout << "socket()Failed:" << WSAGetLastError() << endl; return false; } //建立服務器端地址 ser.sin_family = AF_INET; ser.sin_port = htons(DEFAULT_PORT); //htons()函數把一個雙字節主機字節順序的數轉換為網絡字節順序的數 ser.sin_addr.s_addr = htonl(INADDR_ANY); //htonl()函數把一個主機字節順序的數轉換為網絡字節順序的數 if (bind(sSocket, (LPSOCKADDR)&ser, sizeof(ser)) == SOCKET_ERROR) { cout << "bind()Failed:" << WSAGetLastError() << endl; return false; } return true; }
檢查完運行環境,設置好套接字,就可以運行服務器主函數work() 了,work() 函數的工作過程為使用sSocket接收一個字符串,然后判斷該字符串是哪種消息類型,從而進行相應的處理,如此無限循環。
服務器要處理的消息類型一共有五種,分別是登錄請求、注冊請求、群聊消息、私聊消息、退出命令。這五種消息類型,可以用字符串的第一個字符來進行區分,比如’L‘是Login的首字母,用來作為登錄請求的標志,’R‘是Rigister的首字母,用來
作為注冊請求的標志,’G‘是Group的首字母,用來作為群聊消息的標志,’P'是Personal的首字母,用來作為私聊消息的標志,最后字符串"exit"可以作為用戶退出的命令。
(1)處理登錄請求
首先,將登錄請求中的用戶名和密碼,與服務器存儲的用戶名和密碼進行對比,若存在用戶名和密碼與之匹配,則表明該賬號是合法賬號,否則為未注冊賬號或者登錄密碼錯誤。進一步查看用戶在線列表中是否存在該用戶,如果已經存
在該用戶,則表明該賬號重復登錄,若不存在,則允許登錄該賬戶,並將該賬戶加入用戶在線列表。用戶用一個名為User的類來表示,用來存儲用戶的信息,如客戶端的ip地址,發送器的端口號,接收器的端口號,用戶名等。理所當然地,用戶在線列表可以用一個User類型的vector來存儲。該User類的定義如下:
class user { public: user(string username,string ip,int sender_port,int receiver_port) { this->username = username; this->ip = ip; this->sender_port = sender_port; this->receiver_port = receiver_port; //設置接收器的地址 receiver.sin_family = AF_INET; receiver.sin_port = htons(receiver_port); char *addr = new char[ip.length() + 1]; strcpy(addr, ip.c_str()); receiver.sin_addr.s_addr = inet_addr(addr); } string username; //用戶名 string ip; //客戶端ip地址 int sender_port; //發送器端口 int receiver_port; //接收器端口 struct sockaddr_in receiver; //存儲接收器的地址 };
(2)處理注冊請求
將注冊請求中的設置的用戶名和密碼,與文件中存儲的用戶名進行匹配,若存在匹配的用戶名,則已存在該用戶名,為重復注冊。若無匹配的用戶名則表示無人注冊該用戶名,將該用戶名和密碼寫入文件,並返回注冊成功的信息。
(3)處理群聊消息
接收群聊消息時將發送者的名稱,加在該群聊消息的首部,並轉發給所有在線的用戶。
(4)處理私聊消息
首先確定私聊消息的接收者是否在線,如果在線,在該私聊消息的首部加上發送者的姓名,轉發給該接收者。若該用戶不在線,則將在線的用戶列表返回給發送者,讓發送者根據此列表重新選擇私聊對象。
work() 函數里實現了對這五種消息類型的處理過程。下面給出該函數的實現過程。需要注意的是該函數的實現過程還牽扯到其他函數,這里不再詳列出代碼,讀者只需要清楚它們的功能,是如何為work() 函數服務的,先了解主函數的思
路,其他函數的實現也是輕而易舉了。
void server::work() { cout << "-----------------" << endl; cout << "Server running" << endl; cout << "-----------------" << endl; while (true) //進入一個無限循環,進行數據接收和發送 { memset(recv_buf, 0, sizeof(recv_buf)); //初始化接收緩沖區 iRecv = recvfrom(sSocket, recv_buf, BUFFER_LENGTH, 0, (struct sockaddr*)&cli, &cli_length); if (iRecv == SOCKET_ERROR) { cout << "recvfrom()Failed:" << WSAGetLastError() << endl; continue; } //獲取發送方的地址(ip和端口) char *x = inet_ntoa(cli.sin_addr); string address(x); //獲取客戶端ip int userport = ntohs(cli.sin_port); //獲取客戶端端口 string infortype=string(recv_buf); //根據infortype[0]來判斷消息的類型 if (infortype[0] == 'L') //登錄請求 { string userinfor = infortype.substr(1); //除去消息類型 string username,password,receiver_port; extractLoginuserinfor(userinfor, username, password, receiver_port); //提取用戶名和密碼 //向不合法用戶發送登錄失敗的回應 if (!TestUsernameAndPassword(username,password)) { SendMessage("N", cli); continue; } //查詢該用戶是否重復登錄 if (TestDuplicateLogin(username)) { SendMessage("N", cli); continue; } //將合法的未登錄的用戶加入列表 int receiver_port_int = atoi(receiver_port.c_str()); user newuser(username, address, userport, receiver_port_int); usertable.push_back(newuser); printmessage="(上線消息)"+ newuser.username + "已上線"; //設置要打印的消息 sendmessage = printmessage; //設置要轉發的消息 SendMessage("Y", cli); //向客戶端發送登錄成功的回應 } else if (infortype[0] == 'R') //注冊信息 { string userinfor = infortype.substr(1); //除去消息類型 string username, password; extractRegisteruserinfor(userinfor, username, password); //提取用戶名和密碼 //檢測用戶名是否已經注冊過 if (TestDuplicateRigister(username)) { SendMessage("N", cli); continue; } //向文件寫入新注冊的用戶名和密碼 if (!Checktxt()) { SendMessage("N", cli); continue; } fstream out("C:\\userform\\userform.txt", ios::app); out << userinfor << endl; out.close(); //發送注冊成功的回應 SendMessage("Y", cli); cout << "注冊成功" << endl<<"新用戶名為:"<<username<<endl<<endl; continue; } else if (infortype[0] == 'G') //群聊消息 { string message = infortype.substr(1); string sendername = Getusername(address, userport); //獲取發送者姓名 if (sendername == "") continue; printmessage = "(群消息)" + sendername + ":" + message; //設置要打印的消息 sendmessage = printmessage; //sendmessage = "G#"+sendername + ":" + message; //設置要轉發的消息 } else if (infortype[0] == 'P') //私聊消息 { if (infortype[1] == 'L') //獲取在線好友列表的請求 { Sendonlinelist(); continue; } if (infortype[1] == 'M') //私聊消息 { string message = infortype.substr(2); string sendername = Getusername(address, userport); //提取發送者姓名 if (sendername == "") continue; //提取接收者姓名 string receivername; extactPersonalMessageReceivername(message, receivername); //檢查接收者是否離線 int i = Getuserindex(receivername); if (i == usertable.size()) //接收者已經離線 { Sendonlinelist(); //重新將一份好友在線列表發送給發送方 continue; } SendMessage("Y", cli); //向發送方發送成功的響應 printmessage = "(私消息)" + sendername + "->" + receivername + ":" + message; //設置要打印的消息 cout << printmessage << endl; cout << "用戶ip:" << address << endl; cout << "用戶端口:" << userport << endl; cout << "當前在線人數:" << usertable.size() << endl << endl; sendmessage= printmessage; //設置要發送的消息 SendMessage(sendmessage, usertable[i].receiver); continue; } } else if (infortype == "exit") { string sendername = Getusername(address, userport); if (sendername == "") continue; int i = Getuserindex(sendername); if (i >= usertable.size() || i < 0) continue; SendMessage("exit", usertable[i].receiver); //向該用戶顯示器發送退出命令 usertable.erase(usertable.begin() + i); printmessage = "(下線消息)" +sendername + "已下線"; //設置要打印的消息 sendmessage = printmessage; //設置要轉發的消息 } //在服務器上打印消息 cout << printmessage << endl; cout << "用戶ip:" << address << endl; cout << "用戶端口:" << userport << endl; cout << "當前在線人數:" << usertable.size() << endl << endl; //向客戶端發送消息 for (int i = 0; i < usertable.size(); i++) SendMessage(sendmessage, usertable[i].receiver); } }
值得注意的是,如果客戶端非正常退出,那么服務器仍然認為該用戶在線,繼續向該用戶轉發消息,那么向該客戶端消息的套接字就會出現問題,表現為再用該套接字監聽消息時會產生編號為10054的錯誤。因此為了避免服務器程序崩潰,
非常有必要為專門建立一個套接字用來接收客戶端發來的消息,而不用該套接字發送任何消息。這樣即使客戶端非正常退出,也不會影響服務器繼續運行,處理其他客戶端發送的消息,提高了服務器的容錯性。
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
2.客戶端程序
客戶端程序要滿足發送消息和接收消息兩個功能,發送消息和接收消息這兩個過程要獨立進行,互不干擾,因此發送消息和接收消息可以用兩個線程或者是進程來實現。考慮到,若采用多線程的方法,那么發送消息的線程和接收消息的線
程會搶占控制台的控制權。當客戶端頻繁接收消息時,接收消息的線程會一直在控制台上輸出接收到的消息,會造成用戶無法發送消息的尷尬情況。因此,我決定將發送消息和接收消息這兩個功能用兩個進程實現。這樣接收消息的控制台程序成
為接收器(顯示器),發送消息的控制台程序叫做發送器。
但是這樣做存在一個問題,即在服務器的角度上如何將一個顯示器進程和已上線的用戶名相關聯呢?舉個例子,當服務器收到A轉發給B的私信時,服務器是如何知道用戶B的顯示器的端口號呢?
一個簡單的方法是,發送器和顯示器采用一個提前確定好的端口號,顯示器通過該端口來接收服務器轉發來的信息。用戶通過發送器登錄時,除了將用戶名和密碼發送給服務器,還要將顯示器的端口號發送給服務器。當服務器驗證用戶的登錄成
功以后,將顯示器端口號存儲在User類的對象中,當服務器要轉發消息給該用戶時便知道了該用戶顯示器的地址。這種做法的優點是簡單,缺點也很明顯,顯示器在運行時使用的是固定的端口號,當一台主機上運行多個顯示器程序時,就會發生
端口沖突,報編號為10048的錯誤。
為解決端口沖突的問題,需要讓顯示器在每次運行時使用不同的端口號,這個可以通過隨機函數來實現,讓顯示器在運行時首先通過隨機函數產生一個端口號,再建立套接字接收消息。但是這樣的話,顯示器隨機產生的端口號發送器是不知道
的,那么如何告知服務器該端口號呢?如果讓顯示器直接給服務器發送端口號,需要同時發送用戶名和端口號,才能使服務器將這兩者關聯起來。為了顯示器獲取用戶名,需要用戶在運行顯示器時再一次輸入用戶名。這種做法是不符合使用邏輯
的,因為按正常的邏輯,當我們通過發送器登錄賬號后,打開顯示器就應該可以直接接收消息,所以讓顯示器發送端口號的方法也同樣不太可取。
我采用的方法是前面兩種方法的綜合,即顯示器的端口號要由發送器發送給服務器,且顯示器的端口號也要用隨機函數產生。那么顯示器如何知道發送器產生的端口號呢?考慮到顯示器和發送器運行在一台主機上,發送器可以將隨機產生的端口
號寫入文件,顯示器運行時讀取該文件,便得到了自己接收服務器消息的端口號。而且由於是隨機產生的端口號,在主機上運行多個顯示器程序也不會發生沖突。
這個問題解決以后,發送器和接收器實現起來就很方便了。發送器根據登錄、注冊、群聊、私聊、退出這五種操作設置不同的消息類型(已在服務器實現中詳述),發送給服務器處理就可以了。顯示器更簡單,從文件中讀取端口號建立套
接字以后,就在無限循環中接收服務器轉發的消息並顯示在控制台端口中。邏輯很簡單,不再羅列代碼。
四、運行
代碼已在vs2015中運行成功。
若運行編譯運行源代碼請注意以下事項
- 編譯代碼時請在 源文件屬性-c/c++-常規-SDL檢查 這一路徑中將SDL檢查設置為否
- 由於發送器sender.exe運行時要調用顯示器receiver.exe,所以將編譯receiver.cpp文件產生的receiver.exe文件以相對路徑放在發送器的工程文件夾下
- 運行時請確保主機已關閉防火牆
1.服務器
用戶上線提醒
注冊提醒
顯示私聊消息
顯示群聊消息
2.發送器
登錄界面
登錄成功的界面
私聊界面
群聊界面
3.顯示器
五、可執行文件和源代碼鏈接