六、构建聊天应用

在本章中,我们将使用WebSocket构建一个实时聊天应用程序。您将学习如何使用Ratchet框架使用 PHP 构建独立的 WebSocket 和 HTTP 服务器,以及如何连接到 JavaScript 客户端应用程序中的 WebSocket 服务器。我们还将讨论如何为 WebSocket 应用程序实现身份验证,以及如何在生产环境中部署它们。

WebSocket 协议

在本章中,我们将广泛使用 WebSocket。为了充分理解我们将要构建的聊天应用程序的工作原理,让我们首先看看 WebSocket 是如何工作的。

WebSockets 协议在RFC 6455中指定,并使用 HTTP 作为底层传输协议。与传统的请求/回复范例不同,在传统的请求/回复范例中,客户端向服务器发送一个请求,然后服务器回复一条响应消息,WebSocket 连接可以保持长时间打开,服务器和客户端都可以在 WebSocket 上发送和接收消息(或数据帧

WebSocket 连接始终由客户端启动(因此,通常是用户的浏览器)。以下列表显示了浏览器可能向支持 WebSocket 的服务器发送的请求示例:

GET /chat HTTP/1.1 
Host: localhost 
Upgrade: websocketConnection: upgrade 
Origin: http://localhost 
Sec-WebSocket-Key: de7PkO6qMKuGvUA3OQNYiw== 
Sec-WebSocket-Protocol: chat 
Sec-WebSocket-Version: 13

与常规 HTTP 请求一样,请求包含一个请求方法(GET和一个路径(/chatUpgradeConnection头告诉服务器,客户端希望常规 HTTP 连接升级为 WebSocket 连接。

Sec-WebSocket-Key标头包含一个随机的 base64 编码字符串,该字符串唯一地标识这个 WebSocket 连接。Sec-WebSocket-Protocol标头可用于指定客户端希望使用的子脚本。子策略可用于进一步定义服务器和客户端之间的通信应该是什么样子,并且通常是特定于应用程序的(在我们的例子中,是chat协议)。

当服务器接受升级请求时,会做出101 Switching Protocols响应,如下表所示:

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket 
Connection: Upgrade 
Sec-WebSocket-Accept: BKb5cchTfWayrC7SKtvK5yW413s= 
Sec-WebSocket-Protocol: chat 

Sec-WebSocket-Accept头包含来自请求的Sec-WebSocket-Key的散列(确切的散列在 RFC 6455 中指定)。响应中的Sec-WebSocket-Protocol头确认服务器理解客户端在其请求中指定的协议。

此握手完成后,连接将保持打开状态,服务器和客户端都可以从套接字发送和接收消息。

带棘轮的第一步

在本节中,您将学习如何安装和使用 Ratchet 框架。需要注意的是,Ratchet 应用程序的工作方式不同于部署在 web 服务器中的常规 PHP 应用程序,它们是按请求工作的。这将要求您采用一种新的方式来思考如何运行和部署 PHP 应用程序。

建筑考虑

用 PHP 实现 WebSocket 服务器并非易事。传统上,PHP 的体系结构围绕着经典的请求/应答范式展开:web 服务器接收请求,并将其传递给 PHP 解释器(该解释器通常内置在 web 服务器中,或由 PHP-FPM 等流程管理器管理),它处理请求并将响应返回给 web 服务器,web 服务器反过来响应客户机。PHP 脚本中的数据生命周期仅限于一个请求(这一原则称为无共享

这适用于经典的 web 应用程序;尤其是不共享原则,因为这是 PHP 应用程序通常具有良好伸缩性的原因之一。然而,对于 WebSocket 支持,我们需要一个不同的范例。客户端连接需要保持打开状态很长一段时间(几个小时,可能是几天),服务器需要在连接生命周期内的任何时候对客户端消息做出反应。

实现这个新范例的一个库是Ratchet库,我们将在本章中使用它。与 web 服务器中的常规 PHP 运行时不同,Ratchet 将启动自己的 web 服务器,为长时间运行的 WebSocket 连接提供服务。由于要处理运行时间极长的 PHP 进程(服务器进程可能运行数天、数周或数月),因此需要特别注意内存消耗等问题。

开始

使用组合器可以轻松安装棘轮。它至少需要 5.3.9 版的 PHP,并且与 PHP7 配合使用也很好。首先,在项目目录的命令行上使用composer init命令初始化一个新项目:

$ composer init .

接下来,将 Ratchet 作为依赖项添加到项目中:

$ composer require cboden/ratchet

另外,通过向生成的composer.json文件中添加以下部分来配置 Composer 的自动加载程序:

'autoload': { 
  'PSR-4': { 
    'Packt\Chp6\Example': 'src/' 
  } 
} 

通常,PSR-4 自动加载意味着 Composer 类装入器将在项目目录的src/文件夹中查找Packt\Chp6\Example命名空间的类。需要在文件src/Foo/Bar.php中定义一个(假设的)Packt\Chp6\Example\Foo\Bar类。

由于 Ratchet 实现了自己的 web 服务器,因此您不需要像ApacheNginx(目前)这样的专用 web 服务器。首先创建一个名为server.php的文件,在该文件中初始化并运行 Ratchet web 服务器:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
$app->run() 

然后,您可以使用以下命令启动 web 服务器(它将侦听您指定为Ratchet\App构造函数第二个参数的端口):

$ php server.php

如果您的机器上没有准备好 PHP 7 安装,您可以使用以下命令快速启动Docker

$ docker run --rm -v $PWD:/opt/app -p 8080:8080 php:7 php /opt/app/server.php

这两个命令都将启动一个长期运行的 PHP 进程,该进程可以在命令行上直接处理 HTTP 请求。在后面的部分中,您将学习如何将应用程序部署到生产服务器。当然,这台服务器实际上做的并不多。但是,您仍然可以使用 CLI 命令或浏览器对其进行测试,如以下屏幕截图所示:

Getting started

使用 HTTPie 测试示例应用程序

让我们继续向服务器添加一些业务逻辑。Ratchet 提供的 WebSocket 应用程序需要是实现Ratchet\MessageComponentInterface的 PHP 类。此接口定义了以下四种方法:

  • 每当新客户端连接到 WebSocket 服务器时,都会调用onOpen(\Ratchet\ConnectionInterface $c)
  • 当客户端与服务器断开连接时,将调用onClose(\Ratchet\ConnectionInterface $c)

  • 当客户端向服务器发送消息时,将调用onMessage(\Ratchet\ConnectionInterface $sender, $msg)

  • 处理消息时,当某个点发生异常时,将调用onError(\Ratchet\ConnectionInterface $c, \Exception $e)

让我们从一个简单的例子开始:一个客户端可以向其发送消息的 WebSocket 服务,它将用相同的消息响应同一个客户端,但反向响应。我们把这个班叫做Packt\Chp6\Example\ReverseEchoComponent;代码如下:

namespace Packt\Chp6\Example; 

use Ratchet\ConnectionInterface; 
use Ratchet\MessageComponentInterface; 

class ReverseEchoComponent implements MessageComponentInterface 
{ 
    public function onOpen(ConnectionInterface $conn) 
    {} 

    public function onClose(ConnectionInterface $conn) 
    {} 

    public function onMessage(ConnectionInterface $sender, $msg) 
    {} 

    public function onError(ConnectionInterface $conn, 
                            Exception $e) 
    {} 
} 

请注意,虽然我们不需要MessageComponentInterface指定的所有方法,但我们仍然需要实现所有这些方法以满足接口。例如,如果您不需要在客户端连接或断开连接时发生任何特殊情况,请实现onOpenonClose方法,但只需将它们保留为空即可。

为了更好地了解此应用程序中发生的情况,请向onOpenonClose方法添加一些简单的调试消息,如下所示:

public function onOpen(ConnectionInterface $conn) 
{ 
    echo "new connection from " . $conn->remoteAddress . "\n"; 
} 

public function onClose(ConnectionInterface $conn) 
{ 
    echo "connection closed by " . $conn->remoteAddress . "\n"; 
} 

接下来,实现onMessage方法。$msg参数将包含客户端以字符串形式发送的消息,您可以使用ConnectionInterface类“send()方法将消息发送回客户端,如下代码段所示:

public function onMessage(ConnectionInterface $sender, $msg) 
{ 
    echo "received message '$msg' from {$conn->remoteAddress}\n"; 
    $response = strrev($msg); 
    $sender->send($response); 
} 

提示

您可能倾向于使用 PHP7 的新类型提示功能将$msg参数提示为string。在这种情况下,这不起作用,因为它会更改Ratchet\MessageComponentInterface规定的方法接口,并导致致命错误。

然后,您可以使用以下代码在server.php文件的Ratchet\App实例中注册 WebSocket 应用程序:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
$app->route('/reverse', new Packt\Chp6\Example\ReverseEchoComponent); 
$app->run(); 

测试 WebSocket 应用程序

要测试 WebSocket 应用程序,我可以推荐wscat工具。它是一个用 JavaScript 编写的命令行工具(因此需要 Node.js 在您的机器上运行),可以使用npm安装,如下所示:

$ npm install -g wscat

当 WebSocket 服务器在端口8080侦听时,您可以使用wscat使用以下 CLI 命令打开新的 WebSocket 连接:

$ wscat -o localhost --connect localhost:8080/reverse

这将打开一个命令行提示符,您可以在其中输入发送到 WebSocket 服务器的消息。还将显示从服务器接收的消息。有关 WebSocket 服务器和 wscat 的示例输出,请参见以下屏幕截图:

Testing WebSocket applications

使用 wscat 测试 WebSocket 应用程序

玩事件循环

在前面的示例中,您仅在收到来自同一客户机的消息后才向客户机发送消息。这是传统的请求/应答通信模式,在大多数情况下都能很好地工作。但是,重要的是要了解,在使用 WebSocket 时,您不必遵循这种模式,而是可以随时向连接的客户端发送消息。

为了更好地理解 Ratchet 应用程序中的各种可能性,让我们看看 Ratchet 的体系结构。Ratchet 建立在 PHP 之上;网络应用程序的事件驱动框架。React 应用程序的中心组件是事件循环。应用程序中触发的每个事件(例如,当新用户连接或向服务器发送消息时)都存储在队列中,事件循环处理存储在此队列中的所有事件。

ReactPHP 提供了不同的事件循环实现。其中一些需要安装额外的 PHP 扩展,如libeventev(通常,基于libeventev或类似扩展的事件循环提供最佳性能)。通常,Ratchet 之类的应用程序会自动选择要使用的事件循环实现,这样,如果您不想,就不必关心 PHP 的内部工作。

默认情况下,Ratchet 应用程序创建自己的事件循环;但是,您也可以将自己的事件循环注入您自己创建的Ratchet\App类中。

所有 ReactPHP 事件循环必须实现接口React\EventLoop\LoopInterface。您可以使用React\EventLoop\Factory类自动创建您的环境支持的此接口的实现:

$loop = \React\EventLoop\Factory::create(); 

然后,您可以将此$loop变量传递到 Ratchet 应用程序:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0', $loop) 
$app->run(); 

直接访问事件循环允许您实现一些有趣的特性。例如,您可以使用事件循环的addPeriodicTimer函数注册一个回调,该回调将由事件循环在一个周期间隔内执行。让我们在一个简短的示例中使用此功能,构建一个名为Packt\Chp6\Example\PingComponent的新 WebSocket 组件:

namespace Packt\Chp6\Example; 

use Ratchet\MessageComponentInterface; 
use React\EventLoop\LoopInterface; 

class PingCompoment extends MessageComponentInterface 
{ 
    private $loop; 
    private $users; 

    public function __construct(LoopInterface $loop) 
    { 
        $this->loop  = $loop; 
        $this->users = new \SplObjectStorage(); 
    } 

    // ... 
} 

在本例中,$users属性将帮助我们跟踪已连接的用户。每次新客户端连接时,我们可以使用onOpen事件将连接存储在$users属性中,并使用onClose事件删除连接:

public function onOpen(ConnectionInterface $conn) 
{ 
 $this->users->attach($conn); 
} 

public function onClose(ConnectionInterface $conn) 
{ 
 $this->users->detach($conn); 
} 

由于我们的 WebSocket 组件现在知道连接的用户,我们可以使用事件循环来注册一个计时器,该计时器定期向所有连接的用户广播消息。这可以在构造函数中轻松完成:

public function __construct(LoopInterface $loop) 
{ 
    $this->loop  = $loop; 
    $this->users = new \SplObjectStorage(); 

 $i = 0; 
 $this->loop->addPeriodicTimer(5, function() use (&$i) { 
 foreach ($this->users as $user) { 
 $user->send('Ping ' . $i); 
 } 
 $i ++; 
 }); 
} 

传递给addPeriodicTimer的函数将每五秒钟调用一次,并向每个连接的用户发送一条带有递增计数器的消息。修改您的server.php文件,将此新组件添加到 Ratchet 应用程序中:

$loop = \React\EventLoop\Factory::create(); 
$app = new \Ratchet\App('localhost', 8080, '0.0.0.0', $loop) 
$app->route('/ping', new PingCompoment($loop)); 
$app->run(); 

您可以使用 wscat 再次测试此 WebSocket 处理程序,如以下屏幕截图所示:

Playing with the event loop

由周期事件循环计时器引发的周期消息

这是一个很好的场景示例,其中 WebSocket 客户端从服务器接收更新而不显式请求更新。这提供了近实时地将新数据推送到连接的客户端的有效方法,而无需重复轮询信息。

实现聊天应用

在对 WebSocket 的开发进行了简短的介绍之后,现在让我们开始实现实际的聊天应用程序。聊天应用程序将包括用带有 Ratchet 的 PHP 构建的服务器端应用程序,以及在用户浏览器中运行的基于 HTML 和 JavaScript 的客户端。

引导项目服务器端

如前一节所述,基于 ReactPHP 的应用程序在与事件循环扩展(如libeventev)一起使用时将获得最佳性能。不幸的是,libevent扩展与 PHP7 还不兼容。幸运的是,ReactPHP 还可以使用ev扩展,其最新版本已经支持 PHP7。就像上一章一样,我们将与 Docker 合作,以获得一个干净的软件堆栈。首先为您的应用程序容器创建一个Dockerfile

FROM php:7 
RUN pecl install ev-beta && \ 
    docker-php-ext-enable ev 
WORKDIR /opt/app 
CMD ["/usr/local/bin/php", "server.php"] 

然后,您将能够从此文件构建映像,并使用以下 CLI 命令从项目目录中启动容器:

$ docker build -t packt-chp6
$ docker run -d --name chat-app -v $PWD:/opt/app -p 8080:8080 
      packt-chp6

请注意,只要您的项目目录中没有server.php文件,此命令实际上就不会工作。

与前面的示例一样,我们也将使用 Composer 进行依赖关系管理和自动加载。为您的项目创建一个新文件夹,并创建一个包含以下内容的composer.json文件:

{ 
    "name": "packt-php7/chp6-chat", 
    "type": "project", 
    "authors": [{ 
        "name": "Martin Helmich", 
        "email": "php7-book@martin-helmich.de" 
    }], 
    "require": { 
        "php": ">= 7.0.0", 
        "cboden/ratchet": "^0.3.4" 
    }, 
    "autoload": { 
        "psr-4": { 
            "Packt\\Chp6": "src/" 
        } 
    } 
} 

通过在项目目录中运行composer install继续安装所有必需的软件包,并创建一个包含以下内容的临时server.php文件:

<?php 
require_once 'vendor/autoload.php'; 

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
$app->run(); 

您已经在介绍性示例中使用了Ratchet\App构造函数。关于此类构造函数参数的几句话:

  • 第一个参数$httpHost是应用程序可用的 HTTP 主机名。此值将用作允许的源主机。这意味着,当您的服务器正在监听localhost时,只有localhost域上运行的 JavaScript 才能连接到您的 WebSocket 服务器。
  • $port参数指定 WebSocket 服务器将在哪个端口侦听。端口8080现在就足够了;在后面的部分中,您将了解如何安全地将应用程序配置为在 HTTP 标准端口80上可用。
  • $address参数描述 WebSocket 服务器将侦听的 IP 地址。此参数的默认值为'127.0.0.1',这将允许在同一台机器上运行的客户端连接到您的 WebSocket 服务器。当您在 Docker 容器中运行应用程序时,这将不起作用。字符串'0.0.0.0'将指示应用程序侦听所有可用的 IP 地址。
  • 第四个参数$loop允许您将自定义事件循环注入 Ratchet 应用程序。如果不传递此参数,Ratchet 将构造自己的事件循环。

现在,您应该能够使用以下命令启动应用程序容器:

$ docker run --rm -v $PWD:/opt/app -p 8080:8080 packt-chp6

提示

由于您的应用程序现在是一个单一的、长期运行的 PHP 进程,因此在重新启动服务器之前,对 PHP 代码库的更改不会生效。请记住,在更改应用程序的 PHP 代码时,使用Ctrl+C停止服务器,并使用相同的命令(或使用docker restart chat-app命令)重新启动服务器。

引导 HTML 用户界面

聊天应用程序的用户界面将基于 HTML、CSS 和 JavaScript。为了管理前端依赖关系,我们将在本例中使用Bower。您可以通过以下命令使用npm安装 Bower(作为 root 用户或使用sudo

$ npm install -g bower

继续创建一个新目录public/,您可以在其中放置所有前端文件。在此目录中,放置一个包含以下内容的文件bower.json

{ 
    "name": "packt-php7/chp6-chat", 
    "authors": [ 
        "Martin Helmich <php7-book@martin-helmich.de>" 
    ], 
    "private": true, 
    "dependencies": { 
        "bootstrap": "~3.3.6" 
    } 
} 

创建bower.json文件后,可以使用以下命令安装声明的依赖项(在本例中为Twitter 引导框架):

$ bower install

这将把引导框架及其所有依赖项(实际上,只有 jQuery 库)下载到目录bower_components/中,稍后您可以将它们包含在 HTML 前端文件中。

安装并运行一个可以为 HTML 前端文件提供服务的 web 服务器也很有用。当您的 WebSocket 应用程序被限制在localhost来源时,这一点尤其重要,因为它只允许来自localhost域(不包括在浏览器中打开的本地文件)的 JavaScript 请求。一个简单快捷的方法是使用nginxDocker 图像。请确保从您的public/目录中运行以下命令:

$ docker run -d --name chat-web -v $PWD:/var/www -p 80:80 nginx

之后,您将能够在浏览器中打开http://localhost,并从public/目录中查看静态文件。如果您在该目录中放置一个空的index.html,Nginx 将使用该页面作为索引页面,而不需要通过其路径显式请求该页面(意味着http://localhost将向用户提供文件index.html的内容)。

构建一个简单的聊天应用程序

现在可以开始实现实际的聊天应用程序了。如前面的示例所示,您需要为此实现Ratchet\MessageComponentInterface。首先创建一个Packt\Chp6\Chat\ChatComponent类并实现接口所需的所有方法:

namespace Packt\Chp6\Chat; 

use Ratchet\MessageComponentInterface; 
use Ratchet\ConnectionInterface; 

class ChatComponent implements MessageComponentInterface 
{ 
    public function onOpen(ConnectionInterface $conn) {} 
    public function onClose(ConnectionInterface $conn) {} 
    public function onMessage(ConnectionInterface $from, $msg) {} 
    public function onError(ConnectionInterface $conn, \Exception $err) {} 
} 

聊天应用程序需要做的第一件事是跟踪已连接的用户。为此,您需要维护所有打开连接的集合,在新用户连接时添加新连接,并在用户断开连接时删除它们。为此,在构造函数中初始化SplObjectStorage类的实例:

private $users; 

public function __construct() 
{ 
 $this->users = new \SplObjectStorage(); 
} 

然后,您可以在onOpen事件中将新连接连接到此存储器,并在onClose事件中将其删除:

public function onOpen(ConnectionInterface $conn) 
{ 
 echo "user {$conn->remoteAddress} connected.\n"; 
 $this->users->attach($conn); 
} 

public function onClose(ConnectionInterface $conn) 
{ 
 echo "user {$conn->remoteAddress} disconnected.\n"; 
 $this->users->detach($conn);} 

现在,每个连接的用户都可以向服务器发送消息。对于每个接收到的消息,将调用组件的onMessage方法。要实现真正的聊天应用程序,每个收到的消息都需要方便地转发给其他用户,您的$this->users集合中已经有一个所有连接用户的列表,您可以将收到的消息发送给他们:

public function onMessage(ConnectionInterface $from, $msg) 
{ 
 echo "received message '$msg' from user {$from->remoteAddress}\n";
 foreach($this->users as $user) { 
 if ($user != $from) { 
 $user->send($msg); 
 } 
 }} 

然后,您可以在server.php文件中的 Ratchet 应用程序中注册聊天组件:

$app = new \Ratchet\App('localhost', 8080, '0.0.0.0'); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
$app->run(); 

重新启动应用程序后,通过在两个单独的终端中打开两个与 wscat 的 WebSocket 连接来测试聊天功能。在一个连接中发送的每条消息都应该在另一个连接中弹出。

Building a simple chat application

使用两个 wscat 连接测试基本聊天应用程序

现在,您已经运行了一个聊天服务器(无可否认,它还处于初级阶段),我们可以开始为聊天应用程序构建 HTML 前端了。首先,一个静态 HTML 文件就足够了。首先在public/目录中创建一个空的index.html文件:

<!DOCTYPE html>
<html> 
  <head> 
    <title>Chat application</title> 
 <script src="bower_components/jquery/dist/jquery.min.js"></script> 
 <script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
 <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"/> 
  </head> 
  <body> 
  </body> 
</html> 

在这个文件中,我们已经包含了我们将用于这个示例的前端库;引导框架(带有一个 JavaScript 和一个 CSS 文件)和 jQuery 库(带有另一个 JavaScript 文件)。

由于您将为此应用程序编写大量 JavaScript,因此添加另一个js/app.js文件实例也很有用,您可以在其中将自己的 JavaScript 代码放置到 HTML 页面的<head>部分:

<head> 
  <title>Chat application</title> 
  <script src="bower_components/jquery/dist/jquery.min.js"></script> 
  <script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script> 
 <script src="js/app.js"></script> 
  <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"/> 
</head> 

然后,您可以继续在index.html文件的<body>部分构建一个极简主义聊天窗口。您只需开始编写消息的输入字段、发送消息的按钮以及显示其他用户消息的区域:

<div class="container"> 
  <div class="row"> 
    <div class="col-md-12"> 
      <div class="input-group"> 
        <input class="form-control" type="text" id="message"  placeholder="Your message..." /> 
        <span class="input-group-btn"> 
          <button id="submit" class="btn btn-primary">Send</button> 
        </span> 
      </div> 
    </div> 
  </div> 
  <div class="row"> 
    <div id="messages"></div> 
  </div> 
</div> 

HTML 文件包含一个输入字段(id="message"),用户可以在其中输入新的聊天信息,一个按钮(id="submit")提交信息,以及一个(当前仍然为空)部分(id="messages"),其中可以显示从其他用户接收到的信息。以下屏幕截图显示了此页面在浏览器中的显示方式:

Building a simple chat application

当然,如果没有适当的 JavaScript 使聊天真正起作用,所有这些都不会有任何好处。在 JavaScript 中,您可以使用WebSocket类打开 WebSocket 连接。

关于浏览器支持WebSocket 在所有现代浏览器中都受支持,并且已经存在了相当长的一段时间。您可能会遇到需要支持不支持 WebSocket 的旧 Internet Explorer 版本(9 及以下)的问题。在这种情况下,您可以使用web-socket-js库,它在内部使用使用 Flash 的回退功能,Ratchet 也很好地支持该功能。

在本例中,我们将把所有 JavaScript 代码放在public/目录下的文件js/app.js中。您可以通过使用 WebSocket 服务器的 URL 作为第一个参数实例化WebSocket类来打开新的 WebSocket 连接:

var connection = new WebSocket('ws://localhost:8080/chat'); 

与服务器端组件一样,客户端 WebSocket 提供了几个您可以监听的事件。方便地说,这些事件的命名与 Ratchet、onopenoncloseonmessage使用的方法类似,所有这些都可以(也应该)在您自己的代码中实现:

connection.onopen = function() { 
    console.log('connection established'); 
} 

connection.onclose = function() { 
    console.log('connection closed'); 
} 

connection.onmessage = function(event) { 
    console.log('message received: ' + event.data); 
} 

接收消息

每个客户端连接在 Ratchet 服务器应用程序中都有一个对应的ConnectionInterface实例。当您在服务器上调用连接的send()方法时,这将在客户端触发onmessage事件。

每次收到新消息时;此消息应显示在聊天窗口中。为此,您可以实现一个新的 JavaScript 方法appendMessage,该方法将在先前创建的消息容器中显示一条新消息:

var appendMessage = function(message, sentByMe) { 
    var text = sentByMe ? 'Sent at' : 'Received at'; 
     var html = $('<div class="msg">' + text + ' <span class="date"></span>: <span 
    class="text"></span></div>'); 

    html.find('.date').text(new Date().toLocaleTimeString()); 
    html.find('.text').text(message); 

    $('#messages').prepend(html); 
} 

在本例中,我们使用一个简单的 jQuery 构造来创建一个新的 HTML 元素,并用当前日期和时间以及收到的实际消息文本填充它。请注意,单个消息当前仅由原始消息文本组成,尚未包含任何类型的元数据,例如作者或其他信息。我们稍后再谈。

提示

在这种情况下,用 jQuery 创建 HTML 元素就足够了,您可能需要考虑使用一个专用的模板引擎,例如,在真实世界场景中,如 TytT0、SuthAuthOpjt1 或 Ty2 T2。因为这不是一本 JavaScript 书,所以我们将在这里坚持基本内容。

当收到消息时,您可以调用appendMessage方法:

connection.onmessage = function(event) { 
    console.log('message received: ' + event.data); 
 appendMessage(event.data, false); 
} 

事件的 data 属性将整个接收到的消息作为字符串包含,您可以根据需要使用它。目前,我们的聊天应用程序只能处理纯文本聊天信息;每当需要传输更多或结构化数据时,使用 JSON 编码可能是一个不错的选择。

发送消息

要发送消息,您可以(毫不奇怪)使用连接的send()方法。由于您的 HTML 文件中已经有了相应的用户输入字段,现在要使我们的聊天的第一个版本正常工作,只需稍微增加一点 jQuery:

$(document).ready(function() { 
    $('#submit').click(function() { 
        var message = $('#message').val(); 

        if (message) { 
            console.log('sending message: "' + message + '"'); 
            connection.send(message); 

            appendMessage(message, true); 
        } 
    }); 
}); 

一旦 HTML 页面加载完毕,我们就开始监听 submit 按钮的click事件。单击按钮时,输入字段中的消息将使用连接的send()方法发送到服务器。每次发送消息时,Ratchet 将调用服务器端组件中的onMessage事件,允许服务器对该消息做出反应并将其发送给其他连接的用户。

通常,用户也希望在聊天窗口中看到自己发送的消息。这就是我们调用之前实现的appendMessage的原因,它将把发送的消息插入到消息容器中,就好像它是从远程用户接收的一样。

测试应用程序

当两个容器(web 服务器和 WebSocket 应用程序)都在运行时,您现在可以通过在浏览器中打开 URLhttp://localhost来测试聊天的第一个版本(更好的是,在两个不同的窗口中打开页面两次,以便您可以实际使用该应用程序与自己聊天)。

以下屏幕截图显示了测试应用程序时应获得的结果示例:

Testing the application

使用两个浏览器窗口测试聊天应用程序的第一个版本

防止连接超时

当您将测试站点保持打开状态超过几分钟时,您可能会注意到 WebSocket 连接最终将关闭。这是因为大多数浏览器都会在特定时间段(通常为五分钟)内未发送或接收任何消息时关闭 WebSocket 连接。在使用长时间运行的连接时,还需要考虑连接问题:如果用户使用移动连接,在使用应用程序时临时断开连接呢?

缓解这种情况的最简单方法是在连接关闭时实施简单的重新连接机制,等待几秒钟,然后重试。为此,您可以在打开新连接的onclose事件中启动超时:

connection.onclose = function(event) { 
    console.error(e); 
    setTimeout(function() { 
        connection = new WebSocket('ws://localhost:8080/chat'); 
    }, 5000); 
} 

这样,每次连接关闭时(由于超时、网络连接问题或任何其他原因);应用程序将在 5 秒的宽限期后尝试重新建立连接。

如果要主动防止断开连接,还可以通过连接定期发送消息,以保持连接的活动状态。这可以通过注册间隔函数来完成,该函数定期(以小于超时的间隔)向服务器发送消息:

var interval; 

connection.onopen = function() { 
    console.log('connection established'); 
 interval = setInterval(function() { 
 connection.send('ping'); 
 }, 120000); 
} 

connection.onclose = function() { 
    console.error(e); 
 clearInterval(interval); 
    setTimeout(function() { 
        connection = new WebSocket('ws://localhost:8080/chat'); 
    }, 5000); 
} 

这里有一些注意事项:首先,在连接实际建立之后,您才应该开始发送保持活跃消息(这就是为什么我们登记在 AUTY T0 事件中的间隔),并且当连接被关闭时,您也应该停止发送保留的别名。(这仍然可能发生,例如,当网络不可用时),这就是为什么需要在onclose事件中清除间隔。

此外,您可能不希望将保持活动的消息广播到其他连接的客户端;这意味着这些消息还需要在服务器端组件中进行特殊处理:

public function onMessage(ConnectionInterface $from, $msg) 
{ 
 if ($msg == 'ping') { 
 return; 
 } 

    echo "received message '$msg' from user {$from->remoteAddress}\n"; 
    foreach($this->users as $user) { 
        if ($user != $from) { 
            $user->send($msg); 
        } 
    } 
} 

部署选项

正如您已经注意到的,Ratchet 应用程序不像典型的 PHP 应用程序那样部署,而是运行自己的 HTTP 服务器,可以直接响应 HTTP 请求。此外,大多数应用程序将不仅仅为 WebSocket 连接提供服务,还需要处理常规 HTTP 请求。

提示

本节旨在概述如何在生产环境中部署 Ratchet 应用程序。在本章的其余部分中,为了简单起见,我们将继续使用基于 Docker 的开发设置(没有负载平衡和奇特的流程管理器)。

这将带来一整套需要解决的新问题。其中之一是可伸缩性:默认情况下,PHP 运行单线程,因此即使使用libev提供的异步事件循环,您的应用程序也永远不会扩展到单个 CPU 之外。虽然您可以考虑使用 PosiT1 扩展来启用 PHP 中的线程(并且进入一个全新的痛苦世界),但是通常简单地多启动棘轮应用程序,让它在不同的端口上侦听,并使用负载均衡器(如 NGNX)来分发 HTTP 请求和 WebSoT 连接。

对于处理常规(非 WebSocket)HTTP 请求,您仍然可以使用常规 PHP 进程管理器,如 PHP-FPM 或 Apache 的 PHP 模块。然后,您可以配置 Nginx 将这些常规请求分派给 FPM,并将所有 WebSocket 请求分派给正在运行的 Ratchet 应用程序之一。

Deployment options

使用 Nginx 负载平衡器部署和负载平衡棘轮应用程序

要实现这一点,首先需要创建应用程序侦听的端口,以便可以为每个正在运行的进程单独配置该端口。当应用程序通过命令行启动时,使每个进程都可以配置端口的最简单方法是命令行参数。您可以使用getopt函数轻松解析命令行参数。在进行此操作时,还可以配置侦听地址。在您的server.php文件中插入以下代码:

$options = getopt('l:p:', ['listen:', 'port:']); 
$port = $options['port'] ?? $options['p'] ?? 8080; 
$addr = $options['listen'] ?? $options['l'] ?? '127.0.0.1'; 

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
$app->run(); 

接下来,您需要确保服务器实际上自动启动足够数量的进程。在 Linux 环境中,Supervisor工具通常是一个很好的选择。在 Ubuntu 或 Debian Linux 系统上,您可以使用以下命令从系统的软件包存储库安装它:

$ apt-get install supervisor

然后,您可以在/etc/supervisor/conf.d/中放置一个包含以下内容的配置文件:

[program:chat] 
numprocs=4 
command=php /path/to/application -port=80%(process_num)02d 
process_name=%(program_name)s-%(process_num)02d 
autostart=true 
autorestart=unexpected 

这将配置 Supervisor 在系统引导时启动聊天应用程序的四个实例。他们将在端口80008003上侦听,并在意外终止时由主管自动重新启动记住:PHP 致命错误在 FPM 管理的环境中可能相对无害,但在独立的 PHP 进程中,一个致命错误将导致所有用户的整个应用程序停机,直到有人重新启动该进程。出于这个原因,最好有一个像 Supervisor 这样的服务,它可以自动重新启动崩溃的进程。

接下来,安装一个 nginxweb 服务器,作为四个正在运行的聊天应用程序的负载平衡器。在 Ubuntu 或 Debian 上,按如下方式安装 Nginx:

$ apt-get install nginx

安装 Nginx 后,在目录/etc/nginx/sites-enabled/中放置一个配置文件chat.conf,内容如下:

upstream chat { 
    server localhost:8000; 
    server localhost:8001; 
    server localhost:8002; 
    server localhost:8003; 
} 
server { 
    listen 80; 
    server_name chat.example.com; 

    location /chat/ { 
        proxy_pass http://chat; 
        proxy_http_version 1.1; 
        proxy_set_header Upgrade $http_upgrade; 
        proxy_set_header Connection "upgrade"; 
    } 

    // Additional PHP-FPM configuration here 
    // ... 
} 

此配置将所有四个应用程序进程配置为 Nginx 负载平衡器的上游服务器。所有以/chat/路径开始的 HTTP 请求都将转发到服务器上运行的 Ratchet 应用程序之一。proxy_http_versionproxy_set_header指令是允许 Nginx 在服务器和客户端之间正确转发 WebSocket 握手所必需的。

桥接棘轮和 PSR-7 应用

迟早,您的聊天应用程序还需要响应常规 HTTP 请求(例如,只要您想添加带有登录表单和身份验证处理的身份验证层,就需要这样做)。

如前一节所述,PHP 中 WebSocket 应用程序的常见设置是让 Ratchet 应用程序处理所有 WebSocket 连接,并将所有常规 HTTP 请求定向到常规 PHP-FPM 设置。但是,由于 Ratchet 应用程序实际上也提供了自己的 HTTP 服务器,因此您还可以直接响应 Ratchet 应用程序发出的常规 HTTP 请求。

正如您使用Ratchet\MessageComponentInterface实现 WebSocket 应用程序一样,您也可以使用Ratchet\HttpServerInterface实现常规 HTTP 服务器。举个例子,考虑下面的类:

namespace Packt\Chp6\Http; 

use Guzzle\Http\Message\RequestInterface; 
use Ratchet\ConnectionInterface; 
use Ratchet\HttpServerInterface; 

class HelloWorldServer implements HttpServerInterface 
{ 
    public function onOpen(ConnectionInterface $conn, RequestInterface $request = null) 
    {} 

    public function onClose(ConnectionInterface $conn) 
    {} 

    public function onError(ConnectionInterface $conn, \Exception $e) 
    {} 

    public function onMessage(ConnectionInterface $from, $msg) 
    {} 
} 

如您所见,HttpServerInterface定义的方法与MessageCompomentInterface类似。唯一的区别是$request参数,它现在被额外传递到onOpen方法中。这个类是Guzzle\Http\Message\RequestInterface的一个实例(不幸的是,它没有实现 PSR-7RequestInterface),您可以从中获得基本的 HTTP 请求属性。

您现在可以使用onOpen方法发送常规 HTTP 以响应收到的 HTTP 请求:

public function onOpen(ConnectionInterface $conn, RequestInterface $request = null) 
{ 
   $conn->send("HTTP/1.1 200 OK\r\n");
    $conn->send("Content-Type: text/plain\r\n"); 
    $conn->send("Content-Length: 13\r\n"); 
    $conn->send("\r\n"); 
    $conn->send("Hello World\n"); 
    $conn->close(); 
} 

如您所见,您必须在onOpen方法中发送整个 HTTP 响应(包括响应头!)。这有点乏味,我们稍后会找到更好的方法,但现在就足够了。

接下来,在server.php中注册 HTTP 服务器,注册方式与注册新的 WebSocket 服务器相同:

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
$app->route('/hello', new \Packt\Chp6\Http\HelloWorldServer, ['*']); 
$app->run(); 

特别注意这里的第三个参数['*']:该参数将允许此路由的任何请求来源(不仅仅是localhost),因为大多数浏览器和命令行客户端甚至不会为常规 HTTP 请求发送来源标头。

重新启动应用程序后,可以在命令行或浏览器上使用任何常规 HTTP 客户端测试新的 HTTP 路由。如以下屏幕截图所示:

Bridging Ratchet and PSR-7 applications

使用 cURL 测试 Ratchet HTTP 服务器

手工构建包含标头的 HTTP 响应是一项非常繁琐的任务,尤其是在应用程序包含多个 HTTP 端点的情况下。出于这个原因,最好有一个框架来为您处理所有这些事情。

在上一章中,您已经使用了Slim框架,您还可以很好地将其与 Ratchet 集成。不幸的是,Ratchet(尚未)与 PSR-7 兼容,因此您必须做一些准备工作,将 Ratchet 的请求接口转换为 PSR-7 实例,并将 PSR-7 响应传回ConnectionInterface

首先,使用 Composer 将 Slim 框架安装到应用程序中:

$ composer require slim/slim

本节剩余部分的目标是构建HttpServerInterface的新实现,该实现将 Slim 应用程序作为依赖项,并将所有传入请求转发给 Slim 应用程序。

首先定义实现HttpServerInterface并接受Slim\App作为依赖项的类Packt\Chp6\Http\SlimAdapterServer

namespace Packt\Chp6\Http; 

use Guzzle\Http\Message\RequestInterface; 
use Ratchet\ConnectionInterface; 
use Ratchet\HttpServerInterface; 
use Slim\App; 

class SlimAdapterServer implements HttpServerInterface 
{ 
    private $app; 

    public function __construct(App $app) 
    { 
        $this->app = $app; 
    } 

    // onOpen, onClose, onError and onMessage omitted 
    // ... 
} 

您需要做的第一件事是将 Ratchet 传递到onOpen事件的$request参数映射到 PSR-7 请求对象(然后您可以将其传递到 Slim 应用程序进行处理)。Slim 框架提供了自己的接口实现:类Slim\Http\Request。首先将以下代码添加到您的onOpen方法中,该方法将请求 URI 映射到Slim\Http\Uri类的实例:

$guzzleUri = $request->getUrl(true); 
$slimUri = new \Slim\Http\Uri( 
    $guzzleUri->getScheme() ?? 'http', 
    $guzzleUri->getHost() ?? 'localhost', 
    $guzzleUri->getPort(), 
    $guzzleUri->getPath(), 
    $guzzleUri->getQuery() . '', 
    $guzzleUri->getFragment(), 
    $guzzleUri->getUsername(), 
    $guzzleUri->getPassword() 
); 

这将 Guzzle 请求的 URI 对象映射到 Slim URI 对象中。它们在很大程度上是兼容的,允许您简单地将大多数属性复制到Slim\Http\Uri类的构造函数中。只有$guzzleUri->getQuery()返回值需要通过将其与空字符串串联而强制为字符串。

通过构建 HTTP 请求头对象继续:

$headerValues = []; 
foreach ($request->getHeaders() as $name => $header) { 
    $headerValues[$name] = $header->toArray(); 
} 
$slimHeaders = new \Slim\Http\Headers($headerValues); 

在构建了请求 URI 和标头之后,您可以创建SlimRequest类的实例:

$slimRequest = new \Slim\Http\Request( 
    $request->getMethod(), 
    $slimUri, 
    $slimHeaders, 
    $request->getCookies(), 
    [], 
    new \Slim\Http\Stream($request->getBody()->getStream()); 
); 

然后,您可以使用此请求对象调用作为依赖项传递到SlimAdapterServer类中的 Slim 应用程序:

$slimResponse = new \Slim\Http\Response(200); 
$slimResponse = $this->app->process($slimRequest, $slimResponse); 

$this->app->process()函数将实际执行 Slim 应用程序。它的工作原理与您在上一章中使用的$app->run()方法类似,但直接接受 PSR-7 请求对象并返回 PSR-7 响应对象以供进一步处理。

最后一个挑战是使用$slimResponse对象并将其中包含的所有数据返回给客户端。让我们从发送 HTTP 头开始:

$statusLine = sprintf('HTTP/%s %d %s', 
    $slimResponse->getProtocolVersion(), 
    $slimResponse->getStatusCode(), 
    $slimResponse->getReasonPhrase() 
); 
$headerLines = [$statusLine]; 

foreach ($slimResponse->getHeaders() as $name => $values) { 
    foreach ($values as $value) { 
        $headerLines[] = $headerName . ': ' . $value; 
    } 
} 

$conn->send(implode("\r\n", $headerLines) . "\r\n\r\n"); 

$statusLine包含 HTTP 响应的第一行(通常类似于HTTP/1.1 200 OKHTTP/1.1 404 Not Found。嵌套的foreach循环用于收集来自 PSR-7 响应对象的所有响应头,并将它们连接成可用于 HTTP 响应的字符串(每个头都有自己的行,由回车符CR)和换行符LF分隔)新行)。双精度\r\n最终终止标题并标记响应主体的开始,您将在下一步输出:

$body = $slimResponse->getBody(); 
$body->rewind(); 

while (!$body->eof()) { 
    $conn->send($body->read(4096)); 
} 
$conn->close(); 

在您的server.php文件中,您现在可以实例化一个新的 Slim 应用程序,将其传递到一个新的SlimAdapterServer类中,并在 Ratchet 应用程序中注册此服务器:

use Slim\App; 
use Slim\Http\Request; 
use Slim\Http\Response; 
$slim = new App(); 
$slim->get('/hello', function(Request $req, Response $res): Response { 
 $res->getBody()->write("Hello World!"); 
 return $res; 
}); 
$adapter = new \Packt\Chp6\Http\SlimAdapterServer($slim); 

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
$app->route('/hello', $adapter, ['*']); 
$app->run(); 

将 Slim 框架集成到 Ratchet 应用程序中,可以为同一应用程序提供 WebSocket 请求和常规 HTTP 请求。从一个连续运行的 PHP 进程提供 HTTP 请求提供了有趣的新机会,尽管您必须小心使用这些机会。您需要担心内存消耗之类的问题(PHP 确实有一个垃圾收集器,但如果您不注意,您仍然可能会造成内存泄漏,导致 PHP 进程运行到内存限制并崩溃和烧坏),但是,当您有高性能需求时,构建这样的应用程序可能是一个有趣的选择。

通过 web 服务器访问您的应用程序

在我们的开发设置中,我们目前正在运行两个容器,应用程序容器本身,监听端口8080和一个监听端口80的 Nginx 服务器,该端口提供静态文件,如index.html和各种 CSS 和 JavaScript 文件。在生产设置中,通常不建议为静态文件和应用程序本身公开两个不同的端口。

因此,我们现在将配置我们的 web 服务器容器,以便在静态文件存在时(如index.html或 CSS 和 JavaScript 文件)为其提供服务,并在不存在具有给定名称的实际文件时,将 HTTP 请求委托给应用程序容器。为此,首先创建一个 Nginx 配置文件,您可以将其放置在项目目录中的任何位置,例如,etc/nginx.conf

map $http_upgrade $connection_upgrade { 
    default upgrade; 
    '' close; 
} 

server { 
    location / { 
        root /var/www; 
        try_files $uri $uri/index.html @phpsite; 
    } 

    location @phpsite { 
        proxy_http_version 1.1; 
        proxy_set_header X-Real-IP  $remote_addr; 
        proxy_set_header Host $host; 
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 
        proxy_set_header Upgrade $http_upgrade; 
        proxy_set_header Connection $connection_upgrade; 
        proxy_pass http://app:8080; 
    } 
} 

此配置将导致 Nginx 在/var/www目录中查找文件(使用 Docker 启动 Nginx web 服务器时,您只需将本地目录装入容器的/var/www目录)。在那里,它将首先查找直接文件名匹配,然后查找目录中的index.html,最后一个选项是将请求传递给上游 HTTP 服务器。

提示

此配置也适用于部署选项部分所述的生产设置。当您的应用程序有多个实例在运行时,您将需要在proxy_pass语句中引用具有多个上游应用程序的专用上游配置。

创建配置文件后,您可以按照如下方式重新创建 Nginx 容器(请特别注意docker run命令的--link标志):

$ docker rm -f chat-web 
$ docker run -d --name chat-web **--link chat-app:app** -v $PWD/public:/var/www -p 80:80 nginx

增加认证

目前,我们的应用程序缺少一个关键特性:任何人都可以在聊天中发布消息,而且也无法确定哪个用户发送了哪条消息。因此,在下一步中,我们将向聊天应用程序添加一个身份验证层。为此,我们需要一个登录表单和某种身份验证处理程序。

在本例中,我们将使用典型的基于会话的身份验证。成功验证用户名和密码后,系统将为用户创建一个新会话,并将(随机且不可猜测的)会话 ID 存储在用户浏览器上的 cookie 中。在后续请求中,身份验证层可以使用 cookie 中的会话 ID 来查找当前经过身份验证的用户。

创建登录表单

让我们从实现一个简单的会话管理类开始。该类将命名为Packt\Chp6\Authentication\SessionProvider

namespace Packt\Chp6\Authentication; 

class SessionProvider 
{ 
    private $users = []; 

    public function hasSession(string $sessionId): bool 
    { 
        return array_key_exists($sessionId, $this->users); 
    } 

    public function getUserBySession(string $sessionId): string 
    { 
        return $this->users[$sessionId]; 
    } 

    public function registerSession(string $user): string 
    { 
        $id = sha1(random_bytes(64)); 
        $this->users[$id] = $user; 
        return $id; 
    } 
} 

这个会话处理程序构建得非常简单:它只存储使用哪个会话 ID 的用户(按名称);可以使用registerSession方法注册新会话。由于所有 HTTP 请求都将由相同的 PHP 进程提供服务,因此您甚至不需要将这些会话持久化到数据库中,只需将它们保存在内存中即可(但是,一旦有多个进程在负载平衡环境中运行,您就需要数据库支持的会话存储,因为您不能简单地在不同的 PHP 进程之间共享内存)。

提示

关于真随机数

为了生成加密安全的会话 ID,我们正在使用 PHP7 中添加的random_bytes函数,这是获得加密安全随机数据的建议方法(永远不要使用randmt_rand之类的函数)。

在以下步骤中,我们将在新集成的 Slim 应用程序中实现一些额外的路由:

  1. GET /路由将服务于实际的聊天 HTML 站点。到目前为止,这是一个由 web 服务器直接提供服务的静态 HTML 页面。使用身份验证,我们将需要在该站点上进行更多的登录(例如,当用户未登录时将其重定向到登录页面),这就是我们将索引页面移动到应用程序中的原因。
  2. GET /login路由将提供一个登录表单,用户可以使用用户名和密码进行身份验证。提供的凭据将提交给。。。
  3. POST /authenticate路线。此路由将验证用户提供的凭据,并在用户成功通过身份验证后启动新会话(使用先前构建的SessionProvider类)。认证成功后,/authenticate路由将用户重定向回/路由。

让我们首先在 Ratchet 应用程序中注册这三条路由,并将它们连接到之前在server.php文件中创建的 Slim 适配器:

$app = new \Ratchet\App('localhost', $port, $addr); 
$app->route('/chat', new \Packt\Chp6\Chat\ChatComponent); 
$app->route('/', $adapter, ['*']); 
$app->route('/login', $adapter, ['*']); 
$app->route('/authenticate', $adapter, ['*']); 
$app->run(); 

继续执行/路线。请记住,此路由只需为您之前创建的index.html文件提供服务,但前提是存在有效的用户会话。为此,您必须检查 HTTP 请求中是否存在具有会话 ID 的 HTTP cookie,然后验证是否存在具有此 ID 的有效用户会话。为此,请将以下代码添加到您的server.php(此外,如果仍然存在,请删除先前创建的GET /hello路由)。如以下代码所示:

$provider = new \Packt\Chp6\Authentication\SessionProvider(); 
$slim = new \Slim\App(); 
$slim->get('/', function(Request $req, Response $res) use ($provider): Response { 
 $sessionId = $req->getCookieParams()['session'] ?? ''; 
 if (!$provider->hasSession($sessionId)) { 
 return $res->withRedirect('/login'); 
 } 
 $res->getBody()->write(file_get_contents('templates/index.html')); 
 return $res 
 ->withHeader('Content-Type', 'text/html;charset=utf8'); 
});

此路由将文件templates/index.html提供给您的用户。目前,此文件应位于安装程序中的public/目录中。在项目文件夹中创建templates/目录,并将index.htmlpublic/目录移到那里。这样,文件将不再由 Nginx web 服务器提供,所有对/的请求将直接转发到 Ratchet 应用程序(然后该应用程序将交付索引视图或将用户重定向到登录页面)。

在下一步中,您可以实现/login路由。此路线不需要特殊逻辑:

$slim->get('/login', function(Request $req, Response $res): Response { 
    $res->getBody()->write(file_get_contents('templates/login.html')); 
    return $res 
        ->withHeader('Content-Type', 'text/html;charset=utf8'); 
}); 

当然,要使此路由真正起作用,您需要创建templates/login.html文件。首先为此新模板创建一个简单的 HTML 文档:

<!DOCTYPE html> 
<html lang="en"> 
<head> 
    <meta charset="UTF-8"> 
    <title>Chap application: Login</title> 
    <script src="bower_components/jquery/dist/jquery.min.js"></script> 
    <script src="bower_components/bootstrap/dist/js/bootstrap.min.js"></script> 
    <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"/> 
</head> 
<body> 
</body> 
</html> 

这将加载登录表单工作所需的所有 JavaScript 库和 CSS 文件。在<body>部分,您可以添加实际的登录表单:

<div class="row" id="login"> 
    <div class="col-md-4 col-md-offset-4"> 
        <div class="panel panel-default"> 
            <div class="panel-heading">Login</div> 
            <div class="panel-body"> 
                <form action="/authenticate" method="post"> 
                    <div class="form-group"> 
                        <label for="username">Username</label> 
                        <input type="text" name="username" id="username" placeholder="Username" class="form-control"> 
                    </div> 
                    <div class="form-group"> 
                        <label for="password">Password</label> 
                        <input type="password" name="password" id="password" placeholder="Password" class="form-control"> 
                    </div> 
                    <button type="submit" id="do-login" class="btn btn-primary btn-block"> 
                        Log in 
                    </button> 
                </form> 
            </div> 
        </div> 
    </div> 
</div> 

特别注意<form>标签:表单的动作参数为/authenticate路由;这意味着在表单中输入的所有值都将被传递到(仍有待写入)/authenticate路由处理程序中,您将能够验证输入的凭据并创建新的用户会话。

保存此模板文件并重新启动应用程序后,您只需在浏览器中或使用命令行工具(如HTTPiecurl请求/URL 即可测试新的登录表单。由于您还没有登录会话,应该立即重定向到登录表单。如以下屏幕截图所示:

Creating the login form

未经验证的用户现在被重定向到登录表单

现在缺少的一件事是实际的/authenticate路线。为此,请在您的server.php文件中添加以下代码:

$slim->post('/authenticate', function(Request $req, Response $res) use ($provider): Response { 
    $username = $req->getParsedBodyParam('username'); 
    $password = $req->getParsedBodyParam('password'); 

    if (!$username || !$password) { 
        return $res->withStatus(403); 
    } 

    if (!$username == 'mhelmich' || !$password == 'secret') { 
        return $res->withStatus(403); 
    } 

    $session = $provider->registerSession($username); 
    return $res 
        ->withHeader('Set-Cookie', 'session=' . $session) 
        ->withRedirect('/'); 
}); 

当然,在本例中,实际的用户身份验证仍然非常基本,我们只检查一个硬编码的用户/密码组合。在生产设置中,您可以在此实现任何类型的用户身份验证(通常包括在数据库集合中查找用户,并将提交的密码哈希与用户存储的密码哈希进行比较)。

检查授权

现在,剩下的就是扩展聊天应用程序本身,只允许授权用户连接。幸运的是,WebSocket 连接从常规 HTTP 连接开始(在升级到 WebSocket 连接之前)。这意味着浏览器将传输CookieHTTP 头中的所有 cookie,然后您可以在应用程序中访问这些 cookie。

为了将授权问题与实际的聊天业务逻辑分开,我们将在一个特殊的 decorator 类中实现所有与授权相关的内容,该类还实现了Ratchet\MessageComponentInterface接口并封装了实际的聊天应用程序。我们将这个类称为Packt\Chp6\Authentication\AuthenticationComponent。首先用一个构造函数来实现这个类,该构造函数接受一个MessageComponentInterface和一个SessionProvider作为依赖项:

namespace Packt\Chp6\Authentication; 

use Ratchet\MessageComponentInterface; 
use Ratchet\ConnectionInterface; 

class AuthenticationComponent implements MessageComponentInterface 
{ 
    private $wrapped; 
    private $sessionProvider; 

    public function __construct(MessageComponentInterface $wrapped, SessionProvider $sessionProvider) 
    { 
        $this->wrapped         = $wrapped; 
        $this->sessionProvider = $sessionProvider; 
    } 
} 

继续实施MessageComponentInterface定义的方法。首先,实现所有这些方法以简单地委托给$wrapped对象上的相应方法:

public function onOpen(ConnectionInterface $conn) 
{ 
    $this->wrapped->onOpen($conn); 
} 

public function onClose(ConnectionInterface $conn) 
{ 
    $this->wrapped->onClose($conn); 
} 

public function onError(ConnectionInterface $conn, \Exception $e) 
{ 
    $this->wrapped->onError($conn, $e); 
} 

public function onMessage(ConnectionInterface $from, $msg) 
{ 
    $this->wrapped->onMessage($from, $msg); 
} 

您现在可以向以下新的onOpen方法添加身份验证检查。在这里,您可以检查是否设置了具有会话 ID 的 cookie,使用SessionProvider检查会话 ID 是否有效,并且仅在存在有效会话时接受连接(意思是:委托给包装组件):

public function onOpen(ConnectionInterface $conn) 
{ 
 $sessionId = $conn->WebSocket->request->getCookie('session'); 
 if (!$sessionId || !$this->sessionProvider->hasSession($sessionId)) { 
 $conn->send('Not authenticated'); 
 $conn->close(); 
 return; 
 } 
 $user = $this->sessionProvider->getUserBySession($sessionId); 
 $conn->user = $user; 

    $this->wrapped->onOpen($conn); 
} 

如果找不到会话 ID 或给定的会话 ID 无效,将立即关闭连接。否则,会话 ID 将用于从SessionProvider中查找关联用户,并作为新属性添加到连接对象中。在包装组件中,您只需再次访问$conn->user即可获得对当前已验证用户的引用。

连接用户和消息

您现在可以断言,只有经过身份验证的用户才能在聊天室中发送和接收消息。但是,消息本身还没有与任何特定用户关联,因此您仍然不知道是哪个用户实际发送了消息。

到目前为止,我们一直在处理简单的纯文本消息。由于每条消息现在需要包含比纯消息文本更多的信息,我们将切换到 JSON 编码的消息。每个聊天信息将包含一个从客户端发送到服务器的msg属性,服务器将添加一个author属性,该属性填充当前已验证用户名的用户名。这可以通过您先前构建的ChatComponentonMessage方法完成,如下所示:

public function onMessage(ConnectionInterface $from, $msg) 
{ 
    if ($msg == 'ping') { 
        return; 
    } 

 $decoded = json_decode($msg); 
 $decoded->author = $from->user; 
 $msg = json_encode($decoded); 

    foreach ($this->users as $user) { 
        if ($user != $from) { 
            $user->send($msg); 
        } 
    } 
} 

在本例中,我们首先对从客户端收到的消息进行 JSON 解码。然后,我们将向消息中添加一个"author"属性,填充经过身份验证的用户的用户名(记住,$from->user属性是在上一节中构建的AuthenticationComponent中设置的)。然后对消息进行重新编码并发送给所有连接的用户。

当然,我们的 JavaScript 前端还必须支持这些新的 JSON 编码消息。首先更改app.jsJavaScript 文件中的appendMessage函数,以接受结构化对象形式的消息,而不是简单的字符串:

var appendMessage = function(message, sentByMe) { 
    var text = sentByMe ? 'Sent at' : 'Received at'; 
 var html = $('<div class="msg">' + text + ' <span class="date"></span> by <span class="author"></span>: <span class="text"></span></div>'); 

    html.find('.date').text(new Date().toLocaleTimeString()); 
 html.find('.author').text(message.author); 
    html.find('.text').text(message.msg); 

    $('#messages').prepend(html); 
}; 

WebSocket 连接的onmessage事件和提交按钮侦听器都使用appendMessage函数。onmessage事件需要修改为首先对传入消息进行 JSON 解码:

connection.onmessage = function(event) { 
 var msg = JSON.parse(event.data); 
    appendMessage(msg, false); 
} 

此外,submit 按钮侦听器还需要将 JSON 编码的数据发送到 WebSocket 服务器,并将结构化数据传递到修改后的appendMessage函数中:

$(document).ready(function () { 
    $('#submit').click(function () { 
        var text = $('#message').val(); 
 var msg = JSON.stringify({ 
 msg: text 
 }); 
        connection.send(msg); 

        appendMessage({ 
 author: "me", 
 message: text 
        }, true); 
    }) 
}); 

总结

在本章中,您了解了 WebSocket 应用程序的基本原理以及如何使用 Ratchet 框架构建它们。与大多数 PHP 应用程序不同,Ratchet 应用程序部署为单个、长期运行的 PHP 进程,不需要进程管理器(如 FPM 或 web 服务器)。这需要一个完全不同的部署,我们在本章中也讨论了这一点,无论是用于开发还是用于大规模生产环境。

除了简单地使用 Ratchet 为 WebSocket 提供服务外,我们还研究了如何使用 PSR-7 标准将 Ratchet 应用程序与其他框架(例如,您已经在第 5 章创建 RESTful Web 服务中使用的 Slim 框架)集成。

第 7 章构建异步微服务架构中,您将了解另一种可用于集成应用程序的通信协议。虽然 WebSocket 仍然是基于 HTTP 构建的,但下一章将介绍与 HTTP 完全不同的ZeroMQ协议,它带来了一系列有待解决的全新挑战。