如何使用Spring Boot构建一个视频流应用程序

724 阅读14分钟

如何使用Spring Boot构建一个视频流应用程序

世界上许多流行的应用程序都使用某种形式的视频流功能。实现这一功能可以给你和你的用户带来很多创造性的力量。

在本指南中,我们将介绍如何使用一个简单的Spring Boot应用程序和一个Javascript前端来构建一个视频流功能。

我们还将讨论我们的客户端和服务器在幕后做些什么来实现这一功能。最后,作为奖励,我们将看到我们如何为我们的后端编写自动化测试。

前提条件

要跟上本教程,你需要。

  • Spring框架的基础知识(即[REST控制器]、[Hibernate和Spring Data JPA]以及[Lombok]。

  • 对[HTTP]和[RESTful Web服务]的理解。

  • 对Javascript基础知识的一些基本了解(即[获取]、使用[DOM]。

  • 对[查询参数]的理解。

  • 一些关于如何使用浏览器的开发工具的知识(特别是,检查网络信息)。

应用程序的高级概述

首先,我们需要了解和规划整个应用程序的功能。

在本节中,我们将讨论这个应用程序的所有设计规范和内部运作。这将包括客户-服务器的互动和功能。

设计规范

在本指南中,我们的应用程序将只实现视频文件上传和视频流功能。

在此基础上,你可以添加其他大型应用程序的基本功能,如认证、授权、漂亮的用户界面、更新和删除视频等。

客户端架构

对于文件上传,我们将利用一个表单,其字段将被保存在我们的服务器上。对于视频文件,我们将使用一个接受mp4文件的输入字段和一个作为视频名称的文本输入字段。

我们发送的数据将被视为多部分表单数据。

对于那些不了解的人来说,表单中的每个字段都由浏览器选择的特定分隔符来分隔。在视频文件的情况下,它将以字节的形式发送过来。

为了显示保存的视频,我们的前端应用程序将向服务器发送一个请求,以检索数据库中的所有视频名称。

要播放一个特定的视频,我们向服务器发送另一个请求,将URL中的视频名称作为一个路径变量。

在用户界面中,我们将有一个我们保存的所有视频名称的列表,每个都是一个链接。每一个链接都将引导我们回到当前页面,但要添加一个查询参数,指定要播放的视频。

最后,我们的前端将向后端发送一个请求,根据查询参数检索所需的视频。

请注意,我们不会下载视频的全部内容。相反,我们将根据用户观看视频的程度来检索特定范围的字节。这就是视频流的标准。完整地下载一个视频可能很耗时,特别是对于较长的视频。

为此,浏览器将使用一个范围标头来告诉我们的服务器要检索视频的哪些部分。幸运的是,有了HTML视频元素,我们的浏览器会自动处理这个问题。

服务器端架构

对于后端,我们将设置以下REST端点来与我们的前端对话。

  • /video 用于发布视频的端点。
  • /video/{name} 端点,其中name是要检索的视频的名称。
  • /video/all 获取所有已保存视频的名称的端点。

为了保持简单,用户将可以访问所有视频。因此,我们不需要创建安全系统。然而,对于一个完全充实的应用程序,你应该实现某种形式的认证。

至于较低层次的细节,我们将不得不考虑使用什么数据库,以及如何读取范围头来发送视频的请求部分。

对于这个简单的应用来说,使用一个容易设置的H2 数据库是比较方便的。值得庆幸的是,Spring将为我们做这些繁重的工作。

初始化后端

要开始工作,请浏览Spring Initializr。对于构建工具,我更喜欢使用Maven

对于语言,我们将使用Java 11,对于打包,我们将选择jar。说到依赖性,我们需要Spring Webh2数据库Spring Data JPA,以及可选但建议使用的Lombok

创建我们的视频实体和资源库

让我们创建Hibernate实体来表示保存的视频文件。

package io.john.amiscaray.videosharingdemo.domain;

import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Data
@NoArgsConstructor
public class Video{
   @Id
   @GeneratedValue(strategy = GenerationType.AUTO)
   private Long id;

   @Column(unique = true)
   private String name;

   @Lob
   private byte[] data;

   public Video(String name, byte[] data) {
       this.name = name;
       this.data = data;
   }
}

注意这里我们有一个byte 数组类型的data 字段,注释为@Lob 。这就是Hibernate如何将视频字节数据映射到可以用Java代码读取的形式。

@Lob 注解只是意味着,当保存到数据库时,它将在数据库表中采取BLOB(二进制大对象)的类型。

从那里,我们可以制作我们相应的视频资源库。

package io.john.amiscaray.videosharingdemo.repo;

import io.john.amiscaray.videosharingdemo.domain.Video;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface VideoRepo extends JpaRepository<Video, Long> {
    Video findByName(String name);

    boolean existsByName(String name);

    @Query(nativeQuery = true, value="SELECT name FROM video")
    List<String> getAllEntryNames();
}

在这里,我们创建了一些自定义的抽象方法,以满足我们应用的要求。值得注意的是,最后一个方法getAllEntryNames ,使用@Query 注释的本地查询(针对我们使用的数据库的查询)。

value 字段中,你可以看到我们使用自己的SQL查询来获取我们视频表的名称列。借助Spring Data JPA的魔力,这三种方法将为我们实现。

创建并公开我们的视频服务

从这里,我们可以创建一个VideoService 接口,它将定义我们如何访问和使用我们的Video 实体。

package io.john.amiscaray.videosharingdemo.services;

import io.john.amiscaray.videosharingdemo.domain.Video;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;

public interface VideoService {
    Video getVideo(String name);

    void saveVideo(MultipartFile file, String name) throws IOException;

    List<String> getAllVideoNames();
}

请注意,在Spring中,最好的做法是将你的服务建立在接口上,并注入该通用接口的bean,而不是其实现。这样,你就可以灵活地创建该接口的特定环境类型,并在适当的地方轻松地将它们注入整个应用程序。

因为你是把Bean作为一个通用接口来注入的,你可以根据环境来快速切换实现。如果你需要在不破坏其他代码的情况下废止一个特定的实例,这也会有所帮助。

然后,我们对该接口的实现应该如下所示。

package io.john.amiscaray.videosharingdemo.services;

import io.john.amiscaray.videosharingdemo.domain.Video;
import io.john.amiscaray.videosharingdemo.exceptions.VideoAlreadyExistsException;
import io.john.amiscaray.videosharingdemo.exceptions.VideoNotFoundException;
import io.john.amiscaray.videosharingdemo.repo.VideoRepo;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;

@Service
@AllArgsConstructor
public class VideoServiceImpl implements VideoService {
    private VideoRepo repo;

    @Override
    public Video getVideo(String name) {
        if(!repo.existsByName(name)){
                throw new VideoNotFoundException();
        }
        return repo.findByName(name);
    }

    @Override
    public List<String> getAllVideoNames() {
            return repo.getAllEntryNames();
    }

    @Override
    public void saveVideo(MultipartFile file, String name) throws IOException {
        if(repo.existsByName(name)){
                throw new VideoAlreadyExistsException();
        }
        Video newVid = new Video(name, file.getBytes());
        repo.save(newVid);
    }
}

在上面的代码中,我们有时会抛出一个VideoAlreadyExistsException ,这是一个自定义的运行时异常,定义为。

package io.john.amiscaray.videosharingdemo.exceptions;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(value = HttpStatus.CONFLICT, reason = "A video with this name already exists")
public class VideoAlreadyExistsException extends RuntimeException {

}

通过@ResponseStatus 注解,我们指定了在处理请求时抛出该异常时要发送的HTTP状态代码。

我们还用reason 参数指定了在最终响应中向客户端发送的信息。注意,为了让客户端看到这个消息,我们必须在我们的application.properties 文件中设置以下属性。

server.error.include-message=always

最后,随着服务的最终创建,我们可以建立一个简单的控制器来暴露它。

package io.john.amiscaray.videosharingdemo.controllers;

import io.john.amiscaray.videosharingdemo.services.VideoService;
import lombok.AllArgsConstructor;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;

@RestController
@RequestMapping("video")
@AllArgsConstructor
public class VideoController {
   private VideoService videoService;

   // Each parameter annotated with @RequestParam corresponds to a form field where the String argument is the name of the field
   @PostMapping()
   public ResponseEntity<String> saveVideo(@RequestParam("file") MultipartFile file, @RequestParam("name") String name) throws IOException {
       videoService.saveVideo(file, name);
       return ResponseEntity.ok("Video saved successfully.");
   }

   // {name} is a path variable in the url. It is extracted as the String parameter annotated with @PathVariable
   @GetMapping("{name}")
   public ResponseEntity<Resource> getVideoByName(@PathVariable("name") String name){
       return ResponseEntity
               .ok(new ByteArrayResource(videoService.getVideo(name).getData()));
   }

   @GetMapping("all")
   public ResponseEntity<List<String>> getAllVideoNames(){
       return ResponseEntity
               .ok(videoService.getAllVideoNames());
   }
}

构建客户端应用程序

对于我们的客户端应用程序,我们将在一个单一的HTML文件中实现所有的功能,使事情变得简单。

这将包括保存新视频的表格、视频播放器和视频列表。由此产生的HTML将看起来像这样。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>JohnTube</title>
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <link rel="stylesheet" href="styles.css">
</head>
<body>
   <header>
       <h1>JohnTube</h1>
   </header>
   <main>
       <div id="video-list">
           <header>
               <h3>Your videos</h3>
           </header>
           <ul id="your-videos">
           </ul>
       </div>
       <div id="video-player">
           <header>
               <h3 id="now-playing"></h3>
           </header>
           <video id="video-screen" width="720px" height="480px" controls></video>
       </div>
       <form id="video-form">
           <fieldset>
               <legend>Upload a video</legend>
               <label for="file">Video File</label>
               <input id="file" name="file" type="file" accept="application/mp4">
               <label for="name">Video Name</label>
               <input id="name" name="name" type="text">
               <button type="submit">Save</button>
           </fieldset>
       </form>
   </main>
   <script src="main.js"></script>
</body>
</html>

id为your-videos 的无序列表是空白的,因为我们将使用JavaScript动态地填充它。

同样,我们保持id为now-playing 的标题为空白,这样我们就可以根据正在播放的视频来设置其内容。

此外,视频元素将被隐藏,除非用户通过查询参数指定要观看的视频。这方面的CSS将看起来像这样。

#video-player{
    display: none;
}

#video-form{
    width: 60%;
}

