WebSocket 协议握手流程

HTTP 1.1 的 keep-alive?

WebSocket 协议解决了服务器与客户端全双工通信的问题。

  1. 信息只能单向传送为单工
  2. 信息能双向传送但不能同时双向传送称为半双工
  3. 信息能够同时双向传送则称为全双工

握手流程

客户端请求头

GET /chat HTTP/1.1
Connection: Upgrade                             # 通知服务器协议升级
Upgrade: websocket                              # 协议升级为websocket协议
Host: server.example.com:7001                   # 升级协议的服务主机:端口地址
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==     # 下文解释
Sec-WebSocket-Protocol: chat, superchat         # 子协议(应用层协议层协议)
Sec-WebSocket-Version: 13                       # WebSocket 协议版本必须是13
Origin: http://example.com

其中 Upgrade: websocketConnection: Upgrade 表示

Sec-WebSocket-Key

其中 Sec-WebSocket-Key 是客户端随机生成的一个 Base64 值,模拟生成算法如下:

def _create_sec_websocket_key():
    randomness = os.urandom(16)
    return base64encode(randomness).decode('utf-8').strip()

Sec-WebSocket-Protocol

字段表示客户端可以接受的子协议类型,也就是在 WebSocket 协议上的应用层协议类型。 上面可以看到客户端支持 chat 和 superchat 两个应用层子协议,当服务器接受到这个字段后要从中选出一个子协议返回给客户端。

服务端响应头

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

Upgrade 和 Connection

服务器告知客户端协议已成功升级为 WebSocket 协议,用来完善 HTTP 升级响应。

Sec-WebSocket-Accept

服务端拿到客户端的 Sec-WebSocket-Key 后,跟 MAGIC 全局字符串常量拼接,再经过 sha1base64_encode 后得出 Sec-WebSocket-Accept,模拟生成算法如下:

const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
$accept = base64encode(sha1($key . MAGIC));

echo $accpet;

注意:MAGIC 魔术字符串是 RFC6455 官方定义的一个固定字符串,官方就是这么任性,不得修改。

客户端拿到服务端响应的 Sec-WebSocket-Accept 后,会拿自己之前生成的 Sec-WebSocket-Key 用相同算法算一次,如果匹配,则握手成功。然后判断 HTTP Response 状态码是否为 101(切换协议),如果是,则建立连接,大功告成。

Sec-WebSocket-Protocol

表示服务器最终选择的一个应用层子协议

WebSocket & Socket.io

Socket.io

Socket.io 是一个开源的 JS 实时通信库,包括了客户端和服务端。以下是最简单的 WebSocket 通信流程,分为四个角色:

  1. PHP App Server
  2. WebSocket Server (NodeJS) – Socket.io Server
  3. WebSocket Client (HTML) – Socket.io Client
  4. Redis (订阅&发布)

PHP - 应用服务器

// 连接 Redis
$redis = new Redis;
$redis->connect('127.0.0.1', 6379);
$redis->auth('密码');

// 发布消息
$redis->publish('频道名', '消息载体');

注意:发布时的『消息内容』可以是一个 JSON 字符串,例如 Laravel 里代码如下:

$payload = json_encode([
    'event'  => $event,
    'data'   => $payload,
    'socket' => Arr::pull($payload, 'socket'),  // 排我标识,后面再说
]);

$redis->publish('频道名', $payload);

Socket.io - WebSocket 服务器

npm install --save ioredis
npm install --save socket.io

单文件 server.js 代码如下:

var app = require('http').createServer(function (req, res) {
  res.writeHead(200);
  res.end('');
});

var io = require('socket.io')(app);

app.listen(7002, function () {
  console.log('WebSocketServer is running!');
});

io.on('connection', function (socket) {
  console.log(socket);
  console.log('connected');
  socket.on('message', function (message) {
    console.log(message);
  });
  socket.on('disconnect', function () {
    console.log('disconnected');
  });
});

var Redis = require('ioredis');
var redis = new Redis({
  port: 6379,
  host: '127.0.0.1',
  password: '密码',
});

redis.psubscribe('*', function (err, count) {
  console.log('err: ' + err + ', count: ' + count);
});

redis.on('pmessage', function (subscrbed, channel, message) {
  console.log('subscrbed: ' + subscrbed + ', channel: ' + channel + ', message: ' + message);
  // 这里须和 App Server 约定,消息载体为 JSON 字符串
  message = JSON.parse(message);
  // 拼接完整的『频道名:事件名』
  var eventName = channel + ':' + message.event;
  // 给客户端广播消息
  io.emit(eventName, message.data);
});

Socket.io - WebSocket 客户端

socket.io-client API 手册

引入 socket.io-client 库:

<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>

可以直接到 CDN 下载:https://cdnjs.com/libraries/socket.io

