SpringBoot搭建视频服务器 作为DPlayer后端

miaodi
发布于 2020-11-17 / 1212 阅读
0

SpringBoot搭建视频服务器 作为DPlayer后端

1. 前言

前端使用DPlayer作为播放器,需要提供一个flv视频下载功能。cdn太贵且没必要,自己写一个简单地用着,同时有公网ip,可以做一个小型视频站(当然是个人用)。

本人的下载的视频全部是存在nas中的。通过nfs挂载nas中的视频到本机,通过自定义的规则拼接出在本机的映射地址,然后读取文件并输出文件流。以此方式可以部署多个做集群(nas的传输速度有限,受限于硬盘速度以及网速,集群也只能部署个3-4台,更多的是为了保证服务高可用)。企业用还是买cdn靠谱。

  • 提供视频播放接口(实际就是文件下载服务)
  • 支持range请求头(断点续传/跳跃看视频/部分缓存视频)
  • 须校验referer(暂时未做,因为还没确定前端的域名)
  • 涉及跨域,前端需要请求头:"Access-Control-Allow-Origin", "*"*更换为前端的域名
  • 涉及大文件跨域传输,浏览器会自动发送option请求,需要拦截器处理
  • 视频服务器只做读取文件并下载,保证职责单一,可部署多个做集群。
  • 无须身份校验(可以让其他模块做校验,本模块通过安全通信与其他模块交互)

2. Controller代码

     /**
     * 简单的根据视频编号返回文件流,实际上线需求做中间跳转保证数据安全
     * @param cid 视频编号
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/play/cid/{cid}")
    public void playWithCid(@PathVariable String cid, HttpServletRequest request, HttpServletResponse response) {
        if (StringUtils.isEmpty(cid)) {
            return;
        }
        //根据视频编号,查出视频在本机的路径
        String fullPath = videoPathService.getPathWithCid(cid);
        if (Objects.isNull(fullPath)) {
            return;
        }
//        log.info(fullPath);
        flushFile(request, response, fullPath);
    }

   /**
     * 写出文件流
     * @param request
     * @param response
     * @param fullPath 文件在本机的路径
     */
private void flushFile(HttpServletRequest request, HttpServletResponse response, String fullPath) {
        log.info("下载路径:" + fullPath);
        File downloadFile = new File(fullPath);
        if (!downloadFile.exists() || !downloadFile.isFile()) {
            log.warn("路径错误:{}", fullPath);
            return;
        }
        ServletContext context = request.getServletContext();
        String mimeType = context.getMimeType(fullPath);
        if (mimeType == null) {
            mimeType = "application/octet-stream";
        }
        response.setContentType(mimeType);

        // set headers for the response
        String headerKey = "Content-Disposition";
        String headerValue = String.format("attachment; filename=\"%s\"", downloadFile.getName());
        response.setHeader(headerKey, headerValue);
        // 解析断点续传相关信息
        response.setHeader("Accept-Ranges", "bytes");
        long downloadSize = downloadFile.length();
        long fromPos = 0, toPos = 0;
        if (request.getHeader("Range") == null) {
            response.setHeader("Content-Length", downloadSize + "");
        } else {
            // 若客户端传来Range,从断点处下载文件,设置206状态(SC_PARTIAL_CONTENT)
            response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
            String range = request.getHeader("Range");
            String bytes = range.replaceAll("bytes=", "");
            String[] ary = bytes.split("-");
            fromPos = Long.parseLong(ary[0]);
            if (ary.length == 2) {
                toPos = Long.parseLong(ary[1]);
            }
            int size;
            if (toPos > fromPos) {
                size = (int) (toPos - fromPos);
            } else {
                size = (int) (downloadSize - fromPos);
            }
            response.setHeader("Content-Length", size + "");
            downloadSize = size;
        }
        try (
                RandomAccessFile in = new RandomAccessFile(downloadFile, "rw");
                OutputStream out = response.getOutputStream()
        ) {

            // 设置下载起始位置
            if (fromPos > 0) {
                in.seek(fromPos);
            }
            // 缓冲区大小
            int bufLen = (int) (downloadSize < 2048 ? downloadSize : 2048);
            byte[] buffer = new byte[bufLen];
            int num;
            int count = 0; // 当前写到客户端的大小

            while ((num = in.read(buffer)) != -1) {
                out.write(buffer, 0, num);
                count += num;
                //处理最后一段,计算不满缓冲区的大小
                if (downloadSize - count < bufLen) {
                    bufLen = (int) (downloadSize - count);
                    if (bufLen == 0) {
                        break;
                    }
                    buffer = new byte[bufLen];
                }
            }
            response.flushBuffer();
        } catch (IOException e) {
            log.info("数据被暂停或中断。");
        }
    }

3. 拦截器

拦截器:

  • 前端请求跨域,需要Access-Control-Allow-Origin请求头,这里最好把*改成前端的域名,保证不被盗用视频链接
  • 同时拦截OPTIONS的请求方式,一律直接返回请求头。
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
public class CorsInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
        response.setHeader("Access-Control-Max-Age", "86400");
        response.setHeader("Access-Control-Allow-Headers", "*");

        // 如果是OPTIONS则结束请求
        if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) {
            response.setStatus(HttpStatus.NO_CONTENT.value());
            return false;
        }

        return true;
    }
}

添加拦截器:


import cn.bfmiaodi.videoserver.handle.CorsInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Resource
    private CorsInterceptor corsInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 跨域拦截器需放在最上面
        registry.addInterceptor(corsInterceptor).addPathPatterns("/**");
    }

}