至于JavaScript,让我们首先检索必要的DOM元素来进行操作。

const form = document.querySelector('#video-form');
const videoDiv = document.querySelector('#video-player');
const videoScreen = document.querySelector('#video-screen');

同时,我们需要检索一个对象来访问我们的查询参数。

const queryParams = Object.fromEntries(new URLSearchParams(window.location.search));

有了上述对象,我们只需将任何查询参数作为上述对象的属性来访问。例如,如果我们有一个像http://localhost:4200/video-sharing-app/index.html?video=myVid 的URL,我们可以访问video 的查询参数为:queryParams.video

从那里,让我们开始填充保存的视频列表。

fetch('http://localhost:8080/video/all')
    .then(result => result.json())
    .then(result => {

        const myVids = document.querySelector('#your-videos');
        if(result.length > 0){
            for(let vid of result){
                const li = document.createElement('LI');
                const link = document.createElement('A');
                link.innerText = vid;
                link.href = window.location.origin + window.location.pathname + '?video=' + vid;
                li.appendChild(link);
                myVids.appendChild(li);
            }
        }else{
            myVids.innerHTML = 'No videos found';
        }

    });

在上面的代码中,我们使用fetch向我们的服务器发送一个GET请求:http://localhost:8080/video/all

得到的JSON响应是一个由我们保存的视频名称组成的字符串数组。对于这些字符串中的每一个,我们制作li ,其中包含到当前页面的链接。