也可通过 npm 安装以下库:

npm install socket.io-client

然后从以下路径中拷贝出来:

node_modules/socket.io-client/dist/socket.io.js

Javascript 部分代码:

// @see https://socket.io/docs/client-api/
const socket = io('https://dev.api.gaopeng.com:7002');

socket.on('connect', function (socket) {
  // 即 X-Socket-ID
  console.log(socket.id);
});

socket.on('事件名称', function (event) {
  console.log(event);
});

注:监听的『事件名称』即 io.emit() 时的 eventName 变量(即『频道名:事件名』完整字符串)

值得一提

1、广播的排我发布是如何实现的?

客户端 X-Socket-ID

2、微信小程序如何使用 WebSocket?

由于 Socket.io 在使用过程中会给 client 植入 cookie 完成验证,而微信小程序不支持 cookie,所以就需要修改 Socket.io 客户端。

用法与浏览器端一致:

const io = require('./yout_path/weapp.socket.io.js')

const socket = io('http://localhost:8000')

socket.on('news', d => {
  console.log('received news: ', d)
})

socket.emit('news', {
  title: 'this is a news'
})

3、Socket.io Server 负载均衡

https://socket.io/docs/using-multiple-nodes

通过增加 socket.io-redis 可以在多个 Socket.io Server 节点中传递事件。

4、安卓客户端的 Socket.io Client

https://socket.io/blog/native-socket-io-and-android

5、客户端向服务器、客户端之间发消息

参考 laravel-echo client 里的 Echo.whisper(eventName, data),代码如下:

this.socket.emit('client event', {
  channel: this.name,
  event: 'client-' + eventName,
  data: data
});

Socket.io Server: 通过 socket.broadcast.emit() 发布排我广播

io.on('connection', function(socket){
  socket.broadcast.emit('hi');
});

参考文章

Composer 生产服性能优化指南

Level-1 优化:生成 classmap

生产环境一定要执行该命令,为啥呢?

