基于wokerman实现即时消息推送

目前网站有两个用到实时消息推送的功能,源码:https://github.com/wuzhc/team

  • 最新动态,实时显示用户的操作行为
  • 消息推送,如重要消息通知,任务指派等等
考虑的问题
  • 既然要实现即时,那就少不了socketio。因为项目是PHP写的,所以服务端直接用phpsocket.io
  • 我们应该保存离线消息,否则如果用户不在线,那就接受不到消息。这里我用mongodb来存储消息。

    • 一是消息不需要关联表,一条消息一个文档
    • 二是mongodb适合做海量数据存储,并且分片也很简单
    • 三是消息不需要永久存储,随着时间推移,消息的价值性越低,可以用固定集合来存储消息,当数据量达到一定值时覆盖最久的消息
  • 一个页面连接一个socket,若用户同时打开了多个页面(即一个用户会对应多个socketID),那么消息应该推送到用户每一个打开的页面。一个简单的做法就是把用户多个sockID加到group分组(socket.emit('group')),group分组名称可以是"uid:1",表示userID为1的用户,然后socket.broadcast.to('uid:1').emit('event_name', data);

就可以实现对多个页面推送消息

实时动态

图片描述

实时消息推送

图片描述

客户端
        // 初始化io对象
        var socket = io('http://' + document.domain + ':2120');
        // 当socket连接后发送登录请求
        socket.on('connect', function () {
            socket.emit('login', {
                uid: "<?=Yii::$app->user->id?>",
                companyID: "<?=Yii::$app->user->identity->fdCompanyID?>"
            });
        });
        // 实时消息,当服务端推送来消息时触发
        socket.on('new_msg', function (msg) {
            if (msg.typeID == '<?= \common\config\Conf::MSG_HANDLE?>') {
                var html = '<li>' +
                    '<a href="' + msg.url + '">' +
                    '<div class="pull-left">' +
                    '<img src="<?= $directoryAsset ?>/img/user2-160x160.jpg" class="img-circle" alt="User Image"/>' +
                    '</div>' +
                    '<h4 style="overflow:hidden;text-overflow:ellipsis;">' + msg.title + '</h4>' +
                    '<p style="overflow:hidden;text-overflow:ellipsis;">' + msg.content + '</p>' +
                    '</a>' +
                    '</li>';
                $('#msg-handle').prepend(html);

                var num = $('.msg-handle-num').eq(1).text() * 1;
                $('.msg-handle-num').text(num + 1);
            }
        });
        
        // 实时动态,当服务端推送来消息时触发
        socket.on('update_dynamic', function (msg) {
            var html = '<li>' +
                '<i class="portrait">' +
                '<img class="img-circle img-bordered-sm portrait-img" src="' + msg.portrait + '" alt="User Image"></i>' +
                '<div class="timeline-item">' +
                '<span class="time">' +
                '<i class="fa fa-clock-o"></i>' + msg.date + '</span>' +
                '<h3 class="timeline-header">' +
                '<a href="' + msg.url + '">' + msg.operator + '</a>' +
                '<span class="desc">' + msg.title + '</span>' +
                '</h3>' +
                '<div class="timeline-body">' + msg.content + '</div>' +
                '</div>' +
                '</li>';
            $('#dynamic-list').prepend(html);
        });
        
        // 在线人数
        socket.on('update_online_count', function (msg) {
            $('#online-people').html(msg);
        });