对于每个链接,我们添加一个video 查询参数,其值是数组中的相应字符串。如果数组是空的,我们就在我们的列表中添加一条信息,表明没有找到视频。

现在我们有了显示所有视频和播放视频链接的方法,让我们来实现播放这些视频的功能。

if(queryParams.video){

    videoScreen.src = `http://localhost:8080/video/${queryParams.video}`;
    videoDiv.style.display = 'block';
    document.querySelector('#now-playing')
        .innerText = 'Now playing ' + queryParams.video;

}

首先,我们检查视频查询参数是否存在。如果存在,我们将视频元素的src 属性设置为URL,以便从后端获取它。

从那里,我们使视频播放器可见,并添加一个标题,表明正在播放什么视频。

最后,我们需要使表单发送请求,将视频保存到我们的后端。做到这一点的代码如下。

form.addEventListener('submit', ev => {
    ev.preventDefault();
    let data = new FormData(form);
    fetch('http://localhost:8080/video', {
        method: 'POST',
        body: data
    }).then(result => result.text()).then(_ => {
        window.location.reload();
    });

});

在这里,我们添加一个事件监听器,当我们提交表单时,它将被调用。我们首先需要阻止默认的提交行为,以确保我们计划的活动能够正常进行。

然后,我们创建一个新的FormData 对象,作为我们的请求主体发送。最后,一旦响应回来,我们刷新页面,让我们的应用程序显示一个新的视频列表。

