依赖腾讯云的音视频服务能力,构建一个高可用的在线直播平台

你先等会儿!

这里呢,有同学会说,你都依赖腾讯云了,还构造个啥?

实际上,很多云厂商也确实有那种打包好的解决方案,对外出售,用户可能什么都不要开发,拿来开箱即用,但是,价格不菲!而且这种打包方案,场景都是固定的,很难进行二次开发,想定制独特功能就又是一笔不小的开销。

就算要选择打包方案,大厂也会推出很多眼花缭乱的方向和使用场景,如果没点专业知识,很难选中适合自己的那一套方案,还有就是,这种方案基本都是订阅制,按月或者按天付费,单价也很贵,感兴趣的可以到各厂官网对接销售去深入了解下。

所以如果你或你司有矿不差钱,也确实有合适的场景,或者盈利模式非常清晰,那确实是可以直接购买而不是自己开发。

但对于大部分小微企业,以及相当一部分中型企业,或者说致力于构建基于自身业务的在线直播系统,那这种打包方案可能就不是最佳方案了。

那完全脱离大厂的桎梏,全都自己开发行不行?

这又是另一条极端的路线,从技术角度来说,当然是行,但考虑到高可用,高质量的服务,对大部分开发团队来说,还是要依赖大厂的服务支撑,毕竟资源也是成本,加入你的视频平台要发布给几万,几十万甚至几百万用户使用,那除了自己去建立机房,搭建服务器集群,解决网络资源等,还要考虑自己去维护这些资源,成本就又是无边无际了。

所以,对大部分开发者来说,既不能全靠大厂,也不能没有大厂,大厂给自己定位也是这样,只提供剥离业务的基础设施和资源的运维服务,其他提倡用户自己对接。

我们常说的SaaS,PaaS,LaaS。。。之类的,SaaS就是类似全包的方案,花钱买省心(但往往很难真省心),其他就是少花点钱,在花点心思,最终得到一个不错的结果,所谓开发就是权衡的艺术,在这里还蛮贴切~

背景

我们的直播业务是建立在自由在线教育平台上的,属于其中一个比较大的模块,我就以这个系统为例,展开说一下接入直播服务都需要哪些关键操作。

准备工作

首先,我们要搞清楚定位,腾讯云提供的只是稳定的直播能力,录制能力等与业务不相干的服务,实际的业务还是要在对接之前就已经构建完成了。

所以我们的准备工作包括

业务系统

我们需要一个即便脱离了云服务,也能稳定运行的业务系统。比如我这里除了直播和拉取回放的时候需要对接云服务,其他业务,包括课程管理,用户管理,专家管理,专题管理,评论打卡,数据分析等等,都是在接入云服务之前就已经开发完成的。

准备域名

对接直播服务,需要准备一个推流域名和一个拉流域名,另外需要做好一些关键配置,这些云厂商的开发文档里介绍的都很详细,以文档为准就好;https://cloud.tencent.com/document/product/267/13551

备好Money

域名对接好之后,就可以在云账户里充点米了,如果是新开通的服务,官方给赠送一些流量供测试,上了业务以后,还是要自己购买流量包或者根据实际情况后付费。

具体还是参照文档说明即可https://cloud.tencent.com/document/product/267/52662

需要说明的是,即便是我们提前购买了流量包,账户里还是要预留一些资金,不用多,但得有,因为服务启动之后,除了常规的直播流量,还会产生一些其他按天扣费的费用,如果账户余额为0,搞不好会被停服。

域名对接

域名接入之后,要进行一系列的配置,还是推荐参照文档操作即可。

这里重点聊一下安全性的问题,推荐的做法是推拉流域名都要开启鉴权,且在拉流端除了域名鉴权,还要配置好本地的token服务来完成域名的鉴定,避免流量被盗刷。

以推流为例,我们要开启推流鉴权

然后生成推流地址时,除了可以在腾讯云的控制台生成,也可以集成到我们自己的代码里,方便管理。

由于这块的接入比较简单,官方文档里只提供了Java,Go和PHP的接入案例,其他参照完成就可以。我这里是一个C#版本

