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

参考文章:

HTTP 缓存机制

浏览器缓存分两类:强制缓存、对比缓存。

两类缓存规则可以同时存在,『强制缓存』优先级高于『对比缓存』。如果『强制缓存』生效,则不再发起服务器请求,即不再执行『对比缓存』规则。

强制缓存

强制缓存如果生效,不需要再和服务器发生交互

主要有两个首部字段:ExpiresCache-Control。其中 Expires 是 HTTP/1.0 版本的东西,在 HTTP/1.1 中,被 Cache-Control 替代。

Cache-Control

Alt text

响应首部的 Cache-Control 字段:

字段值 说明
no-cache 不缓存过期的资源。告诉浏览器,不论本地缓存是否过期,都不能直接使用本地缓存。在使用本地缓存之前,必须先通过 ETagLast-Modified 向服务器发起二次验证,如果服务器响应 304 则可用该本地缓存,否则不可。
no-store 请求和响应的信息都不应该被存储在对方的磁盘系统中(Internet 临时文件中)。当下次请求时,都会直接向服务器发送请求,并下载完整的响应。
max-age 指定时间内不需要再问服务器要数据
不产生网络请求,但仍有响应状态码 200 OK from disk cache
s-maxage CDN 或共享缓存服务器响应的最大 Age 值
public 表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存
private 表明响应只能被单个用户缓存,不能作为共享缓存(即 CDN 或代理服务器不能缓存它)
must-revalidate 缓存必须在使用之前验证旧资源的状态,并且不可使用过期资源。
no-transform 代理不可更改媒体类型
proxy-revalidate 要求中间缓存服务器对缓存的响应有效性再进行确认

请求首部的 Cache-Control 字段:

对比缓存

