目录
介绍
视频流广泛用于互联网和移动网络,无需下载即可播放视频流。当处理大量数据时,流允许以特定顺序将数据逐块从源发送到目标。
本文的目的是设置我们自己的视频流服务器,该服务器提供托管在服务器上的视频,而无需下载整个视频文件。最终用户可以在视频的不同位置移动,而无需下载所有文件,这将很重。
如果您在服务器上托管视频,授予对视频文件的访问权限,并在src中使用带有视频文件url的<video>标记,将有两个主要问题:
- 用户将不得不等待浏览器下载整个视频文件才能播放它,如果视频文件很大,这可能会很烦人。
- 您无法阻止最终用户下载视频文件。
在这种情况下,<video>标记将如下所示:
<video src="videos/video.mp4"></video>
因此,我们将使用此api /api/video/:name在端口3000上设置一个视频流服务器,这将允许我们流式传输视频。
这样做,<video>标记将如下所示:
<video src="http://localhost:3000/api/video/video.mp4"></video>
该src属性将指向暂时不存在的视频流服务器。
设置项目
第一步是初始化我们的Node.js项目:
npm init
然后,我们将不得不更新package.json,以便通过设置module为type:
{
"name": "streaming",
"type": "module",
"version": "1.0.0",
"description": "Video Streaming Server",
"author": "Akram El Assas",
}
关于用于流式传输的库,我们可以使用Express或您想要的任何其他库,但在此项目中,我们将使用Koa,因为它的中间件系统非常有趣。
如果需要,您可以使用Express,您必须进行一些修改才能使其正常工作。
现在,我们将安装依赖项:
npm i koa koa-router koa-sendfile dotenv
- koa:Koa是由Express背后的团队设计的Web框架,旨在成为Web应用程序和API的更小,更具表现力,更强大的基础。通过利用异步函数,Koa允许您放弃回调并大大增加错误处理。Koa在其核心中没有捆绑任何中间件,它提供了一套优雅的方法,使编写服务器变得快速而愉快。
- koa-router:Koa的路由器中间件。Koa不支持核心模块中的路由。我们需要使用该koa-router模块在Koa中轻松创建路由。
- koa-sendfile:koa的基本文件发送实用程序。
- dotenv:Dotenv是一个零依赖模块,它将环境变量从.env文件加载到 process.env 中。将配置存储在与代码分开的环境中基于十二因素应用方法。
然后,我们将安装开发依赖项:
npm i -D @types/node @types/koa @types/koa-router nodemon
- @types/node:在Visual Studio Code中使用Node.js时自动完成。
- @types/koa:在Visual Studio Code中使用Koa时具有自动完成功能。
- @types/koa-router:在Visual Studio Code中使用Koa路由器时具有自动完成功能。
- nodemon:每次检测到更改时自动重新启动视频流服务器。
视频流服务器
服务器
首先,我们将从导入Koa开始,创建一个新服务器并开始侦听端口3000:
import Koa from 'koa'
const PORT = parseInt(process.env.PORT, 10) || 3000
const app = new Koa()
//
// Start the server on the specified PORT
//
app.listen(PORT)
console.log('Video Streaming Server is running on Port', PORT)
如果我们使用以下命令运行我们的应用程序:
npm run dev
并尝试访问http://localhost:3000,我们将看到我们的服务器在端口3000上运行。
视频播放器
然后,我们将服务器一个包含视频播放器的HTML页面(public/index.html)。
HTML页面的源代码如下:
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Streaming</title>
<style>
body {
background-color: #000000;
}
video {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
max-height: 100%;
max-width: 100%;
margin: auto;
object-fit: contain;
}
</style>
</head>
<body>
<video
src="http://localhost:3000/api/video/video.mp4"
playsInline
muted
autoplay
controls
controlsList="nodownload"
>
</video>
</body>
</html>
由于我们使用的是ES6,因此变量__dirname不可用。所以我们必须建造它。
为了提供HTML页面,我们将使用sendFile实用程序。sendfile返回解析为文件名fs.stat()结果的promise。如果sendfile()解析,这并不意味着设置了响应——文件名可以是目录。相反,请检查if (context.status)。
在下面的源代码中,我们只需创建一个路由来服务器包含视频播放器的HTML页面。然后,我们添加Koa路由器中间件并在指定端口上创建服务器。
import Koa from 'koa'
import KoaRouter from 'koa-router'
import sendFile from 'koa-sendfile'
import url from 'url'
import path from 'path'
import fs from 'fs'
import util from 'util'
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const PORT = parseInt(process.env.PORT, 10) || 3000
const app = new Koa()
const router = new KoaRouter()
//
// Serve HTML page containing the video player
//
router.get('/', async (ctx) => {
await sendFile(ctx, path.resolve(__dirname, 'public', 'index.html'))
if (!ctx.status) {
ctx.throw(404)
}
})
//
// Add Koa Router middleware
//
app.use(router.routes())
app.use(router.allowedMethods())
//
// Start the server on the specified PORT
//
app.listen(PORT)
router.routes()返回路由器中间件,该中间件调度与请求匹配的路由,并且router.allowedMethods()返回单独的中间件,用于响应具有包含允许方法的Allow标头的OPTIONS请求,以及根据需要使用405 Method Not Allowed和501 Not Implemented进行响应。
Koa应用程序是一个包含中间件函数数组的对象,这些中间件函数根据请求以类似堆栈的方式组合和执行。Koa类似于您可能遇到的许多其他中间件系统,例如Ruby的机架、Connect等——但是做出了一个关键的设计决策,即在其他低级中间件层提供高级“糖”。这提高了互操作性和健壮性,并使编写中间件更加愉快。这包括用于常见任务的方法,如内容协商、缓存新鲜度、代理支持和重定向等。尽管提供了相当多的有用方法,但Koa的占用空间很小,因为没有捆绑中间件。
从哲学上讲,Koa的目标是修复和替换Node,而Express则增强了Node。Koa使用承诺和异步函数来摆脱应用程序的回调地狱并简化错误处理。它公开自己的ctx.request和ctx.response对象,而不是Node的req和res对象。Koa使用自己的自定义对象:ctx、ctx.request和ctx.response。这些对象使用方便的方法和getter/setter抽象Node的req和res对象。
另一方面,Express用额外的属性和方法来增强Node的req和res对象,并包括许多其他框架功能,例如路由和模板,而Koa没有。
因此,Koa可以看作是Node.js的http模块的抽象,而Express是Node.js的应用程序框架。
因此,如果你想更接近Node.js和传统的Node.js风格的编码,你可能想坚持使用Connect/Express或类似的框架。如果你想摆脱回调,请使用Koa。
由于这种不同的哲学,传统的Node.js中间件,即形式(req, res, next)的函数,与Koa不兼容。您的应用程序基本上必须从头开始重写。
现在,如果我们尝试访问http://localhost:3000,我们将暂时获得没有任何内容的视频播放器,因为我们尚未实现视频流。
视频流
我们将从以下网址流式传输视频:http://localhost:3000/api/video/:name
我们将添加另一个中间件,在这个中间件中,我们将首先检查请求的url是否正确:
//
// Serve video streaming
//
router.get('/api/video/:name', async (ctx, next) => {
//
// Check video file name
//
const { name } = ctx.params
if (
!/^[a-z0-9-_ ]+\.mp4$/i.test(name)
) {
return next()
}
})
请求的url必须采用以下格式/api/video/:name,其url参数中必须包含name,并且名称必须是不区分大小写的字母数字字符串,可以包含短划线、下划线、空格和带有扩展名.mp4的结尾。您可以指定所需的任何正则表达式格式,但为了简单起见,我们选择了该格式。
在客户端读取视频时,浏览器将向服务器发送HTTP请求以获取视频的卡盘,服务器将通过逐个发送指定的chucks来响应。这使我们能够避免获取整个视频文件并防止最终用户下载视频文件。
在视频流中,关键点是范围HTTP请求标头。范围HTTP请求标头指示服务器应返回的文档部分。可以使用一个Range标头一次请求多个部分,服务器可能会在多部分文档中发回这些范围。如果服务器发回范围,它将使用206部分内容进行响应。如果范围无效,服务器将返回“416范围不满足”错误。服务器还可以忽略Range标头,并使用200状态代码返回整个文档。
下面是范围HTTP请求标头的示例:
Range: bytes=983040-
Range: <unit>=<start>-<end>
对于视频,范围将包含视频文件的起点(以字节为单位),我们将从那里开始读取视频文件。服务器将向客户端发送从start开始并且以end的流。 end范围从start为0开始。
如果请求标头不包含Range,我们将返回一个错误的请求:
//
// Check Range HTTP request header
//
const { request, response } = ctx
const { range } = request.headers
if (!range) {
ctx.throw(400, 'Range not provided')
}
然后,我们将检查视频文件:
//
// Check video file
//
const videoPath = path.resolve(__dirname, 'videos', name)
try {
await util.promisify(fs.access)(videoPath)
} catch (err) {
if (err.code === 'ENOENT') {
ctx.throw(404)
} else {
ctx.throw(err.toString())
}
}
如果文件不存在,我们将返回状态404,如果在尝试访问该文件时出现任何其他错误,我们将返回状态500。最后,错误将记录在服务器端。
现在,我们需要设置响应标头:
//
// Calculate start Content-Range
//
const parts = range.replace('bytes=', '').split('-')
const rangeStart = parts[0] && parts[0].trim()
const start = rangeStart ? parseInt(rangeStart, 10) : 0
//
// Calculate video size and chunk size
//
const videoStat = await util.promisify(fs.stat)(videoPath)
const videoSize = videoStat.size
const chunkSize = 10 ** 6 // 1mb
//
// Calculate end Content-Range
//
// Safari/iOS first sends a request with bytes=0-1 range HTTP header
// probably to find out if the server supports byte ranges
//
const rangeEnd = parts[1] && parts[1].trim()
const __rangeEnd = rangeEnd ? parseInt(rangeEnd, 10) : undefined
const end = __rangeEnd === 1 ? __rangeEnd : (Math.min(start + chunkSize, videoSize) - 1) // We remove 1 byte because start and end start from 0
const contentLength = end - start + 1 // We add 1 byte because start and end start from 0
//
// Set HTTP response headers
//
response.set('Content-Range', `bytes ${start}-${end}/${videoSize}`)
response.set('Accept-Ranges', 'bytes')
response.set('Content-Length', contentLength)
首先,我们检索Range HTTP请求标头的值,然后从start中提取(以字节为单位)。之后,我们使用stat实用程序函数检索视频文件的大小(以字节为单位)。然后,我们使用块大小和rangeEnd计算以字节为单位的end。Safari/iOS首先发送一个byte=0-1 range HTTP标头的请求,可能是为了找出服务器是否支持字节范围。在Safari/iOS的例子中,如果rangeEnd等于1,我们将以1返回end 。如果rangeEnd未提供或rangeEnd大于1,我们使用块大小来计算end。我们使用1 MB作为块大小,但您可以使用任何您想要的大小。然后,我们使用end和start进行计算contentLength。最后,我们使用response.set函数添加响应HTTP标头。
Content-Range响应HTTP标头指示部分消息在正文消息中的位置。
Accept-Ranges HTTP响应标头是服务器用来通告其对来自客户端的文件下载的部分请求的支持的标记。此字段的值指示可用于定义范围的单位。
Content-Length标头指示发送给收件人的邮件正文的大小(以字节为单位)。
最后,我们将响应状态设置为206,表示请求已成功,正文包含请求的数据范围。我们设置响应类型并将视频流从start发送到end。我们只在每个请求上发送一个视频文件卡盘:
//
// Send video file stream from start to end
//
const stream = fs.createReadStream(videoPath, { start, end })
stream.on('error', (err) => {
console.log(err.toString())
})
response.status = 206
response.type = path.extname(name)
response.body = stream
response.type用于指示资源的原始媒体类型。在我们的例子中,我们使用的是video/mp4媒体类型。
现在,如果我们运行服务器并尝试播放视频,我们将在Chrome Dev Tools的“网络”选项卡中看到,只有请求的部分被发送到浏览器,而不是整个文件:
如果我们单击一个请求,我们可以看到请求和响应标头:
以下是上述请求的时间:
以下是macOS上Safari Dev Tools的“网络”选项卡的屏幕截图:
如果我们在服务器端看到控制台,我们会注意到ECONNRESET,ECANCELED和ECONNABORTED错误。Koa返回这些错误是因为当浏览器关闭连接时,服务器会尝试读取流。所以服务器说它无法读取关闭的流。这些错误可以简单地忽略:
//
// We ignore ECONNRESET, ECANCELED and ECONNABORTED errors
// because when the browser closes the connection, the server
// tries to read the stream. So, the server says that it cannot
// read a closed stream.
//
app.on('error', (err) => {
if (!['ECONNRESET', 'ECANCELED', 'ECONNABORTED'].includes(err.code)) {
console.log(err.toString())
}
})
视频缓存
如果您使用谷歌浏览器播放视频并向后移动,您会注意到相同的视频块再次被提取。您可以在Chrome Dev Tools的“网络”标签中清楚地看到这一点。换句话说,不会缓存视频块。谷歌浏览器上无法使用视频缓存的原因有一些,以下是其中的一些原因:
- 缓存可能需要大量的内存和磁盘存储。
- 如果启用了缓存,视频播放器将播放视频,同时,它将提取的视频块保存在缓存中。如果播放器只播放视频,它将非常高性能。
- 大量视频仅播放一次。另一个非常大的一部分视频在加载后的几秒钟内被跳过。
因此,在仅为一个用户缓存视频文件的高复杂性和低投资回报率之间,缓存视频文件通常是一个失败的主张。
将各个部分组合在一起
我们用Node.js和Koa建立了自己的视频流服务器。现在,我们服务器的整个源代码如下所示:
import Koa from 'koa'
import KoaRouter from 'koa-router'
import sendFile from 'koa-sendfile'
import url from 'url'
import path from 'path'
import fs from 'fs'
import util from 'util'
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const PORT = parseInt(process.env.PORT, 10) || 3000
const app = new Koa()
const router = new KoaRouter()
//
// Serve HTML page containing the video player
//
router.get('/', async (ctx) => {
await sendFile(ctx, path.resolve(__dirname, 'public', 'index.html'))
if (!ctx.status) {
ctx.throw(404)
}
})
//
// Serve video streaming
//
router.get('/api/video/:name', async (ctx, next) => {
//
// Check video file name
//
const { name } = ctx.params
if (
!/^[a-z0-9-_ ]+\.mp4$/i.test(name)
) {
return next()
}
//
// Check Range HTTP request header
//
const { request, response } = ctx
const { range } = request.headers
if (!range) {
ctx.throw(400, 'Range not provided')
}
//
// Check video file
//
const videoPath = path.resolve(__dirname, 'videos', name)
try {
await util.promisify(fs.access)(videoPath)
} catch (err) {
if (err.code === 'ENOENT') {
ctx.throw(404)
} else {
ctx.throw(err.toString())
}
}
//
// Calculate start Content-Range
//
const parts = range.replace('bytes=', '').split('-')
const rangeStart = parts[0] && parts[0].trim()
const start = rangeStart ? parseInt(rangeStart, 10) : 0
//
// Calculate video size and chunk size
//
const videoStat = await util.promisify(fs.stat)(videoPath)
const videoSize = videoStat.size
const chunkSize = 10 ** 6 // 1mb
//
// Calculate end Content-Range
//
// Safari/iOS first sends a request with bytes=0-1 range HTTP header
// probably to find out if the server supports byte ranges
//
const rangeEnd = parts[1] && parts[1].trim()
const __rangeEnd = rangeEnd ? parseInt(rangeEnd, 10) : undefined
const end = __rangeEnd === 1 ? __rangeEnd : (Math.min(start + chunkSize, videoSize) - 1)
const contentLength = end - start + 1
//
// Set HTTP response headers
//
response.set('Content-Range', `bytes ${start}-${end}/${videoSize}`)
response.set('Accept-Ranges', 'bytes')
response.set('Content-Length', contentLength)
//
// Send video file stream from start to end
//
const stream = fs.createReadStream(videoPath, { start, end })
stream.on('error', (err) => {
console.log(err.toString())
})
response.status = 206
response.type = path.extname(name)
response.body = stream
})
//
// We ignore ECONNRESET, ECANCELED and ECONNABORTED errors
// because when the browser closes the connection, the server
// tries to read the stream. So, the server says that it cannot
// read a closed stream.
//
app.on('error', (err) => {
if (!['ECONNRESET', 'ECANCELED', 'ECONNABORTED'].includes(err.code)) {
console.log(err.toString())
}
})
//
// Add Koa Router middleware
//
app.use(router.routes())
app.use(router.allowedMethods())
//
// Start the server on the specified PORT
//
app.listen(PORT)
console.log('Video Streaming Server is running on Port', PORT)
引用
- Koa - Node的富有表现力的中间件.js使用异步函数
- Koa 路由器 - Koa 的路由器中间件
- Koa sendfile - Koa 的基本文件发送实用程序
- MDN - 范围 HTTP 请求标头
- MDN - 接受范围 HTTP 响应标头
- MDN - 内容范围响应 HTTP 标头
- MDN - 内容长度标头
- MDN - 206 部分内容
https://www.codeproject.com/Articles/5350209/Build-Your-Own-Video-Streaming-Server-with-Node-js