Html5 Audio Tag and Cache

关于 html5 中的 audio 标签的填坑之路。

Published @ Sep 09, 2014

之前在设计暖暖数据库的使用,出于对开发效率的考虑,使用了简单粗暴的方式直接将用户的心事录音数据存放在数据库中(不过现在看来这种方式也是没有什么问题的)。然后实现暖暖的管理后台及咨询师工作后台的时候,对于如何在浏览器中播放录音,也是使用最简单的<audio>标签的方式,通过一个链接将录音数据直接暴露给<audio>标签。

Python 端代码:

@bp.route("/<uuid:id>/audio")
@require_right
def audio(id):
    item = bs_record.get_record(id) or bs_record.get_reply(id)
    if not item:
        abort(404)
    r = Response(item.voice.data, mimetype="audio/mp4")
    return r

前端代码:

<audio controls="controls" volume="1.0">
Your browser does not support the <code>audio</code> element.
<source src="{{ url_for(".audio", id=record.id) }}" type="audio/mp4">
</audio>

这种方式是可以实现基本形式上正常工作的,但是有一些问题。

  1. 浏览器不会自动缓存录音数据,导致每次刷新浏览器时都会从服务器下载所有录音数据;有些录音列表页面中有上百的audio标签,这个数据量还是很大的,会导致浏览器和服务器卡顿;
  2. 浏览器端点击播放按钮后,音频可以正常播放,但是播放结束后不能恢复,无法进行第二次播放;
  3. 浏览器端无法通过拖动进度条的方式来控制播放的起始位置;
  4. Safari 在https的环境下无法播放音频,http环境下则正常;

昨天下午不知怎么有了一些思路,于是就磕磕碰碰最终把这些问题解决了。这里记录下解决的所有经过与思考。

关于 Cache

在解决缓存问题的过程中发现 Chrome 与 Safari 的表现不一致,这里就不详细说明区别了,只拿 Chrome 作为目标来说明。

首先要解决浏览器从不缓存音频数据的问题。

首先我知道浏览器的缓存行为是受 HTTP 请求 Header 来控制的。然后看到浏览器对一些 css 和 js 文件做了很好的缓存处理了,所以只要对比这些文件和音频文件的 Response Header 的区别就可以找到解决办法了。

经测试 Chrome 浏览器对这些 css 文件的缓存逻辑其实有两层。第一层是最直观的缓存:浏览器把文件保存到本地,下次就不再向服务器请求文件了,直接使用本地的文件。但是当我在某个页面下直接Command+R强制刷新页面时,上面说的第一层缓存就会失效,浏览器会向服务器发送请求,但这时服务器不是直接返回请求的文件数据,而是返回了一个304 Not Modified 的 Response,表示服务器端的该文件没有变化,浏览器可以直接使用本地缓存的文件。这样虽然依然会发送请求,但是 Response 里面不会带有文件内容,会极大的减少消耗的带宽,这就是第二层缓存。

首先对比一下 css 文件的 Response 和音频文件的 Response 的头信息:

CSS:
Cache-Control:public, max-age=43200
Connection:keep-alive
Content-Length:99548
Content-Type:text/css; charset=utf-8
Date:Thu, 25 Sep 2014 07:24:31 GMT
ETag:"flask-1402394227.18-99548-239868049"
Expires:Thu, 25 Sep 2014 19:24:31 GMT
Last-Modified:Tue, 10 Jun 2014 09:57:07 GMT
Server:nginx/1.6.0

AUDIO:
Connection:keep-alive
Content-Length:32344
Content-Type:audio/mp4
Date:Thu, 25 Sep 2014 07:06:50 GMT
Server:nginx/1.6.0

可以看到区别是 css 文件的头信息里面多了Cache-ControlETagExpiresLast-Modified这些头。

大概查阅了一些资料,这些头信息的确都是和缓存控制有关的。而当前对 css 文件的处理是用flask.send_file来处理的,我可以也用这个函数来将音频数据模拟成文件。代码如下:

@bp.route("/<uuid:id>/audio")
@require_right
def item_audio(id):
    from StringIO import StringIO
    from flask import send_file
    item = bs_record.get_record(id) or bs_record.get_reply(id)
    if not item:
        abort(404)
    return send_file(StringIO(item.voice.data),
                     mimetype="audio/mp4")

这样,浏览器收到的 Response 头信息如下:

Cache-Control:public, max-age=43200
Connection:keep-alive
Content-Type:audio/mp4
Date:Thu, 25 Sep 2014 07:36:19 GMT
Expires:Thu, 25 Sep 2014 19:36:19 GMT
Server:nginx/1.6.0

可以看到,里面添加了Cache-ControlExpires头。这时浏览器的行为已经变化了,在切换页面的过程中已经会使用缓存在本地的音频文件而不是重新发送请求了。但是如果使用Command+R强制刷新页面时,浏览器还是会从服务器重新获取所有的文件数据。也就是说,上面提到的 第一层缓存 已经实现了,但是 第二层缓存 还是不可用的状态,我们继续。

这时可以注意到,我们还没有用到Etag这个东西,查资料知道,这个头信息是可以表示当前文件状态的,当浏览器本地有文件缓存时,它在重新请求文件的时候会将这个文件的Etag放在请求中告诉服务器,而服务器可以通过这个Etag来判断这个文件是否有变化,如果有变化就将文件数据返回给浏览器,如果没有变化就直接返回304 Not Modified让浏览器直接使用本地缓存的文件。这就是 第二层缓存 的逻辑了。

