Laravel + connmix 開發分散式 WebSocket 聊天室

onanying發表於2022-05-06

Star github.com/connmix/examples 獲取最新版本的示例

connmix 是一個基於 go + lua 開發面向訊息程式設計的分散式長連線引擎,可用於網際網路、即時通訊、APP開發、網路遊戲、硬體通訊、智慧家居、物聯網等領域的開發,支援
java,php,go,nodejs 等各種語言的客戶端。

Laravel 是 PHP 業界公認最優雅的傳統框架,當然你也可以選擇 thinkphp 等其他框架。

兩者結合可快速開發出效能強勁的分散式 websocket 長連線服務,非常適合開發 IM、聊天室、客服系統、直播彈幕、頁遊等需求。

安裝

  1. 安裝 CONNMIX 引擎:connmix.com/docs/1.0/#/zh-cn/insta...

  2. 安裝最新版本的 Laravel 框架

composer create-project laravel/laravel laravel-chat
  1. 然後安裝 connmix-php 客戶端
cd laravel-chat
composer require connmix/connmix

解決方案

  • 在命令列中使用 connmix 客戶端消費記憶體佇列 (前端傳送的 WebSocket 訊息)。
  • 我們選擇 Laravel 的命令列模式,也就是 console 來編寫業務邏輯,這樣就可以使用 Laravel 的 DB、Redis 等各種生態庫。

API 設計

作為一個聊天室,在動手之前我們需要先設計 WebSocket API 介面,我們採用最廣泛的 json 格式來傳遞資料,互動採用經典的 pubsub 模式。

功能 格式
使用者登入 {“op”:”auth”,”args”:[“name”,”pwd”]}
訂閱房間頻道 {“op”:”subscribe”,”args”:[“room_101”]}
訂閱使用者頻道 {“op”:”subscribe”,”args”:[“user_10001”]}
訂閱廣播頻道 {“op”:”subscribe”,”args”:[“broadcast”]}
取消訂閱頻道 {“op”:”unsubscribe”,”args”:[“room_101”]}
接收房間訊息 {“event”:”subscribe”,”channel”:”room_101”,”data”:”hello,world!”}
接收使用者訊息 {“event”:”subscribe”,”channel”:”user_10001”,”data”:”hello,world!”}
接收廣播訊息 {“event”:”subscribe”,”channel”:”broadcast”,”data”:”hello,world!”}
傳送訊息到房間 {“op”:”sendtoroom”,”args”:[“room_101”,”hello,world!”]}
傳送訊息到使用者 {“op”:”sendtouser”,”args”:[“user_10001”,”hello,world!”]}
傳送廣播 {“op”:”sendbroadcast”,”args”:[“hello,world!”]}
成功 {“op”:”***”,”success”:true}
錯誤 {“op”:”***“,”error”:”***”}

資料庫設計

我們需要做登入,因此需要一個 users 表來處理鑑權,這裡只是為了演示因此表設計特意簡化。

CREATE TABLE `users`
(
    `id`       int          NOT NULL AUTO_INCREMENT,
    `name`     varchar(255) NOT NULL,
    `email`    varchar(255) NOT NULL,
    `password` varchar(255) NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `idx_n` (`name`)
);

房間 table 這裡暫時不做設計,大家自行擴充套件。

修改 entry.lua

使用者登入需要在 lua 協議增加 conn:wait_context_value 來完成,我們修改 entry.lua 如下:

  • 檔案路徑:entry.lua
  • protocol_input 修改繫結的 url 路徑
  • on_message 增加阻塞等待上下文
require("prettyprint")
local mix_log = mix.log
local mix_DEBUG = mix.DEBUG
local websocket = require("protocols/websocket")
local queue_name = "chat"

function init()
    mix.queue.new(queue_name, 100)
end

function on_connect(conn)
end

function on_close(err, conn)
    --print(err)
end

--buf為一個物件,是一個副本
--返回值必須為int, 返回包截止的長度 0=繼續等待,-1=斷開連線
function protocol_input(buf, conn)
    return websocket.input(buf, conn, "/chat")
end

--返回值支援任意型別, 當返回資料為nil時,on_message將不會被觸發
function protocol_decode(str, conn)
    return websocket.decode(conn)
end

--返回值必須為string, 當返回資料不是string, 或者為空, 傳送訊息時將返回失敗錯誤
function protocol_encode(str, conn)
    return websocket.encode(str)
end