csharp 复制代码
public string GetSafePushUrl(string streamName, long txTime=0)
{
    if (string.IsNullOrEmpty(streamName))
        return "error";
    var config = _cloudConfigFactory.GetConfigByPurpose("live", "tencent");
    
    LiveParamsModel model = JsonHelper.SafeDeserialize<LiveParamsModel>(config.Other);
    if (txTime == 0)
    {
        DateOnly dto = DateOnly.FromDateTime(DateTime.Now);
        TimeOnly tmo = new TimeOnly(23, 59, 59);
        txTime = Utils.GetUnixTimestamp(new DateTime(dto, tmo, DateTimeKind.Utc));
    }
    string input = $"{model.pushKeyMain}{streamName}{txTime:X}";

    string txSecret = Security.ByteArrayToHexString(Security.MD5Hash(input));

    string url = $"{model.protocol}//{model.pushUrl}/{model.appName}/{streamName}?txSecret={txSecret}&txTime={txTime:X}";
    Logger.Debug("推流地址:"+url);
    return url;
}

直播转码

因为我们的直播场景有那种需要和线下导播对接的时候,需要我们提供推流地址,但导播的推流规格一般不固定,有的高,有的很高,比如把码率推到6000Kbps以上,那直播的时候如果原样拉流,流量包买多少也不够用!

所以,为了避免出现流量刺客的情况,要配置好几个统一的转码模板。

注意,配置了转码模板后,再购买直播相关服务的时候,要购买转码包,这个不贵,按小时计费,很合适。

另外配置转码模板之后,生成拉流地址的时候也要指定好转码模板,这个大家参照官方文档说明操作即可。

直播录制

直播录制也是非常重要的增值功能,直播结束后我们可以方便的获取回放文件

需要说明的是,如果我们的回放文件需要在本地进行剪辑,我们还有额外开发一个下载回放的程序,不然每次都要手动去下载,而录制存储的文件名称一般都比较长,文件少还好,多了以后,三头六臂也下载不过来。

这里,官方也有对接下载文件的代码,我这里也提供一个C#的版本

下载回放资源一般至少要分2步,第一是根据streamid搜索媒资,代码如下

csharp 复制代码
public static async Task<MediaInfo[]> SearchMedia(string streamId)
{
    try
    {
        AnsiConsole.MarkupLine($"[#FDE047]搜索vod资源...{DateTime.Now}[/]");
        Credential cred = new Credential
        {
            SecretId = TencentVodSettings.SecretId,
            SecretKey = TencentVodSettings.SecretKey
        };
        // 实例化一个client选项,可选的,没有特殊需求可以跳过
        ClientProfile clientProfile = new ClientProfile();
        // 实例化一个http选项,可选的,没有特殊需求可以跳过
        HttpProfile httpProfile = new HttpProfile();
        httpProfile.Endpoint = ("vod.tencentcloudapi.com");
        clientProfile.HttpProfile = httpProfile;

        // 实例化要请求产品的client对象,clientProfile是可选的
        VodClient client = new VodClient(cred, "", clientProfile);
        // 实例化一个请求对象,每个接口都会对应一个request对象
        SearchMediaRequest req = new SearchMediaRequest();
        req.Offset = 0;
        req.Limit = 100;
        req.StreamId = streamId;
        req.Filters = new string[] { "basicInfo" };
        // 返回的resp是一个SearchMediaResponse的实例,与请求对象对应
        SearchMediaResponse resp = await client.SearchMedia(req);
       
        // 输出json格式的字符串回包
        //Console.WriteLine(AbstractModel.ToJsonString(resp));
        AnsiConsole.MarkupLine($"[cyan]共计搜索到{resp.MediaInfoSet.Length}条媒资记录...{DateTime.Now}[/]");

        return resp.MediaInfoSet;
    }
    catch (Exception e)
    {
        AnsiConsole.MarkupLine($"[red]搜索vod资源失败:{e.Message}...{DateTime.Now}[/]");
    }
    return Array.Empty<MediaInfo>();
}

第二步是获取真正的下载链接