composer dump-autoload -o(-o 等同于 --optimize

原理:

这个命令的本质是将 PSR-4/PSR-0 的规则转化为 classmap 规则(classmap 中包含了所有类名与类文件路径的对应关系)避免了加载器再去文件系统中遍历查找文件产生不必要的 IO。

当加载器找不到目标类时,仍旧会根据 PSR-4/PSR-0 的规则去文件系统中查找。

Level-2 优化:权威的(Authoritative)classmap

如果想实现加载器找不到类时即停止,那可以采用 Authoritative classmap:

composer dump-autoload -a (-a 等同于 --classmap-authoritative

原理:

执行这个命令隐含的也执行了 Level-1 的命令, 即同样也是生成了 classmap,区别在于当加载器在 classmap 中找不到目标类时,不会再去文件系统中查找(即隐含的认为 classmap 中就是所有合法的类,不会有其他的类了,除非法调用)

注意: 如果你的项目在运行时会生成类,使用这个优化策略会找不到这些新生成的类。

Level-1 Plus 优化:使用 APCu Cache

在生产环境下,这个策略一般也会与 Level-1 一起使用, 执行:

composer dump-autoload -o --apcu

APCu 是 APC 去除 opcode 缓存后的精简版,只用于本机数据缓存(共享内存使得数据在多进程间可共享)。

这样,即使生产环境下生成了新的类,只需要文件系统中查找一次即可被缓存 , 弥补了 Level-2/A 的缺陷。

如何选择 & 小结

如果项目在运行时不会生成新的类文件,那么使用 Level-2/A,否则使用 Level-1 及 Level-1 Plus。

Level-2 的优化基本都是 Level-1 优化的补充,Level-2 主要是决定在 classmap 中找不到目标类时是否继续找下去。

Level-1 Plus 主要是在提供了一个缓存机制,将在 classmap 中找不到时,将从文件系统中找到的文件路径缓存起来,加速后续查找的速度。

参考文章

Composer 自动加载原理分析

Composer 自带的几种 autoloader(加载器)

原文参考:https://docs.phpcomposer.com/04-schema.html#autoload

  • PSR-4 autoloading
  • PSR-0 autoloading
  • Classmap generation
  • Files includes

推荐使用 PSR-4,使用更简洁,另外当增加新的类文件时,无需重新生成 autoloader,Composer 会根据类名自动定位文件路径。

(1) PSR-4 Autoloading

当执行 composer install/update 时,会生成 vendor/composer/autoload_psr4.php 文件

{
    "autoload": {
        "psr-4": {
            "Monolog\\": "src/",
            "Vendor\\Namespace\\": ""
        }
    }
}

如果你需要搜索多个目录中一个相同的前缀,你可以将它们指定为一个数组,例:

{
    "autoload": {
        "psr-4": { "Monolog\\": ["src/", "lib/"] }
    }
}

如果想设置一个目录作为任何命名空间的 fallback 查找目录,可以使用空的前缀,像这样:

{
    "autoload": {
        "psr-4": { "": "src/" }
    }
}

请注意:命名空间的申明应该以 \ 结束,以确保 autoloader 能够准确响应。例: Foo 将会与 FooBar 匹配,然而以反斜杠结束就可以解决这样的问题, Foo\ 和 FooBar\ 将会被区分开来,另外:JSON 里要写双斜杠只是因为须在双引号里转义。

(2) PSR-0 Autoloading

composer install/update 过程中,会生成 vendor/composer/autoload_namespaces.php 文件。

{
    "autoload": {
        "psr-0": {
            "Monolog\\": "src/",
            "Vendor\\Namespace\\": "src/",
            "Vendor_Namespace_": "src/"
        }
    }
}

如果你需要搜索多个目录中一个相同的前缀,你可以将它们指定为一个数组,例:

{
    "autoload": {
        "psr-0": { "Monolog\\": ["src/", "lib/"] }
    }
}

PSR-0 方式并不仅限于申明命名空间,也可以是精确到类级别的指定。这对于只有一个类在全局命名空间的类库是非常有用的(如果 PHP 源文件也位于包的根目录)。例如,可以这样申明:

{
    "autoload": {
        "psr-0": { "UniqueGlobalClass": "" }
    }
}

如果想设置一个目录作为任何命名空间的 fallback 查找目录,可以使用空的前缀,像这样:

{
    "autoload": {
        "psr-0": { "": "src/" }
    }
}

(3) Classmap generation

composer install/update 过程中,扫描指定目录(同样支持直接精确到文件)中所有的 .php.inc 文件中的类,建立类名和类文件的映射,以路径层级作为命名空间,生成 vendor/composer/autoload_classmap.php 文件。

我们可以用 classmap 生成不遵循 PSR-0/4 规范的类库路径映射。

{
    "autoload": {
        "classmap": [
            "database/seeds",
            "database/factories"
        ],
    }
}

注意:文件 autoload_classmap.php 还有个巧妙的用途,在执行 composer dump-autoload -o 时,也会冗余存储扫描得出的 PSR-4/0 规则的类文件映射 —— 所以生产服必须启用。

(4) File includes

用于加载某些全局的特定文件,通常作为函数库的载入方式(而非类库)。

{
    "autoload": {
        "files": ["src/MyLibrary/functions.php"]
    }
}

(5) include-path (Legacy)

设置一个目录列表,这是一个过时的做法,用于支持老项目,相当于给 PHP 设置 set_include_path 的扫描目录。

Composer 如何根据类名查找到文件的?

查找顺序是 classmap -> psr4 -> psr0,如图:

image

源代码 vendor/composer/ClassLoader.php 如下:

public function findFile($class)
{
    // class map lookup
    if (isset($this->classMap[$class])) {
        return $this->classMap[$class];
    }

    if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
        return false;
    }

    if (null !== $this->apcuPrefix) {
        $file = apcu_fetch($this->apcuPrefix.$class, $hit);
        if ($hit) {
            return $file;
        }
    }

    $file = $this->findFileWithExtension($class, '.php');

    // Search for Hack files if we are running on HHVM
    if (false === $file && defined('HHVM_VERSION')) {
        $file = $this->findFileWithExtension($class, '.hh');
    }

    if (null !== $this->apcuPrefix) {
        apcu_add($this->apcuPrefix.$class, $file);
    }

    if (false === $file) {
        // Remember that this class does not exist.
        $this->missingClasses[$class] = true;
    }

    return $file;
}

其中 PSR-4 的加载方法 findFileWithExtension 代码如下:

private function findFileWithExtension($class, $ext)
{
    // PSR-4 lookup
    $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;

    $first = $class[0];
    if (isset($this->prefixLengthsPsr4[$first])) {
        $subPath = $class;
        while (false !== $lastPos = strrpos($subPath, '\\')) {
            $subPath = substr($subPath, 0, $lastPos);
            $search = $subPath . '\\';
            if (isset($this->prefixDirsPsr4[$search])) {
                $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
                foreach ($this->prefixDirsPsr4[$search] as $dir) {
                    if (file_exists($file = $dir . $pathEnd)) {
                        return $file;
                    }
                }
            }
        }
    }

    // ....
}

PSR-4 找文件的大致流程是从尾部开始倒着切割类名和命名空间,依次去 autoload_psr4.php 里匹配找出指定命名空间对应的 src 目录,然后把类名拼接在 src 目录后,就是完整的文件路径。

例如 composer.json 中 PSR-4 规则定义为:

{
    "autoload": {
        "psr-4": {
            "App\\": "application/",
            "App\\Models\\" : "application_models/" # 子目录可以另立山头,不一定要放在 app/ 目录里
        }
    }
}

那么类 \App\Controller\Foo\BarController 查找流程是:

  1. 先找 autoload.psr-4 里有没有定义 \App\Controller\Foo\ 对应的目录
  2. 没有的话,继续找有没有定义 \App\Controller\ 对应的目录,一直找到 \App 目录
  3. 然后把目录 \App 切割出来,其余部分 Controller\Foo\BarController 即类名
  4. 拼接成最终的文件路径:application/类名.php(须把类名中的反斜杠 \\ 替换为 DIRECTORY_SEPARATOR

生产服加载优化

综上所述,ClassMap 的查找是最高效的,但缺点是每次有新增的类,都得通过 composer dump-autoload 重新生成,开发时不方便。而 PSR-4 虽然查找遍历灵活,但查找起来运算较多,还有 file_exists 等 IO 操作,总体效率有待加强。

那么生产环境在确定文件不会有动态新增的前提下,我们可以这样优化:

composer dump-autoload -o 或者 --optimize

这条命令的作用是扫描 composer.json 设置的 PSR-4/0 对应目录下所有类文件,把类名和文件路径都冗余记录在 autoload_classmap.php 文件里,以最简单粗暴的方式定位到类所在的文件,内容如下:

$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);

return array(
    'App\\Models\\Article' => $baseDir . '/app/models/Article.php',
    'App\\Controllers\\BaseController' => $baseDir . '/app/controllers/BaseController.php',
    'App\\Controllers\\HomeController' => $baseDir . '/app/controllers/HomeController.php',
);

四合一文件 autoload_static.php 的作用

PHP 5.6 以后,为了优化加载大数组,Composer 把上述四个文件合并成了一个 autoload_static.php 文件。

Optimized the autoloader initialization using static loading on PHP 5.6 and above, this reduces the load time for large classmaps to almost nothing

为什么要定义 composerRequire 这个方法?

为了隔离作用域,防止变量被污染: 想象一下,如果有人在 autoload_files 中的文件中写了 $this 或者 self 那就屎了。

composerRequire 里为什么用的是 require 而不是 require_once?

因为 Composer 的开发者认为 require_once 效率低下,而且认为 vendor/autoload.php 为第一等公民,不论什么框架,都必须在入口第一行就引入。

所以 require 足以满足绝大多数场景,后面作者又为了满足避免重复引入的需求,增加了 $GLOBALS 全局数组来做去重,他觉得这样仍然比 require_once 靠谱。

https://github.com/composer/composer/pull/4186

参考文章

Laravel Broadcaster 进阶使用 & 原理分析

上一篇简单介绍了什么是 Laravel 广播,本篇我们来剖析一下 Laravel 广播的原理,以及使用时的注意事项。

正好看到一篇老外写的搭建攻略,也非常不错:

https://medium.com/@dennissmink/laravel-echo-server-how-to-24d5778ece8b

开始使用

Laravel App Server - 应用服务端

修改 .envBROADCAST_DRIVER = redis,同时启用 QUEUE_DRIVER 队列服务,广播队列应独立一条,默认走 default 队列。

因为所有广播事件 App\Events\* 只要实现了 ShowBroadcast 接口,那么都强制走队列,如果想立即发送,则改成实现 ShowBroadcastNow 接口。

代码示例

App\Events\RealTimeStatsUpdated

/**
 * 实时数据广播更新
 *
 * @author JiangJian <silverd@sohu.com>
 */

namespace App\Events;

use Cache;
use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

class RealTimeStatsUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * 广播指定队列
     *
     * @var string
     */
    public $broadcastQueue = 'broadcasts';

    public $shopId;
    public $apiUri;
    public $shotAt;
    public $extras = [];

    public function __construct(int $shopId, string $apiUri, int $shotAt, array $extras = [])
    {
        $this->shopId = $shopId;
        $this->apiUri = $apiUri;
        $this->shotAt = $shotAt;
        $this->extras = $extras;
    }

    /**
     * 广播频道
     *
     * @return Channel|array
     */
    public function broadcastOn()
    {
        return new PrivateChannel('gmall.shop.' . $this->shopId);
    }

    /**
     * 广播事件名
     *
     * @return string
     */
    public function broadcastAs()
    {
        return 'real-time-stats.updated';
    }

    /**
     * 广播载体 payload 数据
     *
     * @return array
     */
    public function broadcastWith()
    {
        return [
            'api_uri' => $this->apiUri,
            'date'    => date('Y-m-d', $this->shotAt),
            'extras'  => $this->extras,
        ];
    }

    /**
     * 决定是否应该广播此事件
     *
     * @return bool
     */
    public function broadcastWhen()
    {
        // 同一事件冷却5秒
        return Cache::add('RealTimeStatsUpdated:' . $this->apiUri, 1, now()->addSeconds(5));
    }
}

