Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P8)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P8)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P8): Websocket Server End Reconstruction and User Authentication

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P8): Websocket Server End Reconstruction and User Authentication

In the last tutorial, we showed you the implementation of logging in to a chat room based on the Vue + Muse UI front end, but this part of the implementation is mainly the interaction between the front end and the Swoole HTTP server, which does not involve WebSocket connection. Today we will demonstrate Websocket-based Connection authentication implementation. Before that, we still need to spend some effort to refactor the current Websocket backend implementation in order to facilitate subsequent development.

Websocket server refactoring

Prior to the establishment of Websocket socket.io client and server connection time, we have set aside a App\Services\WebSocket\WebSocket class of this hint, and referred to follow-up with Websocket communication-related business logic migrated to implement this class, \App\Services\WebSocket\WebSocketHandler will bear only entry function, so today will focus on the reconstruction WebSocket classes.

Create room interface and implementation class

Before you start writing WebSocket before the class code, we first solve the peripheral business logic, chat room feature an inseparable module module room, where we first refer swooletw/laravel-swoole achieve this expansion pack to the relevant class rooms are migrated, to save app/Services/WebSocket/Rooms Under contents:

Among them, the RoomContract.php code is as follows:

<?php

namespace App\Services\Websocket\Rooms;

interface RoomContract
{
    /**
     * Rooms key
     *
     * @const string
     */
    public const ROOMS_KEY = 'rooms';

    /**
     * Descriptors key
     *
     * @const string
     */
    public const DESCRIPTORS_KEY = 'fds';

    /**
     * Do some init stuffs before workers started.
     *
     * @return RoomContract
     */
    public function prepare(): RoomContract;

    /**
     * Add multiple socket fds to a room.
     *
     * @param int fd
     * @param array|string rooms
     */
    public function add(int $fd, $rooms);

    /**
     * Delete multiple socket fds from a room.
     *
     * @param int fd
     * @param array|string rooms
     */
    public function delete(int $fd, $rooms);

    /**
     * Get all sockets by a room key.
     *
     * @param string room
     *
     * @return array
     */
    public function getClients(string $room);

    /**
     * Get all rooms by a fd.
     *
     * @param int fd
     *
     * @return array
     */
    public function getRooms(int $fd);
}

This defines the interface of the room. The two default implementations are based on Redis as the storage medium RedisRoom.php:

<?php

namespace App\Services\Websocket\Rooms;

use Illuminate\Support\Arr;
use Predis\Client as RedisClient;
use Predis\Pipeline\Pipeline;

/**
 * Class RedisRoom
 */
class RedisRoom implements RoomContract
{
    /**
     * @var \Predis\Client
     */
    protected $redis;

    /**
     * @var array
     */
    protected $config;

    /**
     * @var string
     */
    protected $prefix = 'swoole:';

    /**
     * RedisRoom constructor.
     *
     * @param array $config
     */
    public function __construct(array $config)
    {
        $this->config = $config;
    }

    /**
     * @param \Predis\Client|null $redis
     *
     * @return RoomContract
     */
    public function prepare(RedisClient $redis = null): RoomContract
    {
        $this->setRedis($redis);
        $this->setPrefix();
        $this->cleanRooms();

        return $this;
    }

    /**
     * Set redis client.
     *
     * @param \Predis\Client|null $redis
     */
    public function setRedis(?RedisClient $redis = null)
    {
        if (! $redis) {
            $server = Arr::get($this->config, 'server', []);
            $options = Arr::get($this->config, 'options', []);

            // forbid setting prefix from options
            if (Arr::has($options, 'prefix')) {
                $options = Arr::except($options, 'prefix');
            }

            $redis = new RedisClient($server, $options);
        }

        $this->redis = $redis;
    }

    /**
     * Set key prefix from config.
     */
    protected function setPrefix()
    {
        if ($prefix = Arr::get($this->config, 'prefix')) {
            $this->prefix = $prefix;
        }
    }

    /**
     * Get redis client.
     */
    public function getRedis()
    {
        return $this->redis;
    }

    /**
     * Add multiple socket fds to a room.
     *
     * @param int fd
     * @param array|string rooms
     */
    public function add(int $fd, $rooms)
    {
        $rooms = is_array($rooms) ? $rooms : [$rooms];

        $this->addValue($fd, $rooms, RoomContract::DESCRIPTORS_KEY);

        foreach ($rooms as $room) {
            $this->addValue($room, [$fd], RoomContract::ROOMS_KEY);
        }
    }

    /**
     * Delete multiple socket fds from a room.
     *
     * @param int fd
     * @param array|string rooms
     */
    public function delete(int $fd, $rooms)
    {
        $rooms = is_array($rooms) ? $rooms : [$rooms];
        $rooms = count($rooms) ? $rooms : $this->getRooms($fd);

        $this->removeValue($fd, $rooms, RoomContract::DESCRIPTORS_KEY);

        foreach ($rooms as $room) {
            $this->removeValue($room, [$fd], RoomContract::ROOMS_KEY);
        }
    }

    /**
     * Add value to redis.
     *
     * @param $key
     * @param array $values
     * @param string $table
     *
     * @return $this
     */
    public function addValue($key, array $values, string $table)
    {
        $this->checkTable($table);
        $redisKey = $this->getKey($key, $table);

        $this->redis->pipeline(function (Pipeline $pipe) use ($redisKey, $values) {
            foreach ($values as $value) {
                $pipe->sadd($redisKey, $value);
            }
        });

        return $this;
    }

    /**
     * Remove value from reddis.
     *
     * @param $key
     * @param array $values
     * @param string $table
     *
     * @return $this
     */
    public function removeValue($key, array $values, string $table)
    {
        $this->checkTable($table);
        $redisKey = $this->getKey($key, $table);

        $this->redis->pipeline(function (Pipeline $pipe) use ($redisKey, $values) {
            foreach ($values as $value) {
                $pipe->srem($redisKey, $value);
            }
        });

        return $this;
    }

    /**
     * Get all sockets by a room key.
     *
     * @param string room
     *
     * @return array
     */
    public function getClients(string $room)
    {
        return $this->getValue($room, RoomContract::ROOMS_KEY) ?? [];
    }

    /**
     * Get all rooms by a fd.
     *
     * @param int fd
     *
     * @return array
     */
    public function getRooms(int $fd)
    {
        return $this->getValue($fd, RoomContract::DESCRIPTORS_KEY) ?? [];
    }

    /**
     * Check table for rooms and descriptors.
     *
     * @param string $table
     */
    protected function checkTable(string $table)
    {
        if (! in_array($table, [RoomContract::ROOMS_KEY, RoomContract::DESCRIPTORS_KEY])) {
            throw new \InvalidArgumentException("Invalid table name: `{$table}`.");
        }
    }

    /**
     * Get value.
     *
     * @param string $key
     * @param string $table
     *
     * @return array
     */
    public function getValue(string $key, string $table)
    {
        $this->checkTable($table);

        $result = $this->redis->smembers($this->getKey($key, $table));

        // Try to fix occasional non-array returned result
        return is_array($result) ? $result : [];
    }

    /**
     * Get key.
     *
     * @param string $key
     * @param string $table
     *
     * @return string
     */
    public function getKey(string $key, string $table)
    {
        return "{$this->prefix}{$table}:{$key}";
    }

    /**
     * Clean all rooms.
     */
    protected function cleanRooms(): void
    {
        if (count($keys = $this->redis->keys("{$this->prefix}*"))) {
            $this->redis->del($keys);
        }
    }
}

One is based on Swoole Table as the storage medium TableRoom.php:

<?php
/**
 * Created by PhpStorm.
 * User: sunqiang
 * Date: 2019/11/7
 * Time: 3:14 PM
 */

namespace App\Services\Websocket\Rooms;

use Swoole\Table;

class TableRoom implements RoomContract
{
    /**
     * @var array
     */
    protected $config;

    /**
     * @var Table
     */
    protected $rooms;

    /**
     * @var Table
     */
    protected $fds;

    /**
     * TableRoom constructor.
     *
     * @param array $config
     */
    public function __construct(array $config)
    {
        $this->config = $config;
    }

    /**
     * Do some init stuffs before workers started.
     *
     * @return RoomContract
     */
    public function prepare(): RoomContract
    {
        $this->initRoomsTable();
        $this->initFdsTable();

        return $this;
    }

