在去年,我們公司內部實現了一個聊天室系統,實現了一個即時在線聊天室功能,可以進行群組,私聊,發圖片,文字,語音等功能,那么,這個聊天室是怎么實現的呢?后端又是怎么實現的呢?
后端框架
在后端框架上,我選用了php的easyswoole,easyswoole作為swoole中最簡單易學的框架,上手簡單,文檔齊全,社區活躍
直接通過easyswoole官方文檔的例子,即可實現一個websocket服務器,並且還實現了對控制器的轉發等:
https://www.easyswoole.com/Cn/Socket/webSocket.html
前后端通信協議
由於考慮到聊天室的業務邏輯復雜,我們使用了http+websocket 2種協議,分別用在以下幾個地方:
登錄注冊,個人信息修改,好友申請等,使用http 接口實現
私聊,群聊消息推送,系統消息申請等,使用websocket即時推送
websocket即時推送封包方式
在websocket中,為了區分客戶端不同的操作(發送群消息,發送私聊消息等),我們定義了一個數據格式:
1
2
3
4
5
|
-
op
命令
- args 額外參數
- msg 消息內容
- msgType 消息類型(默認為1)
- flagId 消息標識符(前端隨機生成一個標識符,后台處理完該消息之后,會返回相同的標識符給與前端確認)
|
使用json字符串方式傳遞
同樣,為了區分服務端不同的推送,我們定義了服務端的響應格式:
1
2
3
4
5
|
-
op
命令(響應類型)
- args 額外參數
- msg 消息內容(成功時為OK)
- msgType 消息類型(默認為1)
- flagId 將返回和前端一致的標識符,告知前端該次請求 成功/失敗
|
例如:
1
2
3
4
5
6
7
|
## 發送消息
私聊消息:
`{
"op"
:1001,
"args"
:{
"userId"
:12},
"msg"
:
"test"
,
"flagId"
:10086}`
將回復:
`{
"op"
:1000,
"args"
:[],
"msg"
:
"ok"
,
"flagId"
:10086}`
目標用戶將收到:
`{
"op"
:1101,
"args"
:{
"fromUserId"
:
"12"
,
"msgId"
:16},
"msg"
:
"test"
}`
|
下文有許多op:xxx的數據,可以忽略xxx的數據,直接聯系上下文獲得op的命令類型
聊天記錄存儲
根據消息的類型,我們區分了 私聊消息,群消息,系統消息 3種消息,設計了3個表
為了使得客戶端能夠正常顯示群消息,我們對群成員做了軟刪除處理,確保可以獲取到群成員頭像
用戶可通過http接口,獲得歷史聊天記錄
語音,圖片,視頻聊天
在上面我們可以看到,有一個msgType字段,它將決定了這條數據是文字消息,還是語音,視頻
當msgType為語音類型時,msg將附帶一個語音文件的地址(通過http接口上傳文件,到oss或者服務器)
客戶端進行判斷,如果是語音,則下載文件,點擊即可播放,視頻,圖片同理
心跳設置
由於tcp的特性,在長時間沒有通信時,操作系統可能會自動對tcp連接進行銷毀並且可能沒有close事件提示,所以我們在websocket中提供了ping的命令,該命令發起后,服務器將響應pong,完成一次通信:
1
2
3
4
|
## ping
發送:直接給客戶端發送
"ping"
即可
返回:
`{
"op"
:1000,
"args"
:null,
"msg"
:
"PONG"
}`
|
網絡不穩定推送問題
當服務端推送消息時,為了確保用戶已經收到,提供了isRecv字段,默認為0
當用戶A向用戶B發送消息,服務器向B推送時,該條消息記錄初始isRecv為0,只有當B客戶端接收到消息,並且向服務器發送已接收命令時,才會置為1:
1
2
3
4
5
|
### 消息接收狀態
`{
"op"
:4002,
"args"
:{
"msgId"
:42},
"msg"
:
""
,
"flagId"
:111}`
服務器將響應:
`{
"op"
:1000,
"args"
:[],
"msg"
:
"ok"
,
"msgType"
:1,
"flagId"
:111}`
|
每次重新連接websocket服務時,可通過發起好友未讀消息推送的命令,向服務器獲得之前的未讀消息(網絡不穩定斷線重連)
1
2
3
4
5
6
|
當ws連接成功時,可通過該命令獲取所有的未讀好友消息:
`{
"op"
:4001,
"args"
:{
"userId"
:null,
"size"
:5},
"msg"
:
""
,
"flagId"
:111}`
其中`userId` 為限制單獨一個好友的未讀消息,可不傳
其中`size`為每次響應條數,默認為5,可不傳
服務器將響應:
`{
"op"
:4101,
"args"
:{
"total"
:0,
"list"
:[]},
"msg"
:
"ok"
,
"msgType"
:1,
"flagId"
:111}
|
每次推送完,都需要客戶端遍歷list,進行上面的已接收推送
聊天室流程講解
整個聊天室流程為:
- 用戶http接口登錄獲得授權
- 通過授權請求http接口獲得好友列表,不同好友的最后一條未讀消息以及未讀消息數(用於首頁顯示)
- 通過授權請求獲得群列表(群消息為了節省存儲空間沒有做已讀未讀)
- 建立ws鏈接
- 注冊斷線重連機制,當觸發close事件時,重連ws
- 建立ping定時器,每隔30秒進行一次ping
- 通過ws接口,獲得所有未讀消息,客戶端進行處理,推送到通知欄等
- 接收新消息推送,並顯示到消息列表
- 當點擊進某個群/好友消息界面時,自動獲取最新n條消息,用戶上拉時繼續獲取n條
不同設備數據同步
為了服務端性能問題,所有消息記錄,好友消息,群成員消息將緩存到客戶端,當用戶登錄成功時
直接顯示之前登錄時的所有狀態(消息列表,最后一條消息顯示等)
當新設備登錄時,只獲取未讀消息列表,其他消息需要點擊某個好友/群,才會進行顯示
fd->userId對應
當用戶登錄成功時,我們使用了swoole的Table進行存儲fd->userId以及userId->fd的對應
通過這2者對應的存儲,我們可以通過userId找到fd進行推送數據,也可以通過fd找到userId獲取用戶消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
<?php
namespace
App\Utility;
use
EasySwoole\Component\Singleton;
use
Swoole\Table;
class
FdManager
{
use
Singleton;
private
$fdUserId
;
//fd=>userId
private
$userIdFd
;
//userId=>fd
function
__construct(int
$size
= 1024*256)
{
$this
->fdUserId =
new
Table(
$size
);
$this
->fdUserId->column(
'userId'
,Table::TYPE_STRING,25);
$this
->fdUserId->create();
$this
->userIdFd =
new
Table(
$size
);
$this
->userIdFd->column(
'fd'
,Table::TYPE_INT,10);
$this
->userIdFd->create();
}
function
bind(int
$fd
,int
$userId
)
{
$this
->fdUserId->set(
$fd
,[
'userId'
=>
$userId
]);
$this
->userIdFd->set(
$userId
,[
'fd'
=>
$fd
]);
}
function
delete
(int
$fd
)
{
$userId
=
$this
->fdUserId(
$fd
);
if
(
$userId
){
$this
->userIdFd->del(
$userId
);
}
$this
->fdUserId->del(
$fd
);
}
function
fdUserId(int
$fd
):?string
{
$ret
=
$this
->fdUserId->get(
$fd
);
if
(
$ret
){
return
$ret
[
'userId'
];
}
else
{
return
null;
}
}
function
userIdFd(int
$userId
):?int
{
$ret
=
$this
->userIdFd->get(
$userId
);
if
(
$ret
){
return
$ret
[
'fd'
];
}
else
{
return
null;
}
}
}
|
同理,當需要群發消息時,只需要獲得群成員的userId,即可獲得當前所有在線成員的fd,進行遍歷推送
服務端推送問題
當A客戶端在群發送一條消息時,由於群成員可能有很多,如果直接同步推送給所有群成員,會造成A客戶端等待響應時間過長的情況
所以需要使用task做異步推送:
當A客戶端發送一條消息,先存入數據庫,並調用task進行異步群發推送,同時給A客戶端響應ok,代表接收到此消息
通過easyswoole的task組件,進行推送:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
namespace
App\Task;
use
App\HttpController\Api\User\Message\GroupMessage;
use
App\HttpController\Api\User\Message\SystemMessage;
use
App\Model\Group\GroupUserModel;
use
App\Model\Message\GroupMessageModel;
use
App\Model\Message\SystemMessageModel;
use
App\Model\Message\UserMessageModel;
use
App\Utility\FdManager;
use
App\WebSocket\Command;
use
EasySwoole\EasySwoole\ServerManager;
use
EasySwoole\Task\AbstractInterface\TaskInterface;
//消息異步推送
class
WebSocketPush
implements
TaskInterface
{
protected
$messageModel
;
function
__construct(
$messageModel
)
{
$this
->messageModel =
$messageModel
;
}
function
run(int
$taskId
, int
$workerIndex
)
{
$message
=
$this
->messageModel;
$result
= false;
//好友消息
if
(
$message
instanceof
UserMessageModel) {
$result
=
$this
->friendMsg(
$message
);
}
//群組消息
if
(
$message
instanceof
GroupMessageModel) {
$result
=
$this
->groupMsg(
$message
);
}
//系統消息
if
(
$message
instanceof
SystemMessageModel) {
$result
=
$this
->systemMsg(
$message
);
}
return
$result
;
}
}
|
websocket驗權,提下線功能
用戶在連接ws服務時,需要帶上token進行驗權,
服務端在onopen事件時,會進行token驗權,如果驗證失敗則響應一條消息表示登錄過期:
1
2
3
4
5
6
7
|
{
"op"
: -1003,
"args"
: [],
"msg"
:
"登陸狀態失效"
,
"msgType"
: 1,
"flagId"
: null
}
|
當A用戶在客戶端1登錄成功后,又在客戶端2登錄時,將給客戶端1發送一條已被踢下線消息::
1
2
3
4
5
6
7
|
{
"op"
: -1002,
"args"
: [],
"msg"
:
"你的賬號在其他設備登陸,你已被強制下線"
,
"msgType"
: 1,
"flagId"
: null
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
static
function
onOpen(Server $server, \Swoole\Http\Request $request)
{
$session = $request->get[
'userSession'
] ?? null;
$user = new UserModel();
if
(!empty($session)) {
$user->userSession = $session;
$info = $user->getOneBySession();
if
(empty($info)) {
self::pushSessionError($request->fd);
ServerManager::getInstance()->getSwooleServer()->close($request->fd);
return
true
;
}
//
如果已經有設備登陸,則強制退出
self::userClose($info->userId);
FdManager::getInstance()->bind($request->fd, $info->userId);
//
推送消息
//
self::pushMessage($request->fd,$info->userId);
}
else
{
self::pushSessionError($request->fd);
ServerManager::getInstance()->getSwooleServer()->close($request->fd);
}
}
|
關於客戶端網絡不穩定時候的情況解析
當客戶端發送一條消息之前,需要生成一個flagId,發送消息時附帶flagId
服務端響應消息時,會附帶flagId
因此,當客戶端發送消息時,新增一個flagId的定時器,當定時器到期卻沒有接收到服務端響應消息時,判斷該條消息發送失敗,顯示紅色感嘆號,提示用戶重發
當服務端響應成功時,將取消這個定時器,並直接將消息置為發送成功狀態
本文為仙士可原創文章,轉載無需和我聯系,但請注明來自仙士可博客www.php20.cn