用Nuxt.js、Node和Express构建一个视频流应用程序

941 阅读12分钟

视频以流的形式工作。这意味着,视频不是一次性发送整个视频,而是作为一组较小的块状物发送,构成完整的视频。这就解释了为什么在慢速宽带上观看视频时要缓冲,因为它只播放它所收到的片段,并试图加载更多。

这篇文章是为那些愿意通过建立一个实际项目来学习新技术的开发者准备的:一个以Node.js为后端、Nuxt.js为客户端的视频流应用。

  • Node.js是一个用于构建快速和可扩展应用程序的运行时间。我们将用它来处理视频的获取和流媒体,为视频生成缩略图,并为视频提供标题和字幕。
  • Nuxt.js是一个Vue.js框架,可以帮助我们轻松构建服务器渲染的Vue.js应用程序。我们将为视频消耗我们的API,这个应用程序将有两个视图:可用视频的列表和每个视频的播放器视图。

先决条件

  • 对HTML、CSS、JavaScript、Node/Express和Vue的理解。
  • 一个文本编辑器(如VS Code)。
  • 一个网络浏览器(如Chrome, Firefox)。
  • 在你的工作站上安装FFmpeg
  • Node.jsnvm
  • 你可以在GitHub上获得源代码

设置我们的应用程序

在这个应用程序中,我们将建立路由,以便从前台发出请求。

  • videos 路由,以获得一个视频及其数据的列表。
  • 一个路由,从我们的视频列表中只获取一个视频。
  • streaming 路由,用于流式传输视频。
  • captions 路由,为我们正在播放的视频添加字幕。

在我们的路由被创建之后,我们将建立我们的Nuxt 前端,在那里我们将创建Home 和动态player 页面。然后,我们请求我们的videos 路由将视频数据填充到主页,另一个请求是在我们的player 页面上流式传输视频,最后一个请求是提供视频所使用的字幕文件。

为了设置我们的应用程序,我们创建我们的项目目录。

mkdir streaming-app

设置我们的服务器

在我们的streaming-app 目录中,我们创建一个名为backend 的文件夹。

cd streaming-app
mkdir backend

在我们的后台文件夹中,我们初始化一个package.json 文件,以存储关于我们服务器项目的信息。

cd backend
npm init -y

我们需要安装以下软件包来构建我们的应用程序。

  • nodemon 在我们进行修改时自动重新启动我们的服务器。
  • express 给我们提供了一个很好的接口来处理路由。
  • cors 将允许我们进行跨源请求,因为我们的客户端和服务器将在不同的端口上运行。

在我们的后台目录中,我们创建一个文件夹assets ,以保存我们的视频流。

 mkdir assets

复制一个.mp4 文件到assets文件夹,并命名为video1 。你可以使用.mp4 ,在Github Repo上可以找到的短视频样本。

创建一个app.js 文件,为我们的应用程序添加必要的包。

const express = require('express');
const fs = require('fs');
const cors = require('cors');
const path = require('path');
const app = express();
app.use(cors())

fs 模块用于在我们的服务器上轻松读写文件,而path 模块提供了一种处理目录和文件路径的方法。

现在我们创建一个./video 路由。当请求时,它将发送一个视频文件回给客户端。

// add after 'const app = express();'

app.get('/video', (req, res) => {
    res.sendFile('assets/video1.mp4', { root: __dirname });
});

这个路由在被请求时提供video1.mp4 视频文件。然后我们在端口3000 上监听我们的服务器。

// add to end of app.js file

app.listen(5000, () => {
    console.log('Listening on port 5000!')
});

package.json 文件中添加一个脚本,使用nodemon启动我们的服务器。


"scripts": {
    "start": "nodemon app.js"
  },

然后在你的终端上运行。

npm run start

如果你在终端看到Listening on port 3000! ,那么服务器就能正常工作。在你的浏览器中导航到http://localhost:5000/video,你应该看到视频正在播放。

前台要处理的请求