    /**
     * Add a socket fd to multiple rooms.
     *
     * @param int fd
     * @param array|string rooms
     */
    public function add(int $fd, $roomNames)
    {
        $rooms = $this->getRooms($fd);
        $roomNames = is_array($roomNames) ? $roomNames : [$roomNames];

        foreach ($roomNames as $room) {
            $fds = $this->getClients($room);

            if (in_array($fd, $fds)) {
                continue;
            }

            $fds[] = $fd;
            $rooms[] = $room;

            $this->setClients($room, $fds);
        }

        $this->setRooms($fd, $rooms);
    }

    /**
     * Delete a socket fd from multiple rooms.
     *
     * @param int fd
     * @param array|string rooms
     */
    public function delete(int $fd, $roomNames = [])
    {
        $allRooms = $this->getRooms($fd);
        $roomNames = is_array($roomNames) ? $roomNames : [$roomNames];
        $rooms = count($roomNames) ? $roomNames : $allRooms;

        $removeRooms = [];
        foreach ($rooms as $room) {
            $fds = $this->getClients($room);

            if (! in_array($fd, $fds)) {
                continue;
            }

            $this->setClients($room, array_values(array_diff($fds, [$fd])));
            $removeRooms[] = $room;
        }

        $this->setRooms($fd, array_values(array_diff($allRooms, $removeRooms)));
    }

    /**
     * Get all sockets by a room key.
     *
     * @param string room
     *
     * @return array
     */
    public function getClients(string $room)
    {
        return $this->getValue($room, RoomContract::ROOMS_KEY) ?? [];
    }

    /**
     * Get all rooms by a fd.
     *
     * @param int fd
     *
     * @return array
     */
    public function getRooms(int $fd)
    {
        return $this->getValue($fd, RoomContract::DESCRIPTORS_KEY) ?? [];
    }

    /**
     * @param string $room
     * @param array $fds
     *
     * @return TableRoom
     */
    protected function setClients(string $room, array $fds): TableRoom
    {
        return $this->setValue($room, $fds, RoomContract::ROOMS_KEY);
    }

    /**
     * @param int $fd
     * @param array $rooms
     *
     * @return TableRoom
     */
    protected function setRooms(int $fd, array $rooms): TableRoom
    {
        return $this->setValue($fd, $rooms, RoomContract::DESCRIPTORS_KEY);
    }

    /**
     * Init rooms table
     */
    protected function initRoomsTable(): void
    {
        $this->rooms = new Table($this->config['room_rows']);
        $this->rooms->column('value', Table::TYPE_STRING, $this->config['room_size']);
        $this->rooms->create();
    }

    /**
     * Init descriptors table
     */
    protected function initFdsTable()
    {
        $this->fds = new Table($this->config['client_rows']);
        $this->fds->column('value', Table::TYPE_STRING, $this->config['client_size']);
        $this->fds->create();
    }

    /**
     * Set value to table
     *
     * @param $key
     * @param array $value
     * @param string $table
     *
     * @return $this
     */
    public function setValue($key, array $value, string $table)
    {
        $this->checkTable($table);

        $this->$table->set($key, ['value' => json_encode($value)]);

        return $this;
    }

    /**
     * Get value from table
     *
     * @param string $key
     * @param string $table
     *
     * @return array|mixed
     */
    public function getValue(string $key, string $table)
    {
        $this->checkTable($table);

        $value = $this->$table->get($key);

        return $value ? json_decode($value['value'], true) : [];
    }

    /**
     * Check table for exists
     *
     * @param string $table
     */
    protected function checkTable(string $table)
    {
        if (! property_exists($this, $table) || ! $this->$table instanceof Table) {
            throw new \InvalidArgumentException("Invalid table name: `{$table}`.");
        }
    }
}

Later we will create a database as a storage medium based on the DatabaseRoom class, leaving behind this particular presentation function room when the re-realization. In addition, we app/Services/WebSocket/Facades create a directory for a facade room service proxy class Room.php, and initialization code is as follows:

<?php

namespace App\Services\Websocket\Facades;

use App\Services\Websocket\Rooms\RoomContract;
use Illuminate\Support\Facades\Facade;

/**
 * @method static $this prepare()
 * @method static $this add($fd, $rooms)
 * @method static $this delete($fd, $rooms)
 * @method static array getClients($room)
 * @method static array getRooms($fd)
 *
 * @see RoomContract
 */
class Room extends Facade
{
    /**
     * Get the registered name of the component.
     *
     * @return string
     */
    protected static function getFacadeAccessor()
    {
        return 'swoole.room';
    }
}

Write user authentication implementation code

The room is a static space, and dynamic users are chatting in the room. Therefore, we also need to write user authentication related implementation code in order to uniquely distinguish different users and manage the chat information of different rooms and different users. Here we still refer to swooletw/laravel-swoole achieved in app/Services/WebSocket creating a directory Authenticatable Trait to implement user authentication related business logic:

<?php

namespace App\Services\WebSocket;

use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use InvalidArgumentException;

/**
 * Trait Authenticatable
 */
trait Authenticatable
{
    protected $userId;

    /**
     * Login using current user.
     *
     * @param \Illuminate\Contracts\Auth\Authenticatable $user
     *
     * @return mixed
     */
    public function loginUsing(AuthenticatableContract $user)
    {
        return $this->loginUsingId($user->getAuthIdentifier());
    }

    /**
     * Login using current userId.
     *
     * @param $userId
     *
     * @return mixed
     */
    public function loginUsingId($userId)
    {
        return $this->join(static::USER_PREFIX . $userId);
    }

    /**
     * Logout with current sender's fd.
     *
     * @return mixed
     */
    public function logout()
    {
        if (is_null($userId = $this->getUserId())) {
            return null;
        }

        return $this->leave(static::USER_PREFIX . $userId);
    }

    /**
     * Set multiple recepients' fds by users.
     *
     * @param $users
     *
     * @return Authenticatable
     */
    public function toUser($users)
    {
        $users = is_object($users) ? func_get_args() : $users;

        $userIds = array_map(function (AuthenticatableContract $user) {
            $this->checkUser($user);

            return $user->getAuthIdentifier();
        }, $users);

        return $this->toUserId($userIds);
    }

    /**
     * Set multiple recepients' fds by userIds.
     *
     * @param $userIds
     *
     * @return Authenticatable
     */
    public function toUserId($userIds)
    {
        $userIds = is_string($userIds) || is_integer($userIds) ? func_get_args() : $userIds;

        foreach ($userIds as $userId) {
            $fds = $this->room->getClients(static::USER_PREFIX . $userId);
            $this->to($fds);
        }

        return $this;
    }

    /**
     * Get current auth user id by sender's fd.
     */
    public function getUserId()
    {
        if (! is_null($this->userId)) {
            return $this->userId;
        }

        $rooms = $this->room->getRooms($this->getSender());

        foreach ($rooms as $room) {
            if (count($explode = explode(static::USER_PREFIX, $room)) === 2) {
                $this->userId = $explode[1];
            }
        }

        return $this->userId;
    }

    /**
     * Check if a user is online by given userId.
     *
     * @param $userId
     *
     * @return bool
     */
    public function isUserIdOnline($userId)
    {
        return ! empty($this->room->getClients(static::USER_PREFIX . $userId));
    }

    /**
     * Check if user object implements AuthenticatableContract.
     *
     * @param $user
     */
    protected function checkUser($user)
    {
        if (! $user instanceOf AuthenticatableContract) {
            throw new InvalidArgumentException('user object must implement ' . AuthenticatableContract::class);
        }
    }
}

Implementing Websocket Core Classes

After completing the user authentication and the two peripheral function room, next, the official start of Websocket the class to achieve, or reference swooletw/laravel-swoole achieved, partially achieved just made adjustments to simplify and adapt hhxsv5/laravel-s Expansion Pack :

<?php
namespace App\Services\WebSocket;

use App\Services\Websocket\Rooms\RoomContract;
use Illuminate\Support\Facades\App;
use Swoole\WebSocket\Server;

class WebSocket
{
    use Authenticatable;

    const PUSH_ACTION = 'push';
    const EVENT_CONNECT = 'connect';
    const USER_PREFIX = 'uid_';

    /**
     * Websocket Server
     * @var Server
     */
    protected $server;

    /**
     * Determine if to broadcast.
     *
     * @var boolean
     */
    protected $isBroadcast = false;

    /**
     * Scoket sender's fd.
     *
     * @var integer
     */
    protected $sender;

    /**
     * Recepient's fd or room name.
     *
     * @var array
     */
    protected $to = [];

    /**
     * Websocket event callbacks.
     *
     * @var array
     */
    protected $callbacks = [];

    /**
     * Room adapter.
     *
     * @var RoomContract
     */
    protected $room;