所以我们只要实现Etag逻辑就可以了。看了flask.send_file代码,发现其实里面已经有了Etag逻辑的实现,不过它是基于发送的是 文件 这种场景的,我们用来伪装文件的StringIO不是很适用,所以需要自己修改一些代码:

def send_voice_stream(voice):
    from time import mktime, time
    from werkzeug.datastructures import Headers
    mime = "audio/mp4"
    timestamp = int(mktime(voice.create_time.timetuple()))
    etag = "warmup-audio-%s-%s" % (voice.id.hex, timestamp)
    cache_timeout = 3600

    headers = Headers()

    length = len(voice.data)
    data = voice.data
    resp = current_app.response_class(data, mimetype=mime,
                                      headers=headers)

    resp.headers["Content-Length"] = len(data)
    resp.cache_control.pushlic = True
    resp.cache_control.max_age = cache_timeout
    resp.expires = int(time() + cache_timeout)
    resp.set_etag(etag)

    return resp.make_conditional(request)   

@bp.route("/<uuid:id>/audio")
@require_right
def item_audio(id):
    item = bs_record.get_record(id) or bs_record.get_reply(id)
    if not item:
        abort(404)
    return send_voice_stream(item.voice)

这样,加上了Etag逻辑之后的 Response 头信息如下:

Cache-Control:max-age=3600
Connection:keep-alive
Content-Length:26349
Content-Type:audio/mp4
Date:Thu, 25 Sep 2014 07:53:20 GMT
ETag:"warmup-audio-f3d9fd66ddcd4b01a2e34da3124aa2f5-1411591761"
Expires:Thu, 25 Sep 2014 08:53:20 GMT
Server:nginx/1.6.0

强制刷新浏览器,浏览器发送的请求和收到的回复的头信息如下:

Request:
----------------
Accept:*/*
Accept-Encoding:identity;q=1, *;q=0
Accept-Language:zh-CN,zh;q=0.8,en;q=0.6,ja;q=0.4,zh-TW;q=0.2
Cache-Control:max-age=0
Connection:keep-alive
Host:work.warmup.local
If-None-Match:"warmup-audio-70cbab169e4f4a1fad7796f7f025299d-1411580347"
Range:bytes=0-35920

Response:
-----------------
Cache-Control:max-age=3600
Connection:keep-alive
Date:Thu, 25 Sep 2014 07:54:27 GMT
ETag:"warmup-audio-70cbab169e4f4a1fad7796f7f025299d-1411580347"
Expires:Thu, 25 Sep 2014 08:54:27 GMT
Server:nginx/1.6.0

服务器终于返回了304。至此,对于缓存的优化已经完成了。

关于 206 Partical Content

接下来要解决的是浏览器的audio标签不能拖动的问题。

网上搜寻了一番,得到了一个关键词Stream Server206 Partical ContentRange。最终,我发现,我的音频文件接口缺少了一个返回206 Partical Content的能力。如果这个实现了,audio标签就可以正常工作了。

看了几个关于 Flask 中如下实现206 Partical Content的文章:

发现逻辑还是很简单的,无非就是根据请求头中指明的Range来返回文件的其中一部分数据就行了。但是这两篇文章有个问题就是全部都自己造了一个轮子,但是我发现werkzeug里面已经有对应的代码实现了,既然都用了 Flask 也应该用上 Werkzeug 提供的各种轮子啊。所以我可以给出比他们更优雅的代码:

if request.range:
        start, end = request.range.range_for_length(length)
        data = voice.data[start:end]
        content_range = request.range.make_content_range(length)
        resp = current_app.response_class(data, 206, mimetype=mime,
                                          headers=headers)
        resp.content_range = content_range
    else:
        data = voice.data
        resp = current_app.response_class(data, mimetype=mime,
                                          headers=headers)

扩展了一下send_voice_stream最终代码如下:

def send_voice_stream(voice):
    """ 发送语音流,支持 ETag、Cache Control、Partial Content """
    from time import mktime, time
    from werkzeug.datastructures import Headers
    mime = "audio/mp4"
    timestamp = int(mktime(voice.create_time.timetuple()))
    etag = "warmup-audio-%s-%s" % (voice.id.hex, timestamp)
    cache_timeout = 3600

    headers = Headers()
    headers["Accept-Ranges"] = "bytes"

    length = len(voice.data)
    if request.range:
        start, end = request.range.range_for_length(length)
        data = voice.data[start:end]
        content_range = request.range.make_content_range(length)
        resp = current_app.response_class(data, 206, mimetype=mime,
                                          headers=headers)
        resp.content_range = content_range
    else:
        data = voice.data
        resp = current_app.response_class(data, mimetype=mime,
                                          headers=headers)

    resp.headers["Content-Length"] = len(data)
    resp.cache_control.pushlic = True
    resp.cache_control.max_age = cache_timeout
    resp.expires = int(time() + cache_timeout)
    resp.set_etag(etag)

    return resp.make_conditional(request)

大功告成,不正常工作的audio标签全部都乖乖听话了,连 Safari 在 https 下的问题也不存在了,看来这些问题都是这个原因。

我不知道这个Partical Content是不是流媒体服务器的一个核心概念,但是它能让audio标签正常工作,随意选择播放进度,这应该跟正真的流媒体相差不远了吧。

本文完结,给自己点个赞 ^_^。

END