csharp 复制代码
/// <summary>
/// 获取下载链接
/// </summary>
/// <param name="mediaUrls"></param>
/// <returns></returns>
public static string[] GetDownloadUrl(string[] mediaUrls,string streamId, long expiredAt = 0, string ext = "flv")
{
    AnsiConsole.MarkupLine($"[#FDE047]2.生成下载链接,共计{mediaUrls.Length}条记录...{DateTime.Now}[/]");
    List<string> urls = new List<string>();
    int cnt = 1;
    StringBuilder contentBuilder = new StringBuilder();
    foreach (string mediaUrl in mediaUrls)
    {
        //续命2天
        //long timeStamp = Convert.ToInt64((DateTime.Now.AddDays(2) - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds);
        if(expiredAt==0)
            expiredAt = UtilsHelper.GetUnixTimestamp(DateTime.Now.AddDays(2));
        string[] parts = mediaUrl.Split('/');
        string dir = "/";
        foreach (string part in parts)
        {
            if (part.Contains(".") || part.Contains("/") || part.Contains(":") || string.IsNullOrEmpty(part))
                continue;
            dir += $"{part}/";
        }
        //16进制Unix时间戳
        string t = Convert.ToString(expiredAt, 16).ToLower().PadLeft(8, '0');
        string us = UtilsHelper.GenerateRandomCodePro(10);
        string sign = UtilsHelper.Md5(TencentVodSettings.UrlKey + dir + t + us);
        string downloadUrl = $"{mediaUrl}?download_name={streamId}_{cnt}.{ext}&t={t}&us={us}&sign={sign}";
        urls.Add(downloadUrl);
        AnsiConsole.MarkupLine($"  [#20a162]--链接{cnt}:{downloadUrl}[/]");
        
        contentBuilder.Append("{").Append($"\"FileName\":\"{streamId}_{cnt}.{ext}\",\"Url\":\"{downloadUrl}\",FolderPath:\"\"").Append("},");
        cnt++;
    }
    return urls.ToArray();
}
    

注意,涉及到验签操作,需要我们在cam那里配置密钥

到这里,就可以根据实际情况去下载了,下载的代码我这里就不给出了,不然这一篇文章写不完~

需要说明的是,不论我们的回放是放到vod还是cos,这都是有一定空间的,肯定不能无限存储,所以最好还是对接一个文件管理的接口,当我们把回放文件下载到本地以后,就可以释放线上的存储了,释放方式有多种,比如归档,或者定期删除等等,我这里是选择的定期删除,对接代码如下

csharp 复制代码
public static async Task<bool> ExpiredMediaInfo(string fileId, string ExipreDataISO)
{
    if (string.IsNullOrEmpty(ExipreDataISO))
    {
        // 创建一个DateTimeOffset对象,设置为UTC时间
        DateTimeOffset nowOffset = DateTimeOffset.UtcNow.AddDays(7);

        ExipreDataISO = nowOffset.ToString("o");
    }
    try
    {
        AnsiConsole.MarkupLine($"[#FDE047]修改vod资源过期时间...{DateTime.Now}[/]");
        Credential cred = new Credential
        {
            SecretId = TencentVodSettings.SecretId,
            SecretKey = TencentVodSettings.SecretKey
        };
        // 实例化一个client选项,可选的,没有特殊需求可以跳过
        ClientProfile clientProfile = new ClientProfile();
        // 实例化一个http选项,可选的,没有特殊需求可以跳过
        HttpProfile httpProfile = new HttpProfile();
        httpProfile.Endpoint = ("vod.tencentcloudapi.com");
        clientProfile.HttpProfile = httpProfile;
        // 实例化要请求产品的client对象,clientProfile是可选的
        VodClient client = new VodClient(cred, "", clientProfile);
        // 实例化一个请求对象,每个接口都会对应一个request对象
        ModifyMediaInfoRequest req = new ModifyMediaInfoRequest();
        req.FileId = fileId;
        
        req.ExpireTime = ExipreDataISO;
        ModifyMediaInfoResponse resp = await client.ModifyMediaInfo(req);
        
        AnsiConsole.MarkupLine($"[green]修改vod资源信息成功,已将其设为7天后过期...{DateTime.Now}[/]");
        return true;
    }
    catch (Exception e)
    {
        AnsiConsole.MarkupLine($"[red]修改vod资源信息失败:{e.Message}...{DateTime.Now}[/]");
    }
    return false;
}

每次执行完下载操作后,调用该接口,就会把线上的文件设定为3个月后自动过期,省去了手动处理的烦恼。