    /**
     * DI Container.
     *
     * @var \Illuminate\Contracts\Container\Container
     */
    protected $container;

    /**
     * Websocket constructor.
     *
     * @param RoomContract $room
     */
    public function __construct(RoomContract $room)
    {
        $this->room = $room;
    }

    /**
     * Set broadcast to true.
     */
    public function broadcast(): self
    {
        $this->isBroadcast = true;

        return $this;
    }

    /**
     * Set multiple recipients fd or room names.
     *
     * @param integer, string, array
     *
     * @return $this
     */
    public function to($values): self
    {
        $values = is_string($values) || is_integer($values) ? func_get_args() : $values;

        foreach ($values as $value) {
            if (! in_array($value, $this->to)) {
                $this->to[] = $value;
            }
        }

        return $this;
    }

    /**
     * Join sender to multiple rooms.
     *
     * @param string, array $rooms
     *
     * @return $this
     */
    public function join($rooms): self
    {
        $rooms = is_string($rooms) || is_integer($rooms) ? func_get_args() : $rooms;

        $this->room->add($this->sender, $rooms);

        return $this;
    }

    /**
     * Make sender leave multiple rooms.
     *
     * @param array $rooms
     *
     * @return $this
     */
    public function leave($rooms = []): self
    {
        $rooms = is_string($rooms) || is_integer($rooms) ? func_get_args() : $rooms;

        $this->room->delete($this->sender, $rooms);

        return $this;
    }

    /**
     * Emit data and reset some status.
     *
     * @param string
     * @param mixed
     *
     * @return boolean
     */
    public function emit(string $event, $data): bool
    {
        $fds = $this->getFds();
        $assigned = ! empty($this->to);

        // if no fds are found, but rooms are assigned
        // that means trying to emit to a non-existing room
        // skip it directly instead of pushing to a task queue
        if (empty($fds) && $assigned) {
            return false;
        }

        $payload = [
            'sender'    => $this->sender,
            'fds'       => $fds,
            'broadcast' => $this->isBroadcast,
            'assigned'  => $assigned,
            'event'     => $event,
            'message'   => $data,
        ];

        $server = app('swoole');
        $pusher = Pusher::make($payload, $server);
        $parser = app('swoole.parser');
        $pusher->push($parser->encode($pusher->getEvent(), $pusher->getMessage()));

        $this->reset();

        return true;
    }

    /**
     * An alias of `join` function.
     *
     * @param string
     *
     * @return $this
     */
    public function in($room)
    {
        $this->join($room);

        return $this;
    }

    /**
     * Register an event name with a closure binding.
     *
     * @param string
     * @param callback
     *
     * @return $this
     */
    public function on(string $event, $callback)
    {
        if (! is_string($callback) && ! is_callable($callback)) {
            throw new \InvalidArgumentException(
                'Invalid websocket callback. Must be a string or callable.'
            );
        }

        $this->callbacks[$event] = $callback;

        return $this;
    }

    /**
     * Check if this event name exists.
     *
     * @param string
     *
     * @return boolean
     */
    public function eventExists(string $event)
    {
        return array_key_exists($event, $this->callbacks);
    }

    /**
     * Execute callback function by its event name.
     *
     * @param string
     * @param mixed
     *
     * @return mixed
     */
    public function call(string $event, $data = null)
    {
        if (! $this->eventExists($event)) {
            return null;
        }

        // inject request param on connect event
        $isConnect = $event === static::EVENT_CONNECT;
        $dataKey = $isConnect ? 'request' : 'data';

        return App::call($this->callbacks[$event], [
            'websocket' => $this,
            $dataKey => $data,
        ]);
    }

    /**
     * Set sender fd.
     *
     * @param integer
     *
     * @return $this
     */
    public function setSender(int $fd)
    {
        $this->sender = $fd;

        return $this;
    }

    /**
     * Get current sender fd.
     */
    public function getSender()
    {
        return $this->sender;
    }

    /**
     * Get broadcast status value.
     */
    public function getIsBroadcast()
    {
        return $this->isBroadcast;
    }

    /**
     * Get push destinations (fd or room name).
     */
    public function getTo()
    {
        return $this->to;
    }

    /**
     * Get all fds we're going to push data to.
     */
    protected function getFds()
    {
        $fds = array_filter($this->to, function ($value) {
            return is_integer($value);
        });
        $rooms = array_diff($this->to, $fds);

        foreach ($rooms as $room) {
            $clients = $this->room->getClients($room);
            // fallback fd with wrong type back to fds array
            if (empty($clients) && is_numeric($room)) {
                $fds[] = $room;
            } else {
                $fds = array_merge($fds, $clients);
            }
        }

        return array_values(array_unique($fds));
    }

    /**
     * Reset some data status.
     *
     * @param bool $force
     *
     * @return $this
     */
    public function reset($force = false)
    {
        $this->isBroadcast = false;
        $this->to = [];

        if ($force) {
            $this->sender = null;
            $this->userId = null;
        }

        return $this;
    }
}

In the Websocket middle, we have introduced a user authentication implementation Authenticatable Trait and rooms instance $room, this property is the RoomContract implementation class instance of an interface, we will immediately binding on its introduction. Compared with the original implementation, the middleware function is removed here, purely to simplify business logic. If necessary, we will add it later.

Let's roughly introduce the function of each method:

  • to/setSender: Specify the object to send the message, such as a client, room;
  • join/in: Add the current user to a room;
  • leave: Leave a room;
  • emit: Message sending logic package, before writing WebsocketHandler the message code is ported to this, so that the code clearer functions, here only for sending messages, with no business logic processing;
  • on: Websocket register for event routing, registration for split to routes/websocket.php define, to facilitate the maintenance, but also to the code structure more clearly, specific business logic will be defined here, after the completion of the above-described call processing emit method for transmitting a message to the client;
  • eventExists: Determine whether the specified event route exists;
  • call: If there is an event routing, routing the call event corresponding business logic, so on, eventExists, call the three interlocking: on is responsible for registering, eventExits responsible for matching, call is responsible for implementation;
  • getFds: Get all the objects to send a message;
  • reset: Reset the state of some data to avoid reuse.

And like the rooms here we are app/Services/WebSocket/Facades directory facade to create a proxy class:

<?php

namespace App\Services\Websocket\Facades;

use Illuminate\Support\Facades\Facade;

/**
 * @method static $this broadcast()
 * @method static $this to($values)
 * @method static $this join($rooms)
 * @method static $this leave($rooms)
 * @method static boolean emit($event, $data)
 * @method static $this in($room)
 * @method static $this on($event, $callback)
 * @method static boolean eventExists($event)
 * @method static mixed call($event, $data)
 * @method static boolean close($fd)
 * @method static $this setSender($fd)
 * @method static int getSender()
 * @method static boolean getIsBroadcast()
 * @method static array getTo()
 * @method static $this reset()
 * @method static $this middleware($middleware)
 * @method static $this setContainer($container)
 * @method static $this setPipeline($pipeline)
 * @method static \Illuminate\Contracts\Pipeline\Pipeline getPipeline()
 * @method static mixed loginUsing($user)
 * @method static $this loginUsingId($userId)
 * @method static $this logout()
 * @method static $this toUser($users)
 * @method static $this toUserId($userIds)
 * @method static string getUserId()
 * @method static boolean isUserIdOnline($userId)
 *
 * @see \App\Services\WebSocket\WebSocket
 */
class Websocket extends Facade
{
    /**
     * Get the registered name of the component.
     *
     * @return string
     */
    protected static function getFacadeAccessor()
    {
        return 'swoole.websocket';
    }
}

At this point, our app/Services/WebSocket directory structure of the code is as follows:

This Websocket involves a number of container objects bound to resolve the core class routing and Websocket event registration, then we have to introduce the corresponding implementation code.

Register Websocket event routing

First, we routes create a new directory websocket.php file to store these events routes:

<?php

use Swoole\Http\Request;
use App\Services\WebSocket\WebSocket;
use App\Services\Websocket\Facades\Websocket as WebsocketProxy;

/*
|--------------------------------------------------------------------------
| Websocket Routes
|--------------------------------------------------------------------------
|
| Here is where you can register websocket events for your application.
|
*/

WebsocketProxy::on('connect', function (WebSocket $websocket, Request $request) {
    // Send welcome message
    $websocket->setSender($request->fd);
    $websocket->emit('connect', 'Welcome to chat room');

});

WebsocketProxy::on('disconnect', function (WebSocket $websocket) {
    // called while socket on disconnect
});

