Skip to content

视频流

​ 视频流可以很好的节约用户、服务器流量,减少服务器性能开销。如同文件流同理,根据需要读取某段文件数据并响应数据,而不是直接响应整个文件。

​ 在不使用视频流的情况下,要想服务器响应一个视频就需要服务器读取整个文件并响应给客户端,video标签才能播放,但在大多数情况下,用户并不会把视频看完,我们需要根据用户需要懒加载+预加载视频才能减少服务器性能开销以及流量使用。

​ 其原理就是通过客户端告诉服务器需要加载视频的起始末尾偏移量,服务器端对应读取该范围的数据并响应即可,最终通过video标签不断发送请求加载视频,直到末尾浏览完视频。

先决条件

在了解如何实现服务端响应视频流前,需要知道视频流需要后端的配合,几个必要的头部:

Accept-Ranges

​ 作为响应头部中的属性出现,服务器是否可以接收范围类型的请求,若响应头部中Accept-Ranges字段不为none则说明支持范围类型的请求,常见值都是range

Content-Range

​ 作为响应头部中的属性出现,表示范围数据的起始、末尾偏移量,其格式为:

Content-Range": `bytes ${start}-${end}/${filesize}`
Content-Range": `bytes ${start}-${end}/${filesize}`

start为文件的起始偏移量,end为文件的末尾偏移量,filesize为文件的总大小。

注意:偏移量都是从0开始。

Range

​ 作为请求头部的属性出现,表示本次请求偏移量的范围,其格式为:

bytes=start-[end]
bytes=start-[end]

start为起始偏移量、end为末尾偏移量。end偏移量是可选的,若无end偏移量,则代表end为文件末尾偏移量。

实战

1.请求头部的range

​ 读取出请求头部的range属性,range的值一般都为:bytes=start-end,解析出start和end就知道请求范围的起始和末尾偏移量了。

​ 为了保持健壮性,若无range属性,则直接响应整个文件。

​ end参数是可选的,无end参数,默认为文件的末尾偏移量。

js
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    let [start,end] = range.replace("bytes=","").split("-")
    // 格式化偏移量
    start = Number(start);
    end=end?Math.min(filesize-1,end):filesize-1
}
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    let [start,end] = range.replace("bytes=","").split("-")
    // 格式化偏移量
    start = Number(start);
    end=end?Math.min(filesize-1,end):filesize-1
}

2.设置响应头部

​ 在获取起始、末尾的偏移量后,则需要设置响应头部了,告诉客户端响应数据的偏移量范围、响应体的大小以及文件的总大小。

diff
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    let [start,end] = range.replace("bytes=","").split("-")
    // 格式化偏移量
    start = Number(start);
    end=end?Math.min(filesize-1,end):filesize-1
+  	const headers = {
+    	// Content-Range: 说明本次请求响应的范围以及总大小为
+    	"Content-Range": `bytes ${start}-${end}/${filesize}`,
+    	// Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
+    	"Accept-Ranges": "bytes",
+    	// Content-Length: 本次请求响应体的大小
+    	"Content-Length": end + 1 - start,
+    	// 响应体类型
+    	"Content-Type": "video/mp4",
+    	"Cache-Control": "max-age=20000000",
+  	};
+  	res.writeHead(206, headers);
}
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    let [start,end] = range.replace("bytes=","").split("-")
    // 格式化偏移量
    start = Number(start);
    end=end?Math.min(filesize-1,end):filesize-1
+  	const headers = {
+    	// Content-Range: 说明本次请求响应的范围以及总大小为
+    	"Content-Range": `bytes ${start}-${end}/${filesize}`,
+    	// Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
+    	"Accept-Ranges": "bytes",
+    	// Content-Length: 本次请求响应体的大小
+    	"Content-Length": end + 1 - start,
+    	// 响应体类型
+    	"Content-Type": "video/mp4",
+    	"Cache-Control": "max-age=20000000",
+  	};
+  	res.writeHead(206, headers);
}

