Swoole 实现直播模块

1. 直播表设计;

表的设计
球队表
球员表
球员分数表(得分、助攻等)
直播赛事表
赛事战况表
用户聊天室表
// 球队表
// tinyint unsigned 0~255
// 默认设置 '',如果设置 NULL 不利于 MySQL 的性能
CREATE TABLE `live_team`(
	`id` tinyint(1) unsigned NOT NULL auto_increment,
	`name` varchar(32) NOT NULL DEFAULT '',
	`image` varchar(64) NOT NULL DEFAULT '',
	`type` tinyint(1) unsigned NOT NULL DEFAULT 0, 	//球队分区
	`create_time` int(10) unsigned NOT NULL DEFAULT 0,
	`update_time` int(10) unsigned NOT NULL DEFAULT 0,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT charset=utf8;

// 球员表
CREATE TABLE `live_player`(
	`id` int(10) unsigned NOT NULL auto_increment,
	`name` varchar(32) NOT NULL DEFAULT '',
	`image` varchar(64) NOT NULL DEFAULT '',	// 球员头像
	`age` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`position` tinyint(1) unsigned NOT NULL DEFAULT 0,	// 几号位
	`status` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`create_time` int(10) unsigned NOT NULL DEFAULT 0,
	`update_time` int(10) unsigned NOT NULL DEFAULT 0,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT charset=utf8;

// 直播赛事表
CREATE TABLE `live_game`(
	`id` int(10) unsigned NOT NULL auto_increment,
	`a_id` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`b_id` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`a_score` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`b_score` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`narrator` varchar(32) NOT NULL DEFAULT '',	// 直播员
	`name` varchar(32) NOT NULL DEFAULT '',
	`image` varchar(64) NOT NULL DEFAULT '',
	`start_time` int(10) unsigned NOT NULL DEFAULT 0,
	`status` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`create_time` int(10) unsigned NOT NULL DEFAULT 0,
	`update_time` int(10) unsigned NOT NULL DEFAULT 0,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT charset=utf8;

// 赛事战况表
CREATE TABLE `live_outs`(
	`id` int(10) unsigned NOT NULL auto_increment,
	`game_id` int(10) unsigned NOT NULL DEFAULT 0,	// 直播赛事 id
	`team_id` tiny_int(1) unsigned NOT NULL DEFAULT 0,	// 球队 id
	`content` varchar(256) NOT NULL DEFAULT '',
	`image` varchar(64) NOT NULL DEFAULT '',
	`type` tinyint(1) unsigned NOT NULL DEFAULT 0, 	// 第几节
	`status` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`create_time` int(10) unsigned NOT NULL DEFAULT 0,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT charset=utf8;

// 聊天室表
CREATE TABLE `live_chart`(
	`id` int(10) unsigned NOT NULL auto_increment,
	`game_id` int(10) unsigned NOT NULL DEFAULT 0,	// 直播赛事 id
	`user_id` tiny_int(1) unsigned NOT NULL DEFAULT 0,
	`content` varchar(256) NOT NULL DEFAULT '',
	`status` tinyint(1) unsigned NOT NULL DEFAULT 0,
	`create_time` int(10) unsigned NOT NULL DEFAULT 0,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT charset=utf8;

2. WebSocket 服务器搭建并支持 Http 服务;

  • WebSocket 机制是基于 Swoole 里的 Http Server,所以如果建立一个 WebSocket 脚本,是可以拥有 Http Server 的特性
  • 实例:新建 ws.php
cd /data/project/test/swoole/demo/server
vim ws.php
  • 写入以下内容(基于 http_server 基础上增加)
<?php

class Ws {
    CONST HOST = "0.0.0.0";
    CONST PORT = 8811;

    public $ws = null;
    public function __construct() {
        $this->ws = new swoole_websocket_server(self::HOST, self::PORT);

        $this->ws->set([
            'worker_num' => 4,
            'task_worker_num' => 4,
            'enable_static_handler' => true,    // 开启静态支持
            'document_root'	=> "/data/project/test/swoole/tp5/public/static",
        ]);

        // ws
        $this->ws->on("open", [$this, 'onOpen']);
        $this->ws->on("message", [$this, 'onMessage']);

        $this->ws->on("workerStart", [$this, 'onWorkerStart']);
        $this->ws->on("request", [$this, 'onRequest']);

        $this->ws->on("task", [$this, 'onTask']);
        $this->ws->on("finish", [$this, 'onFinish']);
        $this->ws->on("close", [$this, 'onClose']);


        $this->ws->start();
    }


    /**
     * @param $server
     * @param $worker_id
     */
    public function onWorkerStart($server,  $worker_id) {
        // 定义应用目录
        define('APP_PATH', __DIR__ . '/../application/');
        // 加载框架文件
        // 下次所有请求过来就不需要一一加载
        // 作用其它的回调时,能找到框架里的内容
        // require __DIR__ . '/../thinkphp/base.php';
        require __DIR__ . '/../thinkphp/start.php';
    }

    /**
     * request 回调
     * @param $request
     * @param $response
     */
    public function onRequest($request, $response) {
        $_SERVER = [];
        if(isset($request->server)) {
            foreach($request->server as $k => $v) {
                $_SERVER[strtoupper($k)] = $v;
            }
        }

        if(isset($request->header)) {
            foreach($request->header as $k => $v) {
                $_SERVER[strtoupper($k)] = $v;
            }
        }

        $_FILES = [];
        if(isset($request->files)) {
            foreach($request->files as $k => $v) {
                $_FILES[$k] = $v;
            }
        }

        $_GET = [];
        if(isset($request->get)) {
            foreach($request->get as $k => $v) {
                $_GET[$k] = $v;
            }
        }

        $_POST = [];
        if(isset($request->post)) {
            foreach($request->post as $k => $v) {
                $_POST[$k] = $v;
            }
        }

        $_POST['http_server'] = $this->ws;

        ob_start();
        try{
            think\Container::get('app', [APP_PATH])->run()->send();
        }catch(\Exception $e){
            // todo
        }

        $res = ob_get_contents();
        ob_end_clean();
        $response->end($res);
    }


    /**
     * @param $serv
     * @param $task_id
     * @param $workerId
     * @param $data
     */
    public function onTask($serv, $task_id, $workerId, $data){
        // 分发 task 任务机制,让不同的任务走不同逻辑
        $obj = new app\common\lib\task\Task;

        $method = $data['method'];
        // 执行投放过来的 $method 变量 方法
        // 这里需要判断一些值是否存在
        $flag = $obj->$method($data['data']);

        return $flag;

        // print_R($data);
        // return "on task finish";
    }


    /**
     * @param $serv
     * @param $taskId
     * @param $data
     */
    public function onFinish($serv, $taskId, $data){
        // 这里的 $data 是 onTask() 方法 return 的内容
        echo "taskId:{$taskId}\n";
        echo "finish-data-success:{$data}\n";
    }


    /**
     * 监听 ws 连接事件
     * @param $ws
     * @param $request
     */
    public function onOpen($ws, $request){
        var_dump($request->fd);

    }


    /**
     * 监听 ws 消息事件
     * @param $ws
     * @param $frame
     */
    public function onMessage($ws, $frame){
        // server 端收到来自客户端的消息后,就会向客户端发送消息
        // 后台解说员把直播赛事的数据推到这来的时候,就在 onMessage() 这里把所有连接到 WebSocket 的客户端
        // 也可以理解为用户安全看直播的页面,把数据都推给 client 端
        echo "server-push-message:{$frame->data}\n";
        // $ws->push($frame->fd, "server-push:" . date("Y-m-d H:i:s"));
    }


    /**
     * @param $ws
     * @param $fd
     */
    public function onClose($ws, $fd){
        echo "closed - clientId: {$fd} \n";
    }


}

new Ws();

3. 直播页面搭建;

赛事直播流程图
直播员去后台录入一些直播数据,然后把数据发到服务器(ws.php)
数据会进行两部分的操作。第一,数据会录入 MySQL 表里。第二,数据会推送给互联网用户(比如 10 万个用户打开了直播页面,就要把数据推送给这 10 万个用户)。
互联网用户观看直播(赛况直播页面)的时候,直播页面就找到 Http WebSocket 服务器,服务器把静态页面返回给用户。直播页面和服务器是通过 WebSocket 机制连接服务器。
在这里插入图片描述
  • 赛事直播员解说页面(后台)搭建:public/static/admin/live.html
<!DOCTYPE html>
<html>
  
  <head>
    <meta charset="UTF-8">
    <title>赛事直播-主持人页面</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width,user-scalable=yes, minimum-scale=0.4, initial-scale=0.8,target-densitydpi=low-dpi" />
    <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
    <link rel="stylesheet" href="./css/font.css">
    <link rel="stylesheet" href="./css/xadmin.css">
    <link rel="stylesheet" type="text/css" href="../webuploader/webuploader.css">
    <script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script>
    <script type="text/javascript" src="./lib/layui/layui.js" charset="utf-8"></script>
    <script type="text/javascript" src="./js/xadmin.js"></script>
    <script type="text/javascript" src="../webuploader/webuploader.js"></script>
    <!-- 让IE8/9支持媒体查询,从而兼容栅格 -->
    <!--[if lt IE 9]>
      <script src="https://cdn.staticfile.org/html5shiv/r29/html5.min.js"></script>
      <script src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></script>
    <![endif]-->
  
  </head>
  
  <body>
    <div class="x-body">
        <form class="layui-form">

          <div class="layui-form-item">
            <label for="username" class="layui-form-label">
              <span class="x-red">*</span>第几节
            </label>
            <div class="layui-input-inline">
              <select id="type" name="type" class="valid">
                <option value="1">第一节</option>
                <option value="2">第二节</option>
                <option value="3">第三节</option>
                <option value="4">第四节</option>
              </select>
            </div>
          </div>
          <div class="layui-form-item">
            <label for="username" class="layui-form-label">
              <span class="x-red">*</span>球队
            </label>
            <div class="layui-input-inline">
              <select id="team_id" name="team_id" class="valid">
                <option value="0">请选择</option>
                <option value="1">马刺</option>
                <option value="4">火箭</option>
              </select>
            </div>
          </div>


          <div class="layui-form-item layui-form-text">
              <label for="desc" class="layui-form-label">
                赛况内容
              </label>
              <div class="layui-input-block">
                  <textarea placeholder="请输入内容" id="content" name="content" class="layui-textarea"></textarea>
              </div>
          </div>

          <div class="layui-form-item layui-form-text">
            <label for="desc" class="layui-form-label">
              赛况图
            </label>
            <!--dom结构部分-->
            <div id="uploader-demo">
              <!--用来存放item-->
              <div id="fileList" class="uploader-list"></div>
              <div id="filePicker">选择图片</div>
            </div>

          </div>

          <div class="layui-form-item">
              <label for="L_repass" class="layui-form-label">
              </label>
              <button  type="submit" class="layui-btn"  lay-filter="add" id="submit-btn" lay-submit="">
                  增加
              </button>
          </div>
      </form>
    </div>
    <script>
      // webuploader 插件,官网直接复制过来代码会报错"$list is not defined",等等
      // 需要定义 $list
      var $ = jQuery,
      $list = $('#fileList'),
      // 优化retina, 在retina下这个值是2
      ratio = window.devicePixelRatio || 1,

      // 缩略图大小
      thumbnailWidth = 100 * ratio,
      thumbnailHeight = 100 * ratio,

      // Web Uploader实例
      uploader;

      // 创建Web Uploader实例
      // 初始化Web Uploader
      var uploader = WebUploader.create({

        // 选完文件后,是否自动上传。
        auto: true,

        // swf文件路径
        swf: 'http://192.168.2.214:8811/webuploader/Uploader.swf',

        // 文件接收服务端。
        server: 'http://192.168.2.214:8811/?s=admin/image/index',

        // 选择文件的按钮。可选。
        // 内部根据当前运行是创建,可能是input元素,也可能是flash.
        pick: '#filePicker',

        // 只允许选择图片文件。
        accept: {
          title: 'Images',
          extensions: 'gif,jpg,jpeg,bmp,png',
          mimeTypes: 'image/*'
        }
      });

      // 监听fileQueued事件,通过uploader.makeThumb来创建图片预览图。
      // PS: 这里得到的是Data URL数据,IE6IE7不支持直接预览。可以借助FLASH或者服务端来完成预览。
      // 当有文件添加进来的时候
      uploader.on( 'fileQueued', function( file ) {
        var $li = $(
                '<div id="' + file.id + '" class="file-item thumbnail">' +
                '<img>' + '</div>'
                ),
                $img = $li.find('img');


        // $list为容器jQuery实例
        $list.append( $li );

        // 创建缩略图
        // 如果为非图片文件,可以不用调用此方法。
        // thumbnailWidth x thumbnailHeight 为 100 x 100
        uploader.makeThumb( file, function( error, src ) {
          if ( error ) {
            $img.replaceWith('<span>不能预览</span>');
            return;
          }

          $img.attr( 'src', src );
        }, thumbnailWidth, thumbnailHeight );
      });

      // 然后剩下的就是上传状态提示了,
      // 当文件上传过程中, 上传成功,上传失败,
      // 上传完成都分别对应uploadProgress, uploadSuccess, uploadError, uploadComplete事件。
      // 文件上传过程中创建进度条实时显示。
      uploader.on( 'uploadProgress', function( file, percentage ) {
        var $li = $( '#'+file.id ),
                $percent = $li.find('.progress span');

        // 避免重复创建
        if ( !$percent.length ) {
          $percent = $('<p class="progress"><span></span></p>')
                  .appendTo( $li )
                  .find('span');
        }

        $percent.css( 'width', percentage * 100 + '%' );
      });

      // 文件上传成功,给item添加成功class, 用样式标记上传成功。
      uploader.on( 'uploadSuccess', function( file, response ) {
        if(response.status == 1){
          $( '#'+file.id ).append('<input type="hidden" name="image" value="'+ response.data.image +'">');
        }
        $( '#'+file.id ).addClass('upload-state-done');
      });

      // 文件上传失败,显示上传出错
      uploader.on( 'uploadError', function( file ) {
        var $li = $( '#'+file.id ),
                $error = $li.find('div.error');

        // 避免重复创建
        if ( !$error.length ) {
          $error = $('<div class="error"></div>').appendTo( $li );
        }

        $error.text('上传失败');
      });

      // 完成上传完了,成功或者失败,先删除进度条。
      uploader.on( 'uploadComplete', function( file ) {
        $( '#'+file.id ).find('.progress').remove();
      });



      // 最后提交信息
        var $submitBtn = $('#submit-btn');
        // 提交表单
        $submitBtn.click(function (event) {
          event.preventDefault();
          var formData = $('form').serialize();
          // TODO: 请求后台接口跳转界面,前端跳转或者后台跳
          $.get("http://192.168.2.214:8811/?s=admin/live/push&" + formData, function (data) {

            if (data.status == 1) {
              // 登录成功
            }
            // location.href='index.html';
          }, 'json');
        });



    </script>
   
  </body>

</html>
  • 图片 host 配置文件:新增 config/live.php
<?php

return [
    'host' => 'http://192.168.2.214:8811'
];
  • 图片上传接口:application/admin/controller/Image.php
<?php
namespace app\admin\controller;

use app\common\lib\Util;

class Image{


    // 图片上传
    public function index(){
        $file = request()->file('file');
        $info = $file->move('../public/static/upload');

        if($info){
            $data = [
                'image' =>config('live.host') . "/upload/" .$info->getSaveName(),
            ];
            return Util::show(config('code.success'), 'ok', $data);
        }else{
            return Util::show(config('code.error'), 'error');
        }
    }

}
  • 数据入库、push 到直播页面接口:application/admin/controller/Live.php
<?php
namespace app\admin\controller;

use app\common\lib\Util;

class Live{


    public function push(){
        // 数据入库
        print_r($_GET);

        // push 到直播页面
        $_POST['http_server']->push(7, 'hello-push-data');
    }

}
  • 直播页面引入的 js 文件:public/static/live/js/live.js
var wsUrl = "ws://192.168.2.214:8811";

var websocket = new WebSocket(wsUrl);

// 实例对象的 onopen 属性
websocket.onopen = function(evt){
    console.log("connected-swoole-success");
}

// 实例化 onmessage
websocket.onmessage = function(evt){
    console.log("ws-server-return-data:" + evt.data);
    // to do

}

// 实例化 close
websocket.onclose = function(evt){
    console.log("close");
}

// onerror
websocket.onerror = function(evt, e){
    console.log("error" + evt.data);
}

4. 赛事直播在线用户处理 - redis 方案;

  • 修改 config/redis.php
// 添加一下内容
'live_game_key' => 'live_game_key',    // 直播场景 key
  • 修改 application/common/lib/redis/Predis.php
// 添加 redis 集合的封装方法
/**
 * 集合添加元素
 * @param $key
 * @param $value
 * @return mixed
 */
public function sAdd($key, $value){
    return $this->redis->sAdd($key, $value);
}

/**
 * 删除集合元素
 * @param $key
 * @param $value
 * @return mixed
 */
public function sRem($key, $value){
    return $this->redis->sRem($key, $value);

}

/**
 * 获取集合的值
 * @param $key
 * @return array
 */
public function sMembers($key){
    return $this->redis->sMembers($key);
}

/**
 1. sAdd() 和 sRem() 优化
 2. @param $name
 3. @param $arguments
 4. @return array
 */
public function __call($name, $arguments){
    if(count($arguments) != 2){
        return '';
    }
    return $this->redis->name($arguments[0], $arguments[1]);
}
  • 修改 ws.php
// 修改以下方法
/**
 * 监听 ws 连接事件
 * @param $ws
 * @param $request
 */
public function onOpen($ws, $request){
    // 把 $request->fd 数据 放入集合(sets)
    \app\common\lib\redis\Predis::getInstance()->sAdd(config('redis.live_game_key'), $request->fd);
    var_dump($request->fd);

}

/**
 * @param $ws
 * @param $fd
 */
public function onClose($ws, $fd){
    // 把 $fd 数据 从有序集合里删除
    \app\common\lib\redis\Predis::getInstance()->sRem(config('redis.live_game_key'), $fd);
    echo "closed - clientId: {$fd} \n";
}
  • 修改 application/admin/controller/Live.php
// 修改以下方法
public function push(){
   // 数据入库
    print_r($_GET);

    // 获取连接的用户
    // 获取当前服务器有多少个连接,遍历推送:https://wiki.swoole.com/wiki/page/427.html
    // 方案 2:redis 集合
    // redis 集合最大成员数是 2^32-1,大概 40 亿
    $clients = Predis::getInstance()->sMembers(config('redis.live_game_key'));
    foreach ($clients as $fd) {
        $_POST['http_server']->push($fd, 'hello-push-data-' . $fd);
    }

    // 方案 3:Swoole table

}

5. 赛事直播功能逻辑开发。

  • 完善推送接口:application/admin/controller/Live.php
<?php
namespace app\admin\controller;

use app\common\lib\Util;
use app\common\lib\redis\Predis;

class Live{

    public function push(){
        if(empty($_GET)){
            return Util::show(config('code.error'), 'error');
        }

        // 查询 MySQL
        $teams = [
            1 => ['name' => '马刺', 'logo' => './imgs/team1.png'],
            4 => ['name' => '火箭', 'logo' => './imgs/team2.png']
        ];

        $data = [
            'type' => intval($_GET['type']),
            'title' => !empty($teams[$_GET['team_id']]['name']) ? $teams[$_GET['team_id']]['name'] : '直播员',
            'logo' => !empty($teams[$_GET['team_id']]['logo']) ? $teams[$_GET['team_id']]['logo'] : '',
            'content' => !empty($_GET['content']) ? $_GET['content'] : '',
            'image' => !empty($_GET['image']) ? $_GET['image'] : ''
        ];

        // push 到直播页面
        // $clients = Predis::getInstance()->sMembers(config('redis.live_game_key'));
        // foreach ($clients as $fd) {
        //    $_POST['http_server']->push($fd, json_encode($data));
        // }

        $taskData = [
            'method' => 'pushLive',
            'data' => $data
        ];

        $_POST['http_server']->task($taskData);
        return Util::show(config('code.success'), $code);

    }

}
  • 修改:public/static/live/js/live.js
var wsUrl = "ws://192.168.2.214:8811";

var websocket = new WebSocket(wsUrl);

// 实例对象的 onopen 属性
websocket.onopen = function(evt){
    console.log("connected-swoole-success");
}

// 实例化 onmessage
websocket.onmessage = function(evt){
    console.log("ws-server-return-data:" + evt.data);
    // to do
    push(evt.data)

}

// 实例化 close
websocket.onclose = function(evt){
    console.log("close");
}

// onerror
websocket.onerror = function(evt, e){
    console.log("error" + evt.data);
}


function push(data){
    data = JSON.parse(data);
    html = '<div class="frame">';
    html += '<h3 class="frame-header">';
    html += '<i class="icon iconfont icon-shijian"></i>第'+data.type+'节 01:30';
    html += '</h3>';
    html += '<div class="frame-item">';
    html += '<span class="frame-dot"></span>';
    html += '<div class="frame-item-author">';
    if(data.logo){
        html += '<img src="'+data.logo+'" width="20px" height="20px" />';
    }
    html += data.title;
    html += '</div>';
    html += '<p>'+data.content+'</p>';
    html += '</div>';

    $('#match-result').prepend(html);
}
  • ws.php 修改
// 修改以下方法:$obj->$method() 添加 $serv 参数
 /**
     * @param $serv
     * @param $task_id
     * @param $workerId
     * @param $data
     */
    public function onTask($serv, $task_id, $workerId, $data){
        // 分发 task 任务机制,让不同的任务走不同逻辑
        $obj = new app\common\lib\task\Task;

        $method = $data['method'];
        // 执行投放过来的 $method 变量 方法
        // 这里需要判断一些值是否存在
        $flag = $obj->$method($data['data'], $serv);

        return $flag;

        // print_R($data);
        // return "on task finish";
    }
  • 修改 Task 类:application/common/lib/task/Task.php
<?php

/**
 * Swoole 后续所有 task 异步任务 都放到这里来
 */
namespace app\common\lib\task;

use app\common\lib\ali\Sms;
use app\common\lib\Redis;
use app\common\lib\redis\Predis;

class Task{

    /**
     * 异步发送验证码短信逻辑
     * @param $data
     * @param $serv swoole server对象
     */
    public function sendSms($data, $serv){
        print_R($data);

        // 发送成功,验证码记录到 redis
        try{
            Predis::getInstance()->set(Redis::smsKey($data['phone']), $data['code'], config('redis.out_time'));
        }catch (\Exception $e){
            echo $e->getMessage();

        }

    }

    /**
     * 通过task机制发送赛况实时数据给客户端
     * @param $data
     * @param $serv swoole server对象
     */
    public function pushLive($data, $serv){
        $clients = Predis::getInstance()->sMembers(config("redis.live_game_key"));

        foreach($clients as $fd) {
            // 判断是否 websocket 连接
            // 参考:https://wiki.swoole.com/wiki/page/490.html
            $a = $serv->connection_info($fd);
            if($a && isset($a["websocket_status"]) && intval($a["websocket_status"])>0){
                $serv->push($fd, json_encode($data));
            }
        }
    }

}
发布了119 篇原创文章 · 获赞 12 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/hualaoshuan/article/details/101444266