在現代的 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
方法可以同時被 database
和 broadcast
渠道呼叫,如果你希望 database
和 broacast
兩個渠道有不同的陣列展現方式,你需要定義 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就可以了