3.响应对应范围的数据

​ 有了起始、末尾偏移量,则我们可以通过createReadStream可读流来响应数据,其api天生支持读取start到end的数据,并自动关闭文件。

diff
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    let [start,end] = range.replace("bytes=","").split("-")
    // 格式化偏移量
    start = Number(start);
    end=end?Math.min(filesize-1,end):filesize-1
  	const headers = {
    	// Content-Range: 说明本次请求响应的范围以及总大小为
    	"Content-Range": `bytes ${start}-${end}/${filesize}`,
    	// Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
    	"Accept-Ranges": "bytes",
    	// Content-Length: 本次请求响应体的大小
    	"Content-Length": end + 1 - start,
    	// 响应体类型
    	"Content-Type": "video/mp4",
    	"Cache-Control": "max-age=20000000",
  	};
  	res.writeHead(206, headers);
+	createReadStream("01.mp4", { start, end }).pipe(res);  	
}
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    let [start,end] = range.replace("bytes=","").split("-")
    // 格式化偏移量
    start = Number(start);
    end=end?Math.min(filesize-1,end):filesize-1
  	const headers = {
    	// Content-Range: 说明本次请求响应的范围以及总大小为
    	"Content-Range": `bytes ${start}-${end}/${filesize}`,
    	// Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
    	"Accept-Ranges": "bytes",
    	// Content-Length: 本次请求响应体的大小
    	"Content-Length": end + 1 - start,
    	// 响应体类型
    	"Content-Type": "video/mp4",
    	"Cache-Control": "max-age=20000000",
  	};
  	res.writeHead(206, headers);
+	createReadStream("01.mp4", { start, end }).pipe(res);  	
}

4.每次响应固定长度的数据

​ 若想要支持每次都响应固定长度的数据,可以自己设定end的值,即可固定每次响应的数据大小。

js
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 每次读取文件的长度为 1mb
    const chunkSize = 1024*1024
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    const start = Number(range.replace("bytes=","").split("-")[0])
    const end=Math.min(filesize-1,start+chunkSize)
  	const headers = {
    	// Content-Range: 说明本次请求响应的范围以及总大小为
    	"Content-Range": `bytes ${start}-${end}/${filesize}`,
    	// Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
    	"Accept-Ranges": "bytes",
    	// Content-Length: 本次请求响应体的大小
    	"Content-Length": chunkSize,
    	// 响应体类型
    	"Content-Type": "video/mp4",
    	"Cache-Control": "max-age=20000000",
  	};
  	res.writeHead(206, headers);
	createReadStream("01.mp4", { start, end }).pipe(res);  	
}
function middlware=(req,res)=>{
    const {range} = req.headers
    if(range===undefined){
        // 无range属性
        createReadStream("01.mp4").pipe(res)
        return
    }
    // 每次读取文件的长度为 1mb
    const chunkSize = 1024*1024
    // 读取文件大小
    const filesize = statSync("01.mp4").size
    // 获取偏移量
    const start = Number(range.replace("bytes=","").split("-")[0])
    const end=Math.min(filesize-1,start+chunkSize)
  	const headers = {
    	// Content-Range: 说明本次请求响应的范围以及总大小为
    	"Content-Range": `bytes ${start}-${end}/${filesize}`,
    	// Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
    	"Accept-Ranges": "bytes",
    	// Content-Length: 本次请求响应体的大小
    	"Content-Length": chunkSize,
    	// 响应体类型
    	"Content-Type": "video/mp4",
    	"Cache-Control": "max-age=20000000",
  	};
  	res.writeHead(206, headers);
	createReadStream("01.mp4", { start, end }).pipe(res);  	
}

示例

文件结构:

project
│  package.json
├─client
│      index.html 
├─src
│      index.js    
└─static
    ├─audio
    │      distortion.mp3   
    └─video
            01.mp4