WebsocketProxy::on('login', function (WebSocket $websocket, $data) {
    if (!empty($data['token']) && ($user = \App\User::where('api_token', $data['token'])->first())) {
        $websocket->loginUsing($user);
        // todo Read unread messages
        $websocket->toUser($user)->emit('login', 'login successful');
    } else {
        $websocket->emit('login', 'Login to enter chat room');
    }
});

The reason is called event routing, because these routes are based on the event name passed by the client to match the call, here we initialize connect and login these two events are routed closures implemented here WebsocketProxy corresponds Websocket facade class, so static on method call eventually fall Websocket to on a method to perform, i.e., an event register corresponding service logic, implemented in a closure parameter, $websocket corresponding to the calltransmission method over $this the object, $data it is after Parser message data parsing implementation class. Thus, in a closure function, we can call Websocket any of the methods of the class, and finally by emit the method of sending a message to the client.

Container binding and route loading

Let's look at container bindings, including binding facade and proxy classes, binding interface and implementation class, as well as the above-mentioned event routing when loaded, is the most appropriate time in Swoole Worker Start time event triggered because of the laravels expansion pack, the The Laravel container is initialized before this:

public function onWorkerStart(HttpServer $server, $workerId)
{
    parent::onWorkerStart($server, $workerId);

    // To implement gracefully reload
    // Delay to create Laravel
    // Delay to include Laravel's autoload.php
    $this->laravel = $this->initLaravel($this->laravelConf, $this->swoole);

    // Fire WorkerStart event
    $this->fireEvent('WorkerStart', WorkerStartInterface::class, func_get_args());
}

Is also very simple to implement, monitor application Swoole in the WorkerStart event you can (note not Laravel events, so you can not use Laravel event listener mechanism to achieve), another advantage of the event to listen to bind and registered container load routing is beneficial improve application performance, and if we are onRequest, onOpen, OnMesssage when such events trigger the binding, then each request must be registered again, and based on Laravel PHP-FPM application model lacks distinction, and in addition, it can not be onWorkerStart before execution binding, because this time Laravel not initialized, there is no Application container this thing, where to register it?

Well, ado, we app/Events created the directory WorkerStartEvent.php file, then write binding container loading and routing code is as follows:

<?php
/**
 * Websocket related service container binding and route loading
 * Author: College Jun
 */
namespace App\Events;

use App\Services\WebSocket\Parser;
use App\Services\Websocket\Rooms\RoomContract;
use App\Services\WebSocket\WebSocket;
use Hhxsv5\LaravelS\Swoole\Events\WorkerStartInterface;
use Illuminate\Container\Container;
use Illuminate\Pipeline\Pipeline;
use Swoole\Http\Server;

class WorkerStartEvent implements WorkerStartInterface
{
    public function __construct()
    {
    }

    public function handle(Server $server, $workerId)
    {
        $isWebsocket = config('laravels.websocket.enable') == true;
        if (!$isWebsocket) {
            return;
        }
        // Laravel has been initialized when the WorkerStart event occurs. It is most suitable to do some initialization of component binding to the container here.
        app()->singleton(Parser::class, function () {
            $parserClass = config('laravels.websocket.parser');
            return new $parserClass;
        });
        app()->alias(Parser::class, 'swoole.parser');

        app()->singleton(RoomContract::class, function () {
            $driver = config('laravels.websocket.drivers.default', 'table');
            $driverClass = config('laravels.websocket.drivers.' . $driver);
            $driverConfig = config('laravels.websocket.drivers.settings.' . $driver);
            $roomInstance = new $driverClass($driverConfig);
            if ($roomInstance instanceof RoomContract) {
                $roomInstance->prepare();
            }
            return $roomInstance;
        });
        app()->alias(RoomContract::class, 'swoole.room');

        app()->singleton(WebSocket::class, function (Container $app) {
            return new WebSocket($app->make(RoomContract::class));
        });
        app()->alias(WebSocket::class, 'swoole.websocket');

        // Introducing Websocket routing files
        $routePath = base_path('routes/websocket.php');
        require $routePath;
    }
}

Note that I am here only judge in the case Websocket server startup, before doing the appropriate service Websocket bound container loading and routing, including Parser, RoomContract, WebSocket concrete realization of bindings and aliases settings (can be used to resolve the facade class), and finally Load the Websocket event routing file for the corresponding event routing to take effect.

Finally, do not forget config/laravels.php the event_handlers bound events and the corresponding processor to take effect:

'event_handlers' => [
    'WorkerStart' => \App\Events\WorkerStartEvent::class,
],
Improve Websocket configuration

In the binding implementation of the container above, also added a number of configuration items, we are in config/laravels.php the websocket supplemented these configurations:

'websocket' => [
   'enable' => true,
   'handler' => \App\Services\WebSocket\WebSocketHandler::class,
   'middleware' => [
       //\Illuminate\Auth\Middleware\Authenticate::class,
       //\App\Http\Middleware\VerifyCsrfToken::class,
   ],
   'parser' => \App\Services\WebSocket\SocketIO\SocketIOParser::class,
   'drivers' => [
       'default' => 'table',
       'table' => \App\Services\Websocket\Rooms\TableRoom::class,
       'redis' => \App\Services\Websocket\Rooms\RedisRoom::class,
       'settings' => [
           'table' => [
               'room_rows' => 4096,
               'room_size' => 2048,
               'client_rows' => 8192,
               'client_size' => 2048,
           ],
           'redis' => [
               'server' => [
                   'host' => env('REDIS_HOST', '127.0.0.1'),
                   'password' => env('REDIS_PASSWORD', null),
                   'port' => env('REDIS_PORT', 6379),
                   'database' => 0,
                   'persistent' => true,
               ],
               'options' => [
                   //
               ],
               'prefix' => 'swoole:',
           ],
       ],
   ],
],

At this point, after the initial completion of the business logic reconfiguration of the Websocket backend, we have the last step to connect them in the processor entry.

Refactoring the WebSocketHandler processor

That we have to make up the ring, reconfigurable App\Services\WebSocket\WebSocketHandler processor implementation class as follows:

<?php
namespace App\Services\WebSocket;

use App\Services\WebSocket\SocketIO\Packet;
use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface;
use Illuminate\Support\Facades\Log;
use Swoole\Http\Request;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;

class WebSocketHandler implements WebSocketHandlerInterface
{
    /**
     * @var WebSocket
     */
    protected $websocket;
    /**
     * @var Parser
     */
    protected $parser;

    public function __construct()
    {
        $this->websocket = app('swoole.websocket');
        $this->parser = app('swoole.parser');
    }

    // Triggered when the connection is established
    public function onOpen(Server $server, Request $request)
    {
        // If the connection is not established, establish the connection first
        if (!request()->input('sid')) {
            // Initial connection information socket.io-client
            $payload = json_encode([
                'sid' => base64_encode(uniqid()),
                'upgrades' => [],
                'pingInterval' => config('laravels.swoole.heartbeat_idle_time') * 1000,
                'pingTimeout' => config('laravels.swoole.heartbeat_check_interval') * 1000,
            ]);
            $initPayload = Packet::OPEN . $payload;
            $connectPayload = Packet::MESSAGE . Packet::CONNECT;
            $server->push($request->fd, $initPayload);
            $server->push($request->fd, $connectPayload);
        }
        Log::info('WebSocket Connection establishment:' . $request->fd);
        if ($this->websocket->eventExists('connect')) {
            $this->websocket->call('connect', $request);
        }
    }

    // Triggered when a message is received
    public function onMessage(Server $server, Frame $frame)
    {
        // $frame->fd Is the client id,$frame->data Is the data sent by the client
        Log::info("From {$frame->fd} Received data: {$frame->data}");
        if ($this->parser->execute($server, $frame)) {
            return;
        }
        $payload = $this->parser->decode($frame);
        ['event' => $event, 'data' => $data] = $payload;
        $this->websocket->reset(true)->setSender($frame->fd);
        if ($this->websocket->eventExists($event)) {
            $this->websocket->call($event, $data);
        } else {
            // Pocket processing, generally not performed here
            return;
        }
    }

    // Triggered when the connection is closed
    public function onClose(Server $server, $fd, $reactorId)
    {
        Log::info('WebSocket Connection closed:' . $fd);
        $this->websocket->setSender($fd);
        if ($this->websocket->eventExists('disconnect')) {
            $this->websocket->call('disconnect', 'Connection closed');
        }
    }
}