下面是我们将从前端向后端提出的请求,我们需要服务器来处理。

  • /videos
    返回一个视频模拟数据数组,将用于填充我们前端Home 页面上的视频列表。
  • /video/:id/data
    返回单个视频的元数据。由我们前端的Player 页面使用。
  • /video/:id
    以给定的ID流传视频。由Player 页面使用。

让我们来创建路由。

返回视频列表的模拟数据

对于这个演示应用程序,我们将创建一个对象数组,用来保存元数据,并在请求时将其发送到前端。在一个真实的应用中,你可能会从数据库中读取数据,然后用来生成这样一个数组。为了简单起见,我们不会在本教程中这样做。

在我们的后端文件夹中创建一个文件mockdata.js ,并为我们的视频列表填充元数据。

const allVideos = [
    {
        id: "tom and jerry",
        poster: 'https://image.tmdb.org/t/p/w500/fev8UFNFFYsD5q7AcYS8LyTzqwl.jpg',
        duration: '3 mins',
        name: 'Tom & Jerry'
    },
    {
        id: "soul",
        poster: 'https://image.tmdb.org/t/p/w500/kf456ZqeC45XTvo6W9pW5clYKfQ.jpg',
        duration: '4 mins',
        name: 'Soul'
    },
    {
        id: "outside the wire",
        poster: 'https://image.tmdb.org/t/p/w500/lOSdUkGQmbAl5JQ3QoHqBZUbZhC.jpg',
        duration: '2 mins',
        name: 'Outside the wire'
    },
];
module.exports = allVideos

我们可以从上面看到,每个对象都包含关于视频的信息。注意poster 属性,其中包含视频的海报图片链接。

让我们创建一个videos 路由,因为我们所有要由前端发出的请求都以/videos 为前缀。

要做到这一点,让我们创建一个routes 文件夹,并为我们的/videos 路线添加一个Video.js 文件。在这个文件中,我们将要求express ,并使用Express router来创建我们的路由。

const express = require('express')
const router = express.Router()

当我们进入/videos 路由时,我们想获得我们的视频列表,所以让我们要求mockData.js 文件进入我们的Video.js 文件,并提出我们的请求。

const express = require('express')
const router = express.Router()
const videos = require('../mockData')
// get list of videos
router.get('/', (req,res)=>{
    res.json(videos)
})
module.exports = router;

/videos 路由现在已经声明,保存文件,它应该自动重新启动服务器。一旦启动,导航到http://localhost:3000/videos,我们的数组将以JSON格式返回。

返回单个视频的数据

我们希望能够对我们的视频列表中的某个特定视频进行请求。我们可以通过使用我们给它的id ,在我们的数组中获取一个特定的视频数据。让我们提出一个请求,还是在我们的Video.js 文件中。


// make request for a particular video
router.get('/:id/data', (req,res)=> {
    const id = parseInt(req.params.id, 10)
    res.json(videos[id])
})

上面的代码从路由参数中获取id ,并将其转换为一个整数。然后我们把与videos 数组中的id 匹配的对象送回给客户端。

流媒体视频

在我们的app.js 文件中,我们创建了一个/video 路由,向客户端提供视频。我们希望这个端点能够发送较小的视频块,而不是在请求时提供整个视频文件。

我们希望能够_动态地_提供在allVideos 阵列中的三个视频中的一个,并将视频分块流传,所以。

app.js 删除/video 路由。

我们需要三个视频,所以从教程的源代码中复制示例视频到你的server 项目的assets/ 目录中。确保视频的文件名与videos 阵列中的id 对应。

回到我们的Video.js 文件中,为流媒体视频创建路由。