现在我们已经创建了应用程序,在你的开发工具中打开网络标签,以监控对我们后端的请求。试着保存一个新的视频并点击链接来播放它。你应该看到像这样的东西。

network-info

注意206的状态代码。谷歌快速搜索会告诉你这意味着我们的请求是部分内容。

正如所讨论的,浏览器会自动发送请求,只要求播放我们想要的视频的一部分。进一步检查后,你还可以找到我谈到的范围头。

header-info

上面这一行说明,当我们发送这个请求时,我们要求的是视频的最开始部分。

值得庆幸的是,Spring为我们处理了这些字节块的发送,所以我们不必担心这些低级别的细节问题。

为我们的后端编写单元测试

作为奖励,让我们为我们的应用程序写一些单元测试。知道如何为你的应用程序编写测试以识别和消除错误是很好的。

编写测试可以更容易地快速验证你的代码,以确保你在未来不会意外地破坏东西。

通过Spring,我们将使用JUnit和Mockito来帮助我们编写测试。

单元测试我们的视频服务

注意,我们将只为我们的VideoServiceImpl 类写测试。

在我们的/src/test/java 文件夹中,创建一个VideoServiceImplTest 类,并按如下方式初始化它。

package io.john.amiscaray.videosharingdemo.services;

import org.junit.jupiter.api.Test;

class VideoServiceImplTest {
    @Test
    void getVideo() {
    }

    @Test
    void getAllVideoNames() {
    }

    @Test
    void saveVideo() {
    }
}

由于我们正在为这个类编写单元测试,我们应该对它所依赖的任何类使用模拟。这样我们就可以知道,如果发生错误,是由于VideoServiceImpl ,而不是由于它所使用的另一个类。

我们通过在mocks中硬编码正确的行为来确保这一点。在我们的例子中,我们将不得不为我们的类所使用的VideoRepo 接口创建一个模拟。

package io.john.amiscaray.videosharingdemo.services;

import io.john.amiscaray.videosharingdemo.domain.Video;
import io.john.amiscaray.videosharingdemo.repo.VideoRepo;
import org.junit.jupiter.api.Test;
import org.springframework.web.multipart.MultipartFile;
/*
    Import all the static methods in the Mockito class so we can use them as though they are methods in this class.
    These include methods such as mock, when, etc. Same with the JUnit assertions.
 */
import java.io.IOException;
import java.util.List;

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

class VideoServiceImplTest {
    VideoRepo repo = mock(VideoRepo.class);
    VideoService service = new VideoServiceImpl(repo);
    // Test value for our tests
    String testName = "myVid";
    // Empty tests go here…
}

从那里,让我们写一个检索视频的测试。