Before jumbled code has been split into the Websocket event routing class event calls and messaging implementation code, the entire processor implements become leaner, more elegant, and here, we will Parser parse out the data from the client implementation class event name and message content, and then calls Websocket the instance eventExists method of determining whether there is a corresponding event routing, if present, by a call method corresponding to the closure function call, the business logic processing, and finally sends a message to the corresponding client.

At this point, even if our Swoole Websocket server code is refactored, there may be details optimization later, but the main structure has been completed, and the scalability is very good, especially the processing of the event routing, making Swoole-based Laravel The application is truly integrated with Laravel's ideas, thanks to the design ideas provided by swooletw/laravel-swoole.

Websocket client and server user authentication

If it is based on Session authentication, you can totally follow swooletw/laravel-swoole own middleware and user authentication to achieve, but we end this project before and after the separation, I am here simply in the login event route by acquiring request data from the token field For certification:

WebsocketProxy::on('login', function (WebSocket $websocket, $data) {
    if (!empty($data['token']) && ($user = \App\User::where('api_token', $data['token'])->first())) {
        $websocket->loginUsing($user);
        // todo Read unread messages
        $websocket->toUser($user)->emit('login', 'login successful');
    } else {
        $websocket->emit('login', 'Login to enter chat room');
    }
});

The corresponding client code is as follows(resources/js/app.js):

const userId = store.state.userInfo.userid;
const token = store.state.userInfo.token;
if (userId) {
    socket.emit('login', {
        name: userId,
        token: token
    });
}

Restart the Swoole Websocket server for the refactored code to take effect:

bin/laravels reload

Then refresh the chat page, by F12->Network->WS you can see the new Websocket the communication data:

In the next tutorial, we will implement front-end and back-end room initialization and client code optimization.

The project code has been submitted to the Github repository: https://github.com/nonfu/webchat , you can get the latest code from here.

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P6)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P6)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P6): Establish a Connection Between the Socket.io Client and The Swoole Websocket server

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P6): Establish a Connection Between the Socket.io Client and The Swoole Websocket server

Server transformation plan

After completing the development environment, setting up the back-end Websocket server, and initializing the front-end resources, we then officially started debugging the front-end and back-end interfaces to complete the development of the online chat room function.

The first thing we need to do is to establish a Websocket connection and communication between the client and the server. Here, our Websocket client uses socket.io-client, and the server uses the WebSocket server provided by the Swoole-based LaravelS extension package . socket.io has its own set of connection establishment and data encoding mechanisms, so you must adjust the original Websocket server implementation, otherwise you cannot establish a WebSocket connection.

The LaravelS extension pack is not friendly to the Socket.io client, but another popular Laravel Swoole extension pack Laravel-Swoole has good support for it, and it can even be said that it is a PHP server adaptation of the socket.io client For details, you can refer to its official documentation , so it is natural that we can port this part of its implementation to LaravelS.

The code of this project has been submitted to the Github code repository: https://github.com/nonfu/webchat , you can download the code from here for comparison.

Writing a data parser Parser

The data format sent and received by the socket.io client has its own rules. We need to implement the corresponding data parser on the server according to this rule, in order to decode the received client data before processing the data, and then process it. After that, encode the data before sending it to the client so that the client can parse it correctly.

This part of the logic directly Laravel-Swoole expansion pack copy, first app/services create the directory WebSocket subdirectories for storing WebSocket server-related code, and then the previously created WebSocketHandler handler class to move, at the same time do not forget to change config/laravels.php the profile of WebSocketHandler the path:

'websocket' => [
    'enable' => true,
    'handler' => \App\Services\WebSocket\WebSocketHandler::class,
],

Next, start writing data parser, in app/Services/WebSocket creating an abstract base class directory Parser initialization code as follows:

<?php
/**
 * Data codec abstract base class
 */

namespace App\Services\WebSocket;

use Illuminate\Support\Facades\App;

abstract class Parser
{
    /**
     * Strategy classes need to implement handle method.
     */
    protected $strategies = [];

    /**
     * Execute strategies before decoding payload.
     * If return value is true will skip decoding.
     *
     * @param \Swoole\WebSocket\Server $server
     * @param \Swoole\WebSocket\Frame $frame
     *
     * @return boolean
     */
    public function execute($server, $frame)
    {
        $skip = false;

        foreach ($this->strategies as $strategy) {
            $result = App::call(
                $strategy . '@handle',
                [
                    'server' => $server,
                    'frame' => $frame,
                ]
            );
            if ($result === true) {
                $skip = true;
                break;
            }
        }

        return $skip;
    }

    /**
     * Encode output payload for websocket push.
     *
     * @param string $event
     * @param mixed $data
     *
     * @return mixed
     */
    abstract public function encode(string $event, $data);

    /**
     * Input message on websocket connected.
     * Define and return event name and payload data here.
     *
     * @param \Swoole\Websocket\Frame $frame
     *
     * @return array
     */
    abstract public function decode($frame);
}

Then WebSocket create a subdirectory under the directory SocketIO used to store the relevant code to interact with the client socket.io. The first is the data parser corresponding to the socket.io client SocketIOParser. This class inherits from Parser:

<?php
/**
 * Socket.io corresponding data codec
 */

namespace App\Services\WebSocket\SocketIO;

use App\Services\WebSocket\Parser;
use App\Services\WebSocket\SocketIO\Strategies\HeartbeatStrategy;

class SocketIOParser extends Parser
{
    /**
     * Strategy classes need to implement handle method.
     */
    protected $strategies = [
        HeartbeatStrategy::class,
    ];

    /**
     * Encode output payload for websocket push.
     *
     * @param string $event
     * @param mixed $data
     *
     * @return mixed
     */
    public function encode(string $event, $data)
    {
        $packet = Packet::MESSAGE . Packet::EVENT;
        $shouldEncode = is_array($data) || is_object($data);
        $data = $shouldEncode ? json_encode($data) : $data;
        $format = $shouldEncode ? '["%s",%s]' : '["%s","%s"]';

        return $packet . sprintf($format, $event, $data);
    }

    /**
     * Decode message from websocket client.
     * Define and return payload here.
     *
     * @param \Swoole\Websocket\Frame $frame
     *
     * @return array
     */
    public function decode($frame)
    {
        $payload = Packet::getPayload($frame->data);

        return [
            'event' => $payload['event'] ?? null,
            'data' => $payload['data'] ?? null,
        ];
    }
}

This is used inside the package Packet type communication data analysis processing:

<?php
/**
 * Socket.io communication data parsing underlying class
 */

namespace App\Services\WebSocket\SocketIO;

class Packet
{
    /**
     * Socket.io packet type `open`.
     */
    const OPEN = 0;

    /**
     * Socket.io packet type `close`.
     */
    const CLOSE = 1;

    /**
     * Socket.io packet type `ping`.
     */
    const PING = 2;

    /**
     * Socket.io packet type `pong`.
     */
    const PONG = 3;

    /**
     * Socket.io packet type `message`.
     */
    const MESSAGE = 4;

    /**
     * Socket.io packet type 'upgrade'
     */
    const UPGRADE = 5;

    /**
     * Socket.io packet type `noop`.
     */
    const NOOP = 6;

    /**
     * Engine.io packet type `connect`.
     */
    const CONNECT = 0;

    /**
     * Engine.io packet type `disconnect`.
     */
    const DISCONNECT = 1;

    /**
     * Engine.io packet type `event`.
     */
    const EVENT = 2;

    /**
     * Engine.io packet type `ack`.
     */
    const ACK = 3;

    /**
     * Engine.io packet type `error`.
     */
    const ERROR = 4;

    /**
     * Engine.io packet type 'binary event'
     */
    const BINARY_EVENT = 5;

    /**
     * Engine.io packet type `binary ack`. For acks with binary arguments.
     */
    const BINARY_ACK = 6;

    /**
     * Socket.io packet types.
     */
    public static $socketTypes = [
        0 => 'OPEN',
        1 => 'CLOSE',
        2 => 'PING',
        3 => 'PONG',
        4 => 'MESSAGE',
        5 => 'UPGRADE',
        6 => 'NOOP',
    ];

    /**
     * Engine.io packet types.
     */
    public static $engineTypes = [
        0 => 'CONNECT',
        1 => 'DISCONNECT',
        2 => 'EVENT',
        3 => 'ACK',
        4 => 'ERROR',
        5 => 'BINARY_EVENT',
        6 => 'BINARY_ACK',
    ];

    /**
     * Get socket packet type of a raw payload.
     *
     * @param string $packet
     *
     * @return int|null
     */
    public static function getSocketType(string $packet)
    {
        $type = $packet[0] ?? null;

        if (! array_key_exists($type, static::$socketTypes)) {
            return null;
        }

        return (int) $type;
    }