代码关键配置

如需私有频道,修改 config/app.php 中引入(取消注释) App\Providers\BroadcastServiceProvider

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Broadcast;

class BroadcastServiceProvider extends ServiceProvider
{
    public function boot()
    {
        // 私有频道和存在频道鉴权专用
        // 其中的 /broadcasting/auth 仅检测是否游客
        Broadcast::routes(['middleware' => 'api']);

        // 具体的业务范围鉴权
        // 例如只有订单主人才能监听订单事件
        require base_path('routes/channels.php');
    }
}

附:踩坑说明

Laravel 这里原始代码为 Broadcast::routes(),即使用的缺省中间件 ['middleware' => 'web'],表示鉴权访问 /broadcasting/auth 时会走 web 中间件,我们要改成 ['middleware' => 'api'],否则 web 中间件里的 VerifyCsrfToken 验证过不去。或者 CSRF 排除掉 /broadcasting/auth 这个路由也可以解决,如下:

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        '/broadcasting/auth',
    ];
}

私信通知广播

Notifaction 消息通知可以很轻松的支持广播,走的是私有频道。

私信频道名称定义

私信频道默认的频道名为 $notifiable 对象的类名App\Models\Users.{$uid},如果觉得太长了,可以在 User 模型类中重新定义这个频道名:

class User extends Authenticatable
{
    use Notifiable;

    /**
     * 接收用户的频道广播通知.
     *
     * @return string
     */
    public function receivesBroadcastNotificationsOn()
    {
        // 这里的频道名必须跟 `routes/channels.php` 定义的鉴权路由一致
        // 也必须跟客户端 `EchoClient.private(频道名)` 监听的频道名一致
        return 'users.' . $this->id;
    }
}

在具体 Notifacation 实例中跟广播有关的方法定义(仔细看代码注释):

App\Notifications\ShopAlarm

namespace App\Notifications;

use Illuminate\Notifications\Messages\BroadcastMessage;

class ShopAlarm extends Notification
{
    // 广播通知类型
    // 缺省为类名:App\Notifications\ShopAlarm
    public function broadcastType()
    {
        return $this->msgType;
    }

    /**
     * 广播 Payload 数据
     *
     * @param  mixed  $notifiable
     * @return BroadcastMessage|array
     */
    public function toBroadcast($notifiable)
    {
        $message = new BroadcastMessage([
            'shop_id'    => $this->shop->id,
            'type'       => $this->msgType,
            'message'    => $this->message,
            'created_at' => $GLOBALS['_DATE'],
        ]);

        // 所有广播都必须走队列(框架如此,不可修改)
        // 默认走的 default 队列,建议指定一下,换成其他
        return $message->onQueue('broadcasts');
    }

附:踩坑说明

消息通知广播事件实例,最终会被 Illuminate\Notifications\Events\BroadcastNotificationCreated 类包装,它会覆盖我们在 Notification 类定义的载体中的两个字段 idtype,证据如下:

namespace Illuminate\Notifications\Events;

class BroadcastNotificationCreated implements ShouldBroadcast
{
    public function broadcastWith()
    {
        return array_merge($this->data, [
            'id' => $this->notification->id,
            'type' => $this->broadcastType(),
        ]);
    }
}

所以我们定义通知类时注意避开,或者重写 broadcastType() 定义,否则缺省为 Notification 的类名:App\Notification\ShopAlarm

Laravel Echo Server Socket.io 服务端

首先服务端上安装 NodeJS:http://nvm.sh 然后全局安装:

npm install -g laravel-echo-server

Socket.io Server 官方文档:https://github.com/tlaverdure/laravel-echo-server

NodeJs server for Laravel Echo broadcasting with Socket.io. 支持 Pusher、Redis、HTTP 驱动传递消息

安装配置

按照官方文档生成并配置 laravel-echo-server.json 后(建议按环境区分该配置文件),例如 envs/对应环境/laravel-echo-server.json,注意修改以下几个关键字段:

{
  // 后端
  "authHost": "https://dev.api.gmall.gaopeng.com",
  // 订阅驱动
  "database": "redis",
  // 订阅的 Redis 服务器,务必后端配置保持一致
  "databaseConfig": {
    // @see https://github.com/luin/ioredis/blob/HEAD/API.md#new_Redis
    "redis": {
      "port": "6379",
      "host": "127.0.0.1",
      "password": "gaopeng.123",
      "db": 0  // 注意:PUB/SUB跟数据库编号无关,Redis 同时也负责存储『存在频道』信息
    },
    // ...
  },
  // 调试模式(会输出控制台日志,生产服应关闭)
  "devMode": false,
  // WS 服务接受一切本机IP地址
  "host": null,
  // WS 缺省端口
  "port": "6001",
  // WS + SSL = WSS
  "protocol": "https",
  "sslCertPath": "/usr/local/nginx/conf/ssl/api.gmall.gaopeng.com.crt",
  "sslKeyPath": "/usr/local/nginx/conf/ssl/api.gmall.gaopeng.com.key",
}

然后把 laravel-echo-server start 命令加入到 supervisord 中守护

[program:AI_GMall_WebSocketServer]
process_name=%(program_name)s
autostart=true
autorestart=true
redirect_stderr=true
command=laravel-echo-server start --dir=/home/wwwroot/ai_gmall_server/envs/prod
stdout_logfile=/home/wwwlogs/supervisord_ai_gmall_websocket.out

故障排查心得

问题:假设突然发现生产服的 Websocket 实时消息不正常工作了

