??在当今数字化时代,视频已成为互联网上最主要的内容形式之一。NGINX作为一款高性能的Web服务器和反向代理服务器,提供了强大的MP4模块,用于优化MP4视频的点播传输功能,并支持播放器的任意拖拽功能。本文将通过通过源码分析深入探讨NGINX MP4模块的实现源码,介绍其功能和实现原理。
NGINX MP4模块的作用和优势
NGINX MP4模块的主要作用是优化MP4视频的点播传输功能,提供快速启动和流畅播放的体验。它通过减少客户端和Web服务器之间的交互,降低额外数据消耗,显著减少流媒体播放的启动时间。以下是NGINX MP4模块的优势:
NGINX MP4模块的实现原理
NGINX MP4模块通过读取和解析MP4视频文件的元数据,实现优化的点播传输。它预读取视频文件的元数据,包括视频的时长、编码信息、音频信息等,并将这些信息缓存到内存中。当用户请求播放视频时,NGINX MP4模块直接从内存中获取元数据,根据客户端的请求,按需传输视频片段,实现快速启动和流畅播放的效果。
??要使用NGINX MP4模块,需要在NGINX的配置文件中进行相应的配置。以下是一个简单的配置示例:
location /videos/ {
root html;
mp4; # 开启mp4流媒体功能
mp4_buffer_size 1m; # mp4 moov元数据缓存的默认空间大小
mp4_max_buffer_size 10m; # mp4 moov元数据缓存的最大空间
}
??通过以上配置,就可以通过 curl模拟播放器访问了。例如:
#从头开始播放
curl "http://127.0.0.1/videos/test.mp4"
#从第100s播放到200s
curl "http://127.0.0.1/videos/test.mp4?start=100&end=200"
??这里需要强调的是,对于一些特别大的mp4文件,可能moov元数据的大小就超过了mp4_max_buffer_size,会导致nginx报错的情况,但是如果设置太大,特别是mp4_buffer_size设置得太大,就会使得nginx消耗太多的内存,引起其他问题。因此,需要预先对moov大小有一个预估。
{ ngx_string("mp4"),
NGX_HTTP_LOC_CONF|NGX_CONF_NOARGS,
ngx_http_mp4,
0,
0,
NULL },
? 这个指令开启mp4流媒体功能,从以上定义可以知道这个指令只能在location中配置。
??在ngx_http_mp4配置指令解析函数中,设置了ngx_http_mp4_handler回调函数,如下:
static char *
ngx_http_mp4(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
ngx_http_core_loc_conf_t *clcf;
clcf = ngx_http_conf_get_module_loc_conf(cf, ngx_http_core_module);
clcf->handler = ngx_http_mp4_handler;
return NGX_CONF_OK;
}
??该回调函数会在NGX_HTTP_CONTENT_PHRASE阶段回调这个函数进行mp4的处理。
{ ngx_string("mp4_buffer_size"),
NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1,
ngx_conf_set_size_slot,
NGX_HTTP_LOC_CONF_OFFSET,
offsetof(ngx_http_mp4_conf_t, buffer_size),
NULL },
??这个指令定义了moov数据缓冲区的默认大小,可以在http/server/location中配置。
{ ngx_string("mp4_max_buffer_size"),
NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1,
ngx_conf_set_size_slot,
NGX_HTTP_LOC_CONF_OFFSET,
offsetof(ngx_http_mp4_conf_t, max_buffer_size),
NULL },
??这个指令定义了moov数据缓冲区的最大空间,可以在http/server/location中配置。
{ ngx_string("mp4_start_key_frame"),
NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_FLAG,
ngx_conf_set_flag_slot,
NGX_HTTP_LOC_CONF_OFFSET,
offsetof(ngx_http_mp4_conf_t, start_key_frame),
NULL },
??这个指令设置是否将视频起始帧对齐到最近的关键帧开始发送数据。
??下面以ngx_http_mp4_handler函数为分析对象,概要说明MP4的请求处理过程。
过滤非GET/HEAD请求。
if (!(r->method & (NGX_HTTP_GET|NGX_HTTP_HEAD))) {
return NGX_HTTP_NOT_ALLOWED;
}
取消接收客户端请求的http body部分。
rc = ngx_http_discard_request_body(r);
last = ngx_http_map_uri_to_path(r, &path, &root, 0);
if (last == NULL) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
log = r->connection->log;
path.len = last - path.data;
打开mp4文件
of.read_ahead = clcf->read_ahead;
of.directio = NGX_MAX_OFF_T_VALUE;
of.valid = clcf->open_file_cache_valid;
of.min_uses = clcf->open_file_cache_min_uses;
of.errors = clcf->open_file_cache_errors;
of.events = clcf->open_file_cache_events;
/*
用于设置NGINX服务器是否允许访问符号链接文件的功能。
当启用该功能时,NGINX将拒绝通过符号链接文件访问文件系统中的文件。
*/
if (ngx_http_set_disable_symlinks(r, clcf, &path, &of) != NGX_OK) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
if (ngx_open_cached_file(clcf->open_file_cache, &path, &of, r->pool)
!= NGX_OK)
{
......
}
??从http请求的querystring部分提取到start和end参数,这两个参数的单位都是秒。
if (r->args.len) {
if (ngx_http_arg(r, (u_char *) "start", 5, &value) == NGX_OK) {
/*
* A Flash player may send start value with a lot of digits
* after dot so a custom function is used instead of ngx_atofp().
*/
start = ngx_http_mp4_atofp(value.data, value.len, 3);
}
if (ngx_http_arg(r, (u_char *) "end", 3, &value) == NGX_OK) {
end = ngx_http_mp4_atofp(value.data, value.len, 3);
if (end > 0) {
if (start < 0) {
start = 0;
}
if (end > start) {
length = end - start;
}
}
}
}
if (start >= 0) {
r->single_range = 1;
/* 分配并初始化mp4处理上下文 */
mp4 = ngx_pcalloc(r->pool, sizeof(ngx_http_mp4_file_t));
if (mp4 == NULL) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
mp4->file.fd = of.fd;
mp4->file.name = path;
mp4->file.log = r->connection->log;
mp4->end = of.size;
mp4->start = (ngx_uint_t) start; /* 设置视频起始偏移位置,单位s */
mp4->length = length; /* 设置待响应的视频时长,=0表示一直到末尾*/
mp4->request = r;
/* 加载并调整mp4的moov元信息帧索引 */
switch (ngx_http_mp4_process(mp4)) {
case NGX_DECLINED: /* 跳过mp4的处理,直接返回整个文件 */
if (mp4->buffer) {
ngx_pfree(r->pool, mp4->buffer);
}
ngx_pfree(r->pool, mp4);
mp4 = NULL;
break;
case NGX_OK: /* 处理ok */
r->headers_out.content_length_n = mp4->content_length;
break;
default: /* NGX_ERROR */
if (mp4->buffer) {
ngx_pfree(r->pool, mp4->buffer);
}
ngx_pfree(r->pool, mp4);
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
}
??以下对ngx_http_mp4_file_t的结构定义进行说明:
typedef struct {
ngx_file_t file; # mp4文件对象
u_char *buffer; # 用于mp4分析的缓冲区
u_char *buffer_start; # buffer空闲的起始位置
u_char *buffer_pos; # buffer中可用于分析的起始位置
u_char *buffer_end; # buffer中可用于分析的结束位置
size_t buffer_size; # mp4分析缓冲区buffer的大小
off_t offset; # buffer_start对应的mp4文件读取的偏移量
off_t end; # 当前mp4文件的文件大小
off_t content_length; # 最终发送给客户端响应的内容长度
ngx_uint_t start; # 请求的起始偏移时间
ngx_uint_t length; # 请求的视频时长
uint32_t timescale; # mp4文件中设置的时间scale值
ngx_http_request_t *request; # 对应当前的http request对象
ngx_array_t trak; # mp4包含的track列表,引用traks,最多2个
ngx_http_mp4_trak_t traks[2]; # mp4包含的track列表
size_t ftyp_size; # ftyp atom的大小
size_t moov_size; # moov atom的大小
ngx_chain_t *out;
ngx_chain_t ftyp_atom; # 链接了ftyp_atom_buf的缓冲区链
ngx_chain_t moov_atom; # 链接了moov_atom_buf的缓冲区链
ngx_chain_t mvhd_atom; # 链接了mvhd_atom_buf的缓冲区链
ngx_chain_t mdat_atom; # 链接了mdat_atom_buf的缓冲区链
ngx_chain_t mdat_data; # 链接了mdat_data_buf的缓冲区链
ngx_buf_t ftyp_atom_buf; # ftyp atom的缓冲区
ngx_buf_t moov_atom_buf; # moov atom的缓冲区
ngx_buf_t mvhd_atom_buf; # mvhd atom的缓冲区
ngx_buf_t mdat_atom_buf; # mdat atom的缓冲区
ngx_buf_t mdat_data_buf; # mdat atom的缓冲区
u_char moov_atom_header[8];
u_char mdat_atom_header[16];
} ngx_http_mp4_file_t;
??其实现源码如下,源码里面进行了详细的备注说明:
log->action = "sending mp4 to client";
/* 如果需要,开启directio */
if (clcf->directio <= of.size) {
/*
* DIRECTIO is set on transfer only
* to allow kernel to cache "moov" atom
*/
if (ngx_directio_on(of.fd) == NGX_FILE_ERROR) {
ngx_log_error(NGX_LOG_ALERT, log, ngx_errno,
ngx_directio_on_n " \"%s\" failed", path.data);
}
of.is_directio = 1;
if (mp4) {
mp4->file.directio = 1;
}
}
/* 设置响应状态和相关响应头信息,然后将响应头发送给用户 */
r->headers_out.status = NGX_HTTP_OK;
r->headers_out.last_modified_time = of.mtime;
if (ngx_http_set_etag(r) != NGX_OK) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
if (ngx_http_set_content_type(r) != NGX_OK) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
if (mp4 == NULL) {
b = ngx_calloc_buf(r->pool);
if (b == NULL) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
b->file = ngx_pcalloc(r->pool, sizeof(ngx_file_t));
if (b->file == NULL) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
}
rc = ngx_http_send_header(r);
if (rc == NGX_ERROR || rc > NGX_OK || r->header_only) {
return rc;
}
/* 如果mp4处理上下文已经创建过,那么就发送mp4->out缓冲区中的内容给用户
if (mp4) {
return ngx_http_output_filter(r, mp4->out);
}
/* 将完整的mp4文件发送出去 */
b->file_pos = 0;
b->file_last = of.size;
b->in_file = b->file_last ? 1 : 0;
b->last_buf = (r == r->main) ? 1 : 0;
b->last_in_chain = 1;
b->sync = (b->last_buf || b->in_file) ? 0 : 1;
b->file->fd = of.fd;
b->file->name = path;
b->file->log = log;
b->file->directio = of.is_directio;
out.buf = b;
out.next = NULL;
return ngx_http_output_filter(r, &out);
??关于mp4文件的详细可以可以参见相应的标准文档,互联网上也有大量的文章,甚至可以用工具自己打开一个mp4文件来对照分析。以下对照mp4文件格式,在ngx_http_mp4_module中如何来定义分析器进行说明。?? 对应mp4文件的文件级的atom,本模块进行了如下定义:
static ngx_http_mp4_atom_handler_t ngx_http_mp4_atoms[] = {
{ "ftyp", ngx_http_mp4_read_ftyp_atom },
{ "moov", ngx_http_mp4_read_moov_atom },
{ "mdat", ngx_http_mp4_read_mdat_atom },
{ NULL, NULL }
};
??譬如对于ftyp atom,在分析程序读取到ftyp atom的时候,就会调用ngx_http_mp4_read_ftyp_atom进行解析。其余的atom都是类似的处理方式。对于包含子atom的容器类atom,那么在对应的处理函数里面会递归解析子atom。譬如moov,下面又递归定义了子atom的处理函数,如下:
static ngx_http_mp4_atom_handler_t ngx_http_mp4_moov_atoms[] = {
{ "mvhd", ngx_http_mp4_read_mvhd_atom },
{ "trak", ngx_http_mp4_read_trak_atom },
{ "cmov", ngx_http_mp4_read_cmov_atom },
{ NULL, NULL }
};
??下面是trak atom的子atom处理函数定义:
static ngx_http_mp4_atom_handler_t ngx_http_mp4_trak_atoms[] = {
{ "tkhd", ngx_http_mp4_read_tkhd_atom },
{ "mdia", ngx_http_mp4_read_mdia_atom },
{ NULL, NULL }
};
??这里的trak atom对应每一条音频流或者视频流,所以可以包含多个。
??下面是mdia的子atom处理函数定义:
static ngx_http_mp4_atom_handler_t ngx_http_mp4_mdia_atoms[] = {
{ "mdhd", ngx_http_mp4_read_mdhd_atom },
{ "hdlr", ngx_http_mp4_read_hdlr_atom },
{ "minf", ngx_http_mp4_read_minf_atom },
{ NULL, NULL }
};
??下面是mdia的子minf处理函数定义:
static ngx_http_mp4_atom_handler_t ngx_http_mp4_minf_atoms[] = {
{ "vmhd", ngx_http_mp4_read_vmhd_atom },
{ "smhd", ngx_http_mp4_read_smhd_atom },
{ "dinf", ngx_http_mp4_read_dinf_atom },
{ "stbl", ngx_http_mp4_read_stbl_atom },
{ NULL, NULL }
};
??下面是mdia的子stbl处理函数定义:
static ngx_http_mp4_atom_handler_t ngx_http_mp4_stbl_atoms[] = {
{ "stsd", ngx_http_mp4_read_stsd_atom },
{ "stts", ngx_http_mp4_read_stts_atom },
{ "stss", ngx_http_mp4_read_stss_atom },
{ "ctts", ngx_http_mp4_read_ctts_atom },
{ "stsc", ngx_http_mp4_read_stsc_atom },
{ "stsz", ngx_http_mp4_read_stsz_atom },
{ "stco", ngx_http_mp4_read_stco_atom },
{ "co64", ngx_http_mp4_read_co64_atom },
{ NULL, NULL }
};
下面将上面定义的架构画成对应的图:
?? MP4的处理过程一言以蔽之,就是读取MP4文件头ftyp atom,以及moov atom,然后根据用户请求的start起始时间位置,对moov头中的stbl的各个子 atom进行调整(包括裁减和索引调整),生成新的moov放在ngx_http_mp4_file_t结构体中,最后交由nginx文件异步发送逻辑将mp4发送给客户端。mp4文件的处理核心逻辑都在ngx_http_mp4_process函数中来实现的,主要分为两大步骤:
??下面首先对MP4的加载逻辑进行分析:
??MP4的加载逻辑的入口函数是ngx_http_mp4_read_atom,函数的定义如下:
static ngx_int_t
ngx_http_mp4_read_atom(ngx_http_mp4_file_t *mp4,
ngx_http_mp4_atom_handler_t *atom,
uint64_t atom_data_size);
??这个函数是可以进行递归调用的,当目前的atom容器需要解析其包含的子atom的时候,就进行一次递归。譬如,对于完整的MP4文件分析,我们可以认为完整的MP4本身就是一个大容器,那么这么来调用:
ngx_http_mp4_read_atom(mp4, ngx_http_mp4_atoms, mp4->end);
其中mp4->end表示整个MP4文件的大小。
??那么对于当前是moov atom的情况下,我们可以这么来调用:
ngx_http_mp4_read_atom(mp4, ngx_http_mp4_moov_atoms, atom_data_size);
其中atom_data_size表示moov atom的大小。
以下是ngx_http_mp4_read_atom的实现代码:
static ngx_int_t
ngx_http_mp4_read_atom(ngx_http_mp4_file_t *mp4,
ngx_http_mp4_atom_handler_t *atom, uint64_t atom_data_size)
{
off_t end;
size_t atom_header_size;
u_char *atom_header, *atom_name;
uint64_t atom_size;
ngx_int_t rc;
ngx_uint_t n;
end = mp4->offset + atom_data_size;
/* 如果分析的文件偏移量还没有到atom末尾则继续循环执行分析 */
while (mp4->offset < end) {
/* 确保buffer中至少有atom头中的4字节长度可供分析 */
if (ngx_http_mp4_read(mp4, sizeof(uint32_t)) != NGX_OK) {
return NGX_ERROR;
}
atom_header = mp4->buffer_pos; /* buffer_pos是当前的缓冲区读指针 */
atom_size = ngx_mp4_get_32value(atom_header);
atom_header_size = sizeof(ngx_mp4_atom_header_t);
if (atom_size == 0) {
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, mp4->file.log, 0,
"mp4 atom end");
return NGX_OK;
}
if (atom_size < sizeof(ngx_mp4_atom_header_t)) {
if (atom_size == 1) { /* atom_size=1 表示是一个支持64位长度的atom */
/* 确保buffer中至少有atom头中的ngx_mp4_atom_header64_t长度供分析 */
if (ngx_http_mp4_read(mp4, sizeof(ngx_mp4_atom_header64_t))
!= NGX_OK)
{
return NGX_ERROR;
}
/* 64-bit atom size */
atom_header = mp4->buffer_pos;
atom_size = ngx_mp4_get_64value(atom_header + 8);
atom_header_size = sizeof(ngx_mp4_atom_header64_t);
if (atom_size < sizeof(ngx_mp4_atom_header64_t)) {
ngx_log_error(NGX_LOG_ERR, mp4->file.log, 0,
"\"%s\" mp4 atom is too small:%uL",
mp4->file.name.data, atom_size);
return NGX_ERROR;
}
} else {
ngx_log_error(NGX_LOG_ERR, mp4->file.log, 0,
"\"%s\" mp4 atom is too small:%uL",
mp4->file.name.data, atom_size);
return NGX_ERROR;
}
}
/* 确保buffer中至少有atom头中的ngx_mp4_atom_header_t长度供分析 */
if (ngx_http_mp4_read(mp4, sizeof(ngx_mp4_atom_header_t)) != NGX_OK) {
return NGX_ERROR;
}
atom_header = mp4->buffer_pos;
atom_name = atom_header + sizeof(uint32_t);
ngx_log_debug4(NGX_LOG_DEBUG_HTTP, mp4->file.log, 0,
"mp4 atom: %*s @%O:%uL",
(size_t) 4, atom_name, mp4->offset, atom_size);
if (atom_size > (uint64_t) (NGX_MAX_OFF_T_VALUE - mp4->offset)
|| mp4->offset + (off_t) atom_size > end)
{
ngx_log_error(NGX_LOG_ERR, mp4->file.log, 0,
"\"%s\" mp4 atom too large:%uL",
mp4->file.name.data, atom_size);
return NGX_ERROR;
}
/* 查找当前atom容器下面的子容器定义是否包含刚刚读取到的名字为atom_name的atom
如果找到了,则调用前面注册的回调函数
如果没有找到,则忽略之,本模块不用关心,也不是流媒体播放所必须的。
*/
for (n = 0; atom[n].name; n++) {
if (ngx_strncmp(atom_name, atom[n].name, 4) == 0) {
ngx_mp4_atom_next(mp4, atom_header_size);
rc = atom[n].handler(mp4, atom_size - atom_header_size);
if (rc != NGX_OK) {
return rc;
}
goto next;
}
}
/* 移动buffer_pos指针,在缓冲区中跳过本atom */
ngx_mp4_atom_next(mp4, atom_size);
next:
continue;
}
return NGX_OK;
}
?? 这个函数的主要逻辑就是读取atom头,然后交由前面定义的atom处理函数进行处理,如果当前的atom本模块没有对应的定义则直接忽略,然后切换到下一个atom继续分析,直到整个mp4文件分析完成。以下对代码中的部分内容再做进一步的解析:
?? mp4的atom支持最大支持232-1大小的普通atom,和支持264-1大小的64位的atom,当是后者的情况,在atom头部的前4个字节读取后,得到是1,表示它是64位的atom,需要另行读取64位长度的长度字段,64位的atom的定义如下:
typedef struct {
u_char size[4]; /* 设置的是1 */
u_char name[4];
u_char size64[8];
} ngx_mp4_atom_header64_t;
??而32位的atom定义如下:
typedef struct {
u_char size[4];
u_char name[4];
} ngx_mp4_atom_header_t;
??在ngx_http_mp4_read_atom中还调用了ngx_http_mp4_read,对ngx_http_mp4_read函数的分析可以帮助我们理解ngx_http_mp4_file_t 中offset/end、buffer_pos/buffer_end以及buffer_size字段的具体含义,所以我们再看一下ngx_http_mp4_read函数的实现逻辑,代码如下:
static ngx_int_t
ngx_http_mp4_read(ngx_http_mp4_file_t *mp4, size_t size)
{
ssize_t n;
/* 如果当前的缓冲区读位置+待读取的字节数 < 缓冲区结束位置,
说明缓冲区里面还有足够数据,直接返回OK即可
*/
if (mp4->buffer_pos + size <= mp4->buffer_end) {
return NGX_OK;
}
/* 如果当前的文件读偏移量+缓冲区大小超过的了文件结束位置
则调整缓冲区大小值,避免越过文件结束位置
*/
if (mp4->offset + (off_t) mp4->buffer_size > mp4->end) {
mp4->buffer_size = (size_t) (mp4->end - mp4->offset);
}
if (mp4->buffer_size < size) {
ngx_log_error(NGX_LOG_ERR, mp4->file.log, 0,
"\"%s\" mp4 file truncated", mp4->file.name.data);
return NGX_ERROR;
}
if (mp4->buffer == NULL) {
mp4->buffer = ngx_palloc(mp4->request->pool, mp4->buffer_size);
if (mp4->buffer == NULL) {
return NGX_ERROR;
}
mp4->buffer_start = mp4->buffer;
}
/* 从文件中读取偏移量为mp4->offset,大小为mp4->buffer_size的数据到mp4->buffer_start
缓冲区中
*/
n = ngx_read_file(&mp4->file, mp4->buffer_start, mp4->buffer_size,
mp4->offset);
if (n == NGX_ERROR) {
return NGX_ERROR;
}
if ((size_t) n != mp4->buffer_size) {
ngx_log_error(NGX_LOG_CRIT, mp4->file.log, 0,
ngx_read_file_n " read only %z of %z from \"%s\"",
n, mp4->buffer_size, mp4->file.name.data);
return NGX_ERROR;
}
mp4->buffer_pos = mp4->buffer_start;
mp4->buffer_end = mp4->buffer_start + mp4->buffer_size;
return NGX_OK;
}
??最后再看ngx_mp4_atom_next,它是一个宏定义,源码如下:
#define ngx_mp4_atom_next(mp4, n) \
\
if (n > (size_t) (mp4->buffer_end - mp4->buffer_pos)) { \
mp4->buffer_pos = mp4->buffer_end; \
\
} else { \
mp4->buffer_pos += (size_t) n; \
} \
\
mp4->offset += n
??这个宏说白了就是移动缓冲区的读指针buffer_pos和文件读偏移量offset,目的是用来跳过某个atom到下一个atom,以便后续继续对mp4文件进行分析。那么为什么offset可以直接加n,而buffer_pos不能呢?因为可能某个atom并没有完整的读取到buffer中,所以直接让buffer_pos + n可能会越过了buffer_end,而offset则不同,它是文件读偏移量,offset+n正好是在文件中越过了该atom。
本篇到此结束,关于moov元素的分析、stbl视频帧索引的调整部分的内容将在下篇进行分析介绍。