    /**
     * Get data packet from a raw payload.
     *
     * @param string $packet
     *
     * @return array|null
     */
    public static function getPayload(string $packet)
    {
        $packet = trim($packet);
        $start = strpos($packet, '[');

        if ($start === false || substr($packet, -1) !== ']') {
            return null;
        }

        $data = substr($packet, $start, strlen($packet) - $start);
        $data = json_decode($data, true);

        if (is_null($data)) {
            return null;
        }

        return [
            'event' => $data[0],
            'data' => $data[1] ?? null,
        ];
    }

    /**
     * Return if a socket packet belongs to specific type.
     *
     * @param $packet
     * @param string $typeName
     *
     * @return bool
     */
    public static function isSocketType($packet, string $typeName)
    {
        $type = array_search(strtoupper($typeName), static::$socketTypes);

        if ($type === false) {
            return false;
        }

        return static::getSocketType($packet) === $type;
    }
}

In addition SocketIOParser also introduced strategy for processing the heartbeat connection, so-called heartbeat connection refers to the connection length in order to maintain a connection for communication at predetermined time intervals, which typically are not required for communication processing can be ignored, and there is such a made.

Heartbeat connection policy classes are held in SocketIO/Strategies the directory:

<?php
/**
 * Heartbeat connection processing strategy class
 */

namespace App\Services\WebSocket\SocketIO\Strategies;

use App\Services\WebSocket\SocketIO\Packet;

class HeartbeatStrategy
{
    /**
     * If return value is true will skip decoding.
     *
     * @param \Swoole\WebSocket\Server $server
     * @param \Swoole\WebSocket\Frame $frame
     *
     * @return boolean
     */
    public function handle($server, $frame)
    {
        $packet = $frame->data;
        $packetLength = strlen($packet);
        $payload = '';

        if (Packet::getPayload($packet)) {
            return false;
        }

        if ($isPing = Packet::isSocketType($packet, 'ping')) {
            $payload .= Packet::PONG;
        }

        if ($isPing && $packetLength > 1) {
            $payload .= substr($packet, 1, $packetLength - 1);
        }

        if ($isPing) {
            $server->push($frame->fd, $payload);
        }

        return true;
    }
}

At this point, our communication data parser is all completed. Next, let's look at the encapsulated communication data sending class.

Writing data sending classes Pusher

The reconstructed WebSocketHandler class will bear only routing and controller functions, to business logic related services will be peeled off to complete a separate service unit, comprising sending data, because we need to be uniform encapsulation processing, so that the customer can be End resolution. In the app/Services/WebSocket creation directory Pusher class for data transmission:

<?php
/**
 * Communication data transmission class
 */

namespace App\Services\WebSocket;


class Pusher
{
    /**
     * @var \Swoole\Websocket\Server
     */
    protected $server;

    /**
     * @var int
     */
    protected $opcode;

    /**
     * @var int
     */
    protected $sender;

    /**
     * @var array
     */
    protected $descriptors;

    /**
     * @var bool
     */
    protected $broadcast;

    /**
     * @var bool
     */
    protected $assigned;

    /**
     * @var string
     */
    protected $event;

    /**
     * @var mixed|null
     */
    protected $message;

    /**
     * Push constructor.
     *
     * @param int $opcode
     * @param int $sender
     * @param array $descriptors
     * @param bool $broadcast
     * @param bool $assigned
     * @param string $event
     * @param mixed|null $message
     * @param \Swoole\Websocket\Server
     */
    protected function __construct(
        int $opcode,
        int $sender,
        array $descriptors,
        bool $broadcast,
        bool $assigned,
        string $event,
        $message = null,
        $server
    )
    {
        $this->opcode = $opcode;
        $this->sender = $sender;
        $this->descriptors = $descriptors;
        $this->broadcast = $broadcast;
        $this->assigned = $assigned;
        $this->event = $event;
        $this->message = $message;
        $this->server = $server;
    }

    /**
     * Static constructor
     *
     * @param array $data
     * @param \Swoole\Websocket\Server $server
     *
     * @return Pusher
     */
    public static function make(array $data, $server)
    {
        return new static(
            $data['opcode'] ?? 1,
            $data['sender'] ?? 0,
            $data['fds'] ?? [],
            $data['broadcast'] ?? false,
            $data['assigned'] ?? false,
            $data['event'] ?? null,
            $data['message'] ?? null,
            $server
        );
    }

    /**
     * @return int
     */
    public function getOpcode(): int
    {
        return $this->opcode;
    }

    /**
     * @return int
     */
    public function getSender(): int
    {
        return $this->sender;
    }

    /**
     * @return array
     */
    public function getDescriptors(): array
    {
        return $this->descriptors;
    }

    /**
     * @param int $descriptor
     *
     * @return self
     */
    public function addDescriptor($descriptor): self
    {
        return $this->addDescriptors([$descriptor]);
    }

    /**
     * @param array $descriptors
     *
     * @return self
     */
    public function addDescriptors(array $descriptors): self
    {
        $this->descriptors = array_values(
            array_unique(
                array_merge($this->descriptors, $descriptors)
            )
        );

        return $this;
    }

    /**
     * @param int $descriptor
     *
     * @return bool
     */
    public function hasDescriptor(int $descriptor): bool
    {
        return in_array($descriptor, $this->descriptors);
    }

    /**
     * @return bool
     */
    public function isBroadcast(): bool
    {
        return $this->broadcast;
    }

    /**
     * @return bool
     */
    public function isAssigned(): bool
    {
        return $this->assigned;
    }

    /**
     * @return string
     */
    public function getEvent(): string
    {
        return $this->event;
    }

    /**
     * @return mixed|null
     */
    public function getMessage()
    {
        return $this->message;
    }

    /**
     * @return \Swoole\Websocket\Server
     */
    public function getServer()
    {
        return $this->server;
    }

    /**
     * @return bool
     */
    public function shouldBroadcast(): bool
    {
        return $this->broadcast && empty($this->descriptors) && ! $this->assigned;
    }

    /**
     * Returns all descriptors that are websocket
     *
     * @param \Swoole\Connection\Iterator $descriptors
     *
     * @return array
     */
    protected function getWebsocketConnections(): array
    {
        return array_filter(iterator_to_array($this->server->connections), function ($fd) {
            return $this->server->isEstablished($fd);
        });
    }

    /**
     * @param int $fd
     *
     * @return bool
     */
    public function shouldPushToDescriptor(int $fd): bool
    {
        if (! $this->server->isEstablished($fd)) {
            return false;
        }

        return $this->broadcast ? $this->sender !== (int) $fd : true;
    }

    /**
     * Push message to related descriptors
     *
     * @param mixed $payload
     *
     * @return void
     */
    public function push($payload): void
    {
        // attach sender if not broadcast
        if (! $this->broadcast && $this->sender && ! $this->hasDescriptor($this->sender)) {
            $this->addDescriptor($this->sender);
        }

        // check if to broadcast to other clients
        if ($this->shouldBroadcast()) {
            $this->addDescriptors($this->getWebsocketConnections());
        }

        // push message to designated fds
        foreach ($this->descriptors as $descriptor) {
            if ($this->shouldPushToDescriptor($descriptor)) {
                $this->server->push($descriptor, $payload, $this->opcode);
            }
        }
    }
}

This class is mainly used for business logic processing sent to the client after data processing, including data parsing and unified encapsulation, whether to broadcast, etc.

Writing WebSocket service class

In addition to simple data receiving and sending, our online chat room has many other complex functions, so it is necessary to create a separate service class to implement these functions, such as room join and exit, user authentication and acquisition, data transmission and broadcasting, where the final will call Pusher the class to send data, it can be said that the service class is the core of the WebSocket back-end services, but this tutorial to simplify this section, just copy over an empty placeholder class, more Multifunctionality will be gradually added in subsequent tutorials:

<?php
namespace App\Services\WebSocket;

class WebSocket
{
    const PUSH_ACTION = 'push';
    const EVENT_CONNECT = 'connect';
    const USER_PREFIX = 'uid_';

    /**
     * Determine if to broadcast.
     *
     * @var boolean
     */
    protected $isBroadcast = false;

    /**
     * Scoket sender's fd.
     *
     * @var integer
     */
    protected $sender;

    /**
     * Recepient's fd or room name.
     *
     * @var array
     */
    protected $to = [];

    /**
     * Websocket event callbacks.
     *
     * @var array
     */
    protected $callbacks = [];
}

