1. 前言
即时通信软件的出现使人与人之间的交流相处变得更加便捷。好友即使远隔千里,依旧可以互相通信,使得友谊得以长存;亲人即便因工作相隔很远,依然可以多多联系,以便家人安心,自己舒心;正是即时通信系统使得信息走向便捷化的道路,人与人之间的交流沟通更是方便。反过来也是信息交流的不可或缺,使得即时通信系统更加具备研究价值,互联网对其的重视从不减少,反倒是与日俱增,人们对其的功能构想更是丰富多样,更是推动了互联网大环境的发展。
这篇文章就设计一个简单的即时通讯软件,也就是类似于QQ这种聊天软件,通过这个软件设计实现过程来了解TCP网络编程知识点、客户端设计思路、公网服务器部署方式等知识;本身软件并不复杂,实现的都是一些基本功能,主要是通过这套软件的设计过程来对应介绍相关的知识点。
软件整体包含了一个客户端、一个服务器。客户端采用QT设计,支持跨平台运行,服务器采用Linux系统运行,为了方便实现公网聊天,不局限于局域网,服务器采用了华为云的ECS弹性服务器
,系统选择了ubuntu18.04 64位 。 服务器端存储数据的数据库采用华为云的mySQL云数据,华为云MySQL数据的使用方式在上篇文章里讲过了。客户端的数据库采用SQLITE,数据都是存储在本地的,存储一些聊天记录等信息。
下面是客户端的界面效果: (1)这是客户端登录界面。客户端可以无限运行,一个客户端就登录一个账号。 (2)这个截图清晰些 (3)这是登录之后的窗口 (4)这是两个好友登录之后,聊天的效果。登录需要登录服务器。
接下来就从服务器的购买、部署、开始介绍整体软件的设计。
2. 部署ECS云服务器
华为云的ECS云服务器现在购买也挺便宜的,刚好赶上618活动。 官网地址: www.huaweicloud.com/
鼠标光标放在左上角的产品字样上,会弹出一个列表,介绍可以使用购买的产品。
可以看到,ECS服务器挺火的,HOT字样都打上了。
云服务器本身就类似一台远程电脑,配上公网IP,可以随时远程上去操作。有了这个服务器就很方便了,可以24小时不关机,也不用关心耗电问题,不用关心维护问题,只要自己代码没问题,就能一直稳定运行的跑。
如果初次使用服务器,也可以去免费领取一个的使用权,这个还是很方便的。
领取地址: www.huaweicloud.com/ 就在华为云的首页,可以看到免费试用产品。
现在有点晚了,服务器已经被领取完了,每天早上9点半会发放。
我之前已经领取过了,所以这里就不管了。
领取也很简单,下面贴一下当初领取时的过程记录。 (1)首先点击你想要的配置和类型领取 (2)选择配置。如果有选择,当然选择最高配置。 (3)选择操作系统,这里截图上选择的是ubuntu20.04 64位,也有ubuntu18.04可以选择,还有其他系统,具体看自己的需求。 (4)这里设置服务器的root登录密码,最好在这里就一次设置好,后面也不用再去修改。 (5)接下来就是按照流程点击下一步即可。 (6)在订单详情页面会看到资源正在创建,需要等待一段时间就会成功。
服务器买了之后,可以点击右上角的控制台按钮,去查看自己的服务器了。
在控制台页面,点击资源管理,可以看到自己有哪些实例可以使用,分别在什么区域。
点击进去自己的服务器详情页面。
服务器创建之后已经绑定了公网IP地址,这些就不用自己再次设置了。
接下来还需要设置一下安全组规则,开放一下外网访问的端口,如果不设置,不开放端口,外网无法访问服务器里的服务,这也是防火墙安全的控制手段。
点击添加规则按钮,设置自己需要开放的IP地址网段,端口范围就行了。
接下来点击页面上的远程登录按钮,登录一下服务器。
登录方式有多种,先选择一个默认方式,先体验一下,看看自己的服务器。 输入密登录即可。 登录之后进入命令行终端,熟悉的命令行出现了。
到此,一台全新的服务器已经搞定了,接下来就可以编写代码运行了。
3. 使用SSH协议登录服务器
在网页上直接登录,操作起来始终不方便,我在windows下一般使用SecureCRT
登录服务器。 服务器支持SSH协议登录,登录是很方便。 只需要知道服务器的公网IP地址、系统的用户名、系统密码即可。 公网地址就在服务器详情页面可以看到。用户名就是root,密码就是自己创建服务器时设置的密码。
下面就看一下登录效果: 点击接受并保存。
输入用户名、密码即可登录上去。
到此,采用SSH协议、利用终端软件成功登录了服务器,接下来就可以正常的开发了。
4. 即时系统整体设计思路
(1) 服务器设计:
登录认证: 服务器从客户端接收账户和密码,创建线程比对数据库中该用户账号、密码,比对成功,则返回用户信息给客户端,确认可进行登录。
客户端连接信息保存: 用户在客户端成功登录后,其所属账号、IP以及端口号被记录在数据库的连接信息表中以便管理。此时用户状态被置为1。反之,用户在客户端退出时,其账号、IP被连接信息表清除,此时用户状态被置为0.
用户注册: 当新用户使用时,用户需要填写用户名和密码。此时用户注册信息将存入数据库中,以备下次登录所需[10]。
增加好友: 服务触发增加好友的事件请求后,会即时将其账号信息存录数据库中进行管理。
删除好友: 服务器触发删除好友的事件请求后,会即时清除数据库中该用户的账户信息。
查找好友: 服务器一旦触发查找好友的事件请求时,会即时比对数据库中信息表的好友信息,该账号信息存在就可成功找到。
修改好友: 服务器触发好友信息修改的事件请求后,会即时更新该用户的账户信息。
好友通信: 服务器触发触发通信的事件请求时,会即时查询数据库中好友连接信息,再由此信息返回好友的ip地址及端口号给用户。
(2) 客户端设计:
用户注册: 用户在通信系统的客户端登录界面上输入账号、密码。接着,用户可单击按钮进行注册操作。此时,注册信息会即时发送给服务器,录入数据库进行管理。注册完,关闭客户端。下次登录时,用户通过客户端与服务器连接,查找账户信息,服务器判定账户信息正确,即表明注册成功。
用户登录: 用户点击“登录”按钮,根据客户端输入的账号信息判定其是否为空。若为空,程序运行不执行下述操作:向服务器发送登录请求。若不为空,程序运行即刻执行下述操作:向服务器发送登录请求。
好友在线状况: 客户端收到来自服务器的好友在线信息,好友联系人列表会发现此用户信息,表明该用户处于在线状态。用户可双击任一在线好友,双方即可进行交流。
一对一聊天: 用户双击联系人列表中任一在线友人。这时,客户端会给服务器发请求。服务器收到请求后,即时给客户端发送有关好友ip地址和端口号的连接信息。最终,双方实现信息交互。双方在聊天框中交流,客户端还含有好友聊天记录功能即消息管理。
群组聊天: 用户点击“群组”图标,客户端就会发送事件请求给服务器。服务器触发事件请求后,即时给客户端发送群聊的连接信息,此连接信息与群聊ip地址和端口号有关。最终,多人通信的用户通过客户端聊天界面实现信息交互。
5. 服务器代码
为了方便拷贝代码到服务器上去,我这里采用NFS服务器的方式拷贝,在服务器上搭建NFS服务器,共享一个目录出来,本地linux系统挂载,将代码这些文件拷贝过去。
(1)服务器端需要先安装服务器。
root@ecs-348470:~# sudo apt-get install nfs-kernel-server
(2)创建一个work目录方便当做共享目录使用
root@ecs-348470:~# mkdir work
(3)编写NFS配置文件
再编写NFS服务的配置文件/etc/exports, 填入配置信息。
/home/work *(rw,no_root_squash,sync,no_subtree_check,insecure)
(4)然后启动服务器即可
/etc/init.d/nfs-kernel-server start #启动 NFS 服务
本地我使用的是ubuntu18.04系统,挂载服务器的路径,将群聊代码拷贝上去。
wbyq@wbyq:~$ sudo mount -t nfs -o nolock 122.112.212.171:/home/work /home/wbyq/mnt/
[sudo] wbyq 的密码:
wbyq@wbyq:~$ cd mnt/
wbyq@wbyq:~/mnt$ ls
wbyq@wbyq:~/mnt$ sudo cp /mnt/hgfs/linux-share-dir/socket_app/* ./
wbyq@wbyq:~/mnt$ ls
client_app.c server_app.c
wbyq@wbyq:~/mnt$
这是服务器的第一版群聊代码,采用多线程方式处理客户端的连接请求。群聊模式下,服务器上主要是连接客户端之后,将消息转发给其余的客户端。
代码已经拷贝上去:
群聊服务器版本代码实现如下:
#include
#include
#include
#include /* See NOTES */
#include
#include
#include
#include
#include
#include
#include
#include
/*
创建服务器:
1. 创建socket套接字
2. 绑定端口号(给进程固定端口号和IP地址)--65535之内。
3. 设置监听的队列
4. 等待客户端连接
./app 8080
*/
int server_fd;
/*
发出的消息结构
*/
struct send_pack
{
char name[50];
char data[100];
char type; //0x1 表示上线,0x2表示下线 0x0表示正常聊天数据
};
/*
保存连接上服务器的客户端信息
*/
struct client_info
{
int client_fd;
struct client_info *next;
};
struct client_info *LIST_HEAD=NULL;
pthread_rwlock_t rwlock;
struct client_info *List_CreateHead(struct client_info *head);
void List_del(struct client_info *head,int client_fd);
void List_add(struct client_info *head,int client_fd);
/*信号处理函数*/
void sighandler_func(int sig)
{
/*4. 完毕套接字*/
close(server_fd);
printf("进程正常退出....n");
exit(0);
}
/*
函数功能:线程处理子函数
*/
void *func_start(void *arg)
{
int client_fd=*(int*)arg;
free(arg);
//写加锁
pthread_rwlock_wrlock(&rwlock);
//添加节点
List_add(LIST_HEAD,client_fd);
//解锁
pthread_rwlock_unlock(&rwlock);
int cnt;
struct send_pack data_pack;
struct client_info *tmp_head;
int poll_stat;
struct pollfd poll_fd;
poll_fd.fd=client_fd;
poll_fd.events=POLLIN;
while(1)
{
poll_stat=poll(&poll_fd,1,-1);
if(poll_stat>0)
{
cnt=read(client_fd,&data_pack,sizeof(struct send_pack));
if(cnt==0)
{
//写加锁
pthread_rwlock_wrlock(&rwlock);
//删除节点
List_del(LIST_HEAD,client_fd);
//解锁
pthread_rwlock_unlock(&rwlock);
data_pack.type=2;//下线提醒
pthread_rwlock_rdlock(&rwlock);
tmp_head=LIST_HEAD;
while(tmp_head->next)
{
tmp_head=tmp_head->next;
if(tmp_head->client_fd!=client_fd)
{
write(tmp_head->client_fd,&data_pack,sizeof(struct send_pack));
}
}
//解锁
pthread_rwlock_unlock(&rwlock);
break;
}
//加锁
pthread_rwlock_rdlock(&rwlock);
tmp_head=LIST_HEAD;
while(tmp_head->next)
{
tmp_head=tmp_head->next;
if(tmp_head->client_fd!=client_fd)
{
write(tmp_head->client_fd,&data_pack,sizeof(struct send_pack));
}
}
//解锁
pthread_rwlock_unlock(&rwlock);
}
else if(poll_statclient_fd=client_fd;
new_node->next=NULL;
p->next=new_node;
}
/*
删除链表节点
*/
void List_del(struct client_info *head,int client_fd)
{
struct client_info *p=head;
struct client_info *old;
while(p->next)
{
old=p;
p=p->next;
if(p->client_fd==client_fd)
{
old->next=p->next;
free(p);
break;
}
}
}
在服务器上编译运行代码:
root@ecs-348470:~/work# gcc server_app.c -o tcp_server -lpthread
root@ecs-348470:~/work# ls
server_app.c tcp_server
root@ecs-348470:~/work# ./tcp_server 8899
运行服务器群聊代码,在本地ubuntu系统再运行客户端代码,测试通信效果:
6. 客户端代码设计
在设计过程中,为了测试,代码写了两份,一份命令的行的聊天客户端,一份QT设计带界面的完整版本。
6.1 Linux命令行客户端
下面这是命令行版本的群聊客户端代码:
#include
#include
#include
#include
#include
#include /* See NOTES */
#include
#include
#include
#include
#include
#include
#include
/*
客户端:
1. 创建socket套接字
2. 连接指定的服务器
*/
int client_fd;
/*信号处理函数*/
void sighandler_func(int sig)
{
/*4. 完毕套接字*/
close(client_fd);
printf("进程正常退出....n");
exit(0);
}
/*
发出的消息结构
*/
struct send_pack
{
char name[50];
char data[100];
char type; //0x1 表示上线,0x2表示下线 0x0表示正常聊天数据
};
void *func_start(void *arg)
{
/*1. 通信*/
int client_fd=*(int*)arg;
struct send_pack data;
int cnt;
int poll_stat;
struct pollfd poll_fd;
poll_fd.fd=client_fd;
poll_fd.events=POLLIN;
while(1)
{
poll_stat=poll(&poll_fd,1,-1);
if(poll_stat>0)
{
cnt=read(client_fd,&data,sizeof(struct send_pack));
if(cnt==sizeof(struct send_pack))
{
if(data.type==0)
{
printf("%s:%sn",data.name,data.data);
}
else if(data.type==1)
{
printf("%s 用户上线.n",data.name);
}
else if(data.type==2)
{
printf("%s 用户下线.n",data.name);
}
}
else if(cnt==0)
{
printf("服务器断开连接.n");
break;
}
}
else if(poll_statwaitForConnected(1000))
{
qDebug()write(QString("LOGIN:%1:%2").arg(stra).arg(strb).toLocal8Bit());
}
void QConnectObject::acceptResiger(QString stra, QString strb)
{
tcpClient->write(QString("RESIGER:%1:%2").arg(stra).arg(strb).toLocal8Bit());
}
void QConnectObject::acceptMsg(QString str)
{
tcpClient->write(str.toLocal8Bit());
}
void QConnectObject::ReadError(QAbstractSocket::SocketError)
{
qDebug()readAll();
QString accbuf=QString::fromLocal8Bit(datagram);
qDebug()=2){
if(strlistA.at(1) == "true"){
emit sendLogin(true);
MainWidget::myport = strlistA.at(2);
}else if(strlistA.at(1) == "false"){
emit sendLogin(false);
}
}
}
if(strlistA.at(0) == "RESIGER"){//登录
if(strlistA.size()>=2){
if(strlistA.at(1) == "true"){
emit sendResiger(true);
}else if(strlistA.at(1) == "false"){
emit sendResiger(false);
}
}
}
if(strlistA.at(0) == "CONNECT_CLIENT"){//连接用户
emit sendClientInfo(accbuf);
}
if(strlistA.at(0) == "MSG"){//消息
emit sendMSG(accbuf);
} if(strlistA.at(0) == "GROUP"){//群
emit sendMSG(accbuf);
}
}
}
void QConnectObject::acceptProgress(double)
{
}
void QConnectObject::mtimeout()
{
if(connectstate == false){
QString ipAdd(m_ip), portd(mc_port);
if (ipAdd.isEmpty() || portd.isEmpty())
{
qDebug()