  1. 首先开启调试模式 laravel-echo-server.jsondevMode=true,这样控制台才会输出日志。
  2. 再查看控制台日志文件 supervisord_ai_gmall_websocket.out,日志里会有连接记录、断开记录、广播的事件发布记录等。

可能的原因:

  • 如果是 WebSocket Server 连接失败,则 Chrome 控制台会红色报错
  • 如果是私有频道授权失败,则 Chrome - Network - WS 里的 Frames 页里会有 subscription_error 的提示
  • 如果还不行,则可能是 Echo Server 和 App Server 之间的通信出错,如果用的 Redis 广播驱动,那么确保双方连的同一台 Redis 服务器且 Redis 服务器正常可用。
  • 如果还不行,请确保频道名称是否跟客户端监听的一致,有可能是 Redis Key Prefix 导致频道名不匹配。

附:私有、存在频道鉴权原理

https://laravel.com/docs/5.6/broadcasting#authorizing-channels

以 API 服务器为例子,鉴权标识为请求头里的 api_token,形式如:Authorization: Bearer ABCDEFG

鉴权步骤:

1、Echo Client 把 api_token 通过 Websocket 协议发至 Echo Server 端。

2、Echo Server 端再通过 HTTP 请求向 App Server 的 http://{authHost}/broadcasting/auth 发起鉴权请求(这个 URL 定义在 laravel_echo_server.json 中)。

3、App Server 代码里通过 $request->user() 获取当前用户实例,注意这里 $request->user($guard = null) 等同于 Auth::user(),只是一种解耦注入的写法。$guard 不填则使用定义在 config/auth.php 中的默认守护器 api

1) 第一步鉴权,检测是否游客(只检测 $request->user() 是否空值) 2) 第二步鉴权,检测频道业务权限(例如是否一个店长才能收到该门店通知)

具体代码可见:Illuminate\Broadcasting\Broadcasters\RedisBroadcaster::auth 方法。

Laravel Echo Client - Socket.io 客户端

安装客户端库:https://www.npmjs.com/package/laravel-echo

npm install --save laravel-echo
npm install --save socket.io-client

Vue 内如何使用?

import Echo from 'laravel-echo'
import io from 'socket.io-client';

const EchoClient = new Echo({
  broadcaster: 'socket.io',
  host: 'http://local.api.gmall.gaopeng.com:7002',
  client: io,
  auth: {
    headers: {
      // 重要:用于私有频道鉴权(同 API 用户鉴权)
      Authorization: 'Bearer e05295c388270d7354864c3231ed7e86c791964e',
    },
  },
});

// 公开频道
EchoClient.channel('gmall.borad')
  .listen('.new-message.created', function (event) {
    console.log(event);
  });

// 私有频道
EchoClient.private('gmall.shop.1')
  .listen('.real-time-stats.updated', function (event) {
    console.log(event);
  });

// 私有频道-私信(消息通知)
// 频道名必须和服务端的 `App\Models\User::receivesBroadcastNotificationsOn()` 
// 以及 `routes/channels.php` 定义的频道鉴权路由保持完全一致
EchoClient.private('users.67')
  .notification(function (notification) {
    console.log(notification);
  });

纯网页中如何使用?

<script src="http://115.159.58.121:10088/js/echo.js"></script>
<script src="http://115.159.58.121:10088/js/socket.io.js"></script>
<script>
window.Echo = new Echo({
  broadcaster: 'socket.io',
  host: 'http://' + window.location.hostname + ':6001',
  client: io,
  auth: {
    headers: {
      // ... 用户鉴权信息
    },
  },
});
Echo.private('gmall.shop.1')
  .listen('.real-time-stats.updated', function (event) {
    console.log(event);
  });
</script>

其中的 echo.jssocket.io.js 去哪里下载?