router.get('/video/:id', (req, res) => {
    const videoPath = assets/${req.params.id}.mp4;
    const videoStat = fs.statSync(videoPath);
    const fileSize = videoStat.size;
    const videoRange = req.headers.range;
    if (videoRange) {
        const parts = videoRange.replace(/bytes=/, "").split("-");
        const start = parseInt(parts[0], 10);
        const end = parts[1]
            ? parseInt(parts[1], 10)
            : fileSize-1;
        const chunksize = (end-start) + 1;
        const file = fs.createReadStream(videoPath, {start, end});
        const head = {
            'Content-Range': bytes ${start}-${end}/${fileSize},
            'Accept-Ranges': 'bytes',
            'Content-Length': chunksize,
            'Content-Type': 'video/mp4',
        };
        res.writeHead(206, head);
        file.pipe(res);
    } else {
        const head = {
            'Content-Length': fileSize,
            'Content-Type': 'video/mp4',
        };
        res.writeHead(200, head);
        fs.createReadStream(videoPath).pipe(res);
    }
});

如果我们在浏览器中导航到http://localhost:5000/videos/video/outside-the-wire,我们可以看到视频流。

流媒体视频路由如何工作

在我们的流媒体视频路由中写了相当多的代码,所以让我们一行一行地看一下。

 const videoPath = `assets/${req.params.id}.mp4`;
 const videoStat = fs.statSync(videoPath);
 const fileSize = videoStat.size;
 const videoRange = req.headers.range;

首先,从我们的请求中,我们使用req.params.id ,从路由中获得id ,并使用它来生成视频的videoPath 。然后我们使用我们导入的文件系统fs ,读取fileSize 。对于视频,用户的浏览器将在请求中发送一个range 参数。这让服务器知道要把视频的哪块内容发回给客户端。

一些浏览器在初始请求中发送一个_范围_,但其他浏览器不发送。对于那些没有发送范围的浏览器,或者由于任何其他原因浏览器没有发送范围,我们在else 。这段代码获取文件大小并发送视频的前几块。

else {
    const head = {
        'Content-Length': fileSize,
        'Content-Type': 'video/mp4',
    };
    res.writeHead(200, head);
    fs.createReadStream(path).pipe(res);
}

我们将在if 块中处理包括范围在内的后续请求。

if (videoRange) {
        const parts = videoRange.replace(/bytes=/, "").split("-");
        const start = parseInt(parts[0], 10);
        const end = parts[1]
            ? parseInt(parts[1], 10)
            : fileSize-1;
        const chunksize = (end-start) + 1;
        const file = fs.createReadStream(videoPath, {start, end});
        const head = {
            'Content-Range': bytes ${start}-${end}/${fileSize},
            'Accept-Ranges': 'bytes',
            'Content-Length': chunksize,
            'Content-Type': 'video/mp4',
        };
        res.writeHead(206, head);
        file.pipe(res);
    }

上面这段代码使用范围的startend 值创建一个读流。将响应头的Content-Length 设置为从startend 值中计算出的块大小。我们还使用HTTP代码206,表示响应包含部分内容。这意味着浏览器将继续发出请求,直到它获取了视频的所有块。

在不稳定的连接上会发生什么

如果用户的连接很慢,网络流会发出信号,要求I/O源暂停,直到客户端准备好更多的数据。这就是所谓的_背压_。我们可以把这个例子再往前推一步,看看扩展流是多么容易。我们也可以很容易地添加压缩。

const start = parseInt(parts[0], 10);
        const end = parts[1]
            ? parseInt(parts[1], 10)
            : fileSize-1;
        const chunksize = (end-start) + 1;
        const file = fs.createReadStream(videoPath, {start, end});

我们可以看到,上面创建了一个ReadStream ,并逐块地提供视频。

const head = {
            'Content-Range': bytes ${start}-${end}/${fileSize},
            'Accept-Ranges': 'bytes',
            'Content-Length': chunksize,
            'Content-Type': 'video/mp4',
        };
res.writeHead(206, head);
        file.pipe(res);

请求头包含Content-Range ,这是开始和结束的变化,以获得下一整块的视频流到前端,content-length 是发送的视频整块。我们还指定了我们所流的内容的类型,即mp4 。206的写头被设置为只响应新制作的流。

为我们的视频创建一个标题文件

这就是.vtt 字幕文件的样子。

WEBVTT

00:00:00.200 --> 00:00:01.000
Creating a tutorial can be very