In this tutorial, in order to simplify the process, we WebSocketHandler directly call the Pusher class to send data to the client to quickly demonstrate establish WebSocket communication connection.

Rewrite WebSocketHandler processors

Finally, we re-implement the code according to the new structure of WebSocketHandler the processor onOpen and onMessage method:

<?php
/**
 * WebSocket service communication processor class
 * Author: College Jun
 */
 
namespace App\Services\WebSocket;

use App\Services\WebSocket\SocketIO\SocketIOParser;
use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface;
use Illuminate\Support\Facades\Log;
use Swoole\Http\Request;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;

class WebSocketHandler implements WebSocketHandlerInterface
{
    /**
     * @var WebSocket
     */
    protected $websocket;
    /**
     * @var Parser
     */
    protected $parser;

    public function __construct()
    {
        $this->websocket = app(WebSocket::class);
        $this->parser = app(SocketIOParser::class);

    }

    // Triggered when the connection is established
    public function onOpen(Server $server, Request $request)
    {
        if (!request()->input('sid')) {
            // Initialize the connection information to adapt socket.io-client, this code cannot be omitted, otherwise the connection cannot be established
            $payload = json_encode([
                'sid' => base64_encode(uniqid()),
                'upgrades' => [],
                'pingInterval' => config('laravels.swoole.heartbeat_idle_time') * 1000,
                'pingTimeout' => config('laravels.swoole.heartbeat_check_interval') * 1000,
            ]);
            $initPayload = Packet::OPEN . $payload;
            $connectPayload = Packet::MESSAGE . Packet::CONNECT;
            $server->push($request->fd, $initPayload);
            $server->push($request->fd, $connectPayload);
        }
    
        Log::info('WebSocket Connection establishment:' . $request->fd);
        $payload = [
            'sender'    => $request->fd,
            'fds'       => [$request->fd],
            'broadcast' => false,
            'assigned'  => false,
            'event'     => 'message',
            'message'   => 'Welcome to chat room',
        ];
        $pusher = Pusher::make($payload, $server);
        $pusher->push($this->parser->encode($pusher->getEvent(), $pusher->getMessage()));
    }

    // Triggered when a message is received
    public function onMessage(Server $server, Frame $frame)
    {
        // $frame->fd Is the client id,$frame->data Is the data sent by the client
        Log::info("From {$frame->fd} Received data: {$frame->data}");
        if ($this->parser->execute($server, $frame)) {
            // Skip heartbeat connection processing
            return;
        }
        $payload = $this->parser->decode($frame);
        ['event' => $event, 'data' => $data] = $payload;
        $payload = [
            'sender' => $frame->fd,
            'fds'    => [$frame->fd],
            'broadcast' => false,
            'assigned'  => false,
            'event'     => $event,
            'message'   => $data,
        ];
        $pusher = Pusher::make($payload, $server);
        $pusher->push($this->parser->encode($pusher->getEvent(), $pusher->getMessage()));
    }

    // Triggered when the connection is closed
    public function onClose(Server $server, $fd, $reactorId)
    {
        Log::info('WebSocket Connection closed:' . $fd);
    }
}

First initialized in the constructor $websocket and $parser attributes, then the connection establishment callback method onOpen , it is determined request data contains sid fields not included the need to send connection initiation information to the client in order to successfully establish WebSocket connection, here we also specify the heartbeat interval time and time-out, the appropriate, we also need to profile config/laravels.php the swoole new configuration the following two items:

'heartbeat_idle_time' => 600,
'heartbeat_check_interval' => 60,

Then call the Pusher class push method to send a welcome message encoded in order to be resolved socket.io end customers.

In the method of the callback message is received onMessage, the first call the Parser class execute method determines whether the heartbeat connection, if the heartbeat connection is skipped if not treated, otherwise decoding the received information, after a simple treatment, and then through Pusher the class push method is sent back to the client.

At this point, the Swoole WebSocket server connection establishment and simple communication logic adapted to the socket.io client have been initially implemented. Next, we need to restart the Swoole WebSocket server for the code to take effect:

bin/laravels restart
socket.io client code tuning

Finally, we revise down socket.io client connection code, open resources/js/socket.js modify the connection establishment code is as follows:

import io from 'socket.io-client';
const socket = io('http://webchats.test', {
    path: '/ws',
    transports: ['websocket']
});
export default socket;

Here we set the server address to be driven by the Swoole HTTP server http://webchats.test, then set the path /ws to connect to the Swoole WebSocket server, and finally set the transport layer protocol to websocket replace the default long polling(polling) mechanism.

In addition Vue front-end components and view files and JavaScript files main entrance app.js also made some minor adjustments, the corresponding code to the code repository https://github.com/nonfu/webchat latest submitted version shall prevail, as in app.js, we printed Log of whether the Socket connection is established:

socket.on('connect', async () => {
    console.log('websocket connected: ' + socket.connected);
    ...

Next, we run npm run dev recompiled resources, access the browser http://webchats.test, you can see through F12 WebSocket connection establishment and communication of the data.

In the console Consoletab you can also see the following logs:

websocket connected: true

Indicates that the connection was established successfully.

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P14)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P14)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P14): Send a Picture Message

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P14): Send a Picture Message

In the last tutorial we demonstrated the release of text/emotional messages in chat rooms. Today we will look at how to post picture messages.

Front-end interaction code

We started from the front end assembly, assembly in a chat room Chat.Vue, the client core logic to send pictures located fileup approach, we need to be adjusted to fit the rear end of the original code based interface Laravel + Swoole of:

fileup() {
    const that = this;
    const file1 = document.getElementById('inputFile').files[0];
    if (file1) {
      const formdata = new window.FormData();
      formdata.append('file', file1);
      formdata.append('api_token', this.auth_token);
      formdata.append('roomid', that.roomid);
      this.$store.dispatch('uploadImg', formdata);
      const fr = new window.FileReader();
      fr.onload = function () {
        const obj = {
          username: that.userid,
          src: that.src,
          img: fr.result,
          msg: '',
          roomid: that.roomid,
          time: new Date(),
          api_token: that.auth_token
        };
        socket.emit('message', obj);
      };
      fr.readAsDataURL(file1);
      this.$nextTick(() => {
        this.container.scrollTop = 10000;
      });
    } else {
      console.log('Must have file');
    }
},

When we click on the camera icon in the chat room, the image upload window will pop up:

After selecting the picture, it will call the above fileup method to upload pictures.

It involves two logic: first calls the back-end interface to upload pictures based on the HTTP protocol and save the message to the messages table, it will send a message to Websocket server after a successful upload, and then by Websocket server broadcasts a message to all online users.

The upload image corresponds to this line of code:

this.$store.dispatch('uploadImg', formdata);

The final call to the back-end interface code is located resources/js/api/server.js in:

// upload image
postUploadFile: data => Axios.post('/file/uploadimg', data, {
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
    }
}),

We will write this interface in the backend shortly.

Sending a picture message based on Websocket corresponds to this line of code:

socket.emit('message', obj);

This is no different than sending a text message before the code is simply obj there to add a imgfield only.

Image upload interface

Next, we write an image upload interface on the Laravel backend.

In the routes/api.php new route file/uploadimg:

Route::middleware('auth:api')->group(function () {
    ...
    Route::post('/file/uploadimg', '[email protected]');
}

Then create the controller with Artisan commands FileController:

php artisan make:controller FileController

In the newly generated file controller app/Http/Controllers/FileController.php in preparation uploadImage codes are as follows:

<?php
namespace App\Http\Controllers;

use App\Message;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class FileController extends Controller
{
    public function uploadImage(Request $request)
    {
        if (!$request->hasFile('file') || !$request->file('file')->isValid() || !$request->has('roomid')) {
            return response()->json([
                'data' => [
                    'errno' => 500,
                    'msg'   => 'Invalid parameter (room number/picture file is empty or invalid)'
                ]
            ]);
        }
        $image = $request->file('file');
        $time = time();
        $filename = md5($time . mt_rand(0, 10000)) . '.' . $image->extension();
        $path = $image->storeAs('images/' . date('Y/m/d', $time), $filename, ['disk' => 'public']);
        if ($path) {
            // If the picture is uploaded successfully, the corresponding picture message is saved to the messages table
            $message = new Message();
            $message->user_id = auth('api')->id();
            $message->room_id = $request->post('roomid');
            $message->msg = '';  // Text message left blank
            $message->img = Storage::disk('public')->url($path);
            $message->created_at = Carbon::now();
            $message->save();
            return response()->json([
                'data' => [
                    'errno' => 200,
                    'msg'   => 'Saved successfully'
                ]
            ]);
        } else {
            return response()->json([
                'data' => [
                    'errno' => 500,
                    'msg'   => 'File upload failed, please try again'
                ]
            ]);
        }
    }
}

This mainly involves image upload and message saving logic. Because we will save the picture to the storage/public next directory, in order to let the picture can be requested through the Web URL, you need to storage create a soft catalog:

php artisan storage:link
Websocket server broadcast

Finally, we in routes/websocket.php the messagechannel complementary picture message processing logic:

WebsocketProxy::on('message', function (WebSocket $websocket, $data) {
    ...
    // Get message content
    $msg = $data['msg'];
    $img = $data['img'];
    $roomId = intval($data['roomid']);
    $time = $data['time'];
    // Message content (including pictures) or room number cannot be empty
    if((empty($msg)  && empty($img))|| empty($roomId)) {
        return;
    }
    // Record log
    Log::info($user->name . 'in the room' . $roomId . 'Post message: ' . $msg);
    // Save messages to the database (except for picture messages, because they were saved during the upload)
    if (empty($img)) {
        $message = new Message();
        $message->user_id = $user->id;
        $message->room_id = $roomId;
        $message->msg = $msg;  // Text message
        $message->img = '';  // Picture message left blank
        $message->created_at = Carbon::now();
        $message->save();
    }
    // Broadcast messages to all users in the room
    $room = Count::$ROOMLIST[$roomId];
    $messageData = [
        'userid' => $user->email,
        'username' => $user->name,
        'src' => $user->avatar,
        'msg' => $msg,
        'img' => $img,
        'roomid' => $roomId,
        'time' => $time
    ];
    $websocket->to($room)->emit('message', $messageData);
    ...

Very simple, just add the picture message field uploaded by the client to the field of the previous broadcast message, without any other logic.

At this point, we can complete the front-end and back-end code for image message sending. Next, we test the sending of image messages on the chat room interface.

Test image message release

Before you start, recompile the front-end resources:

npm run dev

Make front-end code changes take effect. And restart Swoole HTTP and WebSocket server:

bin/laravels restart

Let the backend code changes take effect.

Then, open the chat room in Chrome and Firefox browsers, log in and enter the same room, you can send picture messages to each other in real time:

At this point, we have completed the main function of the chat room. Next, we will optimize the project code, especially the performance and elegance of the back-end WebSocket communication.

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P4)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P4)

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P4): Front-end Resource Initialization

Building a Live Online Chat Room Based on Laravel + Swoole + Vue (P4): Front-end Resource Initialization

As said at the beginning , the front-end interface of this practical tutorial will be based on the chat room project implemented by the front-end technology stack https://github.com/hua1995116/webchat in order to focus on the development of chat room functions based on Swoole for the convenience of introduction, it will be called Vue-Webchat.

However, Vue-Webchat's front and back ends are implemented based on JavaScript. The front end uses the Vue framework, the back end uses the Express framework, the database uses MongoDB , and the front and back ends of WebSocket communication are based on Socket.io. In the project, we want to use the Laravel framework as the backend to store data in the MySQL database, and the WebSocket server is implemented based on Swoole. In addition, we also want to use Laravel Mix provided by Laravel as a front-end resource compilation tool to replace the native Webpack, so the original front-end code must be appropriately modified.

Front-end code for this project has been submitted to the Github repository: https://github.com/nonfu/webchat.

Front-end directory structure migration

In the Vue-Webchat this project, located in the front-end source code src directory:

Let's delete project Laravel webchat of resources/js all files in a directory, then the Vue-Webchat of src directory migrated:

Here I make the adjustment on the part of the directory structure, such as the original project static/css/reset.css moved to resources/css/reset.css, the original project src/styles style under the code integrated into resources/sass/app.scss in order to better fit Laravel project directory structure. In addition, I will be the original project src directory App.vue and BaseTransition.vue files to the resources/js/layout directory, the original project src directory under the view subdirectory rename pages, this is just personal preference, of course, the corresponding need to modify all of the introduction of the file path.

Further, a front end JavaScript file entry main.js for renaming app.js.

Front view entry file

The front-end view entry file of the original project is located in the root directory, index.html and the view entry file of this project is located at resources/views/index.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <link href="{{ asset('css/app.css') }}" rel="stylesheet" type="text/css" />

    <link rel="icon" type="image/x-icon" href="/favicon.ico">

    <title>Laravel学院在线聊天室</title>

    <script type='text/javascript'>
        window.Laravel = <?php echo json_encode(['csrfToken' => csrf_token(),]); ?>
    </script>
</head>
<body>
    <div id="app"></div>
    <script type="text/javascript" src="{{ asset('js/app.js') }}"></script>
</body>
</html>

Accordingly, the need to routes/web.php route the file to adjust the default home page view template:

Route::get('/', function () {
    return view('index');
});
Front-end code adjustment

Next, let's focus on the modifications made to the front-end code to fit the Laravel back-end framework and the Swoole WebSocket server.

Interaction with back-end interface

Interaction with back-end interfaces are packaged in a unified resources/js/api directory, in axios.js the base package provides a library of Axios, and all back-end interface calls are located in server.js, where we need to axios.js be adjusted, adding CSRF Token request header:

import axios from 'axios';
const baseURL = '/api/';

const instance = axios.create();

instance.defaults.timeout = 30000; // 30s timeout on all interfaces
instance.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

// CSRF Token for all request headers
let token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
    instance.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
} else {
    console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
}

...

The routing and processing logic that specifically calls the back-end interface is ignored first, and then debug one by one when developing specific functions later.

Interaction with WebSocket server

Vue-Webchat project-based socket.io-client libraries to communicate as a client and WebSocket server, where we follow the client program, but the need to modify the connection configuration, back-end configuration changes to Swoole WebSocket server, modify the resources/js/socket.js code as follows:

// WebSocket request via Socket.io client
import io from 'socket.io-client'
const socket = io.connect(process.env.APP_URL + ':' + process.env.LARAVELS_LISTEN_PORT + '/ws/')
export default socket

And axios as specific call this socket module where the first matter, the development of specific functions to back one by one when debugging.

Adjustment of alias paths

Vue-Webchat item view upon introduction assembly and the module configuration @ path alias, such that:

import {queryString} from '@utils/queryString';

This project canceled this configuration and was introduced directly through relative paths:

import {queryString} from './utils/queryString';

All involve @ local path references should be adjusted, mainly in the pages Vue components under a subdirectory.

Front-end style compilation adjustments

As mentioned earlier, JavaScript files of this project is the entrance app.js, CSS resource file in my original project introduced here out of all, integrated into the webpack.mix.js lower compiled by Laravel Mix:

mix.js('resources/js/app.js', 'public/js')
    .sass('resources/sass/app.scss', 'public/css')
    .styles([
        'resources/css/reset.css',
        'public/css/app.css',
        'node_modules/muse-ui/dist/muse-ui.css'
    ], 'public/css/app.css');

Add front-end dependencies to package.json

Next, we add the front-end code involved in all new dependencies to the project under the root directory package.json in which:

"devDependencies": {
    "axios": "^0.18",
    "cross-env": "^5.1",
    "file-loader": "^4.2.0",
    "jquery": "^3.2",
    "laravel-mix": "^4.0.7",
    "lodash": "^4.17.5",
    "muse-ui": "^3.0.2",
    "popper.js": "^1.12",
    "resolve-url-loader": "^2.3.1",
    "sass": "^1.23.1",
    "sass-loader": "7.*",
    "socket.io-client": "^2.3.0",
    "stylus": "^0.54.7",
    "stylus-loader": "^3.0.2",
    "timers": "^0.1.1",
    "url-loader": "^2.2.0",
    "vivus": "^0.4.5",
    "vue": "^2.6.10",
    "vue-cropper": "^0.4.9",
    "vue-picture-preview": "^1.3.0",
    "vue-router": "^3.1.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "vuex": "^3.1.1",
    "xss-filters-es6": "^1.0.0"
}

And then run the npm install install all front-end dependent libraries.

Of course, all the above front-end code migrations and adjustments have been submitted to the Github repository, you can download the corresponding resources directly to the local here: https://github.com/nonfu/webchat.

Compile front-end resources

Finally, in the root directory of the project npm run dev to compile all of these resources through the front Laravel Mix:

After the compilation is successful, next, we will start debugging the interaction between the front-end interface and the back-end interface, which will be explained in subsequent tutorials.