用户棘轮存储连接并在服务器实例外部发送消息

Ratchet Store Connection of User & Send Message Outside of Server Instance

我一直在学习教程 here 并让棘轮服务器正常工作。

我的聊天 class 目前与教程或多或少相同,所以这里没有必要展示,因为我的问题更多是关于 实施策略 .

在我附加的问题中,用户正在寻找如何获取特定用户的连接对象。在最佳答案解决方案中,跟踪资源 ID 似乎是执行此操作的方法。

例如创建连接时有这段代码。

public function onOpen(ConnectionInterface $conn) {
        // Store the new connection to send messages to later
        $this->clients[$conn->resourceId] = $conn;
        echo "New connection! ({$conn->resourceId})\n";
    }

这会创建一个成员变量 clients 来存储所有连接,您现在只需通过 ID 引用它即可发送消息。此客户端 然而是 ConnectionInterface $conn

的一个实例

然后要发送消息,您只需使用下面的代码输入客户端 ID 作为数组键。很简单。

$client = $this->clients[{{insert client id here}}];
$client->send("Message successfully sent to user.");

正如我们所知,棘轮在服务器上作为脚本在一个永无止境的事件循环中运行。

我是 运行 一个 Symfony 项目,其中 在服务器实例之外 运行 当用户在system 我需要它向连接到服务器的特定客户端发送消息。

我不确定如何执行此操作,因为客户端 是 ConnectionInterface 的实例,并且是在用户首次通过 WebSockets 连接时创建的。如何以这种方式向特定客户端发送消息?

这是我想要实现的目标的视觉效果。

参考文献:

how to get the connection object of a specific user?

我即将 post 的解决方案涵盖了在 Web 浏览器上从服务器到客户端通信的整个过程,包括在后台创建 Websocket 服务器 运行 的方法(使用和没有 docker).

第 1 步:

假设你已经通过 composer 安装了 ratchet,在你的项目中创建一个名为 bin 的文件夹并将文件命名为 "startwebsocketserver.php"(或任何你想要的)

第 2 步:

将下面的代码复制进去。

<?php
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use React\Socket\Server;
use React\EventLoop\Factory;

use WebSocketApp\Websocketserver;
use WebSocketApp\Htmlserver;
use WebSocketApp\Clientevent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Ratchet\App;

require dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/bootstrap/bootstrap.php';

$websocketserver = new Websocketserver();

$dispatcher = new EventDispatcher(); //@JA - This is used to maintain communication between the websocket and HTTP Rest API Server
$dispatcher->addListener('websocketserver.updateclient', array($websocketserver, 'updateClient'));

//// 1. Create the event loop
$loop = Factory::create();

//// 2. Create websocket servers
$webSock = new Server($loop);
new IoServer(
    new HttpServer(
        new WsServer( $websocketserver )
    ),
    $webSock
);
$webSock->listen('8080', '0.0.0.0');

$app = new App( 'localhost', 6677, '0.0.0.0',$loop );
$app->route( '/', new Htmlserver(), [ '*' ] );//@JA - Allow any origins for last parameter

$app->run();

请注意,在我的示例中,我使用 bootstrap 文件来加载数据库。 如果您没有使用数据库或其他方法,请忽略它。 为了这个答案的目的,我将假设 Doctrine 2 作为数据库。

此代码的作用是在同一代码库中同时创建一个 HTTP 服务器和一个 WebSocket 服务器。我正在使用 $app->route 方法,因为您可以为 HTTP 服务器添加更多路由以组织 API 调用以从您的 PHP Web 服务器与 WebSocket 服务器通信。

$loop 变量包括应用程序循环中的 Websocket 服务器以及 HTTPServer。

第 3 步:

在您的项目目录中创建一个名为 websockets 的文件夹。在其中创建另一个名为 WebSocketApp 的文件夹。现在在其中创建 3 个空文件。

Clientevent.php Htmlserver.php Websocketserver.php

接下来我们将逐一逐一研究这些文件。 未能按此顺序创建这些目录将导致 composer Autoload PSR-0 无法找到它们。

您可以更改名称,但请确保相应地编辑您的作曲家文件。

第 4 步:

在你的 composer.json 文件中确保它看起来像这样。