--data為任意型別, 值等於protocol_decode返回值
function on_message(data, conn)
    --print(data)
    if data["type"] ~= "text" then
        return
    end

    local auth_op = "auth"
    local auth_key = "uid"

    local s, err = mix.json_encode({ frame = data, uid = conn:context()[auth_key] })
    if err then
       mix_log(mix_DEBUG, "json_encode error: " .. err)
       return
    end

    local tb, err = mix.json_decode(data["data"])
    if err then
       mix_log(mix_DEBUG, "json_decode error: " .. err)
       return
    end

    local n, err = mix.queue.push(queue_name, s)
    if err then
       mix_log(mix_DEBUG, "queue push error: " .. err)
       return
    end

    if tb["op"] == auth_op then
       conn:wait_context_value(auth_key)
    end
end

編寫業務邏輯

然後我們在 console 編寫程式碼,生成一個命令列 class

php artisan make:command Chat

我們使用 connmix-php 客戶端來處理記憶體佇列的消費。

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Nette\Utils\ArrayHash;
use phpDocumentor\Reflection\DocBlock\Tags\BaseTag;

class Chat extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:chat';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $client = \Connmix\ClientBuilder::create()
            ->setHost('127.0.0.1:6787')
            ->build();
        $onConnect = function (\Connmix\AsyncNodeInterface $node) {
            // 消費記憶體佇列
            $node->consume('chat');
        };
        $onReceive = function (\Connmix\AsyncNodeInterface $node) {
            $message = $node->message();
            switch ($message->type()) {
                case "consume":
                    $clientID = $message->clientID();
                    $data = $message->data();

                    // 解析
                    $json = json_decode($data['frame']['data'], true);
                    if (empty($json)) {
                        $node->meshSend($clientID, '{"error":"Json format error"}');
                        return;
                    }
                    $op = $json['op'] ?? '';
                    $args = $json['args'] ?? [];
                    $uid = $data['uid'] ?? 0;

                    // 業務邏輯
                    switch ($op) {
                        case 'auth':
                            $this->auth($node, $clientID, $args);
                            break;
                        case 'subscribe':
                            $this->subscribe($node, $clientID, $args, $uid);
                            break;
                        case 'unsubscribe':
                            $this->unsubscribe($node, $clientID, $args, $uid);
                            break;
                        case 'sendtoroom':
                            $this->sendToRoom($node, $clientID, $args, $uid);
                            break;
                        case 'sendtouser':
                            $this->sendToUser($node, $clientID, $args, $uid);
                            break;
                        case 'sendbroadcast':
                            $this->sendBroadcast($node, $clientID, $args, $uid);
                            break;
                        default:
                            return;
                    }
                    break;
                case "result":
                    $success = $message->success();
                    $fail = $message->fail();
                    $total = $message->total();
                    break;
                case "error":
                    $error = $message->error();
                    break;
                default:
                    $payload = $message->payload();
            }
        };
        $onError = function (\Throwable $e) {
            // handle error
            print 'ERROR: ' . $e->getMessage() . PHP_EOL;
        };
        $client->do($onConnect, $onReceive, $onError);
        return 0;
    }

    /**
     * @param \Connmix\AsyncNodeInterface $node
     * @param int $clientID
     * @param array $args
     * @return void
     */
    protected function auth(\Connmix\AsyncNodeInterface $node, int $clientID, array $args)
    {
        list($name, $password) = $args;
        $row = \App\Models\User::query()->where('name', '=', $name)->where('password', '=', $password)->first();
        if (empty($row)) {
            // 驗證失敗,設定一個特殊值解除 lua 程式碼阻塞
            $node->setContextValue($clientID, 'user_id', 0);
            $node->meshSend($clientID, '{"op":"auth","error":"Invalid name or password"}');
            return;
        }

        // 設定上下文解除 lua 程式碼阻塞
        $node->setContextValue($clientID, 'uid', $row['id']);
        $node->meshSend($clientID, '{"op":"auth","success":true}');
    }


    /**
     * @param \Connmix\AsyncNodeInterface $node
     * @param int $clientID
     * @param array $args
     * @param int $uid
     * @return void
     */
    protected function subscribe(\Connmix\AsyncNodeInterface $node, int $clientID, array $args, int $uid)
    {
        // 登入判斷
        if (empty($uid)) {
            $node->meshSend($clientID, '{"op":"subscribe","error":"No access"}');
            return;
        }

        // 此處省略業務許可權效驗
        // ...

        $node->subscribe($clientID, ...$args);
        $node->meshSend($clientID, '{"op":"subscribe","success":true}');
    }

    /**
     * @param \Connmix\AsyncNodeInterface $node
     * @param int $clientID
     * @param array $args
     * @param int $uid
     * @return void
     */
    protected function unsubscribe(\Connmix\AsyncNodeInterface $node, int $clientID, array $args, int $uid)
    {
        // 登入判斷
        if (empty($uid)) {
            $node->meshSend($clientID, '{"op":"unsubscribe","error":"No access"}');
            return;
        }

        $node->unsubscribe($clientID, ...$args);
        $node->meshSend($clientID, '{"op":"unsubscribe","success":true}');
    }

    /**
     * @param \Connmix\AsyncNodeInterface $node
     * @param int $clientID
     * @param array $args
     * @param int $uid
     * @return void
     */
    protected function sendToRoom(\Connmix\AsyncNodeInterface $node, int $clientID, array $args, int $uid)
    {
        // 登入判斷
        if (empty($uid)) {
            $node->meshSend($clientID, '{"op":"sendtoroom","error":"No access"}');
            return;
        }

        // 此處省略業務許可權效驗
        // ...

        list($channel, $message) = $args;
        $message = sprintf('uid:%d,message:%s', $uid, $message);
        $node->meshPublish($channel, sprintf('{"event":"subscribe","channel":"%s","data":"%s"}', $channel, $message));
        $node->meshSend($clientID, '{"op":"sendtoroom","success":true}');
    }

    /**
     * @param \Connmix\AsyncNodeInterface $node
     * @param int $clientID
     * @param array $args
     * @param int $uid
     * @return void
     */
    protected function sendToUser(\Connmix\AsyncNodeInterface $node, int $clientID, array $args, int $uid)
    {
        // 登入判斷
        if (empty($uid)) {
            $node->meshSend($clientID, '{"op":"sendtouser","error":"No access"}');
            return;
        }

        // 此處省略業務許可權效驗
        // ...

        list($channel, $message) = $args;
        $message = sprintf('uid:%d,message:%s', $uid, $message);
        $node->meshPublish($channel, sprintf('{"event":"subscribe","channel":"%s","data":"%s"}', $channel, $message));
        $node->meshSend($clientID, '{"op":"sendtouser","success":true}');
    }

    /**
     * @param \Connmix\AsyncNodeInterface $node
     * @param int $clientID
     * @param array $args
     * @param int $uid
     * @return void
     */
    protected function sendBroadcast(\Connmix\AsyncNodeInterface $node, int $clientID, array $args, int $uid)
    {
        // 登入判斷
        if (empty($uid)) {
            $node->meshSend($clientID, '{"op":"sendbroadcast","error":"No access"}');
            return;
        }

        // 此處省略業務許可權效驗
        // ...

        $channel = 'broadcast';
        list($message) = $args;
        $message = sprintf('uid:%d,message:%s', $uid, $message);
        $node->meshPublish($channel, sprintf('{"event":"subscribe","channel":"%s","data":"%s"}', $channel, $message));
        $node->meshSend($clientID, '{"op":"sendbroadcast","success":true}');
    }
}