@Test
void getVideo() {
   Video expected = new Video(testName, null);
   // When our VideoService object calls repo.findByName(testName), return expected
   when(repo.findByName(testName))
           .thenReturn(expected);
   // When our VideoService object calls repo.existsByName(testName), return true
   when(repo.existsByName(testName))
           .thenReturn(true);
   Video actual = service.getVideo(testName);
   assertEquals(expected, actual);
   verify(repo, times(1)).existsByName(testName);
   verify(repo, times(1)).findByName(testName);
}

在这里,我们使用Mockito的when 方法硬编码了我们的mock的行为。因此,我们知道我们的VideoService 所使用的VideoRepo 类正在适当地工作。

然后我们检查VideoService 对象是否正确地返回了从我们的资源库中检索的视频。

最后,我们使用Mockito的verify 方法确认该类调用一次existsByNamefindByName 方法。

同样地,我们也可以为这个类的其他方法编写简单的测试。

@Test
void getAllVideoNames() {
   List<String> expected = List.of("myVid", "otherVid");
   when(repo.getAllEntryNames())
           .thenReturn(expected);
   List<String> actual = service.getAllVideoNames();
   assertEquals(expected, actual);
   verify(repo, times(1)).getAllEntryNames();
}

@Test
void saveVideo() throws IOException {
   MultipartFile file = mock(MultipartFile.class);
   Video testVid = new Video(testName, file.getBytes());
   service.saveVideo(file, testName);
   verify(repo, times(1)).existsByName(testName);
   verify(repo, times(1)).save(testVid);
}

为我们的后端编写集成测试

现在,让我们看看如何为同一服务编写集成测试。与单元测试不同,集成测试检查不同组件之间的交互。

因此,我们不会为我们的依赖关系使用mocks,而是将提起Spring上下文来注入实际的bean。

在这种情况下,我将模拟一个MultipartFile ,所以我们可以很容易地有一个实例传递给我们正在测试的方法。

集成测试我们的视频服务

为了给我们的测试类带来Spring上下文,我们需要在类中添加@SpringBootTest 注解。

此外,我们将添加@Transactional 注解,在这个上下文中,它将在测试执行后回滚任何数据库操作。

这样,在一个测试中发生的数据库交互就不会干扰另一个测试,因为数据库将在每个测试前被清除。

在这里,我们可以像往常一样将Spring Bean注入我们的类中,并创建一个简单的testName 字段,我们将在测试中使用。

@Autowired
VideoService service;

@Autowired
VideoRepo repo;

String testName = "myVid";

有了这些,创建我们的测试应该是相当简单的。

package io.john.amiscaray.videosharingdemo.services;

import io.john.amiscaray.videosharingdemo.domain.Video;
import io.john.amiscaray.videosharingdemo.repo.VideoRepo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.web.multipart.MultipartFile;
import javax.transaction.Transactional;
import java.io.IOException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@SpringBootTest
@Transactional
public class VideoServiceImplIT {
    @Autowired
    VideoService service;

    @Autowired
    VideoRepo repo;

    String testName = "myVid";

    @Test
    void getVideo() {
        Video expected = new Video(testName, null);
        repo.save(expected);
        Video actual = service.getVideo(testName);
        // The result from service.getVideo(testName) should be expected Video instance above
        assertEquals(expected, actual);
    }


    @Test
    void saveVideo() throws IOException {
        MultipartFile file = mock(MultipartFile.class);
        service.saveVideo(file, testName);
        // After saving the video using the service, the repository should say that the video exists
        assertTrue(repo.existsByName(testName));
    }

    @Test
    void getAllVideoNames() {
        List<String> expected = List.of(testName);
        repo.save(new Video(testName, null));
        List<String> actual = service.getAllVideoNames();
        // Check the service returns a list of the same contents as the expected list of videos
        assertTrue(expected.size() == actual.size() && expected.containsAll(actual) && actual.containsAll(expected));
    }
}

结论

就这样,我们讨论了如何用我们选择的技术栈开发一个全栈的视频流应用。

我们还讨论了如何将视频文件上传到数据库,如何检索和播放我们保存的视频,以及作为一个有用的奖励,我们如何为我们的后端编写测试。