对比缓存不管是否生效,都一定会与服务端发生交互。

  • Last-Modified / If-Modified-Since
  • ETag / If-None-Match(优先级高于 Last-Modified

Last-Modified 与 ETag 的区别:

1、服务器会优先验证 ETag,一致的情况下,再对比 Last-Modified; 2、Last-Modified 只能精确到秒,有些资源在1秒内改变过,只能靠 ETag 来区分; 3、 一些资源的最后修改时间变了,但是内容并没改变,使用 ETag 就认为资源还是没有修改。

三种刷新的姿势

假设对一个资源,浏览器第一次访问,响应头为:

  • Cache-Control: max-age:600
  • Last-Modified: Wed, 10 Aug 2013 15:32:18 GMT

于是浏览器把资源放到缓存中,下次直接到缓存中取。

1、浏览器中写地址,回车

浏览器不会发送任何网络请求,直接去缓存中取资源。

2、F5 刷新

浏览器请求头中忽略 Expires/Cache-Control 的设置,并带着 If-Modified-SinceIf-None-Match 去请求服务器,服务端返回完整资源或者 304200 状态码。

3、Ctrl+F5 强刷

浏览器请求头中带着 Cache-Control:no-cache 并且不带 If-None-MatchIf-Modified-Since 去请求服务器,服务端虽然收到 no-cache,但并没有收到 ETagLast-Modified,这种情况下,服务器无法使用对比缓存(无法检验资源有效性),只能响应完整资源给浏览器,不可能响应 304 状态码。

Ctrl+F5 强刷等价于在 Chrome 控制台中勾选 Disable cache

借一图胜前言

Alt text

在HTTP请求和响应的消息报头中,常见的与缓存有关的消息报头有:

Alt text

示例

禁止缓存

发送如下指令可以关闭缓存。此外,可以参考Expires 和 Pragma 标题。

Cache-Control: no-cache, no-store, must-revalidate

缓存静态资源

对于应用程序中不会改变的文件,你通常可以在发送响应头前添加积极缓存。这包括例如由应用程序提供的静态文件,例如图像,CSS文件和JavaScript文件。另请参阅Expires标题。

Cache-Control:public, max-age=31536000

Nginx 对 Vue SPA 的 index.html 禁用缓存

location = /index.html {
    add_header Cache-Control "no-cache, no-store";
}

参考文章

Laravel Broadcaster 介绍

什么是广播?

广播 Broadcaster 是指发送方发送一条消息,订阅频道的各个接收方都能及时收到推送的消息,想象一下广播电视塔向外辐射发送消息的场景。

比如 A同学写了一篇文章,这时候 B同学在文章底下评论了,A同学在页面上是不用刷新就能收到提示有文章被评论了,这个本质上就是A同学收到了广播消息,这个广播消息是由B同学评论这个动作触发了发送广播消息。

Laravel Broadcast 模块组成

Alt text

在 Laravel 中可以 Broadcast 有三种驱动方式:

  • Pusher
  • Redis
  • Log

修改 .envBROADCAST_DRIVER 属性,或在 config/broadcasting.php 中配置。

在整个广播行为中,频道的类型有三种:

  • 公共频道 public
  • 私有频道 private
  • 存在频道 presence

公开频道

公开频道是任何人都可以订阅或监听的频道,默认定义的都是公开频道。

/**
 * Get the channels the event should be broadcast on.
 *
 * @return Channel|array
 */
public function broadcastOn()
{
    return ['test-channel'];
}

私有频道(Private Channel)

私有频道要求监听前必须先授权当前认证用户。

// 定义频道,绑定事件
var channel = pusher.subscribe('private-first-channel');
channel.bind('login', function(data) {
    alert(data);
});

如果订阅的是私有频道(频道名是以 private- 开头)或存在频道(频道名是以 presence- 开头),则会发出权限检查请求;对应的后端需要定义私有频道和存在频道的权限。

授权私有频道

通过向服务端发送包含频道名称的 HTTP 请求,来判断该用户是否允许监听该频道。使用 Laravel Echo 时,授权订阅私有频道的 HTTP 请求会自动发送。

频道的授权检测在 routes/channels.php 里,例如:

Broadcast::channel('first-channel', function ($user) {
    return (int) $user->id === 1;
});

注意:这里频道名不需要加 private-presence- 修饰前缀。

广播到私有频道

/**
 * Get the channels the event should be broadcast on.
 *
 * @return Channel|array
 */
public function broadcastOn()
{
    return new PrivateChannel('room.' . $this->message->room_id);
}

存在频道(Presence Channel)

存在频道构建于私有频道之上,并且提供了额外功能:获知谁订阅了频道。基于这一点,我们可以构建强大的、协作的应用功能,例如当其他用户访问同一个页面时通知当前用户。

授权存在频道

如果用户没有被授权加入存在频道,应该返回 false null; 如果用户被授权加入频道不要返回 `true`,而应该返回关于该用户的数据数组

Broadcast::channel('chat.*', function ($user, $roomId) {
    if ($user->canJoinRoom($roomId)) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});

广播到存在频道

/**
 * Get the channels the event should be broadcast on.
 *
 * @return Channel|array
 */
public function broadcastOn()
{
    return new PresenceChannel('room.' . $this->message->room_id);
}

参考文章

Laravel Pusher 介绍

Pusher 介绍

Pusher 是一个第三方中间服务商,专注 C/S 实时消息推送,类似极光、个推这样的消息推送商

原理:客户端通过 WebSocket 或 HTTP 建立和 Pusher 云服务器的持久链接,并不断接收 Pusher 云服务器推送过来的数据。我们自己的业务服务器只需要 HTTP POST 数据给 Puhser 云服务器即可。

Alt text

所以,Pusher 本质上可以适用于任意语言的 C/S,双端只需要接入官方 SDK 即可:https://pusher.com/docs/libraries

Pusher On Laravel

适用于 Laravel 框架的 Pusher 服务端 SDK,文档在此:https://github.com/vinkla/laravel-pusher

先通过 Composer 安装:

composer require vinkla/pusher

注册 ServiceProviderAlias

// add providers
Vinkla\Pusher\PusherServiceProvider::class,

// add aliases
'Pusher' => Vinkla\Pusher\Facades\Pusher::class,

发布配置文件 config/pusher.php

php artisan vendor:publish --provider='Vinkla\Pusher\PusherServiceProvider'

继续修改 config/pusher.php

'connections' => [

    'main' => [
        'auth_key' => env('PUSHER_APP_KEY'),
        'secret' => env('PUSHER_APP_SECRET'),
        'app_id' => env('PUSHER_APP_ID'),
        'options' => [
            'cluster' => env('PUSHER_APP_CLUSTER'),
            'encrypted' => true,
        ],
        'host' => null,
        'port' => null,
        'timeout' => null,
    ],
    ....

修改 .env 中的配置:

PUSHER_APP_ID=YOUR_APP_ID
PUSHER_KEY=YOUR_APP_KEY
PUSHER_SECRET=YOUR_APP_SECRET

安装完成后,开始使用:

直接调用

// 消息载体
$payload = [
    'title' => $title,
    'message' => $message,
];

use Vinkla\Pusher\Facades\Pusher;
Pusher::trigger(
    'test-channel',
    'test-event',
    $payload,
    $excludeSocketId
);

或者

app('pusher')->trigger(
    'test-channel',
    'test-event',
    $payload,
    $excludeSocketId
);

例如简易调用下

Route::get('/broadcasting/pusher', function () {
    app('pusher')->trigger(
        'test-channel',
        'test-event',
        $payload,
        $excludeSocketId
    );
    return 'This is a Laravel Pusher Bridge Test!';
});

查看触发结果

登录 Pusher 网站,在 Debug Console 面板中可查看 Api 推送日志,也可以在 Web 上直接发起事件推送(不赘述了,和极光一个样)。

注入日志记录器

如果发现推送失败或者了解推送步骤,可以开启推送日志:


// 日志记录器
// 这里用到了 PHP7 匿名类的语法
$logger = new class {
    public function log(string $msg)
    {
        // 日志记录在 `storage/logs/laravel.log` 中
        \Log::info($msg);
    }
};

$pusher = app('pusher');

// 注入日志记录器
$pusher->set_logger($logger);

// 正常使用 ...
$pusher->trigger(
    'test-channel',
    'test-event',
    ['message' => 'Hello World']
);

日志文件内容如下:

[2017-08-27 00:23:01] local.INFO: Pusher: ->trigger received string channel "test-channel". Converting to array.
[2017-08-27 00:23:01] local.INFO: Pusher: create_curl( http://api.pusherapp.com:80/apps/your-app-id/events?auth_key=your-auth-key&auth_signature=ca561c9df94720ef9d8d157e65187601112b0df659ee783ee67a311712c5e70a&auth_timestamp=1503764581&auth_version=1.0&body_md5=f22dec0bcf6ef3d5f06f3f1d06dbfa06 )
[2017-08-27 00:23:01] local.INFO: Pusher: trigger POST: {"name":"test-event","data":"{\"text\":\"2017-08-27 00:23:01-hello\"}","channels":["test-channel"]}
[2017-08-27 00:23:03] local.INFO: Pusher: exec_curl error:
[2017-08-27 00:23:03] local.INFO: Pusher: exec_curl response: Array
(
    [body] => auth_key should be a valid app key
    [status] => 400
)

其实也可以看出,底层就是通过 CURL 发 HTTP POST 请求给 Pusher 云服务器。

其他触发方式

如果用的 PHP 框架不是 Laravel,我们也可以通过原生 PHP 方式发起推送:

首先 Composer 安装服务端 SDK:

composer require pusher/pusher-php-server

发起推送:

require __DIR__ . '/vendor/autoload.php';

$options = [
  'cluster' => 'ap1',
  'encrypted' => true
];

$pusher = new Pusher\Pusher(
  'c8d9bff0b5eaa518e5fc',
  '183fc63d5a19a60283c1',
  '390114',
  $options
);

$data['message'] = 'hello world';
$pusher->trigger('my-channel', 'my-event', $data);

通过 Event Broadcaster 集成调用

除了直接通过 Pusher Api 发起推送之外,也可以利用 Laravel 本身的 Event/Listener 机制来触发推送,Laravel Broadcaster 本身是 Laravel 的一个广播模块,Puhser 只是 Broadcaster 支持的其中一种通信驱动,其他支持的通信驱动还有 Redis、Socket.io 等。

开始集成:

0、修改 config/broadcasting.phpconnections.pusher 的配置。

1、创建一个 Event 类:

# 生成 `app/Events/ChatMessageWasReceived.php` 文件
php artisan make:event ChatMessageWasReceived

2、修改 ChatMessageWasReceived 类,实现 ShouldBroadcast 接口,增加 broadcastOn 方法:


class PusherEvent extends Event implements ShouldBroadcast
{
    use SerializesModels;

    public $text, $id;
    private $content;
    protected $title;

    public function __construct($text, $id, $content, $title)
    {
        $this->text    = $text;
        $this->id      = $id;
        $this->content = $content;
        $this->title   = $title;
    }

    /**
     * Get the channels the event should be broadcast on.
     * 推送到哪个频道
     *
     * @return array
     */
    public function broadcastOn()
    {
        return ['test-channel'];
    }

    // 自定义广播名称(可选)
    // 缺省事件名为类名:App\Events\PusherEvent
    public function broadcastAs()
    {
        return 'test-event';
    }
}

3、注意 ChatMessageWasReceived 类的所有 public 的成员变量,都将会自动同步到 Pusher 云服务器并推送给客户端(即消息 payload)。或者我们可以通过 broadcastWith 方法来自定义消息 payload

/**
 * 获取广播数据
 *
 * @return array
 */
public function broadcastWith()
{
    return [
      'text'    => $this->text,
      'content' => $this->content,
      'title'   => $this->title,
  ];
}

4、如何触发事件推送?

// 事件实例
$event = new \App\Events\ChatMessageWasReceived($message, $user);

event($event);

或者

broadcast($event);

或者

$manager = app(Illuminate\Broadcasting\BroadcastManager::class);
$manager->event($event);

或者

// 使用队列
$manager = app(Illuminate\Broadcasting\BroadcastManager::class);
$manager->queue($event);

event() 和 broadcast() 两个函数的区别

broadcast() 数还暴露了 toOthers() 方法以便允许你从广播接收者中排除当前用户:

broadcast(new ShippingStatusUpdated($update))->toOthers();

注意:toOthers() 实际是读取请求头中的 X-Socket-ID (可理解为当前连接ID)并做排除。

JS 客户端监听、接收事件

<script src="//js.pusher.com/3.0/pusher.min.js"></script>

<script>

// 打开 Pusher 的调试日志
Pusher.logToConsole = true;

// 定义 Pusher 实例
var pusher = new Pusher('');

// 当前连接的 X-Socket-ID
// 自己触发的操作,在广播时可用于排除掉自己(排我广播)
var currentSocketId = pusher.connection.socket_id;

// 定义频道、绑定监听事件
var channel = pusher.subscribe('test-channel');
channel.bind('test-event', function(data) {
  console.log(data);
  console.log(data.text);
});

</script>

可以使用 Pusher Debug Console 控制面板查看触发情况。

结语

这一章我们介绍了 Pusher 的概念和简单是使用,下一章介绍广播模块。

参考文章

Redis GEO 实现 LBS 搜索:附近的人

介绍

早在15年的 Redis 3.2 开始就已提供了 GEO 相关方法,可用来实现 LBS 搜索:

  • geoadd:增加某个地理位置的坐标
  • geopos:获取某个地理位置的坐标
  • geodist:获取两个地理位置的距离
  • georadius:根据给定地理位置坐标获取指定范围内的地理位置集合。
  • georadiusbymember:根据给定地理位置获取指定范围内的地理位置集合
  • geohash:获取某个地理位置的 geohash 值

GEOADD 添加位置

GEOADD key longitude latitude member-x [longitude latitude member-y ...]

GEOPOS 获取位置

GEOPOS key member-x [member-y ...]

GEODIST 计算两个成员的距离

GEODIST key member-x member-y m|km|ft|mi

GEORADIUS 半径搜索、获取指定范围内的成员

GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [ASC|DESC] [COUNT count]
  • m/km/ft/mi 指定的是计算范围时的单位
  • WITHCOORD 将位置的经纬度一并返回
  • WITHDIST 将位置与中心点之间的距离一并返回
  • 在默认情况下, GEORADIUS 和 GEORADIUSBYMEMBER 的结果是未排序的,ASC - 可以让查找结果根据距离从近到远排序,而 DESC 则从远到近排序;
  • COUNT 参数指定要返回的结果数量

GEORADIUSBYMEMBER

相当于 GETPOS + GEORADIUS 二合一。

GEOHASH

获取某个地理位置的 GEOHASH 值。GEOHASH 是将二维的经纬度转换成字符串 hash 值的算法。

GEOHASH key member-x [member-y ...]

实现原理

  1. 使用 GeoHash 保存地理位置的坐标。
  2. 使用有序集合(zSet)保存地理位置的集合。

GeoHash 是什么?

详情点击:GeoHash 核心原理解析

GeoHash 的思想是将二维的经纬度转换成一维的字符串,GeoHash 有以下三个特点:

  1. 字符串越长,表示的范围越精确。编码长度为8时,精度在19米左右,而当编码长度为9时,精度在2米左右。
  2. 字符串相似的表示距离相近,利用字符串的前缀匹配,可以查询附近的地理位置。这样就实现了快速查询某个坐标附近的地理位置。
  3. GeoHash 计算的字符串,可以反向解码出原来的经纬度。

参考文章