你先等会儿!
这里呢,有同学会说,你都依赖腾讯云了,还构造个啥?
实际上,很多云厂商也确实有那种打包好的解决方案,对外出售,用户可能什么都不要开发,拿来开箱即用,但是,_价格不菲_!而且这种打包方案,场景都是固定的,很难进行二次开发,想定制独特功能就又是一笔不小的开销。
就算要选择打包方案,大厂也会推出很多眼花缭乱的方向和使用场景,如果没点专业知识,很难选中适合自己的那一套方案,还有就是,这种方案基本都是订阅制,按月或者按天付费,单价也很贵,感兴趣的可以到各厂官网对接销售去深入了解下。
所以如果你或你司有矿不差钱,也确实有合适的场景,或者盈利模式非常清晰,那确实是可以直接购买而不是自己开发。
但对于大部分小微企业,以及相当一部分中型企业,或者说致力于构建基于自身业务的在线直播系统,那这种打包方案可能就不是最佳方案了。
那完全脱离大厂的桎梏,全都自己开发行不行?
这又是另一条极端的路线,从技术角度来说,当然是行,但考虑到高可用,高质量的服务,对大部分开发团队来说,还是要依赖大厂的服务支撑,毕竟资源也是成本,加入你的视频平台要发布给几万,几十万甚至几百万用户使用,那除了自己去建立机房,搭建服务器集群,解决网络资源等,还要考虑自己去维护这些资源,成本就又是无边无际了。
所以,对大部分开发者来说,既不能全靠大厂,也不能没有大厂,大厂给自己定位也是这样,只提供剥离业务的基础设施和资源的运维服务,其他提倡用户自己对接。
我们常说的 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#版本
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 搜索媒资,代码如下
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>();
}
复制代码
第二步是获取真正的下载链接
/// <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,这都是有一定空间的,肯定不能无限存储,所以最好还是对接一个文件管理的接口,当我们把回放文件下载到本地以后,就可以释放线上的存储了,释放方式有多种,比如归档,或者定期删除等等,我这里是选择的定期删除,对接代码如下
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
实际上,我们并不需要把每一个回调都对接上,把关键的几个接入好就可以了;
推拉流回调
这个是基础事件,需要对接,可以实时的接收到推拉流的情况;
代码如下
[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]属性,但同时需要注意在外层过滤一些常见的风险,比如频繁请求,危险参数,还要在接口内验证签名是否是来自腾讯云的请求,验证通过后,就可以记录业务数据了。
控制台收到接口请求的效果如下:
录制文件回调
这个和推拉流回调的方式差不多
[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 完成静态资源的边缘加速,让回放课程的播放也更加流畅。
这部分,云端的操作就是购买流量包,绑定域名,做好一些访问控制,并配置好回源策略等,这些参照文档操作即可。
本地需要对应的可以方便的把本地资源设置成加速资源。我这里的处理方式是,配置加速时,要求设置一个过期时间,避免长期加速造成流量被刷爆。代码如下
/// <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 万次,当然早期因为架构不成熟,也经常崩溃,但这两年已经很少因为系统不稳定而造成崩溃了,真正实现了我们这个规模下的,高可用,高并发,高性能。
评论