{
    "require": {
        "doctrine/orm": "^2.5",
        "slim/slim": "^3.0",
        "slim/twig-view": "^2.1",
        "components/jquery": "*",
        "components/normalize.css": "*",
        "robloach/component-installer": "*",
        "paragonie/random_compat": "^2.0",
        "twilio/sdk": "^5.5",
        "aws/aws-sdk-php": "^3.22",
        "mailgun/mailgun-php": "^2.1",
        "php-http/curl-client": "^1.7",
        "guzzlehttp/psr7": "^1.3",
        "cboden/ratchet": "^0.3.6"
    },
    "autoload": {
        "psr-4": {
            "app\":"app",
            "Entity\":"entities"
        },
        "psr-0": {
            "WebSocketApp":"websockets"
        },
        "files": ["lib/utilities.php","lib/security.php"]
    }
}

在我的例子中,我使用的是 doctrine & slim,重要的部分是 "autoload" 部分。这部分特别重要。

"psr-0": {
            "WebSocketApp":"websockets"
        },

这将自动加载 WebSocketApp 命名空间中的文件夹 websockets 中的任何内容。 psr-0 假定代码将按名称空间的文件夹组织,这就是为什么我们必须在 websockets 中添加另一个名为 WebSocketApp 的文件夹。

第 5 步:

在 htmlserver.php 文件中放这个...

<?php
namespace WebSocketApp;
use Guzzle\Http\Message\RequestInterface;
use Guzzle\Http\Message\Response;
use Guzzle\Http\Message\Request;
use Ratchet\ConnectionInterface;
use Ratchet\Http\HttpServerInterface;

class Htmlserver implements HttpServerInterface {
    protected $response;

    public function onOpen( ConnectionInterface $conn, RequestInterface $request = null ) {
        global $dispatcher;

        $this->response = new Response( 200, [
            'Content-Type' => 'text/html; charset=utf-8',
        ] );

        $query = $request->getQuery();
        parse_str($query, $get_array);//@JA - Convert query to variables in an array

        $json = json_encode($get_array);//@JA - Encode to JSON

        //@JA - Send JSON for what you want to do and the token representing the user & therefore connected user as well.
        $event = new ClientEvent($json);
        $dispatcher->dispatch("websocketserver.updateclient",$event);

        $this->response->setBody('{"message":"Successfully sent message to websocket server")');
        echo "HTTP Connection Triggered\n";
        $this->close( $conn );
    }

    public function onClose( ConnectionInterface $conn ) {
        echo "HTTP Connection Ended\n";
    }

    public function onError( ConnectionInterface $conn, \Exception $e ) {
        echo "HTTP Connection Error\n";
    }

    public function onMessage( ConnectionInterface $from, $msg ) {
        echo "HTTP Connection Message\n";
    }

    protected function close( ConnectionInterface $conn ) {
        $conn->send( $this->response );
        $conn->close();
    }
}

此文件的目的是通过基本 HTTP 简化与 WebSocket 服务器的通信,稍后我将演示如何从 PHP Web 服务器使用 cURL。我设计它是为了使用 Symfony 的事件系统将消息传播到 WebSocket 服务器,并通过查看查询字符串并将其转换为 JSON 字符串。如果您愿意,它也可以保存为一个数组,但在我的例子中,我需要 JSON 字符串。

第 6 步:

接下来在clientevent.php放这段代码...

<?php
namespace WebSocketApp;

use Symfony\Component\EventDispatcher\Event;

use Entity\User;
use Entity\Socket;

class Clientevent extends Event
{
    const NAME = 'clientevent';

    protected $user; //@JA - This returns type Entity\User

    public function __construct($json)
    {
        global $entityManager;

        $decoded = json_decode($json,true);
        switch($decoded["command"]){
            case "updatestatus":
                //Find out what the current 'active' & 'busy' states are for the userid given (assuming user id exists?)
                if(isset($decoded["userid"])){
                    $results = $entityManager->getRepository('Entity\User')->findBy(array('id' => $decoded["userid"]));
                    if(count($results)>0){
                        unset($this->user);//@JA - Clear the old reference
                        $this->user = $results[0]; //@JA - Store refernece to the user object
                        $entityManager->refresh($this->user); //@JA - Because result cache is used by default, this will make sure the data is new and therefore the socket objects with it
                    }
                }
                break;
        }
    }

