前言
本篇博客记录了学习HLS流媒体协议的原理、工作流程以及搭建最简单的HLS服务器的过程。通过学习HLS协议,将能够理解流媒体技术的核心概念,并具备搭建自己的流媒体服务的基础知识。
一、HLS协议是什么?
HLS协议一般只用作拉流观看,但是严格意义上讲,HLS并不是流式协议,工作原理就是通过HTTP协议下载静态文件。不同的是,HLS协议的文件由两部分组成,一是多个只有几秒长度的ts碎片视频文件,另一个是记录这些视频文件地址的m3u8索引文件,且这些静态文件都是直接写入磁盘的。
HLS协议可以用于点播和直播观看,其适配多种播放场景,一般加入插件就可以播放了,如网页加入HLS的js插件就可以播放了,苹果设备是原生支持HLS协议的。
点播的场景下,也即普通网络视频观看的场景下,m3u8索引文件会记录所有的碎片视频文件地址,HLS在点播场景下优势是更加明显的,由于HLS的相关文件是无状态的静态文件且每个文件的大小是有限的,所以负载均衡、CDN加速的效果更加明显。HLS协议的点播视频会比mp4、flv的视频更快地播放出来,且在加载中跳转视频也会更加顺滑。
直播场景下的HLS相关文件与点播是有些不同的,视频流数据每几秒会打包成一个以ts为后缀的碎片视频文件,每生成一个新的视频文件都会同步更新m3u8索引文件,且碎片视频文件的个数是有上限的,当达到上限后,默认会将最旧的视频文件删除且更新m3u8索引文件,所以在直播的场景下,客户端也需要不断定时重新获取m3u8索引文件。
HLS协议优缺点。
二、实现一个最简单的HLS服务器
1.ffmpeg命令行生成m3u8切片
如下(示例):
ffmpeg -i input.mp4 -c:v libx264 -c:a copy -f hls -hls_time 10 -hls_list_size 0 -hls_start_number 0 input/index.m3u8
备注:-hls_time n: 设置每片的长度,默认值为2,单位为秒
-hls_list_size n:设置播放列表保存的最多条目,设置为0会保存有所片信息,默认值为5
-hls_start_number n:设置播放列表中sequence number的值为number,默认值为0
-hls_wrap n:设置多少片之后开始覆盖,如果设置为0则不会覆盖,默认值为0
这个选项能够避免在磁盘上存储过多的片,而且能够限制写入磁盘的最多的片的数量
test目录:
第一个是m3u8索引文件,之后都是序号从0开始的ts文件
2.实现
HLS主要就是将流媒体数据分成一个一个小的ts分片,通过m3u8索引文件维持的分片序号实现播放。
现在文件已经生成了,现在只需要实现服务即可。
以下是main函数的具体实现流程:
#include <stdint.h>
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <iostream>
#include "Utils/Log.h"
#pragma comment(lib, "ws2_32.lib")
#include "Connection.h"
#include <thread>
int main() {
//ffplay -i http://127.0.0.1:8080/index.m3u8
int port = 8080;
LOGI("1-hlsServer http://127.0.0.1:%d/index.m3u8", port);
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
LOGE("WSAStartup error");
return -1;
}
SOCKET serverFd;
SOCKADDR_IN server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
//server_addr.sin_addr.s_addr = inet_addr("192.168.2.61");
server_addr.sin_port = htons(port);
serverFd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (bind(serverFd, (SOCKADDR*)&server_addr, sizeof(SOCKADDR)) == SOCKET_ERROR) {
LOGE("socket bind error");
return -1;
}
if (listen(serverFd, SOMAXCONN) < 0) {
LOGE("socket listen error");
return -1;
}
while (true)
{
LOGI("等待新连接...");
int len = sizeof(SOCKADDR);
SOCKADDR_IN accept_addr;
int clientFd = accept(serverFd, (SOCKADDR*)&accept_addr, &len);
//const char* clientIp = inet_ntoa(accept_addr.sin_addr);
if (clientFd == SOCKET_ERROR) {
LOGE("accept connection error");
break;
}
LOGI("发现新连接 clientFd=%d", clientFd);
//std::thread t([&]() {
Connection conn(clientFd);
conn.start();
//});
//t.detach();
}
closesocket(serverFd);
return 0;
}
以下是Connection.cpp:
#include "Connection.h"
#include <iostream>
#include <string>
#include <thread>
#include "Utils/Log.h"
#include "Utils/Utils.h"
#pragma warning(disable: 4996)
#include <WinSock2.h>
#include <WS2tcpip.h>
char buf[1500000];
Connection::Connection(int clientFd):mClientFd(clientFd) {
LOGI("clientFd=%d", clientFd);
}
Connection::~Connection() {
LOGI("clientFd=%d", mClientFd);
closesocket(mClientFd);
}
int Connection::start() {
char bufRecv[2000] = { 0 }; // 用于接收从客户端发来的数据
int bufRecvSize = recv(mClientFd, bufRecv, 2000, 0); // 将数据存储到 bufRecv 中
LOGI("bufRecvSize=%d,bufRecv=%s", bufRecvSize, bufRecv); // 用于存储从 HTTP 请求中解析出的 URI
char uri[100] = { 0 };// /, /index0.ts, /index1.ts ,,,
const char* sep = "\n"; // 用于分割 HTTP 请求的不同行
char* line = strtok(bufRecv, sep); // 将接收到的 HTTP 请求按照 \n 分割成不同的行,并从中提取出第一行
while (line) {
if (strstr(line, "GET")) { // 检查当前行是否包含 "GET"
if (sscanf(line, "GET %s HTTP/1.1\r\n", &uri) != 1) { // 解析出 HTTP 请求中的 URI,并将其存储到 uri 中
LOGE("parse uri error");
}
}
line = strtok(NULL, sep);
}
printf("uri=%s\n", uri);
std::string filename = "../data/test" + std::string(uri); // 根据解析出的 URI 构建文件路径
FILE* fp;
fp = fopen(filename.data(), "rb"); // 打开相应的文件,准备读取数据
if (!fp) {
LOGE("fopen %s error", filename.data());
return -1;
}
int bufLen = fread(buf, 1, sizeof(buf), fp); // 从文件中读取数据到缓冲区 buf 中,并记录读取的数据长度
LOGI("bufLen=%d", bufLen);
if (fp) {
fclose(fp);
}
char http_headers[2000]; // 构建 HTTP 响应头部
if (0 == strcmp("/index.m3u8", uri)) {
sprintf(http_headers, "HTTP/1.1 200 OK\r\n"
"Access-Control-Allow-Origin: * \r\n" // 让客户端能够跨域访问资源
"Connection: keep-alive\r\n"
"Content-Length: %d\r\n"
"Content-Type: application/vnd.apple.mpegurl; charset=utf-8\r\n"
"Keep-Alive: timeout=30, max=100\r\n"
"Server: hlsServer\r\n"
"\r\n",
bufLen);
}
else {
sprintf(http_headers, "HTTP/1.1 200 OK\r\n"
"Access-Control-Allow-Origin: * \r\n"
"Connection: close\r\n"
"Content-Length: %d\r\n"
"Content-Type: video/mp2t; charset=utf-8\r\n"
"Keep-Alive: timeout=30, max=100\r\n"
"Server: hlsServer\r\n"
"\r\n",
bufLen);
}
int http_headers_len = strlen(http_headers);
LOGI("http_headers_len=%d", http_headers_len);
send(mClientFd, http_headers, http_headers_len, 0);
send(mClientFd, buf, bufLen, 0);
Sleep(10);
return 0;
}
这个服务启动以后,实际上就是一个最简单的http请求,请求和读取的是m3u8文件,第一次返回头文件,第二次返回值。如果不是index.m3u8,那就是对应的ts分片,读取分片文件并返回。这里的区别在Content-Type部分体现!
这部分通过检查 URI 是否为 “/index.m3u8”,来区分对不同类型资源的处理,主要分为两种情况:
- 如果 URI 为 “/index.m3u8”,则构建的 HTTP 响应头中的 Content-Type 设置为 “application/vnd.apple.mpegurl”,表示返回的是 M3U8 文件,同时使用 “Connection: keep-alive” 保持连接。然后将该响应头和文件内容发送给客户端。
- 如果 URI 不是 “/index.m3u8”,则构建的 HTTP 响应头中的 Content-Type 设置为 “video/mp2t”,表示返回的是 TS 文件,同时使用 “Connection: close” 关闭连接。然后将该响应头和文件内容发送给客户端。
整体流程就是根据 URI 的不同,设置不同的 Content-Type,并选择是否保持连接,然后将响应头和文件内容发送给客户端。
以上是针对点播服务,如果是直播服务的话,m3u8索引文件是会动态变化的,比如每隔5秒变化一次,这种情况下就得每隔5秒重新请求一下,重新请求后会获得更新后的ts名称,然后再根据ts名称逐个请求。后续会继续学习针对直播服务的动态变化的hls服务器。
3.抓包分析:
总结
希望通过本文的学习,能够对HLS流媒体协议有一个全面的了解,并且能够运用这些知识搭建自己的流媒体服务,为用户提供更优质的视频播放体验。