00:00:01.500 --> 00:00:04.300
fun to do.

字幕文件包含视频中所说的文字。它还包含每行文字应该显示的时间代码。我们希望我们的视频有字幕,而且我们不会为这个教程创建我们自己的字幕文件,所以你可以到repoassets 目录下的字幕文件夹中下载字幕。

让我们创建一个新的路由,来处理字幕请求。

router.get('/video/:id/caption', (req, res) => res.sendFile(assets/captions/${req.params.id}.vtt, { root: __dirname }));

建立我们的前端

为了开始我们系统的视觉部分,我们必须建立我们的前端支架。

注意你需要vue-cli来创建我们的应用程序。如果你的电脑上没有安装它,你可以运行npm install -g @vue/cli 来安装它。

安装

在我们项目的根部,让我们创建我们的前端文件夹。

mkdir frontend
cd frontend

并在其中初始化我们的package.json 文件,在其中复制并粘贴以下内容。

{
  "name": "my-app",
  "scripts": {
    "dev": "nuxt",
    "build": "nuxt build",
    "generate": "nuxt generate",
    "start": "nuxt start"
  }
}

然后安装nuxt

npm add nuxt

并执行以下命令来运行Nuxt.js应用程序。

npm run dev

我们的Nuxt文件结构

现在我们已经安装了Nuxt,我们可以开始布置我们的前端了。

首先,我们需要在我们应用的根部创建一个layouts 文件夹。这个文件夹定义了应用程序的布局,无论我们导航到哪个页面。像我们的导航栏和页脚就在这里。在前端文件夹中,当我们启动我们的前端应用程序时,我们为我们的默认布局创建default.vue

mkdir layouts
cd layouts
touch default.vue

然后创建一个components 文件夹来创建我们所有的组件。我们将只需要两个组件,NavBarvideo 组件。所以在我们的前端根目录下,我们。

mkdir components
cd components
touch NavBar.vue
touch Video.vue

最后,一个pages文件夹,在这里可以创建我们所有的页面,如homeabout 。在这个应用程序中,我们需要的两个页面,是显示我们所有的视频和视频信息的home 页面,以及一个动态的播放器页面,它可以引导我们点击的视频。

mkdir pages
cd pages
touch index.vue
mkdir player
cd player
touch _name.vue

我们的前台目录现在看起来是这样的。

|-frontend
  |-components
    |-NavBar.vue
    |-Video.vue
  |-layouts
    |-default.vue
  |-pages
    |-index.vue
    |-player
      |-_name.vue
  |-package.json
  |-yarn.lock

导航栏组件

我们的NavBar.vue 看起来是这样的。

<template>
    <div class="navbar">
        <h1>Streaming App</h1>
    </div>
</template>
<style scoped>
.navbar {
    display: flex;
    background-color: #161616;
    justify-content: center;
    align-items: center;
}
h1{
    color:#a33327;
}
</style>

NavBar 有一个h1 标签,显示Streaming App,有一些小的样式。

让我们把NavBar 导入我们的default.vue 布局中。

// default.vue
<template>
 <div>
   <NavBar />
   <nuxt />
 </div>
</template>
<script>
import NavBar from "@/components/NavBar.vue"
export default {
    components: {
        NavBar,
    }
}
</script>

default.vue 布局现在包含我们的NavBar 组件,它后面的<nuxt /> 标签标志着我们创建的任何页面将在哪里显示。

在我们的index.vue (也就是我们的主页),让我们向http://localhost:5000/videos 发出请求,从我们的服务器上获取所有的视频。将数据作为道具传递给我们稍后创建的video.vue 组件。但现在,我们已经导入了它。

<template>
<div>
  <Video :videoList="videos"/>
</div>
</template>
<script>
import Video from "@/components/Video.vue"
export default {
  components: {
    Video
  },
head: {
    title: "Home"
  },
    data() {
      return {
        videos: []
      }
    },
    async fetch() {
      this.videos = await fetch(
        'http://localhost:5000/videos'
      ).then(res => res.json())
    }
}
</script>