    public function getUser()
    {
        return $this->user;
    }
}

请注意,User 和 Socket 实体是我根据 Doctrine 2 创建的实体。您可以使用您喜欢的任何数据库。在我的例子中,我需要根据来自数据库的登录令牌从 PHP Web 服务器向特定用户发送消息。

Clientevent 假定 '{"command":"updatestatus","userid":"2"}'

的 JSON 字符串

不过您可以根据自己的喜好进行设置。

第 7 步:

在 Websocketserver.php 文件中放这个...

<?php
namespace WebSocketApp;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Symfony\Component\EventDispatcher\Event;

use Entity\User;
use Entity\Authtoken;
use Entity\Socket;

class Websocketserver implements MessageComponentInterface {
    protected $clients;

    public function updateClient(Event $event)
    {
       $user = $event->getUser();//@JA - Get reference to the user the event is for.

       echo "userid=".$user->getId()."\n";
       echo "busy=".($user->getBusy()==false ? "0" : "1")."\n";
       echo "active=".($user->getActive()==false ? "0" : "1")."\n";

       $json["busy"]    = ($user->getBusy()==false ? "0" : "1");
       $json["active"]  = ($user->getActive()==false ? "0" : "1");

       $msg = json_encode($json);

       foreach($user->getSockets() as $socket){
            $connectionid = $socket->getConnectionid();
            echo "Sending For ConnectionID:".$connectionid."\n";
            if(isset($this->clients[$connectionid])){
                $client = $this->clients[$connectionid];
                $client->send($msg);
            }else{
                echo "Client is no longer connected for this Connection ID:".$connectionid."\n";
            }
       }
    }

    public function __construct() {
        $this->clients = array();
    }

    public function onOpen(ConnectionInterface $conn) {
        // Store the new connection to send messages to later
        $this->clients[$conn->resourceId] = $conn;
        echo "New connection! ({$conn->resourceId})\n";
    }

    public function onMessage(ConnectionInterface $from, $msg) {
        global $entityManager;

        echo sprintf('Connection %d sending message "%s"' . "\n", $from->resourceId, $msg);

        //@JA - First step is to decode the message coming from the client.  Use token to identify the user (from cookie or local storage)
        //@JA - Format is JSON {token:58d8beeb0ada3:4ffbd272a1703a59ad82cddc2f592685135b09f2,message:register}
        $json = json_decode($msg,true);
        //echo 'json='.print_r($json,true)."\n";
        if($json["message"] == "register"){
            echo "Registering with server...\n";

            $parts = explode(":",$json["token"]);

            $selector = $parts[0];
            $validator = $parts[1];

            //@JA - Look up records in the database by selector.
            $tokens = $entityManager->getRepository('Entity\Authtoken')->findBy(array('selector' => $selector, 'token' => hash('sha256',$validator)));

            if(count($tokens)>0){
                $user = $tokens[0]->getUser();
                echo "User ID:".$user->getId()." Registered from given token\n";
                $socket = new Socket();
                $socket->setUser($user);
                $socket->setConnectionid($from->resourceId);
                $socket->setDatecreated(new \Datetime());

                $entityManager->persist($socket);
                $entityManager->flush();
            }else{
                echo "No user found from the given cookie token\n";
            }

        }else{
            echo "Unknown Message...\n";
        }     
    }

    public function onClose(ConnectionInterface $conn) {
        global $entityManager;

        // The connection is closed, remove it, as we can no longer send it messages
        unset($this->clients[$conn->resourceId]);

        //@JA - We need to clean up the database of any loose ends as well so it doesn't get full with loose data
        $socketResults = $entityManager->getRepository('Entity\Socket')->findBy(array('connectionid' => $conn->resourceId));
        if(count($socketResults)>0){
            $socket = $socketResults[0];
            $entityManager->remove($socket);
            $entityManager->flush();
            echo "Socket Entity For Connection ID:".$conn->resourceId." Removed\n";
        }else{
            echo "Was no socket info to remove from database??\n";
        }

        echo "Connection {$conn->resourceId} has disconnected\n";
    }

    public function onError(ConnectionInterface $conn, \Exception $e) {
        echo "An error has occurred: {$e->getMessage()}\n";

        $conn->close();
    }
}

