Laravel + 微信小程式 websocket 搭建廣播訊息系統

yanthink發表於2019-02-24

在現代的 Web 應用中很多場景都需要運用到即時通訊,比如常見的掃碼登入,聊天室,廣播訊息等。

在過去,為了實現這種即時通訊(推送)通常都是使用Ajax輪詢。輪詢就是在指定的時間間隔內,進行HTTP 請求來獲取資料,而這種方式會產生一些弊端,一方面產生過多的HTTP請求,佔用頻寬,增大伺服器的相應,浪費資源,另一方面,因為不是每一次請求都會有資料變化(就像聊天室),所以就會造成請求的利用率低。

而 websocket 正好能夠解決上面的弊端,它是一種雙向協議,允許服務端主動推送資訊到客戶端。

Redis

在開始之前,我們需要開啟一個 redis 服務,並在 Laravel 應用中進行配置啟用,因為在整個流程中,我們需要藉助 redis 的訂閱和釋出機制來實現即時通訊。

安裝 Predis 庫

composer require predis/predis

配置

Redis 在應用中的配置檔案儲存在 config/database.php,在這個檔案中,你可以看到一個包含了 Redis 服務資訊的 redis 陣列:

'redis' => [
    'client' => 'predis',
    'default' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD', null),
        'port' => env('REDIS_PORT', 6379),
        'database' => 0,
    ],
],

廣播

配置

所有關於事件廣播的配置都儲存在 config/broadcasting.php 配置檔案中。 Laravel 自帶了幾個廣播驅動: Pusher 、 Redis , 和一個用於本地開發與除錯的 log 驅動,我們將使用 Redis 作為廣播驅動,這裡需要依賴 predis/predis 類庫。

通知

首先我們需要先建立一個資料庫表來存放通知,使用命令 notifications:table 生成包含特定表結構的遷移檔案。

php artisan notifications:table
php artisan migrate

然後我們建立一個 CommentArticle 類用來廣播使用者的文章評論通知:

php artisan make:notification CommentArticle
<?php

namespace App\Notifications;

use App\Models\Comment;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\BroadcastMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;

class CommentArticle extends Notification implements ShouldQueue
{
    use Queueable;

    protected $comment;

    public function __construct(Comment $comment)
    {
        $this->comment = $comment;
    }

    public function via()
    {
        return ['database', 'broadcast']; // 指定 broadcast 頻道系統會把通知訊息廣播出去
    }

    public function toArray()
    {
        return [
            'form_id' => $this->comment->id, // 評論id
            'form_user_id' => $this->comment->user_id, // 評論使用者id
            'form_user_name' => $this->comment->user->name, // 評論使用者名稱
            'form_user_avatar' => $this->comment->user->user_info->avatarUrl,
            'content' => $this->comment->content, // 評論內容
            'target_id' => $this->comment->target_id, // 文章id
            'target_name' => $this->comment->target->title, // 文章標題
        ];
    }
}

toArray 方法可以同時被 databasebroadcast 渠道呼叫,如果你希望 databasebroacast 兩個渠道有不同的陣列展現方式,你需要定義 toDatabase 或者 toBroadcast 以取代定義 toArray 方法。

自定義通知訊息廣播渠道

自定義通知實體接受其廣播臺通知的頻道,我們需要在通知實體上定義一個 receivesBroadcastNotificationsOn 方法

編輯 App\Models\User

<?php

namespace App\Models;

use Eloquent;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Notifications\Notifiable;
use Illuminate\Auth\Authenticatable;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Auth\Passwords\CanResetPassword;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Eloquent implements
    AuthenticatableContract,
    AuthorizableContract,
    CanResetPasswordContract,
    JWTSubject
{
    use Authenticatable, Authorizable, CanResetPassword, Notifiable;

    protected $table = 'users';

    protected $fillable = ['name', 'email', 'password'];

    protected $hidden = ['password', 'remember_token'];

    public function receivesBroadcastNotificationsOn()
    {
        return 'notification.' . $this->id;
    }

    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
}

傳送通知

首先我們給 App\Models\Comment Model 註冊一個觀察者事件監聽,當使用者新建一條評論的時候我們給文章作者傳送一條通知。

<?php

namespace App\Providers;

use App\Models\Comment;
use App\Observers\CommentObserver;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Comment::observe(CommentObserver::class);
    }
}
<?php

namespace App\Observers;

use App\Models\Article;
use App\Models\Comment;
use App\Notifications\CommentArticle;

class CommentObserver
{
    public function created(Comment $comment)
    {
        if ($comment->target_type == Article::class) {
            if ($comment->target->author_id != $comment->user_id) {
                $comment->target->author->notify(new CommentArticle($comment));
            }
        }
    }
}

websocket 服務

socket.io 支援的協議版本(4)和 微信小程式 websocket 協議版本(13)不一致,微信小程式 websocket 是無法連上服務的,所以我們選用 websockets/ws 來做 websocket 服務

安裝相關依賴

npm i -S ws ioredis ini jsonwebtoken

我們在 Laravel 專案根目錄下新建一個server.js

const WebSocket = require('ws')
const Redis = require('ioredis')
const fs = require('fs')
const ini = require('ini')
const jwt = require('jsonwebtoken')

const config = ini.parse(fs.readFileSync('./.env', 'utf8')) // 讀取.env配置

const wss = new WebSocket.Server({
    port: 6001,
    clientTracking: false,
    verifyClient ({ req }, cb) {
        try {
            const { authorization } = req.headers
            const token = authorization.split(' ')[1]
            const jwtSecret = env('JWT_SECRET')
            const algorithm = env('JWT_ALGO', 'HS256')

            const { sub, nbf, exp } = jwt.verify(token, jwtSecret, { algorithm })

            // if (Date.now() / 1000 + 30 * 60 > exp) {
            //     cb(false, 401, 'token已過期.')
            // }
            //
            // if (Date.now() /1000 < nbf) {
            //     cb(false, 401, 'token在(nbf)時間之前不能被接收處理.')
            // }

            if (!(sub > 0)) {
                cb(false, 401, '無法驗證令牌簽名.')
            }

            cb(true)
        } catch (e) {
            cb(false, 401, 'Token could not be parsed from the request.')
        }

    },
})

const clients = {}
wss.on('connection', (ws, req) => {
    try {
        const { authorization } = req.headers
        const token = authorization.split(' ')[1]
        const jwtSecret = env('JWT_SECRET')
        const algorithm = env('JWT_ALGO', 'HS256')

        const { sub } = jwt.verify(token, jwtSecret, {algorithm})

        if (clients[sub] && clients[sub].readyState === 1) {
            try {
                clients[sub].close()
            } catch (e) {
                //
            }
        }

        ws.user_id = sub
        clients[sub] = ws
    } catch (e) {
        ws.close()
    }

    ws.on('message', message => { // 接收訊息事件
        console.info('message:%s', message)
    }) 

    ws.on('close', () => { // 關閉連結事件
        if (ws.user_id) {
            try {
                delete clients[ws.user_id]
            } catch (e) {
                //
            }
        }
    })
})

// redis 訂閱

const redis = new Redis({
    port: env('REDIS_PORT', 6379),          // Redis port
    host: env('REDIS_HOST', '127.0.0.1'),   // Redis host
    // family: 4,           // 4 (IPv4) or 6 (IPv6)
    password: env('REDIS_PASSWORD', null),
    db: 0,
})

redis.psubscribe('*', function (err, count) {
})

redis.on('pmessage', (subscrbed, channel, message) => { // 接收 laravel 推送的訊息
    console.info('[%s] %s %s', getNowDateTimeString(), channel, message)

    const { event, data } = JSON.parse(message)
    switch (event) {
        case 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated':
            const userId = channel.split('.')[1]
            if (clients[userId] && clients[userId].readyState === 1) { // 只給對應的使用者傳送通知
                clients[userId].send(message)
            }
        break
    }
})

function env(key, def = '') {
    return config[key] || def
}

function getNowDateTimeString () {
    const date = new Date()
    return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`
}

啟動服務

node server.js

Nginx配置實現ssl反向代理

upstream websocket {
    server 127.0.0.1:6001; # websocket 服務
}

server {
    listen 80;
    server_name xxx.com www.xxx.com;
    rewrite ^(.*) https://www.xxx.com$1 permanent;
}

server {
    listen 443 ssl;
    server_name xxx.com  www.xxx.com;

    root "/www/code/blog";
    index index.html index.htm;

    #add_header Content-Security-Policy "default-src 'self';script-src 'self' 'unsafe-inline' 'unsafe-eval' hm.baidu.com;style-src 'self' 'unsafe-inline';img-src 'self' data: *.einsition.com http://*.einsition.com hm.baidu.com;font-src 'self' data:;";
    add_header X-Content-Type-Options "nosniff";
    add_header X-XSS-Protection "1";

    charset utf-8;

    set $realip $remote_addr;
    if ($http_x_forwarded_for ~ "^(\d+\.\d+\.\d+\.\d+)") {
        set $realip $1;
    }
    fastcgi_param REMOTE_ADDR $realip;

    location ~ ^/api/ {
        rewrite /api/(.+)$ /$1 break;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass https://api.xxx.com;
    }

    location /wss {
        access_log /www/log/nginx/www.xxx.com-websocket-access.log;
        error_log  /www/log/nginx/www.einsition.com-websocket-error.log;
        proxy_pass http://websocket; # 代理到 websocket 服務
        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_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        rewrite /wss/(.*) /$1 break;
        proxy_redirect off;
        proxy_read_timeout 300s;
    }

    location / {
        #if ($http_user_agent ~* "Baiduspider|360Spider|Sogou web spider") {
        #    proxy_pass http://spider.xxx.com;
        #}
        try_files $uri $uri/ /index.html?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log /www/log/nginx/www.xxx.com-access.log main;
    error_log  /www/log/nginx/www.xxx.com-error.log error;

    sendfile off;

    gzip on;
    gzip_static on;
    gzip_min_length 5k;
    gzip_buffers 4 16k;
    gzip_http_version 1.1;
    gzip_comp_level 4;
    gzip_vary on;
    gzip_types text/plain application/javascript text/css application/json image/jpeg image/gif image/png;

    client_max_body_size 10m;

    location ~ /\.ht {
        deny all;
    }

    ssl_certificate /www/cert/blog/ssl.pem;
    ssl_certificate_key /www/cert/blog/ssl.key;
    ssl_session_timeout 5m;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;
}

websocket url:wss://www.xxx.com/wss

重啟 nginx 服務

service nginx reload

微信小程式連線 websocket

我們新建一個 utils/websocket.js 檔案:

import regeneratorRuntime from './runtime'
const { getAuthorization } = require('./authority')
const config = require('../config')

let socketTask = null

function createWebSocket () {
  (async () => {
    /*
    * readyState:
    * 0: 正在建立連線連線。
    * 1: 連線成功建立,可以進行通訊。
    * 2: 連線正在進行關閉握手,即將關閉。
    * 3: 連線已經關閉或者根本沒有建立。
    */
    if (socketTask && socketTask.readyState !== 3) {
      return false
    }

    socketTask = await wxConnectSocket(config.socketUrl, {
      header: {
        Authorization: getAuthorization(),
      },
    })

    heartbeat.reset().start() // 心跳檢測重置

    socketTask.onMessage(({ data: msg }) => { // 通知訊息
      heartbeat.reset().start()
      const { event, data } = JSON.parse(msg)
      switch (event) {
        case 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated':
          // 接收到通知訊息
          break
      }
    })

    socketTask.onClose((data) => {
      heartbeat.reset()
      // createWebSocket()
    })
  })()
}

function getSocketTask() {
  return socketTask
}

function wxConnectSocket(url, options) {
  const socketTask = wx.connectSocket({
    url,
    ...options,
  })

  wx.onSocketOpen(() => {
    console.info('websocket opened!')
  })

  wx.onSocketClose(() => {
    console.info('websocket fail!')
  })

  return new Promise((resolve, reject) => {
    socketTask.onOpen(() => {
      resolve(socketTask)
    })

    socketTask.onError(reject)
  })
}

const heartbeat = {
  timeout: 240000, // 4分鐘觸發一次心跳,避免 websocket 斷線
  pingTimeout: null,
  reset () {
    clearTimeout(this.pingTimeout)
    return this;
  },
  start () {
    this.pingTimeout = setTimeout(async () => {
      await wxApiPromiseWrap(socketTask.send.bind(socketTask), { data: 'ping' }) // 這裡傳送一個心跳
      this.reset().start()
    }, this.timeout)
  },
}

function wxApiPromiseWrap(fn, options = {}) {
  return new Promise((resolve, reject) => {
    fn({
      ...options,
      success: resolve,
      fail: reject,
    })
  })
}

module.exports = {
  createWebSocket,
  getSocketTask,
}

最後我們在登入完成後連線websocket就可以了

相關文章