除錯

啟動服務

  • 啟動 connmix 引擎
% bin/connmix dev -f conf/connmix.yaml 
  • 啟動 Laravel 命令列 (可以啟動多個來增加效能)
% php artisan command:chat

WebSocket Client 1

連線:ws://127.0.0.1:6790/chat

  • 登入
send: {"op":"auth","args":["user1","123456"]}
receive: {"op":"auth","success":true}
  • 加入房間
send: {"op":"subscribe","args":["room_101"]}
receive: {"op":"subscribe","success":true}
  • 傳送訊息
send: {"op":"sendtoroom","args":["room_101","hello,world!"]}
receive: {"event":"subscribe","channel":"room_101","data":"uid:1,message:hello,world!"}
receive: {"op":"sendtoroom","success":true}

WebSocket Client 2

連線:ws://127.0.0.1:6790/chat

  • 登入
send: {"op":"auth","args":["user2","123456"]}
receive: {"op":"auth","success":true}
  • 加入房間
send: {"op":"subscribe","args":["room_101"]}
receive: {"op":"subscribe","success":true}
  • 接收訊息
receive: {"event":"subscribe","channel":"room_101","data":"uid:1,message:hello,world!"}

結語

基於 connmix 客戶端我們只需很少的程式碼就可以快速打造一個分散式長連線服務。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章