服务端
    /**
     * 消息推送
     */
    public function actionStart()
    {
        // PHPSocketIO服务
        $io = new SocketIO(2120);
        // 客户端发起连接事件时,设置连接socket的各种事件回调
        $io->on('connection', function ($socket) use ($io) {

            /** @var Connection $redis */
            $redis = Yii::$app->redis;

            // 当客户端发来登录事件时触发
            $socket->on('login', function ($data) use ($socket, $redis, $io) {

                $uid = isset($data['uid']) ? $data['uid'] : null;
                $companyID = isset($data['companyID']) ? $data['companyID'] : null;

                // uid和companyID应该做下加密 by wuzhc 2018-01-28
                if (empty($uid) || empty($companyID)) {
                    return ;
                }
                $this->companyID = $companyID;

                // 已经登录过了
                if (isset($socket->uid)) {
                    return;
                }

                $key = 'socketio:company:' . $companyID;
                $field = 'uid:' . $uid;

                // 用hash方便统计用户打开页面数量
                if (!$redis->hget($key, $field)) {
                    $redis->hset($key, $field, 0);
                }

                // 同个用户打开新页面时加1
                $redis->hincrby($key, $field, 1);

                // 加入uid分组,方便对同个用户的所有打开页面推送消息
                $socket->join('uid:'. $uid);

                // 加入companyID,方便对整个公司的所有用户推送消息
                $socket->join('company:'.$companyID);

                $socket->uid = $uid;
                $socket->companyID = $companyID;

                // 整个公司在线人数更新
                $io->to('company:'.$companyID)->emit('update_online_count', $redis->hlen($key));
            });

            // 当客户端断开连接是触发(一般是关闭网页或者跳转刷新导致)
            $socket->on('disconnect', function () use ($socket, $redis, $io) {
                if (!isset($socket->uid)) {
                    return;
                }

                $key = 'socketio:company:' . $socket->companyID;
                $field = 'uid:' . $socket->uid;
                $redis->hincrby($key, $field, -1);

                if ($redis->hget($key, $field) <= 0) {
                    $redis->hdel($key, $field);

                    // 某某下线了,刷新整个公司的在线人数
                    $io->to('company:'.$socket->companyID)->emit('update_online_count', $redis->hlen($key));
                }
            });
        });

        // 开始进程时,监听2121端口,用户数据推送
        $io->on('workerStart', function () use ($io) {
            /** @var Connection $redis */
            $redis = Yii::$app->redis;

            $httpWorker = new Worker('http://0.0.0.0:2121');
            // 当http客户端发来数据时触发
            $httpWorker->onMessage = function ($conn, $data) use ($io, $redis) {
                $_POST = $_POST ? $_POST : $_GET;
                switch (@$_POST['action']) {
                    case 'message':
                        $to = 'uid:' . @$_POST['receiverID'];
                        $_POST['content'] = htmlspecialchars(@$_POST['content']);
                        $_POST['title'] = htmlspecialchars(@$_POST['title']);

                        // 有指定uid则向uid所在socket组发送数据
                        if ($to) {
                            $io->to($to)->emit('new_msg', $_POST);
                        }

                        $companyID = @$_POST['companyID'];
                        $key = 'socketio:company:' . $companyID;
                        $field = 'uid:' . $to;

                        // http接口返回,如果用户离线socket返回fail
                        if ($to && $redis->hget($key, $field)) {
                            return $conn->send('offline');
                        } else {
                            return $conn->send('ok');
                        }
                        break;
                    case 'dynamic':
                        $to = 'company:' . @$_POST['companyID'];
                        $_POST['content'] = htmlspecialchars(@$_POST['content']);
                        $_POST['title'] = htmlspecialchars(@$_POST['title']);

                        // 有指定uid则向uid所在socket组发送数据
                        if ($to) {
                            $io->to($to)->emit('update_dynamic', $_POST);
                        }

                        $companyID = @$_POST['companyID'];
                        $key = 'socketio:company:' . $companyID;

                        // http接口返回,如果用户离线socket返回fail
                        if ($to && $redis->hlen($key)) {
                            return $conn->send('offline');
                        } else {
                            return $conn->send('ok');
                        }
                }

                return $conn->send('fail');
            };

            // 执行监听
            $httpWorker->listen();
        });

        // 运行所有的实例
        global $argv;
        array_shift($argv);

        if (isset($argv[2]) && $argv[2] == 'daemon') {
            $argv[2] = '-d';
        }

        Worker::runAll();
    }
