大纲
上一篇文章我们使用AWS IVS实现了一个简易的浏览器直播互动平台,客户听说AWS IVS理论上可以交付延迟低于5s的视频大呼牛逼,于是新需求来了,客户想尝试一下IVS,但又不能影响自己原有业务场景,所以本文将会讲述直播流转发至IVS并拉流的可行性方案。
首先我们先了解一下直播的具体流程
上图中我们大致将直播分为3个步骤:
- 在直播端采集视频图像以及音频,并推流
- 流媒体服务器(做推流和拉流的中转服务器)
- 在客户端拉流进行播放
在不影响原有业务的前提下,我们的解决方案选择从服务器上加一层。通过下图可以看出,我们在左侧的EC2去接收推流,右侧的EC2实现客户端的拉流操作,将客户原有的服务器空置出来,这样可以通过更改直播端和客户端的配置就可以最小改动的实现使用IVS。
1 利用node-media-server实现流视频转发
一开始我们设计了两套方案,一种是通过Nginx转发,另一种就是本文讲述的node-media-server,考虑到公司人力,最终选择了node-media-server这套方案。
1.1 搭建推流侧服务器
- 创建文件目录并安装node-media-server包
mkdir nms && cd nms
npm install node-media-server
- 创建app.js,并编写下述代码
const NodeMediaServer = require('node-media-server');
const config = {
rtmp: {
port: 1935,
chunk_size: 60000,
gop_cache: true,
ping: 30,
ping_timeout: 60
},
http: {
port: 8000,
allow_origin: '*'
}
};
var nms = new NodeMediaServer(config)
nms.run();
- 运行app.js
node app.js
1.2 通过ffmpeg工具进行推流转发
-
我们需要通过ffmpeg这个工具来进行推流
-
在app.js的config中增加转发配置
const config = {
//省略的是rtmp和http端口配置
...
relay: {
ffmpeg: 'C:\\Users\\*******\\Downloads\\ffmpeg\\bin\\ffmpeg.exe', //这里是ffmpeg安装的目录
tasks: [
{
app: 'live',
mode: 'push',
edge: 'rtmp://192.168.0.1', //推流的地址
}
]
}
};
- 使用ffmpeg命令进行推流
ffmpeg -re -i INPUT_FILE_NAME -c copy -f flv rtmp://localhost/live/STREAM_NAME
1.3 向aws ivs推流
上述两个步骤已经实现了最基础的视频流转发操作,但在IVS中接收的流需要是rtmps协议,node-media-server中并不支持rtmps,因此我们需要对node-media-server源码进行一些改动。
- 事件回调
在上面的两个步骤中,假设的场景是我们已知推流地址,这样方便我们测试。真实场景中推流地址有很多,我们需要在数据库中查询到空闲的推流地址并向该地址进行推流,客户端只需根据推流配置的app_name和stream_name进行拉流即可。因此我们需要在node-media-server中下钩子,满足我们对数据库读写的需求。
//app.js
const db_func = require('./db.js')
......
let nms = new NodeMediaServer(config);
nms.run();
nms.on('prePublish',(id, StreamPath, args) => {
db_func.checkData(StreamPath);
console.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
});
nms.on('donePublish', (id, StreamPath, args) => {
db_func.resetData(StreamPath);
console.log('[NodeEvent on donePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
});
- 配置mysql
//db_config.js
const express = require("express");
const mysql = require("mysql");
const app = express();
app.all("*", function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*"); //*表示可以跨域任何域名都行(包括直接存在本地的html文件)出于安全考虑最好只设置 你信任的来源也可以填域名表示只接受某个域名
res.header("Access-Control-Allow-Headers", "X-Requested-With,Content-Type"); //可以支持的消息首部列表
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS"); //可以支持的提交方式
res.header("Content-Type", "application/json;charset=utf-8"); //响应头中定义的类型
next();
});
//建立一个连接参数方法
var db_config = {
host: "", //数据库地址
port: "3306", //端口号
user: "", //用户名
password: "", //密码
database: "", //数据库名称
};
var connection;
function handleDisconnect() {
connection = mysql.createConnection(db_config);
connection.connect(function (err) {
if (err) {
console.log('error when connecting to db:', err);
setTimeout(handleDisconnect, 2000);
}
});
connection.on('error', function (err) {
console.log('db error', err);
if (err.code === 'PROTOCOL_CONNECTION_LOST') {
handleDisconnect();
} else {
throw err;
}
});
}
handleDisconnect();
//查询当前直播是否有推流地址
function getStream(app_name, stream_name) {
let sql = `SELECT t.stream_url FROM channel_data AS t WHERE t.app_name = '${app_name}' and t.stream_name = '${stream_name}'`;
return new Promise(function (resolve, reject) {
connection.query(sql, (err, data) => {
if (err) {
reject(err);
} else {
resolve(...data);
}
});
});
};
//获取未被使用的推流地址
function getNullStream() {
let sql = `SELECT t.stream_url FROM channel_data AS t WHERE t.app_name IS NULL and t.stream_name IS NULL`;
return new Promise(function (resolve, reject) {
connection.query(sql, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
//将当前直播与推流地址绑定
function updateNullStream(app_name, stream_name, stream_url) {
let updateSql = `UPDATE channel_data SET app_name = '${app_name}', stream_name = '${stream_name}', is_use = 1 WHERE stream_url = '${stream_url}'`;
return new Promise(function (resolve, reject) {
connection.query(updateSql, (err, result) => {
if (err) {
reject(err)
} else {
result = {
status: 200,
msg: '修改成功'
};
resolve(result)
}
})
})
}
//数据置null
function updateStream(stream_url) {
let updateSql = `UPDATE channel_data SET app_name = NULL, stream_name = NULL, is_use = 0 WHERE stream_url = '${stream_url}'`;
return new Promise(function (resolve, reject) {
connection.query(updateSql, (err, result) => {
if (err) {
reject(err)
} else {
result = {
status: 200,
msg: '修改成功'
};
resolve(result)
}
})
})
}
module.exports = {
getStream,
getNullStream,
updateNullStream,
updateStream
};
- 调用数据库方法
//db.js
const streamFunc = require('./db_config.js')
async function checkData(val) {
let app_name = val.split("/")[1];
let stream_name = val.split("/")[2];
let res = await streamFunc.getStream(app_name, stream_name);
if (res) {
global[val] = res.stream_url;
} else {
getData(val);
}
}
async function getData(val) {
let res = await streamFunc.getNullStream();
let stream_url = res[0].stream_url;
insertData(val, stream_url);
}
async function insertData(val, stream_url) {
let app_name = val.split("/")[1];
let stream_name = val.split("/")[2];
let res = await streamFunc.updateNullStream(app_name, stream_name, stream_url);
if (res.status == 200) {
global[val] = stream_url;
}
}
async function resetData(val) {
let res = await streamFunc.updateStream(global[val]);
if (res.status == 200) {
delete global[val]
}
}
module.exports = {
checkData,
getData,
insertData,
resetData
};
- 修改node-media-server推流源码
我们在上一步将查询到推流地址存入到global的变量里面,因此我们需要在下面这个文件里面去读取这个global的值。下面这段代码中,加注释的地方是我修改的东西,
//node_relay_server.js
onPostPublish(id, streamPath, args) {
if (!this.config.relay.tasks) {
return;
}
//let regRes = /\/(.*)\/(.*)/gi.exec(streamPath);
//let [app, stream] = _.slice(regRes, 1);
let i = this.config.relay.tasks.length;
while (i--) {
let conf = this.config.relay.tasks[i];
let isPush = conf.mode === 'push';
//if (isPush && app === conf.app) {
//去除掉app的判断
if (isPush) {
//let hasApp = conf.edge.match(/rtmp:\/\/([^\/]+)\/([^\/]+)/);
conf.ffmpeg = this.config.relay.ffmpeg;
conf.inPath = `rtmp://127.0.0.1:${this.config.rtmp.port}${streamPath}`;
//conf.ouPath = conf.appendName === false ? conf.edge : (hasApp ? `${conf.edge}/${stream}` : `${conf.edge}${streamPath}`);
//下面这行是我加的代码
conf.ouPath = global[streamPath];
if (Object.keys(args).length > 0) {
conf.ouPath += '?';
conf.ouPath += querystring.encode(args);
}
let session = new NodeRelaySession(conf);
session.id = id;
session.on('end', (id) => {
this.dynamicSessions.delete(id);
});
this.dynamicSessions.set(id, session);
session.run();
Logger.log('[relay dynamic push] start id=' + id, conf.inPath, 'to', conf.ouPath);
}
}
}
- 配置obs工具推流
基于以上的步骤,我们已经完成了推流这一侧的代码,通过obs工具就可以开始直播啦。下图为obs的基本配置,按需配置即可,这里不做过多讲解。
2 提供拉流侧接口并播放
2.1 使用egg.js搭建项目
mkdir media-pull && cd media-pull
npm init egg --type=simple
npm i
2.2 开发拉流侧接口
- 配置路由文件
//router.ts
module.exports = (app: { router: any; controller: any }) => {
const { router, controller } = app;
router.get('/:appname/:streamname', controller.forward.forward);
};
- controller层
import { Controller } from 'egg';
class ForwardController extends Controller {
async forward() {
const { ctx } = this;
if (!ctx.params.appname || !ctx.params.streamname) {
ctx.status = 400;
ctx.body = 'Param Error';
return;
}
const result = await ctx.service.forward.findForward(
ctx.params.appname,
ctx.params.streamname
);
ctx.status = 200;
ctx.set('Content-Type', result ? 'application/x-mpegURL' : 'html/text');
ctx.set('Access-Control-Allow-Origin', '*');
ctx.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
ctx.set('Access-Control-Allow-Headers', '*');
ctx.body = result ? result : 'No data';
}
}
module.exports = ForwardController;
- service层
import { Service } from 'egg';
class ForwardService extends Service {
async findForward(appName: any, streamName: any) {
const { app } = this;
try {
if (
(app as any).cache &&
(app as any).cache.urlCache &&
(app as any).cache.urlCache[`${appName}/${streamName}`] &&
(app as any).cache.urlCache[`${appName}/${streamName}`].expiration >
+new Date()
) {
//判断有缓存且时间戳有效
return (app as any).cache.urlCache[`${appName}/${streamName}`].data;
}
const lessonInfo: any = await this.app.mysql.get('channel_data', {
app_name: appName,
stream_name: streamName,
is_use: 1,
});
if (!lessonInfo || !lessonInfo.channel_url) {
return null;
}
const result = await this.ctx.curl(lessonInfo.channel_url);
if (!(app as any).cache) {
(app as any).cache = {
urlCache: {},
};
}
(app as any).cache.urlCache[`${appName}/${streamName}`] = {
expiration: +new Date() + 30 * 1000, // 有效期30s
data: result.data.toString(),
};
return result.data.toString();
} catch (error) {
return null;
}
}
}
export default ForwardService;
2.3 视频播放
这里我们使用的是在线的m3u8网站去验证的,由于没有使用https证书,所以这里需要将网站设置为允许不安全内容。
3 部署aws服务
3.1 配置推流侧EC2
- 启动EC2实例并进行配置(实例名称、实例类型、创建新的密钥对、启用分配公有IP、创建安全组)
- EC2安装node环境
参阅Linux安装nodejs文档 - EC2安装ffmpeg
参阅EC2编译的ffmpeg文档 - 将推流侧代码上传至EC2
3.2 配置拉流侧EC2
- 启动EC2实例并进行配置(实例名称、实例类型、创建新的密钥对、启用分配公有IP、创建安全组)
此处配置与推流侧EC2一致 - EC2安装node环境
参见3.1中的教程 - 将拉流侧代码上传至EC2
3.3 创建IVS通道
3.4 搭建RDS(MySQL)
3.5 配置负载均衡器
-
为推流侧创建NLB
-
为拉流侧创建ALB
3.6 配置Auto Scaling
- 制作AMI,这里只需要制作拉流的AMI即可
- 配置Auto Scaling
剩余的配置均可默认,最后提交审核并创建Auto Scaling即可。
参考文献
作者
zzstriker