题主感兴趣的应该是即时通讯软件的开发.
最简单的即时通讯软件(IM)就是聊天室(Chat Room).
聊天室可以理解为一个包含所有在线用户的QQ群.
服务器会把用户发布的消息广播推送(Push)给所有在线的用户.
这里涉及到即时通讯中的关键操作就是:服务器推(Server Push).
这里以PHP Swoole为例,实现基于现代浏览器WebSocket全双工连接的服务器推:
浏览器: var ws = new WebSocket("ws://localhost:8080"); ws.onmessage = function(e) { var msg = JSON.parse(e.data); console.log(msg.fd); console.log(msg.data); console.log(msg.time); } 服务器: $ws = new SwooleWebsocketServer('localhost', 8080); $ws->on('message', function($ws, $frame) { // 消息格式可以用JSON $msg = json_encode(array( 'fd' => $frame->fd, 'data' => $frame->data, 'time' => date('Y年m月d日 H:i:s') )); // 【服务器推】推送消息给所有客户端 foreach($ws->connections as $fd) { $ws->push($fd, $msg); } });
上面服务器是向所有连接fd推送消息.
向某个用户对应的连接fd推送,那就是私聊.
向某几个用户对应的连接fd推送,那就是群聊.
道理都类似,关键是建立用户编号uid和连接编号fd的关系.
比如用户1向用户2发送信息"你好":
浏览器: ws.send(JSON.stringify({"uid": 2, "msg": "你好"})); 服务器: $ws->push($fd, $msg); 服务器推送消息时,需要先判断对方是否在线,是否是其好友. 显然,这些关系判断需要访问数据库如MySQL.
服务器可以用Redis存储用户2对应的若干个连接(电脑和手机),从而根据用户编号uid找到用户对应的连接编号fd.
比如用户2对应的连接编号为4和6:
key(uid:2:fd) => value(4, 6)
Swoole推送消息给用户2时就是这样:
foreach(array(4, 6) as $fd) { $ws->push($fd, $msg); }
向群组(多个用户多个连接)推送消息也是同样道理.
用户编号uid和连接编号fd的绑定和唯一约束:
数据库用户表添加一个fd字段(唯一约束),用于记录用户对应的连接编号fd.
唯一约束的fd字段没有内容时,可以使用用户编号的负数作为默认值,做到不违反唯一约束.
用户登录时: onopen 填充fd字段,缓存新的fd到Redis中,旧的fd通过$ws->close($fd)触发onclose事件使其下线.
用户退出时: onclose 清空fd字段,删除缓存Redis中用户对应的该fd.
心跳检测关闭连接时: 也会触发onclose事件.
对于其他异常的连接:
异常关闭的连接的fd如果还在用户表中,下次该fd插入用户表时,因为唯一约束将会导致插入失败.这时需要先删除该fd对应的用户在缓存Redis(或MySQL内存表)中的fd,然后删除该用户在MySQL用户表中的异常fd,最后插入fd到新用户的MySQL记录中.
QQ支持1个账号两个客户端(手机+电脑)同时登录,即允许手机电脑同步在线.
这样用户表fd字段就需要存储2个fd数字,这时就需要自己配合FULLTEXT KEY实现唯一约束.
假设用户1分配得到一个值为100的fd,全文检索用户表的fd字段,查看是否存在包含100的记录:
SELECT * FROM user WHERE MATCH(fd) AGAINST('100' IN BOOLEAN MODE) FOR UPDATE;
假设查询发现用户2的fd字段为'50,100',这时应该删除掉100:
UPDATE user SET fd = '50' WHERE id = 2;
$redis->set('uid:2:fd', '50');
然后往用户1的fd字段加入100(假设用户1本来已经存在一个值为20的fd):
SELECT * FROM user WHERE id = 1 FOR UPDATE;
UPDATE user SET fd = '20,100' WHERE id = 1;
$redis->set('uid:1:fd', '20,100');
心跳检测机制用于回收异常断开的连接.
服务器端WebSocket连接的心跳检测,Swoole已经帮开发者实现,Swoole提供了2个参数:
heartbeat_check_interval: 默认30(心跳检测间隔,单位秒.Swoole会轮询所有TCP连接,将超过heartbeat_idle_time的连接关掉)
heartbeat_idle_time: 默认60(TCP连接的最大闲置时间,单位秒.如果某fd最后一次发包距离现在的时间超过这个值,Swoole会把这个连接关闭)
至于客户端浏览器上的WebSockets心跳检测,则需要开发者自行实现,比如:
https://github.com/zimv/WebSocketHeartBeat/blob/master/heartBeat.js
QQ是支持离线消息存储的,也就是如果对方不在线,消息将被持久化到数据库中.
等到对方上线拉取到消息后,数据库里的消息,将会在某个时间后删除.
不过QQ还为用户提供了一个付费的消息漫游服务.
也就是说,消息的存储和处理,仍然离不开传统的数据库CRUD操作.
QQ用户的注册/登录,好友的查找/添加/删除/分组,这些也离不开传统数据库操作.
作为区别,好友上线/下线通知,私聊/群聊(组聊),这些则依赖服务器推.
QQ还支持文件传输,在浏览器里,如果你不用WebRTC提供的点对点P2P的RTCDataChannel技术,可以这样做:用户通过HTTP把文件上传到服务器,然后服务器把文件的URL(字符串)推送给接收文件的用户.
这样做的话,对服务器的带宽和存储压力比较大,传一些小文件(比如图片和文档)还可以,传大文件就太消耗服务器了.
QQ还支持语音和视频聊天,显然,这种场景用点对点的P2P技术最合适,在浏览器里,可以用WebRTC实现.WebRTC里实现点对点的P2P通信,需要一个防火墙打洞服务器ICE(STUN+TURN)来实现NAT穿透:
https://github.com/coturn/coturn
WebRTC信令(signaling)的传输可以用WebSocket.
WebRTC已经很大程度简化了P2P实时通信的编程,但对普通Web开发者还是有一定的难度,普通Web开发者还必须熟悉JS会话建立协议JSEP的建立过程,才能跑通WebRTC服务.
最后有一个问题就是,腾讯QQ具有这么大规模的在线用户,不可能所有用户的连接fd都在同一台服务器上,这时就需要实现跨服务器消息推送.也就是Redis里不仅要记录用户对应的fd,而且还要记录这些fd所在的服务器.
比如服务器A上的用户张三向服务器B上的用户李四发送信息. 利用Redis的订阅发布功能,B服务器异步订阅,一旦A服务器发布消息到Redis,B服务器就会即时拿到,然后推送消息给B服务器上的用户李四即可.
还有个问题就是,一些老旧的浏览器比如IE8和IE9都不支持WebSocket协议,这时只能退化到HTTP,比如可以用AJAX长轮询,用PHP+Swoole实现大概可以这样:
浏览器(接收消息) => AJAX长轮询 => PHP7-Swoole => 异步订阅 => Redis <= 同步发布 <= PHP7-FPM(消息入库) <= 浏览器(发送消息)
也就是PHP-FPM跟PHP-Swoole通过Redis实现通信.
好处是能利用PHP-Swoole内置的事件驱动异步无阻塞的HTTP服务器和Redis客户端等.
2019/07/01 补充:
用一个字典保存用户(user)和连接(fd)的映射关系,然后根据用户找到对应的连接,从而向该用户推送消息.思路是可行的,但建议引入一个PubSub订阅发布中间件比如Redis,使用订阅发布的模式实现私聊和群聊.
私聊:
用户subscribe订阅自己的用户频道,比如(webim:user:1000:channel).
其他用户给该用户发消息时,向该用户的频道publish发布消息即可.
该用户的订阅事件message会自动触发,在该事件中可以推送消息给用户.
群聊:
用户subscribe订阅自己的群频道,比如(webim:group:2000:channel).
其他用户向该群publish发布消息时,
该用户的订阅事件message会自动触发,在该事件中可以推送群消息给用户.
说明:订阅事件message就是subscribe操作的回调函数.