Laravel Broadcast——廣播系統原始碼剖析

leoyang發表於2017-12-17

前言

本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/laravel-source-analysis

在現代的 web 應用程式中,WebSockets 被用來實現需要實時、即時更新的介面。當伺服器上的資料被更新後,更新資訊將通過 WebSocket 連線傳送到客戶端等待處理。相比於不停地輪詢應用程式,WebSocket 是一種更加可靠和高效的選擇。

我們先用一個電子商務網站作為例子來概覽一下事件廣播。當使用者在檢視自己的訂單時,我們不希望他們必須通過重新整理頁面才能看到狀態更新。我們希望一旦有更新時就主動將更新資訊廣播到客戶端。

laravel 的廣播系統和佇列系統類似,需要兩個程式協作,一個是 laravelweb 後臺系統,另一個是 Socket.IO 伺服器系統。具體的流程是頁面載入時,網頁 js 程式 Laravel EchoSocket.IO 伺服器建立連線, laravel 發起通過驅動釋出廣播,Socket.IO 伺服器接受廣播內容,對連線的客戶端網頁推送資訊,以達到網頁實時更新的目的。

laravel 發起廣播的方式有兩種,redispusher。對於 redis 來說,需要支援 Socket.IO 伺服器系統,官方推薦 nodejs 為底層的 tlaverdure/laravel-echo-server。對於 pusher 來說,該第三方服務包含了驅動與 Socket.IO 伺服器。

本文將會介紹 redis 為驅動的廣播原始碼,由於 laravel-echo-servernodejs 編寫,本文也無法介紹 Socket.IO 方面的內容。

 

廣播系統服務的啟動

和其他服務類似,廣播系統服務的註冊實質上就是對 Ioc 容器註冊門面類,廣播系統的門面類是 BroadcastManager:

class BroadcastServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton(BroadcastManager::class, function ($app) {
            return new BroadcastManager($app);
        });

        $this->app->singleton(BroadcasterContract::class, function ($app) {
            return $app->make(BroadcastManager::class)->connection();
        });

        $this->app->alias(
            BroadcastManager::class, BroadcastingFactory::class
        );
    }
}

除了註冊 BroadcastManagerBroadcastServiceProvider 還進行了廣播驅動的啟動:

public function connection($driver = null)
{
    return $this->driver($driver);
}

public function driver($name = null)
{
    $name = $name ?: $this->getDefaultDriver();

    return $this->drivers[$name] = $this->get($name);
}

protected function get($name)
{
    return isset($this->drivers[$name]) ? $this->drivers[$name] : $this->resolve($name);
}

protected function resolve($name)
{
    $config = $this->getConfig($name);

    if (is_null($config)) {
        throw new InvalidArgumentException("Broadcaster [{$name}] is not defined.");
    }

    if (isset($this->customCreators[$config['driver']])) {
        return $this->callCustomCreator($config);
    }

    $driverMethod = 'create'.ucfirst($config['driver']).'Driver';

    if (! method_exists($this, $driverMethod)) {
        throw new InvalidArgumentException("Driver [{$config['driver']}] is not supported.");
    }

    return $this->{$driverMethod}($config);
}

protected function createRedisDriver(array $config)
{
    return new RedisBroadcaster(
        $this->app->make('redis'), Arr::get($config, 'connection')
    );
}

 

廣播資訊的釋出

廣播資訊的釋出與事件的釋出大致相同,要告知 Laravel 一個給定的事件是廣播型別,只需在事件類中實現 Illuminate\Contracts\Broadcasting\ShouldBroadcast 介面即可。該介面已經被匯入到所有由框架生成的事件類中,所以可以很方便地將它新增到自己的事件中。

ShouldBroadcast 介面要求你實現一個方法:broadcastOn. broadcastOn 方法返回一個頻道或一個頻道陣列,事件會被廣播到這些頻道。頻道必須是 ChannelPrivateChannelPresenceChannel 的例項。Channel 例項表示任何使用者都可以訂閱的公開頻道,而 PrivateChannelsPresenceChannels 則表示需要 頻道授權 的私有頻道:

class ServerCreated implements ShouldBroadcast
{
    use SerializesModels;

    public $user;

    //預設情況下,每一個廣播事件都被新增到預設的佇列上,預設的佇列連線在 queue.php 配置檔案中指定。可以通過在事件類中定義一個 broadcastQueue 屬性來自定義廣播器使用的佇列。該屬性用於指定廣播使用的佇列名稱:
    public $broadcastQueue = 'your-queue-name';

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function broadcastOn()
    {
        return new PrivateChannel('user.'.$this->user->id);
    }

