1

Laravel + connmix develop distributed WebSocket chat room

Star https://github.com/connmix/examples for the latest version of the example

connmix is a distributed long connection engine based on go + lua to develop message-oriented programming, which can be used for the development of the Internet, instant messaging, APP development, online games, hardware communication, smart home, Internet of Things and other fields.
Clients in various languages such as java, php, go, nodejs, etc.

Laravel is recognized as the most elegant traditional framework in the PHP industry. Of course, you can also choose other frameworks such as thinkphp.

The combination of the two can quickly develop a distributed websocket long connection service with strong performance, which is very suitable for the development of IM, chat room, customer service system, live broadcast barrage, page games and other needs.

Install

  1. Install CONNMIX engine: https://connmix.com/docs/1.0/#/zh-cn/install-engine
  2. Install the latest version of the Laravel framework
 composer create-project laravel/laravel laravel-chat
  1. Then install the connmix-php client
 cd laravel-chat
composer require connmix/connmix

solution

  • Use in the command line connmix client consumes the memory queue (WebSocket messages sent by the front end).
  • We choose Laravel's command line mode, which is console to write business logic, so that we can use Laravel's DB, Redis and other ecological libraries.

API Design

As a chat room, we need to design the WebSocket API interface before we start. We use the most widely used json format to transmit data, and the interaction adopts the classic pubsub mode.

Features Format
User login {"op":"auth","args":["name","pwd"]}
Subscribe to room channel {"op":"subscribe","args":["room_101"]}
Subscribe to user channel {"op":"subscribe","args":["user_10001"]}
Subscribe to radio channels {"op":"subscribe","args":["broadcast"]}
Unsubscribe from a channel {"op":"unsubscribe","args":["room_101"]}
Receive room messages {"event":"subscribe","channel":"room_101","data":"hello,world!"}
Receive user messages {"event":"subscribe","channel":"user_10001","data":"hello,world!"}
receive broadcast messages {"event":"subscribe","channel":"broadcast","data":"hello,world!"}
send message to room {"op":"sendtoroom","args":["room_101","hello,world!"]}
send message to user {"op":"sendtouser","args":["user_10001","hello,world!"]}
send broadcast {"op":"sendbroadcast","args":["hello,world!"]}
success {"op":" * ","success":true}
mistake {"op":"***","error":" * "}

Database Design

We need to do login, so we need a users table to handle authentication, here is just for demonstration so the table design is deliberately simplified.

 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`)
);

The room table is temporarily not designed here, everyone can expand it by themselves.

Modified entry.lua

User login needs to be added to the lua protocol conn:wait_context_value to complete, we modify entry.lua as follows:

  • File path: entry.lua
  • protocol_input Modify the bound url path
  • on_message Add blocking wait context
 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

Write business logic

Then we write code in console to generate a command line class

 php artisan make:command Chat

We use the connmix-php client to handle the consumption of the memory queue.

 <?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}');
    }
}

debugging

start the service

  • start connmix engine
 % bin/connmix dev -f conf/connmix.yaml
  • Start Laravel command line (you can start multiple to increase performance)
 % php artisan command:chat

WebSocket Client 1

Connection: ws://127.0.0.1:6790/chat

  • Log in
 send: {"op":"auth","args":["user1","123456"]}
receive: {"op":"auth","success":true}
  • join room
 send: {"op":"subscribe","args":["room_101"]}
receive: {"op":"subscribe","success":true}
  • Send a message
 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

Connection: ws://127.0.0.1:6790/chat

  • Log in
 send: {"op":"auth","args":["user2","123456"]}
receive: {"op":"auth","success":true}
  • join room
 send: {"op":"subscribe","args":["room_101"]}
receive: {"op":"subscribe","success":true}
  • receive message
 receive: {"event":"subscribe","channel":"room_101","data":"uid:1,message:hello,world!"}

Epilogue

Based on connmix client, we can quickly create a distributed long connection service with very little code.


撸代码的乡下人
252 声望46 粉丝

我只是一个单纯爱写代码的人,不限于语言,不限于平台,不限于工具