本博客由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.显示器
五、可执行文件和源代码链接