    //Laravel 預設會使用事件的類名作為廣播名稱來廣播事件,自定義:
    public function broadcastAs()
    {
        return 'server.created';
    }

    //想更細粒度地控制廣播資料:
    public function broadcastWith()
    {
        return ['id' => $this->user->id];
    }

    //有時,想在給定條件為 true ,才廣播事件:
    public function broadcastWhen()
    {
        return $this->value > 100;
    }
}

然後,只需要像平時那樣觸發事件。一旦事件被觸發,一個佇列任務會自動廣播事件到你指定的廣播驅動器上。

當一個事件被廣播時,它所有的 public 屬性會自動被序列化為廣播資料,這允許你在你的 JavaScript 應用中訪問事件的公有資料。因此,舉個例子,如果你的事件有一個公有的 $user 屬性,它包含了一個 Elouqent 模型,那麼事件的廣播資料會是:

{
    "user": {
        "id": 1,
        "name": "Patrick Stewart"
        ...
    }
}

 

廣播發布的原始碼

廣播的釋出與事件的觸發是一體的,具體的流程我們已經在 event 的原始碼中介紹清楚了,現在我們來看唯一的不同:

public function dispatch($event, $payload = [], $halt = false)
    {
        list($event, $payload) = $this->parseEventAndPayload(
            $event, $payload
        );

        if ($this->shouldBroadcast($payload)) {
            $this->broadcastEvent($payload[0]);
        }

        ...
    }

    protected function shouldBroadcast(array $payload)
    {
        return isset($payload[0]) && $payload[0] instanceof ShouldBroadcast;
    }

    protected function broadcastEvent($event)
    {
        $this->container->make(BroadcastFactory::class)->queue($event);
    }

可見,關鍵之處在於 BroadcastManagerquene 方法:

public function queue($event)
{
    $connection = $event instanceof ShouldBroadcastNow ? 'sync' : null;

    if (is_null($connection) && isset($event->connection)) {
        $connection = $event->connection;
    }

    $queue = null;

    if (isset($event->broadcastQueue)) {
        $queue = $event->broadcastQueue;
    } elseif (isset($event->queue)) {
        $queue = $event->queue;
    }

    $this->app->make('queue')->connection($connection)->pushOn(
        $queue, new BroadcastEvent(clone $event)
    );
}

可見,quene 方法將廣播事件包裝為事件類,並且通過佇列釋出,我們接下來看這個事件類的處理:

class BroadcastEvent implements ShouldQueue
{
    public function handle(Broadcaster $broadcaster)
    {
        $name = method_exists($this->event, 'broadcastAs')
                ? $this->event->broadcastAs() : get_class($this->event);

        $broadcaster->broadcast(
            array_wrap($this->event->broadcastOn()), $name,
            $this->getPayloadFromEvent($this->event)
        );
    }

    protected function getPayloadFromEvent($event)
    {
        if (method_exists($event, 'broadcastWith')) {
            return array_merge(
                $event->broadcastWith(), ['socket' => data_get($event, 'socket')]
            );
        }

        $payload = [];

        foreach ((new ReflectionClass($event))->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
            $payload[$property->getName()] = $this->formatProperty($property->getValue($event));
        }

        return $payload;
    }

    protected function formatProperty($value)
    {
        if ($value instanceof Arrayable) {
            return $value->toArray();
        }

        return $value;
    }
}

可見該事件主要呼叫 broadcasterbroadcast 方法,我們這裡講 redis 的釋出:

class RedisBroadcaster extends Broadcaster
{
    public function broadcast(array $channels, $event, array $payload = [])
    {
        $connection = $this->redis->connection($this->connection);

        $payload = json_encode([
            'event' => $event,
            'data' => $payload,
            'socket' => Arr::pull($payload, 'socket'),
        ]);

        foreach ($this->formatChannels($channels) as $channel) {
            $connection->publish($channel, $payload);
        }
    }
}

protected function formatChannels(array $channels)
{
    return array_map(function ($channel) {
        return (string) $channel;
    }, $channels);
}

broadcast 方法運用了 redispublish 方法,對 redis 進行了頻道的資訊釋出。

 

頻道授權

對於私有頻道,使用者只有被授權後才能監聽。實現過程是使用者向 Laravel 應用程式發起一個攜帶頻道名稱的 HTTP 請求,應用程式判斷該使用者是否能夠監聽該頻道。在使用 Laravel Echo 時,上述 HTTP 請求會被自動傳送;儘管如此,仍然需要定義適當的路由來響應這些請求。

定義授權路由

我們可以在 Laravel 裡很容易地定義路由來響應頻道授權請求。

Broadcast::routes();

Broadcast::routes 方法會自動把它的路由放進 web 中介軟體組中;另外,如果你想對一些屬性自定義,可以向該方法傳遞一個包含路由屬性的陣列

Broadcast::routes($attributes);

定義授權回撥

接下來,我們需要定義真正用於處理頻道授權的邏輯。這是在 routes/channels.php 檔案中完成。在該檔案中,你可以用 Broadcast::channel 方法來註冊頻道授權回撥函式:

Broadcast::channel('order.{orderId}', function ($user, $orderId) {
    return $user->id === Order::findOrNew($orderId)->user_id;
});

channel 方法接收兩個引數:頻道名稱和一個回撥函式,該回撥通過返回 truefalse 來表示使用者是否被授權監聽該頻道。

所有的授權回撥接收當前被認證的使用者作為第一個引數,任何額外的萬用字元引數作為後續引數。在本例中,我們使用 {orderId} 佔位符來表示頻道名稱的「ID」部分是萬用字元。

授權回撥模型繫結

就像 HTTP 路由一樣,頻道路由也可以利用顯式或隱式 路由模型繫結。例如,相比於接收一個字串或數字型別的 order ID,你也可以請求一個真正的 Order 模型例項:

Broadcast::channel('order.{order}', function ($user, Order $order) {
    return $user->id === $order->user_id;
});

 

頻道授權原始碼分析

授權路由

class BroadcastManager implements FactoryContract
{
    public function routes(array $attributes = null)
    {
        if ($this->app->routesAreCached()) {
            return;
        }

        $attributes = $attributes ?: ['middleware' => ['web']];

        $this->app['router']->group($attributes, function ($router) {
            $router->post('/broadcasting/auth', BroadcastController::class.'@authenticate');
        });
    }
}

頻道專門有 Controller 來處理授權服務:

class BroadcastController extends Controller
{
    public function authenticate(Request $request)
    {
        return Broadcast::auth($request);
    }
}

Socket Io 伺服器對 javascript 程式推送資料的時候,首先會經過該 controller 進行授權驗證:

public function auth($request)
{
    if (Str::startsWith($request->channel_name, ['private-', 'presence-']) &&
        ! $request->user()) {
        throw new HttpException(403);
    }

    $channelName = Str::startsWith($request->channel_name, 'private-')
                        ? Str::replaceFirst('private-', '', $request->channel_name)
                        : Str::replaceFirst('presence-', '', $request->channel_name);

    return parent::verifyUserCanAccessChannel(
        $request, $channelName
    );
}

verifyUserCanAccessChannel 根據頻道與其繫結的閉包函式來驗證該頻道是否可以通過授權:

protected function verifyUserCanAccessChannel($request, $channel)
{
    foreach ($this->channels as $pattern => $callback) {
        if (! Str::is(preg_replace('/\{(.*?)\}/', '*', $pattern), $channel)) {
            continue;
        }

        $parameters = $this->extractAuthParameters($pattern, $channel, $callback);

        if ($result = $callback($request->user(), ...$parameters)) {
            return $this->validAuthenticationResponse($request, $result);
        }
    }

    throw new HttpException(403);
}

由於頻道的命名經常帶有 userid 等引數,因此判斷頻道之前首先要把 channels 中的頻道名轉為萬用字元 *,例如 order.{userid} 轉為 order.*,之後進行正則匹配。

extractAuthParameters 用於提取頻道的閉包函式的引數,合併 $request->user() 之後呼叫閉包函式。

protected function extractAuthParameters($pattern, $channel, $callback)
{
    $callbackParameters = (new ReflectionFunction($callback))->getParameters();

    return collect($this->extractChannelKeys($pattern, $channel))->reject(function ($value, $key) {
        return is_numeric($key);
    })->map(function ($value, $key) use ($callbackParameters) {
        return $this->resolveBinding($key, $value, $callbackParameters);
    })->values()->all();
}

protected function extractChannelKeys($pattern, $channel)
{
    preg_match('/^'.preg_replace('/\{(.*?)\}/', '(?<$1>[^\.]+)', $pattern).'/', $channel, $keys);

    return $keys;
}

public function validAuthenticationResponse($request, $result)
{
    if (is_bool($result)) {
        return json_encode($result);
    }

    return json_encode(['channel_data' => [
        'user_id' => $request->user()->getKey(),
        'user_info' => $result,
    ]]);
}

extractChannelKeys 用於將 order.{userid}order.23userid23 建立 keyvalue 關聯。如果 useridUser 的主鍵,resolveBinding 還可以為其自動進行路由模型繫結。

相關文章