视频组件

下面,我们首先声明我们的道具。由于视频数据现在在组件中可用,使用Vue的v-for ,我们对所有收到的数据进行迭代,对于每一个数据,我们都显示信息。我们可以使用v-for 指令来循环浏览数据,并将其显示为一个列表。一些基本的样式也被添加进来。

<template>
<div>
  <div class="container">
    <div
    v-for="(video, id) in videoList"
    :key="id"
    class="vid-con"
  >
    <NuxtLink :to="`/player/${video.id}`">
    <div
      :style="{
        backgroundImage: `url(${video.poster})`
      }"
      class="vid"
    ></div>
    <div class="movie-info">
      <div class="details">
      <h2>{{video.name}}</h2>
      <p>{{video.duration}}</p>
      </div>
    </div>
  </NuxtLink>
  </div>
  </div>
</div>
</template>
<script>
export default {
    props:['videoList'],
}
</script>
<style scoped>
.container {
  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 2rem;
}
.vid-con {
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
  justify-content: center;
  width: 50%;
  max-width: 16rem;
  margin: auto 2em;

}
.vid {
  height: 15rem;
  width: 100%;
  background-position: center;
  background-size: cover;
}
.movie-info {
  background: black;
  color: white;
  width: 100%;
}
.details {
  padding: 16px 20px;
}
</style>

我们还注意到,NuxtLink 有一个动态路由,即路由到/player/video.id

我们想要的功能是,当用户点击任何一个视频时,它就开始播放。为了实现这个目标,我们利用了_name.vue 路由的动态特性。

在其中,我们创建了一个视频播放器,并将源设置为我们的端点,用于播放视频,但我们在this.$route.params.name 的帮助下,动态地将要播放的视频附加到我们的端点,该链接收到了哪些参数。

<template>
    <div class="player">
        <video controls muted autoPlay>
            <source :src="http://localhost:5000/videos/video/${vidName}" type="video/mp4">
        </video>
    </div>
</template>
<script>
export default {
 data() {
      return {
        vidName: ''
      }
    },
mounted(){
    this.vidName = this.$route.params.name
}
}
</script>
<style scoped>
.player {
    display: flex;
    justify-content: center;
    align-items: center;
    margin-top: 2em;
}
</style>

当我们点击任何一个视频时,我们会得到。

添加我们的字幕文件

为了添加我们的音轨文件,我们要确保_字幕_文件夹中所有的.vtt 文件都与我们的id 。用轨道更新我们的视频元素,对字幕提出要求。

<template>
    <div class="player">
        <video controls muted autoPlay crossOrigin="anonymous">
            <source :src="http://localhost:5000/videos/video/${vidName}" type="video/mp4">
            <track label="English" kind="captions" srcLang="en" :src="http://localhost:5000/videos/video/${vidName}/caption" default>
        </video>
    </div>
</template>

我们已经在视频元素中添加了crossOrigin="anonymous" ;否则,对字幕的请求将失败。现在刷新,你会看到字幕已被成功添加。

构建弹性视频流时应注意的事项。

在构建像Twitch、Hulu或Netflix这样的流媒体应用时,有许多事情要考虑到。

  • 视频数据处理管道
    这可能是一个技术挑战,因为需要高性能的服务器来为用户提供数百万的视频。应不惜一切代价避免高延时或停机时间。
  • 缓存
    构建这类应用时应使用缓存机制,例如 Cassandra、Amazon S3、AWS SimpleDB。
  • 用户的地理位置
    考虑到你的用户的地理位置,应该考虑到分布。

总结

在本教程中,我们已经看到了如何在Node.js中创建一个服务器,以流式传输视频,为这些视频生成字幕,并提供视频的元数据。我们还看到了如何在前端使用Nuxt.js来消费服务器产生的端点和数据。

与其他框架不同,用Nuxt.js和Express.js构建一个应用程序是相当容易和快速的。Nuxt.js最酷的地方在于它能管理你的路由,让你更好地架构你的应用。

资源