例子
    /**
     * 新建任务
     * @param $projectID
     * @param $categoryID
     * @return string
     * @throws ForbiddenHttpException
     * @since 2018-01-26
     */
    public function actionCreate($projectID, $categoryID)
    {
        $userID = Yii::$app->user->id;

        if (!Yii::$app->user->can('createTask')) {
            throw new ForbiddenHttpException(ResponseUtil::$msg[1]);
        }

        if ($data = Yii::$app->request->post()) {
            if (empty($data['name'])) {
                ResponseUtil::jsonCORS(['status' => Conf::FAILED, 'msg' => '任务标题不能为空']);
            }

            // 保存任务
            $taskID = TaskService::factory()->save([
                'name'       => $data['name'],
                'creatorID'  => $userID,
                'companyID'  => $this->companyID,
                'level'      => $data['level'],
                'categoryID' => $categoryID,
                'projectID'  => $projectID,
                'content'    => $data['content']
            ]);

            if ($taskID) {
                $url = Url::to(['task/view', 'taskID' => $taskID]);
                $portrait = UserService::factory()->getUserPortrait($userID);
                $username = UserService::factory()->getUserName($userID);
                $title = '创建了新任务';
                $content = $data['name'];

                // 保存操作日志
                LogService::factory()->saveHandleLog([
                    'objectID'   => $taskID,
                    'companyID'  => $this->companyID,
                    'operatorID' => $userID,
                    'objectType' => Conf::OBJECT_TASK,
                    'content'    => $content,
                    'url'        => $url,
                    'title'      => $title,
                    'portrait'   => $portrait,
                    'operator'   => $username
                ]);

                // 动态推送
                MsgService::factory()->push('dynamic', [
                    'companyID'  => $this->companyID,
                    'operatorID' => $userID,
                    'operator'   => $username,
                    'portrait'   => $portrait,
                    'title'      => $title,
                    'content'    => $content,
                    'url'        => $url,
                    'date'       => date('Y-m-d H:i:s'),
                ]);

                ResponseUtil::jsonCORS(null, Conf::SUCCESS, '创建成功');
            } else {
                ResponseUtil::jsonCORS(null, Conf::FAILED, '创建失败');
            }
        } else {
            return $this->render('create', [
                'projectID' => $projectID,
                'category'  => TaskCategory::findOne(['id' => $categoryID]),
            ]);
        }
    }
调用保存操作日志底层接口
LogService::factory()->saveHandleLog($args)
参数说明
参数说明
operatorID操作者ID,用于用户跳转链接
companyID公司ID,用于该公司下的动态
operator操作者姓名
portrait操作者头像
receiver接受者,可选
title动态标题,例如创建任务
content动态内容,例如创建任务的名称
date动态时间
url动态跳转链接
token验证
objectType对象类型,例如任务,文档等等
objectID对象ID,例如任务ID,文档ID等等
  • companyID 用于查询该公司下的动态
  • objectType和objectID组合可以查询某个对象的操作日志,例如一个任务被操作的日志
保存消息
参数说明
senderID发送者ID,用于用户跳转链接
companyID公司ID,用于该公司下的动态
sender发送者姓名
portrait发送者头像
receiverID接受者ID
title动态标题,例如创建任务
content动态内容,例如创建任务的名称
date动态时间
url动态跳转链接
typeID消息类型,1系统通知,2管理员通知,3操作通知
  • receiverID和typeID和isRead组合可以查询用户未读消息
请求接口
MsgService::factory()->push($action, $args)
即时动态
参数说明
operatorID操作者ID,用于用户跳转链接
companyID公司ID,用于给该公司的所有socket推送
operator操作者姓名
portrait操作者头像
receiver接受者,可选
title动态标题,例如创建任务
content动态内容,例如创建任务的名称
date动态时间
url动态跳转链接
即时消息
参数说明
senderID发送者ID,用于用户跳转链接
sender发送者姓名
receiverID接受者ID,用于给接受者的所有socket推送消息
typeID消息类型,1系统通知,2管理员通知,3操作通知
portrait发送者头像
title消息标题
content消息内容
url消息跳转链接
完整代码参考:https://github.com/wuzhc/team
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值