project
│  package.json
├─client
│      index.html 
├─src
│      index.js    
└─static
    ├─audio
    │      distortion.mp3   
    └─video
            01.mp4

源码:

js
const { createServer } = require("http");
const { createReadStream, existsSync, statSync } = require("node:fs");
const { resolve: R } = require("node:path");

/**
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const middleware = (req, res) => {
  if (req.url === "/") {
    handleClient(req, res);
  } else if (req.url.startsWith("/static")) {
    hanldeStaticWithStream(req, res);
  }
};

/**
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const handleClient = (_, res) => {
  createReadStream(R(__dirname, "../client/index.html")).pipe(res);
};

/**
 * 通过媒体流来响应文件
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const hanldeStaticWithStream = (req, res) => {
  if (!req.headers.range) {
    // 未携带请求头部的range,直接返回整个文件
    handleStatic(req, res);
    return;
  }
  // 每次响应的片段大小 1mb
  const chunkSize = 1024 * 1024;
  // 解码出真实的请求路径
  const path = decodeURI(req.url);
  // 文件路径
  const filepath = R(__dirname, `..${path}`);
  if (!existsSync(filepath)) {
    // 文件不存在
    res.writeHead(404, {
      "content-type": "text/plain;charset=utf-8",
    });
    res.end(`Error:The File path is not found.\nFilePath:${path}`);
    return;
  }
  // 文件大小
  const filesize = statSync(filepath).size;
  // 解析出请求头部中的偏移量
  // 请求头部的range属性,包含了本次请求的文件的开始偏移量和结束偏移量
  const position = req.headers.range
    .slice(req.headers.range.indexOf("bytes=") + 6)
    .split("-");

  // 根据浏览器自适应返回对应大小的片段
  // 获取请求头部中range的起始和末尾偏移量
  // const [start, end] = position.map((item, index) => {
  //   if (item.length) {
  //     return Number(item);
  //   } else {
  //     // 接收到空串
  //     if (index === 0) {
  //       // 下标为0,为起始偏移量
  //       return 0;
  //     } else {
  //       // 下标不为0,则为末尾偏移量
  //       // 结束偏移量默认为文件大小长度减一,因为在内存中某个文件的初始偏移量就是0
  //       return filesize - 1;
  //     }
  //   }
  // });

  // 固定返回chunkSize大小的片段
  const start = Number(position[0]);
  const end = Math.min(start + chunkSize, filesize - 1);

  const headers = {
    // Content-Range: 说明本次请求响应的范围以及总大小为
    "Content-Range": `bytes ${start}-${end}/${filesize}`,
    // Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
    "Accept-Ranges": "bytes",
    // Content-Length: 本次请求响应体的大小
    "Content-Length": end + 1 - start,
    // 响应体类型
    "Content-Type": "video/mp4",
    "Cache-Control": "max-age=20000000",
  };

  res.writeHead(206, headers);

  createReadStream(filepath, { start, end }).pipe(res);
};

/**
 * 直接响应文件
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const handleStatic = (req, res) => {
  const path = decodeURI(req.url);
  const filepath = R(__dirname, `..${path}`);
  if (existsSync(filepath)) {
    // 告诉浏览器将文件内嵌入网页中
    res.setHeader("Content-Disposition", "inline");
    // Content-length:告诉浏览器文件大小
    res.setHeader("Content-Length", statSync(filepath).size);
    // 设置响应体类型
    if (filepath.includes(".mp3")) {
      res.setHeader("Content-Type", "audio/mpeg");
    } else if (filepath.includes(".mp4")) {
      res.setHeader("Content-Type", "video/mp4");
    }
    createReadStream(filepath).pipe(res);
  } else {
    res.writeHead(404, undefined, {
      "content-type": "text/plain;charset=utf-8",
    });
    res.end(`Error:The File path is not found.\nFilePath:${path}`);
  }
};

createServer(middleware).listen(3000);
const { createServer } = require("http");
const { createReadStream, existsSync, statSync } = require("node:fs");
const { resolve: R } = require("node:path");

/**
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const middleware = (req, res) => {
  if (req.url === "/") {
    handleClient(req, res);
  } else if (req.url.startsWith("/static")) {
    hanldeStaticWithStream(req, res);
  }
};

/**
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const handleClient = (_, res) => {
  createReadStream(R(__dirname, "../client/index.html")).pipe(res);
};

/**
 * 通过媒体流来响应文件
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const hanldeStaticWithStream = (req, res) => {
  if (!req.headers.range) {
    // 未携带请求头部的range,直接返回整个文件
    handleStatic(req, res);
    return;
  }
  // 每次响应的片段大小 1mb
  const chunkSize = 1024 * 1024;
  // 解码出真实的请求路径
  const path = decodeURI(req.url);
  // 文件路径
  const filepath = R(__dirname, `..${path}`);
  if (!existsSync(filepath)) {
    // 文件不存在
    res.writeHead(404, {
      "content-type": "text/plain;charset=utf-8",
    });
    res.end(`Error:The File path is not found.\nFilePath:${path}`);
    return;
  }
  // 文件大小
  const filesize = statSync(filepath).size;
  // 解析出请求头部中的偏移量
  // 请求头部的range属性,包含了本次请求的文件的开始偏移量和结束偏移量
  const position = req.headers.range
    .slice(req.headers.range.indexOf("bytes=") + 6)
    .split("-");

  // 根据浏览器自适应返回对应大小的片段
  // 获取请求头部中range的起始和末尾偏移量
  // const [start, end] = position.map((item, index) => {
  //   if (item.length) {
  //     return Number(item);
  //   } else {
  //     // 接收到空串
  //     if (index === 0) {
  //       // 下标为0,为起始偏移量
  //       return 0;
  //     } else {
  //       // 下标不为0,则为末尾偏移量
  //       // 结束偏移量默认为文件大小长度减一,因为在内存中某个文件的初始偏移量就是0
  //       return filesize - 1;
  //     }
  //   }
  // });

  // 固定返回chunkSize大小的片段
  const start = Number(position[0]);
  const end = Math.min(start + chunkSize, filesize - 1);

  const headers = {
    // Content-Range: 说明本次请求响应的范围以及总大小为
    "Content-Range": `bytes ${start}-${end}/${filesize}`,
    // Accept-Ranges:bytes 代表了该服务器可以接受范围请求,none不支持
    "Accept-Ranges": "bytes",
    // Content-Length: 本次请求响应体的大小
    "Content-Length": end + 1 - start,
    // 响应体类型
    "Content-Type": "video/mp4",
    "Cache-Control": "max-age=20000000",
  };

  res.writeHead(206, headers);

  createReadStream(filepath, { start, end }).pipe(res);
};

/**
 * 直接响应文件
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 */
const handleStatic = (req, res) => {
  const path = decodeURI(req.url);
  const filepath = R(__dirname, `..${path}`);
  if (existsSync(filepath)) {
    // 告诉浏览器将文件内嵌入网页中
    res.setHeader("Content-Disposition", "inline");
    // Content-length:告诉浏览器文件大小
    res.setHeader("Content-Length", statSync(filepath).size);
    // 设置响应体类型
    if (filepath.includes(".mp3")) {
      res.setHeader("Content-Type", "audio/mpeg");
    } else if (filepath.includes(".mp4")) {
      res.setHeader("Content-Type", "video/mp4");
    }
    createReadStream(filepath).pipe(res);
  } else {
    res.writeHead(404, undefined, {
      "content-type": "text/plain;charset=utf-8",
    });
    res.end(`Error:The File path is not found.\nFilePath:${path}`);
  }
};

createServer(middleware).listen(3000);