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

参考文章:

理解 MonoLog 日志类库的工作流程

GitHub: https://github.com/Seldaek/monolog/

最佳实践

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\FirePHPHandler;

// 创建日志实例
$logger = new Logger('日志实例标识');

// 添加日志处理器
$logger->pushHandler(new StreamHandler(__DIR__ . '/my_app.log', Logger::DEBUG));
$logger->pushHandler(new FirePHPHandler());

// 开始记录
$logger->info('My logger is now ready');
$logger->error('My logger is now ready');

核心名词概念

1. Logger

日志实例对象(或者称日志场景)例如订单支付成功日志、队列失败日志等

$logger = new Logger('日志实例标识');

2. Handler

负责落地的日志处理器,例如 MailHanderRedisHandlerStreamHandler 等。

存放 Handler 的数据结构是一个“栈”,最后压入的的会被最先执行。 所有的 Handler 都会继承 AbstractProcessingHandler 并实现 write() 方法。构造函数有两个参数:

  • level 表示该 Handler 关心的最低日志级别(查看级别定义
  • bubble 表示日志被当前 Handler 处理后是否还需要继续传递

3. Formatter

定义了日志记录的格式。 每个 Handler 可以单独设置记录的日志格式,例如:

// 文件日志处理器
$handler = new StreamHandler(__DIR__.'/my_app.log', Logger::INFO);

// 转换为 JSON 记录
$handler->setFormatter(new JsonFormatter());

可以看到my_app.log 中记录的日志就变为 JSON 格式了。

4. Processor

额外信息添加器,可以给一条日志添加额外的信息。

Monolog 有两种方法可以记录除了 message (第一个参数)之外的信息。

(1) 记录时使用第二个参数 context 记录上下文

$logger->info('Adding a new user', ['username' => 'Seldaek']);

(2) 使用 Processors

Processors 可以是任何 callable 的对象(例如闭包函数和类方法)。

$logger->pushProcessor(function (array $record) {
    $record['extra']['dummy'] = 'Hello world!';
    return $record;
});

提示:Processors 不仅可以应用在 Logger 上,也可以应用在指定 Handler 上。

Monolog 提供的开箱即用的 Processors:

https://github.com/Seldaek/monolog/blob/master/doc/02-handlers-formatters-processors.md#processors

use Monolog\Processor\UidProcessor;
use Monolog\Processor\ProcessIdProcessor;

$logger = new Logger('my_logger');
$logger->pushHandler(new StreamHandler('...', Logger::INFO));

// 额外记录 $_REQUEST 等请求报文信息
$logger->pushProcessor(new WebProcessor);

// 额外记录进程ID
$logger->pushProcessor(new ProcessIdProcessor);

$logger->info('Adding a new user');

5. Message

记要记录的日志文本信息 参见数据结构

使用心得

查看 Monolog 自带的 Handler / Formatters / Processors 一览

Monolog/ErrorHandler

https://github.com/Seldaek/monolog/blob/master/src/Monolog/ErrorHandler.php

不要被它的类名骗了,它实际上一个助手类,主要负责注册并接管捕捉所有异常和错误(即通过 set_exception_handler + set_error_handler + register_shundown_function 接管所有错误异常),对于没有集成日志管理的 PHP 框架来说,非常有用。

原理详见 PHP7 Error & Exception 知识点整理

Wrapper Handler 装饰器

  • Monolog/Handler/FingersCrossedHandler

先缓冲住所有等级的日志,直到某条新日志达到了我们指定的等级(可理解为触发了我们设置的错误红线),所有日志才会批量落地,否则之前缓冲在 PHP 数组中的日志将被丢弃。

  • Monolog\Handler\BufferHandler

先将 $record 临时积攒在 PHP 数组中(即内存中),当达到一定条数后,再批量落地。

  • Monolog\Handler\DeduplicationHandler

继承于 BufferHandler。对一段时间内的 $record 进行缓冲并去重,可避免生产服短时间内大量重复的邮件错误报警。

  • Monolog\Handler\GroupHandler

对多个 Handler 进行分组,相当于使用时调用多次 pushHandler,这个包装器只是为了便于分组复用。

foreach ($this->handlers as $handler) {
    $handler->handle($record);
}
  • Monolog\Handler\WhatFailureGroupHandler

继承于 GroupHandler,在遍历循环处理时 try-catch-continue 遇到错误则忽略,不中断,让循环可以继续执行。

foreach ($this->handlers as $handler) {
    try {
        $handler->handle($record);
    } catch (\Throwable $e) {
        // What failure?
        // do nothing
    }
}

扩展自己的 Handler

例如 Monolog 没有现成的 Db 日志落地处理器(虽然不常用),我在 Laravel 里补充了一下,上代码:

use Monolog\Logger;
use Monolog\Handler\AbstractProcessingHandler;

// @see https://github.com/Seldaek/monolog/blob/master/doc/04-extending.md
class DatabaseHandler extends AbstractProcessingHandler
{
    protected $table;

    public function __construct(string $table, $level = Logger::INFO, $bubble = true)
    {
        $this->table = $table;

        parent::__construct($level, $bubble);
    }

    protected function write(array $record)
    {
        DB::table($this->table)->insert([
            'level'      => $record['level'],
            'level_name' => $record['level_name'],
            'channel'    => $record['channel'],
            'message'    => $record['message'],
            'context'    => toJson($record['context']),
            'extra'      => toJson($record['extra']),
            'created_at' => $record['datetime']->format('Y-m-d H:i:s'),
        ]);
    }
}

另一个完整示例,零信 LeanChatHandler

namespace App\Extensions\Logger;

use Monolog\Logger;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Handler\Curl\Util;

/**
 * 零信 Incoming 通知(扩展 Monolog)
 *
 * @author JiangJian <silverd@sohu.com>
 *
 * @see https://pubu.im/integrations
 * @see https://github.com/Seldaek/monolog/blob/master/doc/04-extending.md
 */

class LeanChatHandler extends AbstractProcessingHandler
{
    private $channel;

    public function __construct($channel, $level = Logger::ERROR, $bubble = true)
    {
        parent::__construct($level, $bubble);

        $this->channel = $channel;
    }

    protected function write(array $record)
    {
        $url = 'https://hooks.pubu.im/services/' . $this->channel;

        $ch = curl_init();

        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, [
            'text' => $record['message'],
        ]);

        Util::execute($ch);
    }
}

附录

如果是独立的项目想使用 Monolog 库,可以尝试 https://github.com/theorchard/monolog-cascade

这个库作用类似于 Laravel 5.6+ 封装的 config/logging.phpIlluminate\Log\LogManager,集中配置、管理日志。

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-ModifiedETag 的区别:

  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 限定返回的记录条数

缺点:

  1. 无法搜索指定半径内的记录(例如搜方圆3公里内的店)
  2. 在默认情况下,GEORADIUS 命令会返回所有匹配的位置元素。虽然可以用 COUNT 选项去获取前 N 个匹配元素,但是因为命令在内部可能会需要对所有被匹配的元素进行处理,所以在对一个非常大的区域进行搜索时,即使用了 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 计算的字符串,可以反向解码出原来的经纬度。

参考文章