一、多进程实现
前面我们在SOCKET网络编程学习中,简单实现了服务器为客户端单独开一个进程,客户端网络连接成功之后可以输入数据,并陆续给服务器发送消息,服务器只实现读取操作。
基于VS2019 C++的跨平台(Linux)开发(2.2)——SOCKET网络编程
因为之前服务器中没有实现write,所以只能实现客户端发消息给服务器。那么如果想要让客户端和客户端进行聊天该怎么办?
服务器作为中转站,要实现客户对聊,就要把服务器保存的消息再发送给另一方的客户端。(如下图)利用write写出去,并且writefd是5
但是此时会出现这样的问题:每一个客户端是一个进程,比如客户端A有一个acceptfd为4,当客户端B上线之后,才有第二个acceptfd为5,会把上一次的acceptfd覆盖(因为声明的acceptfd变量就一个)。
因为开的是进程,即此时acceptfd=4的进程要给acceptfd=5的进程发消息(acceptfd=5的进程要给acceptfd=4的进程发消息),但是拿不到两个进程都获取不到对方的acceptfd ,因为进程之前数据不能共享。(不能定义全局变量进行加减操作获取acceptfd,就算可以,也不能保证另一个客户端acceptfd=5,就像你登录QQ的时候,腾讯公司不会告诉你是今天第几个上线的用户,但是登录QQ要使用唯一的QQ号,服务器只能通过QQ号识别客户端)
所以,通过id号和acceptfd就可以一一对应了,就像map中的键值对,这时候大家可能会想到定义一个全局map容器把两者存起来,但是依然逃不掉进程间不能共享全局变量问题,所以很遗憾还是解决不了问题。
综上,使用进程代表一个客户端本身就是不对的,用了进程就不得不用IPC,这样都会变得更加复杂。所以就得使用多线程技术。
二、多线程实现
所以,客户端上线之后服务器要调用pthread_create创建线程为他服务,然后要去处理acceptfd的读写操作。思路如下
首先,要使用map将用户ID和acceptfd绑定。因为客户端上线只有acceptfd,还不知道用户的ID,实现不了将acceptfd和账号进行绑定。所以要在线程的处理函数进行死循环,再用read读取acceptfd,并查询数据库获取账号密码,判断账号密码是否正确,只有验证登录成功后才用map保存下来并插入数据库。(即map保存要在read后操作)。所以在pthread_create的时候要传入acceptfd。
其次,read后要write(acceptfd)。那么只有明确了“我是谁、跟谁聊、聊什么”才能告诉服务器要跟谁聊天,这时就可以定义一个结构体(如下图)—— 根据业务自定义通信协议。然后根据接收者账号(recvID),在map容器查询对应的acceptfd,然后即可以write(acceptfd)了。
注意:
1、调用线程处理函数没必要传map,本来就可以支持全局变量的使用,可以直接访问。
2、不能将acceptfd定义为全局变量,因为服务器调用accept后返回acceptfd会一直把全局的acceptfd覆盖,导致所有的线程只为最后一次上线的客户端服务
三、案例
四、详细步骤
服务端逻辑
总框架:接收客户端请求——>处理请求操作——>返回业务处理结果(使用的通信协议如下)
- 1、read读客户端请求,如果是登录,则做登录验证
- 2、成功或者失败结果write给客服端fd
- 3、read读客户端请求,如果是聊天,则确定1-发送者,2-接收者,3-聊天内容
- 3-1、从在线用户map容器中找到聊天对象
- 3-2、给原发送者发一次write,给聊天对象发一次write
客户端逻辑
- 1、登录操作,write发送客户端自己的账号密码
- 2、read接收读取的登录结果(失败就重新登录)
- 3、如果成功则开始聊天,失败继续登录
- 3-1、write数据:1-发送者;2-接收者;3-聊天内容
注意点:
1、void*传参,函数体赋值参数
- int fd=* (int *)p; ——赋值的是值,进入线程执行一次
- int* fd= (int *)p;——赋值的是地址 ,不能使用
如果赋值地址,保存到map容器,后续上线一个客户端,地址内容就改变了。会出现第一次可以发送成功,但是之后每次都是自己给自己发送消息。如下图,第二个客户端上线之后开辟的是子线程2号,它的pfd的指向的地址还是原来的0x19ff2c,pfd的值表示5,子线程1号的pfd也被改成了5,因此绝对不可以使用指针
2、为什么服务器还要给原发送者发一次write呢?
因为网络的数据传输需要时间,如果只给接收者发送,可能导致数据不能同一时间到达, 就如客户端1发了10句“你好”花了1毫秒,而在客户端2中就收到了一句花了一分钟,导致不能正常聊天。如下图。如果服务器也给原发送者发一次,那么数据就基本会同时到达(只是时间问题)
3、完整的通信协议=通信协议头+通信协议体
服务端要read两次,第一次先read头,第二次再根据读到的类型和长度来执行业务。
//通信协议头 typedef struct protocolHead { int businessTyep;//业务类型 登录1 聊天2 int businessLen;//业务长度 登录40字节 聊天140 字节 }HEAD; //通信协议体==业务数据 typedef struct login { char userName[20]; char pwd[20]; }LOGIN; typedef struct chatMsg { char sendID[10]; char recID[10]; char msg[100]; }CHAT; //示例 //... Head h; LOGIN l; CHAT ch; read(fd, &h, sizeof(HEAD));//读头,确定业务 if (h.businessTyep == 1)//读体,处理业务 { read(fd,&l,h.businessLen); } else if (h.businessTyep == 2) { read(fd, &msg, h.businessLen); }
注意:
处理业务时,读取的是协议头的长度businessLen,而不是直接sizeof(Head),是因为发送的数据有可能是空的,而使用businessLen才是真正的业务长度,就可以解决read登录、read聊天