这是解释起来最复杂的文件。首先有一个受保护的变量 clients,它存储与这个棘轮 websocket 服务器建立的每个连接。它是在 onOpen 事件中创建的。

接下来的 onMessage 事件是 Web 浏览器客户端将自己注册以接收消息的地方。我使用 JSON 协议做到了这一点。一个例子是我特别使用的格式代码,其中我使用他们 cookie 中的令牌来识别它在我的系统中的用户以及一个简单的注册消息。

我在这个函数中简单地查看了数据库,看看是否有 authToken 与 cookie 一起使用。

如果在你的数据库中有写入 Socket table 的 $from->resourceId

这是 ratchet 用来跟踪特定连接号的号码。

接下来在 onClose 方法中注意,我们必须确保在连接关闭时删除我们创建的条目,这样数据库就不会被不必要的和额外的数据填满。

最后请注意,updateClient 函数是一个 symfony 事件,由我们之前做的 HtmlServer 触发。

这是将消息实际发送到客户端 Web 浏览器的内容。首先,如果用户打开了许多 Web 浏览器以创建不同的连接,我们将遍历与该用户相关的所有已知套接字。 Doctrine 使用 $user->getSockets() 使这很容易,你必须决定最好的方法来做到这一点。

然后您只需说 $client->send($msg) 即可将消息发送到网络浏览器。

第 8 步:

最后在您的 javascript 浏览器中输入类似这样的内容。

var hostname = window.location.hostname; //@JA - Doing it this way will make this work on DEV and LIVE Enviroments
    var conn = new WebSocket('ws://'+hostname+':8080');
    conn.onopen = function(e) {
        console.log("Connection established!");
        //@JA - Register with the server so it associates the connection ID to the supplied token
        conn.send('{"token":"'+$.cookie("ccdraftandpermit")+'","message":"register"}');
    };

    conn.onmessage = function(e) {
        //@JA - Update in realtime the busy and active status
        console.log(e.data)
        var obj = jQuery.parseJSON(e.data);
        if(obj.busy == "0"){
            $('.status').attr("status","free");
            $('.status').html("Free");
            $(".unbusy").css("display","none");
        }else{
            $('.status').attr("status","busy");
            $('.status').html("Busy");
            $(".unbusy").css("display","inline");
        }
        if(obj.active == "0"){
            $('.startbtn').attr("status","off");
            $('.startbtn').html("Start Taking Calls");
        }else{
            $('.startbtn').attr("status","on");
            $('.startbtn').html("Stop Taking Calls");
        }
    };

我的演示展示了使用 JSON 来回传递信息的简单方法。

第 9 步:

为了从 PHP Web 服务器发送消息,我在辅助函数中做了类似的事情。

function h_sendWebsocketNotificationToUser($userid){
    //Send notification out to Websocket Server
    $ch = curl_init(); 
    curl_setopt($ch, CURLOPT_URL, "http://localhost/?command=updatestatus&userid=".$userid); 
    curl_setopt($ch, CURLOPT_PORT, 6677);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 
    $output = curl_exec($ch); 
    curl_close($ch); 
}

这将尝试随时为特定用户发送 updateStatus 消息。

第 10 步:

没有第10步你完成了!好吧,不完全是...对于 运行 我在后台使用 Docker 的网络服务器,这很容易。只需使用以下命令执行网络服务器。

docker exec -itd draftandpermit_web_1 bash -c "cd /var/www/callcenter/livesite; php bin/startwebsocketserver.php"

或与您的情况相当的东西。这里的关键是我在后台使用的 -d 选项 运行s。即使您再次 运行 命令,它也不会产生两个实例,这很漂亮。关闭服务器不在本文讨论范围之内,但如果您找到一个好的方法,请修改或评论此答案。

另外不要忘记在 docker-compose 文件上正确打开端口。我为我的项目做了类似的事情。

ports: 
            - "80:80"
            - "8080:8080"
            - "6060:80"
            - "443:443"
            - "6677:6677"
            #This is used below to test on local machines, just portforward this on your router.
            - "8082:80"

记住 WebSockets 使用 8080,所以它必须完全通过。

如果你对实体和数据库结构感到好奇,我在这里使用的是附图。