koa使用静态目录如何支持视屏调节时间进度
背景
搭建基于koa的VueSSR的时候,有个功能需要上传下载预览视屏。实现思路,首先设置静态目录(koa-static),上传时直接保存文件到静态目录,预览和下载都通过返回对应静态目录文件的路径进行操作。
问题
当前端通过video
的播放的时候,画面能正常播放,但是令人惊讶的事情发生了,居然不能手动调节进度,无论是拖拽进度条还是点击都无效。
过程
- 第一反应是前端设置有问题,但是前端控制只有一个设置
control
,并没有其余的单独设置。于是猜想是不是视屏有问题,换了一个视屏,但是仍然如此。 - 多次使用过
video
从来没发现过这种情况,如果下载视屏,准备本地调试。令人惊讶的事情发生了,本地测试居然是好的。那么问题确定了,就是服务返回过程的一些东西丢失了或者格式错误导致video
识别视屏时候产生bug,所以才不能调节进度。 - 面向百度编程开始,关键字
koa返回视屏无法调节进度
。出现这个bug是因为返回的视屏没有文件信息Content-Length
,所以无法设置进度。
-
解决方法一:通过接口读取文件,然后修改返回头信息并且返回文件流。
代码:async (ctx, next) => { let { filepath } = ctx.query; if (!path) return next(); filepath= path.resolve(config.public,'.'+ filepath); console.log(filepath) let stats = fs.statSync(filepath); ctx.set("Accept-Ranges", "bytes") ctx.set("Content-Length", stats.size) ctx.set("Content-Type", "video/mp4") ctx.body = fs.createReadStream(filepath) next(); }
整体思路是:通过接口传递文件路径,然后根据文件路径读取文件的信息,设置头信息
Content-Length
,然后返回视屏的文件流。
这方法亲测可行,但是切换进度或频繁请求过程中会遇到一个很尴尬的bug:Error: write ECONNRESET at afterWriteDispatched (internal/stream_base_commons.js:156:25)
这个bug是因为node自带的
http
模块,因为切换和频繁请求过程中,导致一些TCP没有正常关闭,所以这个错误,但是并不会影响你正在进行的请求。
解决方案:
1:增加错误监听,别直接抛出错误影响整个程序的运行既可以,因为不影响内容。
2:更换node版本8.12.0
(实际是更换了http模块版本),这版本的node疑似修复了换这个问题。
优化:因为我们调节进度的时候,我们所需的视屏流只是当前视屏点后续的内容,所以我们可以返回对应视屏的范围:async (ctx, next) => { let { filepath } = ctx.query; if (!path) return next(); let range = ctx.headers.range; // 实际上请求头的 Range 视屏调节请求会自动带上开始内容 类似这样 Range: bytes=26214400- if (!range) { // 初始化请求不会带上range 造一个 并且返回200 range = "bytes=0-"; ctx.status = 200 } else { // 带range的请求返回 206 表明返回目标url上的部分内容 ctx.status = 206 } let startBytes = range.replace(/bytes=/, "").split("-")[0]; startBytes= Number(startBytes); filepath = path.resolve(config.public, '.' + filepath); let stats = fs.statSync(filepath); ctx.set("Accept-Ranges", "bytes") console.log("bytes " + startBytes + "-" + (stats.size - 1) + "/" + stats.size) ctx.set("Content-Length", stats.size - startBytes) ctx.set("Content-Range", "bytes " + startBytes + "-" + (stats.size - 1) + "/" + stats.size) ctx.set("Content-Type", "video/mp4") ctx.body = fs.createReadStream(filepath, { start: startBytes, end: stats.size }) next(); }
-
解决方法二:采用
koa-range
+koa-static
搭建静态文件目录:
代码:const static = require('koa-static') const range = require('koa-range'); app.use(range) app.use(static(config.public))
完事。
其原理和方法基本一致,不过实现顺序有些差异,各种情况更加完善。koa-range
实现设置返回头的Content-Range
和Content-Length
,并将ctx.body中的Stream
按照请求头中的Range
进行切割,再返回给ctx.body。 这里实现使用了await next()
,所以koa-range
必须放在koa-static
前面,才能正确获取在koa-static
中间件中设置在ctx.body
的文件流。
主要逻辑代码如下:
var range = ctx.header.range; ctx.set('Accept-Ranges', 'bytes'); ... var ranges = rangeParse(range); // [[start,end]] 这里采用了双层数据 获取到的是一组 ... var firstRange = ranges[0]; var start = firstRange[0]; var end = firstRange[1]; ... var rawBody = ctx.body; var len = rawBody.length; ... if (!Buffer.isBuffer(rawBody)) { if (rawBody instanceof Stream.Readable) { len = ctx.length || '*'; rawBody = rawBody.pipe(slice(start, end + 1)); } else if (typeof rawBody !== 'string') { rawBody = new Buffer(JSON.stringify(rawBody)); len = rawBody.length; } else { rawBody = new Buffer(rawBody); len = rawBody.length; } } ... if (rawBody instanceof Stream) { ctx.body = rawBody; } else { ctx.body = rawBody.slice.apply(rawBody, args); }
...
的部分省略了一些逻辑判断,koa-range
组件在请求头存在Range
,并且ctx.body
中内容为Stream
或者能转化为Stream
的时候的时候,会设置对应返回头的参数,并对ctx.body
中的Stream
进行切割后部分返回。koa-static
也使用await next()
,它的实际逻辑会在这个中间件后续的中间件的同步逻辑走完之后再调用,也就是说,如果我们有其他的路由,放在当前组件的前后都没有影响,koa-static
会保证最后再调用自己的逻辑。
koa-static
的主要逻辑:当ctx.body内没有内容,并且状态不是404的是情况下,j将当前访问当做请求静态文件来尝试获取静态文件,调用koa-send
进行返回文件操作:
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return // response is already handled if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line try { await send(ctx, ctx.path, opts) } catch (err) { if (err.status !== 404) { throw err } }
koa-send
:所做的事情就读取对应的路径,请求文件,如果没有该文件,就返回404,和错误信息。如果能找到就把文件读取文件流存到ctx.body。(这个存了文件流,再经过koa-range才会返回,这也是为什么要koa-range在koa-static前面)。let stats try { stats = await stat(path) // Format the path to serve static file servers // and not require a trailing slash for directories, // so that you can do both `/directory` and `/directory/` if (stats.isDirectory()) { if (format && index) { path += `/${index}` stats = await stat(path) } else { return } } } catch (err) { const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR'] if (notfound.includes(err.code)) { throw createError(404, err) } err.status = 500 throw err } if (setHeaders) setHeaders(ctx.res, path, stats) // stream ctx.set('Content-Length', stats.size) if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString()) if (!ctx.response.get('Cache-Control')) { const directives = [`max-age=${(maxage / 1000 | 0)}`] if (immutable) { directives.push('immutable') } ctx.set('Cache-Control', directives.join(',')) } if (!ctx.type) ctx.type = type(path, encodingExt) ctx.body = fs.createReadStream(path)
- 最后的最后一定要注意的是:
- 方法一中一定要错误处理
- 方法二顺序一定要对
- 方法一不能和方法二同时进行,会导致方法一接口返回视屏流也被处理无法播放。
收获
了解了头部信息range
的相关知识和返回文件流的一些基本操作以及koa-range
和koa-static
+koa-send
的工作原理。而且这种返回的视屏可以使用a标签进行下载。