可以自己通过 npm 安装以下库:

npm install laravel-echo
npm install socket.io-client

然后从以下路径中拷贝出来:

node_modules/laravel-echo/dist/echo.js
node_modules/socket.io-client/dist/socket.io.js

PHP7 Error & Exception 知识点整理

先说 PHP7 之前,各大框架是如何捕捉处理 所有 错误和异常的,以 Laravel 为例:

Laravel 的异常处理由类 \Illuminate\Foundation\Bootstrap\HandleExceptions 完成(略加精简)


namespace Illuminate\Foundation\Bootstrap;

use Illuminate\Contracts\Debug\ExceptionHandler;
use Symfony\Component\Debug\Exception\FatalErrorException;
use Symfony\Component\Debug\Exception\FatalThrowableError;

class HandleExceptions
{
    public function bootstrap(Application $app)
    {
        $this->app = $app;

        error_reporting(-1);

        set_error_handler([$this, 'handleError']);

        set_exception_handler([$this, 'handleException']);

        register_shutdown_function([$this, 'handleShutdown']);

        if (! $app->environment('testing')) {
            ini_set('display_errors', 'Off');
        }
    }

    // Convert PHP errors to ErrorException instances.
    // @link http://php.net/manual/zh/function.set-error-handler.php
    public function handleError($errno, $errstr, $errfile = '', $errline = 0)
    {
        if (error_reporting() & $errno) {
            throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
        }
    }

    // Handle an uncaught exception from the application.
    public function handleException($e)
    {
        if (! $e instanceof Exception) {
            // FatalThrowableError 是 Symfony 装饰模式包装的 \ErrorException 子类
            $e = new FatalThrowableError($e);
        }

        $exceptionHandler = $this->app->make(ExceptionHandler::class);

        try {
            // 汇报异常(例如以日志或邮件形式)
            $exceptionHandler->report($e);
        } catch (\Exception $e) {
            // 忽略掉上报时的异常,不然没完没了了
        }

        // 正式渲染显示异常信息
        $exceptionHandler->render($this->app['request'], $e)->send();
    }

    public function handleShutdown()
    {
        if (! is_null($error = error_get_last()) && $this->isFatal($error['type'])) {
            $this->handleException($this->fatalExceptionFromError($error, 0));
        }
    }

    // Create a new fatal exception instance from an error array.
    // FatalErrorException 是 Symfony 继承 \ErrorException 的子类
    protected function fatalExceptionFromError(array $error, $traceOffset = null)
    {
        return new FatalErrorException(
            $error['message'], $error['type'], 0, $error['file'], $error['line'], $traceOffset
        );
    }

    /**
     * Determine if the error type is fatal.
     *
     * @param  int  $type
     * @return bool
     */
    protected function isFatal($type)
    {
        return in_array($type, [E_COMPILE_ERROR, E_CORE_ERROR, E_ERROR, E_PARSE]);
    }
}

小结:

  1. 对于不致命的错误,例如 E_NOTICE、E_USER_ERROR、E_USER_WARNING、E_USER_NOTICE,handleError 会捕捉并将错误转成 \ErrorException,转交给 handleException($e) 处理。

  2. 对于致命错误,例如 E_PARSE,handleShutdown 将会接手捕捉,并且根据 error_get_last() 获取最后一个错误(说明一下,handleShutdown 会在脚本运行结束时执行,但无法确定脚本是正常结束,还是因为发生了致命错误而结束,所以我们这里需要判断:如果最后一个错误是 E_ERROR 之类的,则说明脚本发生了致命错误导致结束,如果是 E_NOTICE 之类的,则无需处理),并把错误转化为 \ErrorException,转交给 handleException($e) 处理。

1. 建议用 error_reporting(-1) 代替 E_ALL

So in place of E_ALL consider using a larger value to cover all bit fields from now and well into the future, a numeric value like 2147483647 (includes all errors, not just E_ALL).

But it is better to set “-1” as the E_ALL value. For example, in httpd.conf or .htaccess, use php_value error_reporting -1 to report all kind of error without be worried by the PHP version.

2. 兜底 - 异常处理器

set_exception_handler() 负责捕获所有在应用层 throw抛出但未 catch 的异常。

3. 兜底 - 错误警告处理器

set_error_handler() 负责处理:

  • 用户通过 trigger_error() 主动触发的错误
  • Warning、Notice 级别的错误:E_NOTICE、E_USER_ERROR、E_USER_WARNING、E_USER_NOTICE
  • 不能捕捉致命错误,如 E_ERROR、E_PARSE、E_CORE_ERROR、E_CORE_WARNING、E_COMPILE_ERROR、E_COMPILE_WARNING,以及调用 set_error_handler() 函数所在文件中产生的大多数 E_STRICT

