uniapp websocket 前后端

自己编写 谨慎用于生产环境
6.27 修复运行中出现的异常导致的接不到消息
7.4 优化

场景

uniapp 小程序

需求

列表区自动推送更新,系统通知,动态通知,聊天室等..

描述

  1. 系统级的消息推送
  2. 页面级的推送,列表的自动刷新;聊天页面。
  3. 断线重连
  4. laravel + swoole

简单的 socket.js

本文是笔者自己摸索出来的,可靠性自然比不上socket.io,无奈目前只有微信小程序有开源的socket.io

import { isJson } from '@/utils/socket/helper.js'
import jwt from '@/utils/auth/jwt.js'
var Pool = {};   // Promise池
var IsConnected = false;    // 是否connect成功;
var IsOpen = false;     // 是否打开;
var IsLogin = false;    //是否登录到socket进行fd绑定
var heartTimer = null;      // 心跳句柄 如果服务器连接不可用 则重新连接
var reConnectTimer = null;   // 重连句柄
var user=null;
var callback={};
var that;

export default class PromiseWebSocket
{
    config = {
        url: '',
        debug: false,
        reConnectTime: 5000, // 断线重连检测时间间隔
        heartTime: 1 * 60 * 1000, //心跳间隔
    };

    // socket 重连
    _connectionLoop () {
        reConnectTimer = setInterval(
            () => {
                if( getApp().globalData.isLogin !== true ){
                    this.config.debug && console.warn('用户未登录!');
                    return;
                }
                if (!IsConnected || !IsOpen || !IsLogin) {
                    this.config.debug && console.log('连接中..')
                    this.initSocket(
                        // 下面的function 只会在open之后才会执行哦
                        function () {
                            user = jwt.getUser();
                            if(user){
                                that._send('login',{u: 'user:'+ user.user_id, token: user.socket_token}).then(res=>{
                                    IsLogin = true;console.log('登录socket成功');
                                    uni.showToast({icon:'none',title: '连接成功...',duration:2000});
                                }).catch(err=>{
                                    IsLogin = false;console.log('登录socket失败',err);
                                });
                            }
                        }
                    )
                }else{
                    this.config.debug && console.log('连接正常')
                }
            },
            this.config.reConnectTime
        )
    }

    // ----------初始化websocket连接----------
    initSocket (success) {

        if (IsConnected) {
            this.config.debug && console.log('已经建立连接')
            if(IsOpen){
                // 必须在open之后才能发送消息
                typeof success === 'function' && success()
            }
            return
        }
        uni.getNetworkType({
            success: (res) => {
                console.log('网络状态',res)
                if (res.networkType === 'none') {
                    this.config.debug && console.log('网络异常,无法连接');
                } else {
                    this.config.debug && console.log('网络正常,开始建立连接...');

                    uni.connectSocket({
                        url: this.config.url,
                        success: () => {
                            IsConnected = true;
                            this.config.debug && console.log('建立连接成功')
                            typeof success === 'function' && success()
                            this.config.debug && console.log('开始心跳...')
                            this._clearHeart();
                            this._startHeart();
                        },
                        fail: (err) => {
                            this.config.debug && console.log('建立连接失败',err);
                        }
                    });

                }
            }
        })
    }

    constructor(config){
        that = this;
        this.config = {
            url: config.url,
            debug: config.debug || this.config.debug,
            reConnectTime: config.reConnectTime || this.config.reConnectTime, // 断线重连检测时间间隔
            heartTime: config.heartTime || this.config.heartTime //心跳间隔
        };

        uni.onSocketOpen((header) => {
            IsConnected = true;
            IsOpen = true;
            this.config.debug && console.log('socket打开成功')
        })

        uni.onSocketMessage((e) => {
            try{
                const msg = isJson(e.data);
                if(!msg){
                    this.config.debug && console.log('不是json对象'); return;
                }else{
                    this.config.debug && console.log('收到消息:', msg)
                }

                const type = msg['type'];
                if( type == 'app' ){
                    let event = msg['event'];
                    if( callback.hasOwnProperty(event) ){
                        callback[event](msg);
                    }
                }else if( type == 'respon' ){
                    const uuid = msg['event'];
                    if(!uuid || !Pool[uuid]){ console.log('响应缺少event参数,或者非应答参数'); return;}
                    let data = msg['data'] || null
                    if (data.error === 0) {
                        Pool[uuid].resolve(data);
                    } else {
                        Pool[uuid].reject(data);
                    }
                    delete Pool[uuid]
                }else{
                    this.config.debug && console.log('缺少type参数或无此事件!');
                }
            }catch (e) {
                console.log('socket on message',e)
            }
        })

        uni.onSocketError((res) => {
            this.config.debug && console.error('发生错误', res)
            this._close();
        })

        uni.onSocketClose((res) => {
            this.config.debug && console.error('连接被关闭', res)
            this._close();
        })

        // 监听网络状态
        uni.onNetworkStatusChange((res) => {

            if (res.isConnected) {
                this.config.debug && console.log('监听到有网络服务')
            } else {
                this.config.debug && console.log('监听到没有网络服务')
                uni.showToast({icon:'none',title: '网络错误,请打开网络服务',duration:3000});
            }
        })
    }

    // 清理心跳
    _clearHeart () {
      clearInterval(heartTimer)
      heartTimer = null
    }

    // 开始心跳
    _startHeart () {
        heartTimer = setInterval(() => {
            if( user ){
                this._send('ping','user:'+ user.user_id).then(res=>{
                    IsLogin = true;
                    this.config.debug && console.log('socket身份验证正常');
                }).catch(err=>{
                    IsLogin = false;
                    this.config.debug && console.log('socket身份验证失败重新登入');
                });
            }
        }, this.config.heartTime)
    }

    /**
     * 发送socket消息
     * @param string event 事件名称 ask 响应式问答 | ping
     * @param object data  请求数据 必须json对象或者空对象{}或者不传值
     */
    _send (event, data) {
        let message = { event, data };
        const uuid = (new Date()).getTime();
        return new Promise((resolve, reject) => {
            if ( IsConnected && IsOpen ) {
                if (!Pool[uuid]) {
                    Pool[uuid] = { message, resolve, reject }
                }
                this.config.debug && console.log('发送消息:',  message);
                message.uuid = uuid;
                uni.sendSocketMessage({
                    data: JSON.stringify(message),
                    success: (res) => {
                        console.log(res,'发送请求成功..')
                    },
                    fail: (fail) => {
                        that._close();
                        console.log(fail,'发送请求失败..')
                    }
                })
            } else {
                this.config.debug && console.log('PING..socket 未打开:',  message);
            }
      })
    }

    // 主动关闭
    _close (option) {
        IsConnected = false;
        IsOpen = false;
        IsLogin = false;
        //user = null;

        this.config.debug && console.log('关闭心跳');
        this._clearHeart();

        this.config.debug && console.log('主动退出');
        uni.closeSocket(option);
    }

    on (event,func){
        if(typeof func === 'function'){
            callback[event] = func;
        }
        console.log(callback,'自定义callback列表')
    }

    uninstall (event){
        delete(callback[event]);
        console.log(callback,'自定义callback列表')
    }
}

helper.js

/**
 * 是否是json字符串 如果是直接返回json对象
 * @param str
 */
exports.isJson = function (str) {
    if (typeof str === 'object')
        return str;
    try {
        str = str.replace(/\ufeff/g, "");
        var obj = JSON.parse(str);
        return !!(typeof obj === 'object' && obj) ? obj : false;
    }
    catch (e) {
        return false;
    }
};

app.vue 中在应用启动时建立 socket 连接

<script>
    import Vue from 'vue'
    import UniSocketPromise from "@/utils/socket/socket.js"
    export default {
        onLaunch: function() {
            console.log('App Launch');
            // 登录检测
            var user = this.checkLogin();
            if( !user ){this.globalData.isLogin = false;}

            // websocket
            this.globalData.socket = new UniSocketPromise({
                url: "ws://4.2.7.2:9502",
                debug: true,
                reConnectTime: 5*1000,
                heartTime: 30 * 1000
            });

            // 连接
            this.globalData.socket._connectionLoop();

            // 系统消息
            this.globalData.socket.on('user_tabbar',function(msg){
                let sound = uni.getStorageSync('sound')
                if( !sound || sound == 1 ){
                    const innerAudioContext = uni.createInnerAudioContext();
                    innerAudioContext.src = '/static/voice/2.mp3';
                    innerAudioContext.play();
                }
                let index = Number(msg.msg);
                uni.setTabBarBadge({index: index,text:'新消息'})
            })
        },
        onShow: function() {
            console.log('App Show');
        },
        onHide: function() {
            console.log('App Hide')
        },
        globalData:{
            isLogin : true,
            socket: null
        }
    }
</script>

<style>
</style>

页面级的连接

<template>
    <view>
        <view>chat 聊天页面</view>
        <scroll-view  scroll-y="true" :scroll-top="scrollTop" @scroll="scroll">
        </scroll-view>
    </view>
</template>

<script>
    var that;
    export default {
        data() {
            return {
                chat_id:0,
                scrollTop:0,
                old: {
                    scrollTop: 0
                },
            }
        },
        onLoad(option){
            that = this;
            this.chat_id = option.chat_id;
            getApp().globalData.socket.on('chat'+this.chat_id,function(msg){
                console.log('聊天页面收到消息',msg)
                that.pageInit(that.chat_id);
                that.scrollTop = that.old.scrollTop
                setTimeout(function(){
                    that.$nextTick(function() {
                        that.scrollTop = 99999
                    });
                },500)
            })
        },
        onShow() {
            this.pageInit(this.chat_id);
        },
        onReady() {
            this.scrollTop = 99999; // 拉到底部
        },
        onUnload(){
            // 页面卸载时 卸载页面socket事件
            console.log('chat on onUnload!!!!!')
            getApp().globalData.socket.uninstall('chat'+this.chat_id)
        },
        methods: {
            scroll:function(e){
                this.old.scrollTop = e.detail.scrollTop
            },
            pageInit(chat_id){
                //拉取聊天记录
            }
        },
        components: {}
    }
</script>

<style>
</style>

后端 laravel swoole

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Library\SwConsTool;

class SwooleServer extends Command
{
    protected $signature = 'swoole:server';
    protected $description = 'swoole 服务器';
    public function __construct(){
        parent::__construct();
    }
    protected $server;

    public function handle()
    {
        $this->server = new \Swoole\WebSocket\Server("0.0.0.0", 9502);
        $this->server->set([
            'worker_num' => 1,
            'daemonize' => true,
            'debug_mode' => 1,
            'log_file' => '/www/wwwroot/swoole_log.txt',
        ]);
        /**
         * 打开
         */
        $this->server->on('open', function (\Swoole\WebSocket\Server $server, $request) {
            echo "server: handshake success with fd{$request->fd}\n";
        });
        /**
         * 收到消息
         */
        $this->server->on('message', function (\Swoole\WebSocket\Server $server, $frame) {
            echo "receive from {$frame->fd}:{$frame->data},opcode:{$frame->opcode},fin:{$frame->finish}\n";
            $receive = json_decode($frame->data,true);
            $event = @$receive['event']; // 事件名称
            $key = @$receive['uuid'];
            $message = @$receive['data']; // 携带的数据

            try{
                // 用户连接后绑定fd与id
                if( $event == 'login' ){
                    try{
                        //{"event":"login","data":{"u":"user:6","token":""}}
                        $u = $message['u'];
                        echo 'U:'.$u.'发来登录请求'.PHP_EOL;
                        $u_arr = explode(':',$u);
                        $token = $message['token'];
                        list($role,$uid) = $u_arr;
                        if( $role == 'user'){
                            if( $token == md5($uid.'user') ){
                                SwConsTool::bindFd($u,$frame->fd);
                                echo 'U:'.$u.'用户登录成功'.PHP_EOL;
                                $server->push($frame->fd, $this->respon($key,0,'用户登录成功') );
                            }else{
                                echo 'U:'.$u.'用户身份验证失败'.PHP_EOL;
                                $server->push($frame->fd, $this->respon($key,1,'用户身份验证失败') );
                            }
                        }elseif( $role == 'xxx' ){

                        }else{
                            echo 'U:'.$u.'不明身份'.PHP_EOL;
                            $server->push($frame->fd, $this->respon($key,1,'不明身份') );
                        }
                    } catch (\Exception $e) {
                        echo '出现错误,用户登录失败'.PHP_EOL;
                        $server->push($frame->fd, $this->respon($key,1,'出现错误,用户登录失败') );
                    }
                }elseif( $event == 'ping' ){
                    //{"event":"ping","data":'user:6'}
                    try{
                        $u = $message;
                        echo 'U:'.$u.'发来检查连接请求'.PHP_EOL;
                        $ping_fd = SwConsTool::getFdByKey($u);
                        if($ping_fd && $frame->fd == $ping_fd){
                            echo 'U:'.$u.'PING检查连接可用'.PHP_EOL;
                            SwConsTool::active($u);
                            $server->push($frame->fd, $this->respon($key,0,'PING检查连接可用') );
                        }else{
                            echo 'U:'.$u.'PING检查连接不可用'.PHP_EOL;
                            $server->push($frame->fd, $this->respon($key,1,'PING检查连接不可用') );
                        }
                    } catch (\Exception $e) {
                        echo 'PING检查连接服务器出现错误'.PHP_EOL;
                        $server->push($frame->fd, $this->respon($key,1,'PING检查连接服务器出现错误') );
                    }
                }
            } catch (\Exception $e) {
                echo 'ONMESSAGE服务器出现错误'.PHP_EOL;
                $server->push($frame->fd, $this->respon($key,1,'ONMESSAGE服务器出现错误') );
            }
        });
        /**
         * 关闭
         */
        $this->server->on('close', function ($server, $fd) {
            echo "client {$fd} closed\n";
            SwConsTool::delUserByFd($fd);
        });
        $this->server->on('request', function (\Swoole\Http\Request $request, \Swoole\Http\Response $response) {
            $post = $request->post;
            $event = @$post['event']; //名称
            $json2Arr = json_decode($post['msg'],true);
            $post['msg'] = is_null($json2Arr) ? $post['msg'] : $json2Arr;
            $to = @$post['to'];

            switch ($event) {
                // 小程序底部 tobar 和 列表显示小红点
                case $event == 'user_tabbar':
                if( $to != '' ){
                    $this->sendMsg($post, $to);
                    $response->end('ok');
                }
                break;
                // 聊天
                case 'chat':
                    if( $to != '' ){
                        $this->sendMsg($post, $to);
                       $response->end('ok');
                    }
                break;
                default:
                    $response->end('fail');
                break;
            }
        });
        $this->server->start();
    }

    /**
     * data 数组
     * to 发送人 数组或,分割的字符串
     */
    public function sendMsg($data, $to){
        $to_list = is_array($to)? $to : explode(',',trim($to,','));
        var_dump($to_list,'发送人列表');
        var_dump($data,'发送数据');
        foreach( $to_list as $v){
            $fd =  SwConsTool::getFdByKey($v);
            if ($this->server->isEstablished($fd)) {
                $this->server->push($fd,json_encode($data));
            }
        }
    }

    // 应答
    public function respon($key,$errno,$body){
        $respon['type'] = 'respon';
        $respon['event'] = $key;
        $respon['data'] = [
            'error' => $errno,
            'body'  => $body
        ];
        return json_encode($respon);
    }
}

消息转发 Job 任务

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Library\Y;
use Illuminate\Support\Facades\Log;

class Notice implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 2; // 最大尝试次数
    public $timeout = 30; // 任务执行最大秒数

    protected $data;

    public function __construct($data=[])
    {
        Log::error('job notice: 收到消息');
        Log::info($data);
        $this->data = $data;
    }

    public function handle()
    {
        $data = $this->data;

        Log::info('job notice: 处理消息');
        Log::info($data);

        if( !isset($data['event']) || !isset($data['msg']) ){
            return;
        }
        if( !isset($data['to']) || empty($data['to']) ){
            $data['to'] = '';
        }

        $data['msg']  = is_array($data['msg'])?json_encode($data['msg']):$data['msg'];

        $data['type'] = 'app'; // 为了和应答区别
        Y::curl('http://127.0.0.1:9502',$data,1); // 发送到swoole服务器
    }

    //要处理的失败任务
    public function failed(\Exception $exception)
    {
        Log::error('notice:' . $exception->getMessage());
        Log::error('notice:' . $exception->getTraceAsString());
    }
}

消息投递

<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

use App\Jobs\Notice;

class TestController extends Controller
{
    public function index(){
    $user_id = 999; //用户ID
    $chat_id = 1; //聊天室ID
Notice::dispatch(['event'=>'chat'.$chat_id,'msg'=>'reload','to'=>'user:'.$user_id]);
    Notice::dispatch(['event'=>'user_tabbar','msg'=>0,'to'=>'user:'.$user_id]);
    }
}
本作品采用《CC 协议》,转载必须注明作者和本文链接

好的代码就是一种艺术

《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 4

可以,看到论坛里偶尔有人讨论uni-app。 去年刚入手的uni-app,解决了小程序 APP两头开发的痛点……真是省事多了。

2个月前 评论
lianglunzhong 2个月前
xiaogui 1个月前

uni-app是真的很好用~!h5 + 微信小程序妥妥的!别的没有尝试发布

2个月前 评论

6.27 修复 Bug

1个月前 评论

7.4 优化

1个月前 评论

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!