录制文件的处理,其实相对还是比较复杂的,我这里并没有把这部分的业务放到主系统里,而是单独开发了一个服务,可以完成一系列的下载,转码,上传等服务,之前也写过相关的博客,这里不在多说(传送门:https://xie.infoq.cn/article/9a8abebacf858783c67166623

直播回调

回调服务,是云服务和我们本地系统直接对接的一个主要渠道,当我们启动推流,断流,完成录制等操作时,云厂商都会以固定的形式将事件详情发送到我们业务系统。

文档地址:https://cloud.tencent.com/document/product/267/32744

实际上,我们并不需要把每一个回调都对接上,把关键的几个接入好就可以了;

推拉流回调

这个是基础事件,需要对接,可以实时的接收到推拉流的情况;

代码如下

csharp 复制代码
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> CssPushCallback([FromBody] CallbackRequestModel model)
{
    Logger.Warning($"推拉流回调:CssPushCallback: {JsonConvert.SerializeObject(model)}");
    string sign = Security.GenerateMD5Hash("magicexam" + model.t);
    if (model.sign != sign)
    {
        Logger.Error("签名验证失败," + sign);
        return Json(_resp.error("我靠,你谁啊!"));
    }
    Logger.Info("签名验证通过," + sign);
    try
    {        
        await RecordCssPushInfo(model);
    }
    catch (Exception ex)
    {

        Logger.Error("记录推流信息失败" + ex.Message);
    }
    return Json(_resp.success("success"));
}

注意,暴露的接口要保证腾讯云可以直接访问到,所以我们在接口方法上制定了[AllowAnonymous]属性,但同时需要注意在外层过滤一些常见的风险,比如频繁请求,危险参数,还要在接口内验证签名是否是来自腾讯云的请求,验证通过后,就可以记录业务数据了。

控制台收到接口请求的效果如下:

录制文件回调

这个和推拉流回调的方式差不多

csharp 复制代码
[AllowAnonymous]
[HttpPost]
public async Task<IActionResult> CssRecordCallback([FromBody] CallbackRecordModel model)
{
    Logger.Warning($"录制回调:CallbackRecordModel: {JsonConvert.SerializeObject(model)}");
    string sign = Security.GenerateMD5Hash("magicexam" + model.t);
    if (model.sign != sign)
    {
        Logger.Error("签名验证失败," + sign);
        return Json(_resp.error("我靠,你谁啊!"));
    }
    Logger.Info("签名验证通过," + sign);
    try
    {
        await RecordCssRecordInfo(model);
    }
    catch (Exception ex)
    {

        Logger.Error("记录录制信息是失败:" + ex.Message);
    }
    await _redisProvider.StringSetAsync($"{model.stream_id}_css_record_callback", JsonConvert.SerializeObject(model), TimeSpan.FromMinutes(30));
    return Json(_resp.success("success"));
}

同样,也是暴露为开放接口,并在接口内完成验证签名的操作。

服务台收到的效果如下:

* CDN加速

这其实不是直播范畴了,但一个完整的直播课服务,除了直播环节,另外一个重要的环节就是回放。

实际上,我们可以使用腾讯vod的能力,来分发回放视频,只需要在云平台做好一些配置就好。

而前面也提到了,Vod的资源不可能一直有效,存储空间也不是无限大的,而且有些时候,不是开发者能决定要把回放文件放在哪里的,决策层可能觉得还是要在本地保管跟放心,那我们本地的带宽资源又有限,这时候就可以搭配cdn完成静态资源的边缘加速,让回放课程的播放也更加流畅。

这部分,云端的操作就是购买流量包,绑定域名,做好一些访问控制,并配置好回源策略等,这些参照文档操作即可。

本地需要对应的可以方便的把本地资源设置成加速资源。我这里的处理方式是,配置加速时,要求设置一个过期时间,避免长期加速造成流量被刷爆。代码如下

csharp 复制代码
/// <summary>
/// 加速视频
/// </summary>
/// <param name="liveId"></param>
/// <param name="minutes">加速的分钟数,默认360分钟(6小时)</param>
/// <returns></returns>
[HttpPost,ValidateAntiForgeryToken]
public async Task<IActionResult> TurboTs(string liveId,int minutes=360)
{
    var live = await _context.CourseLive.Where(u => u.LiveID == Guid.Parse(liveId)).FirstOrDefaultAsync();
    
    if (string.IsNullOrEmpty(live.FileAddress))
        return Json(_resp.ret(-1, "加速失败,回放文件为空"));

    string originAddress = live.FileAddress.ToLower();
    string turboAddress = "";
    if (!originAddress.EndsWith("m3u8"))
        return Json(_resp.ret(-1, "不能加速非HLS协议的回访文件"));
    if (originAddress.StartsWith("http") && originAddress.Contains(Common.ConfigurationHelper.GetSectionValue("turboHost")))
    {
        return Json(_resp.ret(0, "已加速"));
    }
    if (originAddress.StartsWith("http"))
    {
        return Json(_resp.ret(-1, "不能加速外链文件"));
    }
    turboAddress = Common.ConfigurationHelper.GetSectionValue("turboHost") + originAddress;
    live.FileAddress = turboAddress;
    live.Updated_at = DateTime.Now;
    _context.CourseLive.Update(live);
    DateTime expireTime = DateTime.Now.AddMinutes(minutes);
    await RedisConfigure.db.HashSetAsync("turboFiles", live.LiveID.ToString(), $"{expireTime}|{originAddress}|{turboAddress}");
    await _context.SaveChangesAsync();
    return Json(_resp.success(turboAddress));
}

/// <summary>
/// 取消加速视频
/// </summary>
/// <param name="liveId"></param>
/// <returns></returns>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> CancleTurbo(string liveId)
{
    try
    {
        if (!await RedisConfigure.db.HashExistsAsync("turboFiles", liveId))
        {
            return Json(_resp.ret(-1, "视频未加速或加速时长已过期,无需取消"));
        }
        var live = await _context.CourseLive.Where(u => u.LiveID == Guid.Parse(liveId)).FirstOrDefaultAsync();
        if (string.IsNullOrEmpty(live.FileAddress))
        {
            return Json(_resp.ret(-1, "无回放文件"));
        }
        live.FileAddress = live.FileAddress.ToLower().Replace(Common.ConfigurationHelper.GetSectionValue("turboHost"), "");
        live.Updated_at = DateTime.Now;
        await RedisConfigure.db.HashDeleteAsync("turboFiles", liveId);
        _context.CourseLive.Update(live);
        await _context.SaveChangesAsync();
        return Json(_resp.success(live.FileAddress, "操作成功"));
    }
    catch (Exception ex)
    {
        NLogUtil.fileLogger.Error($"取消加速失败,{ex.Message}||{ex.StackTrace}");
        return Json(_resp.ret(-1, $"取消加速失败,{ex.Message}||{ex.StackTrace}"));
    }
}

Redis的作用只是记录加速的课程和过期时间,每隔1小时检测一次,过期之后就取消加速了;

效果如下

这部分笔者之前也单独出过一篇博客:传送门👉:https://blog.csdn.net/juanhuge/article/details/144692801?spm=1011.2415.3001.5331

实时音视频

这部分也是我们系统里的一个模块,和直播相关的业务就是有一个混流的操作,把线上房间的课程进行云端混流,转发到直播平台,截图如下:

笔者之前曾单独写过一篇关于这部分的博客,传送门:https://blog.csdn.net/juanhuge/article/details/127983710?spm=1011.2415.3001.5331

本篇受篇幅限制,不在赘述。

结语

至此,需要开发的任务量基本完成。那这套架构真的稳定吗?我前面放过的一些地址里有一些数据截图,这几年运营下来,我们这个系统部署了3个节点,此外还有很多子服务,均为分布式的部署形式,日均访问量最高曾达到300万次,当然早期因为架构不成熟,也经常崩溃,但这两年已经很少因为系统不稳定而造成崩溃了,真正实现了我们这个规模下的,高可用,高并发,高性能。

ps.架构知识真的很重要。

相关推荐
husertuo2 小时前
Linux下的网络管理配置
linux·云计算
StudyWinter4 小时前
【FFmpeg从入门到精通】第四章-FFmpeg转码
ffmpeg·音视频
编程在手天下我有5 小时前
深度解析云计算:概念、优势与分类全览
云计算·数据安全·信息技术·企业应用·部署模式·服务模式
XINVRY-FPGA5 小时前
XCZU7EG‑L1FFVC1156I 赛灵思XilinxFPGA ZynqUltraScale+ MPSoC EG
c++·嵌入式硬件·阿里云·fpga开发·云计算·fpga·pcb工艺
weixin_424381006 小时前
下载油管视频 - yt-dlp
音视频
ZStack开发者社区8 小时前
替代升级VMware | 云轴科技ZStack构建山西证券一云多芯云平台
云计算
weixin_307779139 小时前
实现AWS Step Function安全地请求企业内部API返回数据
开发语言·python·云计算·aws
EQ-雪梨蛋花汤9 小时前
【Unity笔记】Unity音视频播放监听器封装笔记:VideoPlayer + AudioSource事件触发与编辑器扩展
笔记·unity·音视频
周周的奇妙编程9 小时前
解决方案评测|告别复杂配置!基于阿里云云原生应用开发平台CAP快速部署Bolt.diy
阿里云·云原生·云计算
yuzhangfeng10 小时前
【日志体系】ELK Stack与云原生日志服务
elk·云原生·云计算