4. 兜底 - 致命错误处理器

register_shutdown_function() 会在以下情况下执行:

  • 当页面被用户强制停止时
  • 当程序代码运行超时时
  • 当 PHP 代码执行完成时,代码执行存在异常和错误、警告

通过 $e = error_get_last() 获取最后的错误数组, 转成 \ErrorException 交给 handleException($e) 处理。

小坑备注

当定义 register_shutdown_function() 方法的文件本身有 E_PARSE 错误时,则捕捉不到错:

register_shutdown_function('test', function () {
    if ($error = error_get_last()) {
        var_dump($error);
    }
});

var_dump(23+-+); // 此处语法错误

因为如果本身有错,脚本直接就 parse-time 编译错误了,根本就没运行起来。只有在 run-time 运行出错的时候,才会捕捉到。所幸框架都是单入口 index.php ,其他文件都是通过运行时 include() 加载,只需要保证 index.php 本身无错就行,无需担心这个问题。

其他说明

1、关于 \EngineException

现已更名为 \Error,只是在 PHP7 alpha-2 中临时叫 \EngineException

2、PHP 错误种类和级别

Fatal Error 致命错误(脚本终止运行)

  • E_ERROR // 致命的运行错误,错误无法恢复,暂停执行脚本
  • E_CORE_ERROR // PHP 启动时初始化过程中的致命错误
  • E_COMPILE_ERROR // 编译时致命性错,就像由 Zend 脚本引擎生成了一个 E_ERROR
  • E_USER_ERROR // 自定义错误消息。像用 PHP 函数 trigger_error(错误类型设置为:E_USER_ERROR)

Parse Error 编译时解析错误,语法错误(脚本终止运行)

  • E_PARSE // 编译时的语法解析错误

Warning Error 警告错误(仅给出提示信息,脚本不终止运行)

  • E_WARNING // 运行时警告 (非致命错误)。
  • E_CORE_WARNING // PHP初始化启动过程中发生的警告 (非致命错误) 。
  • E_COMPILE_WARNING // 编译警告
  • E_USER_WARNING // 用户产生的警告信息

Notice Error 通知错误(仅给出通知信息,脚本不终止运行)

  • E_NOTICE // 运行时通知。表示脚本遇到可能会表现为错误的情况.
  • E_USER_NOTICE // 用户产生的通知信息

由此可知有5类是产生ERROR级别的错误,这种错误直接导致PHP程序退出

const ERROR = E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_PARSE;

3、关于 \Throwable

PHP7 新增定义了 \Throwable 接口,原来的 \Exception 和部分 \Error 都实现了这个接口。

更多的错误和异常可以被现场 try-catch 或兜底 set_exception_handler() 捕获了,也就是说 set_exception_handler() 捕获的不只是 \Exception 的实例,还包括 \Error,这也就是下面 handleException($e) 里面这么判断的原因:

    // Handle an uncaught exception from the application.
    public function handleException($e)
    {
        if (! $e instanceof Exception) {
            // FatalThrowableError 是 Symfony 装饰模式包装的 \ErrorException 子类
            $e = new FatalThrowableError($e);
        }

        // ...
    }

4、详见 \Throwable 层次树:http://php.net/manual/en/class.error.php#122323

Throwable
    Error
        ArithmeticError
            DivisionByZeroError
        AssertionError
        ParseError
        TypeError
            ArgumentCountError
    Exception
        ClosedGeneratorException
        DOMException
        ErrorException
        IntlException
        LogicException
            BadFunctionCallException
              BadMethodCallException
            DomainException
            InvalidArgumentException
            LengthException
            OutOfRangeException
        PharException
        ReflectionException
        RuntimeException
            OutOfBoundsException
            OverflowException
            PDOException
            RangeException
            UnderflowException
            UnexpectedValueException
        SodiumException

5、关于 \ErrorException

注意 \ErrorException 跟 PHP7+ 的 \Erorr 的区别:

(1) \Error 是 PHP7+ 新增的错误类型,例如上述的 DivisionByZeroError,供 try-catchset_exception_handler() 捕获。

(2) \ErrorException 是 PHP5+ 原生继承于 \Exception 的异常子类, 扩展了更多的参数,例如文件名和行号。一般专门用在 set_error_handler() 或者 register_shutdown_function() 将错误转化为异常时,可记录更多信息(因为普通异常只有 code/message 两个属性)。

定义如下:http://php.net/manual/en/class.errorexception.php

ErrorException extends \Exception {
    public __construct(
        string $message = "",
        int $code = 0,
        int $severity = E_ERROR,
        string $filename = __FILE__,
        int $lineno = __LINE__,
        Exception $previous = NULL
    )
}

使用如下:

throw new \ErrorException($message, $code, $severity, $errfile, $errline);

参考文章: