使用 rails、nginx 和 send_file 在 Chrome 中流式传输 mp4
Posted
技术标签:
【中文标题】使用 rails、nginx 和 send_file 在 Chrome 中流式传输 mp4【英文标题】:Streaming mp4 in Chrome with rails, nginx and send_file 【发布时间】:2015-03-16 12:14:44 【问题描述】:我一辈子都无法使用 html5 <video>
标签将 mp4 流式传输到 Chrome。如果我将文件放在public
中,那么一切都是肉汁并且按预期工作。但是如果我尝试使用send_file
来服务它,几乎所有可以想象的事情都会出错。我正在使用一个由 nginx 代理的 rails 应用程序,其 Video
模型具有 location
属性,该属性是磁盘上的绝对路径。
一开始我试过:
def show
send_file Video.find(params[:id]).location
end
我确信我会沉浸在现代 Web 开发的荣耀中。哈。这在 Chrome 和 Firefox 中都可以播放,但既不寻找也不知道视频有多长。我戳了一下响应标头,发现Content-Type
以application/octet-stream
发送,并且没有设置Content-Length
。嗯……怎么了?
好的,我想我可以在 rails 中设置它们:
def show
video = Video.find(params[:id])
response.headers['Content-Length'] = File.stat(video.location).size
send_file(video.location, type: 'video/mp4')
end
此时,一切都在 Firefox 中正常运行。它知道视频有多长,并按预期搜索作品。 Chrome 似乎知道视频有多长(不显示时间戳,但搜索栏看起来很合适),但搜索不起作用。
显然 Chrome 比 Firefox 更挑剔。它要求服务器使用带有值 bytes
的 Accept-Ranges
标头响应,并使用 206
和文件的适当部分响应后续请求(在用户搜索时发生)。
好的,所以我从here 借了一些代码,然后我得到了这个:
video = Video.find(params[:id])
file_begin = 0
file_size = File.stat(video.location).size
file_end = file_size - 1
if !request.headers["Range"]
status_code = :ok
else
status_code = :partial_content
match = request.headers['Range'].match(/bytes=(\d+)-(\d*)/)
if match
file_begin = match[1]
file_end = match[2] if match[2] && !match[2].empty?
end
response.header["Content-Range"] = "bytes " + file_begin.to_s + "-" + file_end.to_s + "/" + file_size.to_s
end
response.header["Content-Length"] = (file_end.to_i - file_begin.to_i + 1).to_s
response.header["Accept-Ranges"]= "bytes"
response.header["Content-Transfer-Encoding"] = "binary"
send_file(video.location,
:filename => File.basename(video.location),
:type => 'video/mp4',
:disposition => "inline",
:status => status_code,
:stream => 'true',
:buffer_size => 4096)
现在 Chrome 会尝试搜索,但当您这样做时,视频会停止播放,并且在页面重新加载之前不再播放。啊。所以我决定用 curl 来看看发生了什么,我发现了这个:
$ curl --header "范围:字节=200-400" http://localhost:8080/videos/1/001.mp4 ftypisomisomiso2avc1mp41 �moovlmvhd��@��trak\tkh��
$ curl --header "范围:字节=1200-1400" http://localhost:8080/videos/1/001.mp4 ftypisomisomiso2avc1mp41 �moovlmvhd��@��trak\tkh��
无论字节范围请求如何,数据总是从文件的开头开始。返回适当数量的字节(在本例中为 201 个字节),但它始终从文件的开头开始。显然 nginx 尊重 Content-Length
标头,但忽略了 Content-Range
标头。
我的nginx.conf
是默认不变的:
user www-data;
worker_processes 4;
pid /run/nginx.pid;
events
worker_connections 768;
http
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
ssl_prefer_server_ciphers on;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
我的 app.conf 非常基础:
upstream unicorn
server unix:/tmp/unicorn.app.sock fail_timeout=0;
server
listen 80 default deferred;
root /vagrant/public;
try_files $uri/index.html $uri @unicorn;
location @unicorn
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header HOST $http_host;
proxy_redirect off;
proxy_pass http://unicorn;
error_page 500 502 503 504 /500.html;
client_max_body_size 4G;
keepalive_timeout 5;
首先我尝试了 Ubuntu 14.04 附带的 nginx 1.4.x,然后从 ppa 尝试了 1.7.x - 结果相同。我什至尝试了 apache2 并得到了完全相同的结果。
我想重申视频文件不是的问题。如果我将它放在public
中,那么 nginx 会为它提供适当的 mime 类型、标题以及 Chrome 正常工作所需的一切。
所以我的问题分为两部分:
为什么 nginx/apache 不像 public
静态提供文件时那样使用 send_file
(X-Accel-Redirect
/X-Sendfile
) 自动处理所有这些东西?在 Rails 中处理这些东西太落后了。
到底如何才能将 send_file 与 nginx(或 apache)一起使用,以便 Chrome 满意并允许搜索?
更新 1
好的,所以我想我会尝试消除 Rails 的复杂性,看看我是否可以让 nginx 正确代理文件。所以我启动了一个非常简单的 nodjs 服务器:
var http = require('http');
http.createServer(function (req, res)
res.writeHead(200,
'X-Accel-Redirect': '/path/to/file.mp4'
);
res.end();
).listen(3000, '127.0.0.1');
console.log('Server running at http://127.0.0.1:3000/');
而 chrome 就像一只蛤蜊一样快乐。 =/ curl -I
甚至显示 Accept-Ranges: bytes
和 Content-Type: video/mp4
正在由 nginx 自动插入 - 因为它应该如此。 Rails 会做什么来阻止 nginx 这样做?
更新 2
我可能越来越近了……
如果我有:
def show
video = Video.find(params[:id])
send_file video.location
end
然后我得到:
$ curl -I localhost:8080/videos/1/001.mp4
HTTP/1.1 200 OK
Server: nginx/1.7.9
Date: Sun, 18 Jan 2015 12:06:38 GMT
Content-Type: application/octet-stream
Connection: keep-alive
Status: 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename="001.mp4"
Content-Transfer-Encoding: binary
Cache-Control: private
Set-Cookie: request_method=HEAD; path=/
X-Meta-Request-Version: 0.3.4
X-Request-Id: cd80b6e8-2eaa-4575-8241-d86067527094
X-Runtime: 0.041953
我有上面描述的所有问题。
但如果我有:
def show
video = Video.find(params[:id])
response.headers['X-Accel-Redirect'] = video.location
head :ok
end
然后我得到:
$ curl -I localhost:8080/videos/1/001.mp4
HTTP/1.1 200 OK
Server: nginx/1.7.9
Date: Sun, 18 Jan 2015 12:06:02 GMT
Content-Type: text/html
Content-Length: 186884698
Last-Modified: Sun, 18 Jan 2015 03:49:30 GMT
Connection: keep-alive
Cache-Control: max-age=0, private, must-revalidate
Set-Cookie: request_method=HEAD; path=/
ETag: "54bb2d4a-b23a25a"
Accept-Ranges: bytes
一切都很完美。
但是为什么呢?那些应该做完全相同的事情。为什么 nginx 不像简单的 nodejs 示例那样在此处自动设置 Content-Type
?我有config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'
设置。我在application.rb
和development.rb
之间来回移动它,结果相同。我想我从来没有提到过……这是 rails 4.2.0。
更新 3
现在我已经将我的独角兽服务器更改为侦听端口 3000(因为对于 nodejs 示例,我已经将 nginx 更改为侦听 3000)。现在我可以直接向 unicorn 发出请求(因为它正在侦听端口而不是套接字)所以我发现 curl -I
直接向 unicorn 表明没有发送 X-Accel-Redirect
标头,而只是 curl
ing unicorn 直接直接发送文件。这就像send_file
没有做它应该做的事情。
【问题讨论】:
【参考方案1】:我终于得到了我最初问题的答案。我没想到我会到这里。我所有的研究都导致了死胡同、hacky non-solutions 和“它只是开箱即用”(嗯,不适合我)。
为什么 nginx/apache 不像从公共静态提供文件时那样使用 send_file (X-Accel-Redirect/X-Sendfile) 自动处理所有这些东西?在 Rails 中处理这些东西太落后了。
可以,但必须正确配置才能取悦 Rack::Sendfile(见下文)。试图在 Rails 中处理这个问题是一个 hacky 非解决方案。
我怎么能真正将 send_file 与 nginx(或 apache)一起使用,这样 Chrome 才会开心并允许搜索?
我绝望到开始研究机架源代码,这就是我在Rack::Sendfile
的 cmets 中找到答案的地方。它们的结构是文档,您可以在 rubydoc 找到。
无论出于何种原因,Rack::Sendfile
要求前端代理发送 X-Sendfile-Type
标头。对于 nginx,它还需要一个 X-Accel-Mapping
标头。该文档还包含 apache 和 lighttpd 的示例。
人们会认为 rails 文档可以链接到 Rack::Sendfile 文档,因为 send_file 在没有额外配置的情况下无法开箱即用。也许我会提交一个拉取请求。
最后我只需要在我的 app.conf 中添加几行:
upstream unicorn
server unix:/tmp/unicorn.app.sock fail_timeout=0;
server
listen 80 default deferred;
root /vagrant/public;
try_files $uri/index.html $uri @unicorn;
location @unicorn
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header HOST $http_host;
proxy_set_header X-Sendfile-Type X-Accel-Redirect; # ADDITION
proxy_set_header X-Accel-Mapping /=/; # ADDITION
proxy_redirect off;
proxy_pass http://localhost:3000;
error_page 500 502 503 504 /500.html;
client_max_body_size 4G;
keepalive_timeout 5;
现在我的原始代码按预期工作:
def show
send_file(Video.find(params[:id]).location)
end
编辑:
虽然这最初有效,但在我重新启动我的 vagrant box 后它停止工作,我不得不进行进一步的更改:
upstream unicorn
server unix:/tmp/unicorn.app.sock fail_timeout=0;
server
listen 80 default deferred;
root /vagrant/public;
try_files $uri/index.html $uri @unicorn;
location ~ /files(.*) # NEW
internal; # NEW
alias $1; # NEW
# NEW
location @unicorn
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header HOST $http_host;
proxy_set_header X-Sendfile-Type X-Accel-Redirect;
proxy_set_header X-Accel-Mapping /=/files/; # CHANGED
proxy_redirect off;
proxy_pass http://localhost:3000;
error_page 500 502 503 504 /500.html;
client_max_body_size 4G;
keepalive_timeout 5;
我发现将一个 URI 映射到另一个 URI,然后将该 URI 映射到磁盘上的某个位置的整个过程是完全没有必要的。这对我的用例没用,我只是将一个映射到另一个然后再返回。 Apache 和 lighttpd 不需要它。但至少它有效。
我还将Mime::Type.register('video/mp4', :mp4)
添加到config/initializers/mime_types.rb
,以便使用正确的mime 类型提供文件。
【讨论】:
应该location ~ /files(.*)
是 location ~ /files/(.*)
以上是关于使用 rails、nginx 和 send_file 在 Chrome 中流式传输 mp4的主要内容,如果未能解决你的问题,请参考以下文章
使用 send_file 下载 txt 文件时,safari 和 chrome 浏览器无法解析它
Python Flask send_file StringIO 空白文件
使用 Ruby on Rails 4 和 Nginx 读写 cookie
使用 Nginx、Puma 和 Redis 部署 Rails 5 Action Cable