Java 实现在线视频播放完整方案:从后端服务到前端播放
一、技术选型
1. 核心技术栈
后端:Spring Boot 2.x(快速构建 RESTful 接口)、Spring Security(可选,权限控制)、FFmpeg(视频转码,可选)
前端:Vue 3(视图框架)、Video.js(跨浏览器视频播放器,支持 HLS/DASH 协议)
存储方案:
本地存储(开发 / 测试环境):直接存储视频文件到服务器磁盘
云存储(生产环境):阿里云 OSS、腾讯云 COS(高可用、支持 CDN 加速)
传输协议:
HTTP 分片传输(基础方案,支持断点续传)
HLS(HTTP Live Streaming,适配移动端 / 浏览器,支持自适应码率)
2. 关键技术说明
Video.js:开源播放器,支持 MP4、WebM、HLS(.m3u8)等格式,兼容性好(IE11+、主流浏览器),可自定义皮肤和控制栏。
分片传输:将大视频文件分割为多个小分片(如 1MB / 片),前端按需请求,避免一次性加载大文件导致的卡顿。
视频转码:通过 FFmpeg 将原始视频(如 AVI、MKV)转为 MP4(兼容性最好)或 HLS 格式(.m3u8+ts 分片),适配不同设备。
二、后端实现(Spring Boot)
1. 项目初始化
<!-- Spring Boot Web --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId></dependency><!-- 工具类(文件操作、IO流) --><dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.11.0</version></dependency><!-- Spring Security(可选,权限控制) --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId></dependency>
2. 配置文件(application.yml)
spring: servlet: multipart: max-file-size: 1024MB # 支持大文件上传 max-request-size: 1024MBserver: port: 8080video: storage-path: D:/video-storage/ # 本地存储路径(Linux可改为 /usr/local/video-storage/) max-shard-size: 1048576 # 分片大小(1MB,单位:字节)
3. 核心功能实现
(1)视频上传接口(支持大文件分片上传)
@RestController@RequestMapping("/api/video")public class VideoController {
@Value("${video.storage-path}")
private String storagePath;
@Value("${video.max-shard-size}")
private long maxShardSize;
// 确保存储目录存在
@PostConstruct
public void init() {
File dir = new File(storagePath);
if (!dir.exists()) {
dir.mkdirs();
}
}
/**
* 分片上传视频
* @param file 分片文件
* @param fileName 原文件名(含后缀)
* @param chunkIndex 分片索引(从0开始)
* @param totalChunks 总分片数
* @return 上传结果
*/
@PostMapping("/upload/shard")
public Result<?> uploadShard(@RequestParam("file") MultipartFile file,
@RequestParam("fileName") String fileName,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks) {
try {
// 1. 生成临时分片存储目录(按文件名哈希,避免重名)
String fileHash = DigestUtils.md5DigestAsHex(fileName.getBytes());
String tempShardDir = storagePath + "temp/" + fileHash + "/";
File tempDir = new File(tempShardDir);
if (!tempDir.exists()) {
tempDir.mkdirs();
}
// 2. 保存当前分片(文件名:分片索引)
File shardFile = new File(tempShardDir + chunkIndex);
file.transferTo(shardFile);
// 3. 检查是否所有分片上传完成,若完成则合并
if (chunkIndex == totalChunks - 1) {
// 合并后的最终文件路径
String finalFilePath = storagePath + fileName;
File finalFile = new File(finalFilePath);
// 4. 合并所有分片(按索引顺序)
try (FileOutputStream outputStream = new FileOutputStream(finalFile)) {
for (int i = 0; i < totalChunks; i++) {
File currentShard = new File(tempShardDir + i);
byte[] bytes = FileUtils.readFileToByteArray(currentShard);
outputStream.write(bytes);
// 删除临时分片
currentShard.delete();
}
}
// 5. 删除临时分片目录
FileUtils.deleteDirectory(tempDir);
// (可选)生成HLS格式(需安装FFmpeg,调用命令行转码)
generateHls(finalFilePath, storagePath + "hls/" + fileHash + "/");
return Result.success("上传完成,视频路径:" + finalFilePath);
}
return Result.success("分片上传成功,当前分片:" + chunkIndex);
} catch (Exception e) {
e.printStackTrace();
return Result.error("分片上传失败:" + e.getMessage());
}
}
/**
* 生成HLS格式(.m3u8 + ts分片)
* @param sourcePath 原视频路径
* @param targetDir HLS输出目录
*/
private void generateHls(String sourcePath, String targetDir) {
File targetDirFile = new File(targetDir);
if (!targetDirFile.exists()) {
targetDirFile.mkdirs();
}
// FFmpeg命令:将MP4转为HLS(10秒分片,支持自适应码率)
String command = String.format(
"ffmpeg -i %s -profile:v baseline -level 3.0 -hls_time 10 -hls_list_size 0 -f hls %s/index.m3u8",
sourcePath, targetDir );
try {
// 执行命令行
Process process = Runtime.getRuntime().exec(command);
process.waitFor();
} catch (Exception e) {
e.printStackTrace();
System.err.println("HLS转码失败:" + e.getMessage());
}
}}(2)视频流式播放接口(支持断点续传)
/**
* 视频流式播放(支持断点续传)
* @param fileName 视频文件名(含后缀)
* @param request 请求(获取Range头)
* @param response 响应(返回视频流)
*/@GetMapping("/play/{fileName}")public void playVideo(@PathVariable("fileName") String fileName,
HttpServletRequest request,
HttpServletResponse response) {
File videoFile = new File(storagePath + fileName);
if (!videoFile.exists()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
long fileLength = videoFile.length();
// 解析Range请求头(格式:bytes=0-1023)
String range = request.getHeader("Range");
long start = 0;
long end = fileLength - 1;
if (range != null && range.startsWith("bytes=")) {
String[] rangeArr = range.split("=")[1].split("-");
start = Long.parseLong(rangeArr[0]);
if (rangeArr.length > 1 && !rangeArr[1].isEmpty()) {
end = Long.parseLong(rangeArr[1]);
}
}
// 计算响应的字节长度
long contentLength = end - start + 1;
// 设置响应头(支持断点续传)
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setContentType(getContentType(fileName)); // 动态设置MIME类型
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
response.setHeader("Accept-Ranges", "bytes");
response.setContentLengthLong(contentLength);
// 流式输出视频数据
try (RandomAccessFile raf = new RandomAccessFile(videoFile, "r");
OutputStream outputStream = response.getOutputStream()) {
raf.seek(start); // 定位到起始字节
byte[] buffer = new byte[4096];
int len;
long remaining = contentLength;
while (remaining > 0 && (len = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) {
outputStream.write(buffer, 0, len);
remaining -= len;
}
outputStream.flush();
} catch (Exception e) {
e.printStackTrace();
}}/**
* 根据文件名获取MIME类型
*/private String getContentType(String fileName) {
if (fileName.endsWith(".mp4")) return "video/mp4";
if (fileName.endsWith(".m3u8")) return "application/x-mpegURL";
if (fileName.endsWith(".ts")) return "video/MP2T";
if (fileName.endsWith(".webm")) return "video/webm";
return "application/octet-stream";}(3)权限控制(可选)
@Configuration@EnableWebSecuritypublic class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/video/play/**").authenticated() // 播放接口需登录
.antMatchers("/api/video/upload/**").hasRole("ADMIN") // 上传接口需管理员权限
.anyRequest().permitAll()
.and()
.formLogin() // 表单登录(可替换为JWT令牌登录)
.and()
.csrf().disable();
}
// 测试用户(生产环境需对接数据库)
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("{noop}123456").roles("USER")
.and()
.withUser("admin").password("{noop}admin123").roles("ADMIN");
}}三、前端实现(Vue + Video.js)
1. 安装依赖
# 安装Video.js及HLS插件npm install video.js videojs-contrib-hls --save
2. 视频上传组件(分片上传)
<template>
<div>
<input type="file" accept="video/*" @change="handleFileSelect" />
<div v-if="file">
<p>文件名:{{ file.name }}</p>
<p>大小:{{ formatFileSize(file.size) }}</p>
<button @click="startUpload">开始上传</button>
<div class="progress">
<div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
</div>
<p>进度:{{ uploadProgress }}%</p>
</div>
</div>
</template>
<script>
export default {
data() {
return {
file: null,
uploadProgress: 0,
chunkSize: 1024 * 1024, // 1MB分片
};
},
methods: {
handleFileSelect(e) {
this.file = e.target.files[0];
this.uploadProgress = 0;
},
formatFileSize(size) {
if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + "KB";
} else {
return (size / (1024 * 1024)).toFixed(2) + "MB";
}
},
async startUpload() {
if (!this.file) return;
const fileName = this.file.name;
const totalSize = this.file.size;
const totalChunks = Math.ceil(totalSize / this.chunkSize);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
// 计算当前分片的起始和结束位置
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, totalSize);
const chunk = this.file.slice(start, end);
// 构建表单数据
const formData = new FormData();
formData.append("file", chunk);
formData.append("fileName", fileName);
formData.append("chunkIndex", chunkIndex);
formData.append("totalChunks", totalChunks);
// 上传分片
await this.uploadChunk(formData, chunkIndex, totalChunks);
}
alert("上传完成!");
},
async uploadChunk(formData, chunkIndex, totalChunks) {
try {
await this.$axios.post("/api/video/upload/shard", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
// 计算单个分片的上传进度,并累加到总进度
const chunkProgress = progressEvent.loaded / progressEvent.total;
this.uploadProgress = Math.floor(
(chunkIndex + chunkProgress) / totalChunks * 100
);
},
});
} catch (error) {
console.error("分片上传失败:", error);
alert(`第${chunkIndex + 1}分片上传失败,请重试`);
throw error; // 中断上传
}
},
},
};
</script>
<style scoped>
.progress {
width: 100%;
height: 20px;
border: 1px solid #ccc;
border-radius: 10px;
margin: 10px 0;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: #42b983;
transition: width 0.3s ease;
}
</style>3. 视频播放组件(支持 HLS/MP4)
<template>
<div class="video-player-container">
<video
id="videoPlayer"
class="video-js vjs-big-play-centered"
controls
preload="auto"
width="100%"
height="auto"
></video>
</div>
</template>
<script>
import "video.js/dist/video-js.css";
import videojs from "video.js";
import "videojs-contrib-hls"; // 支持HLS格式
export default {
mounted() {
this.initPlayer();
},
beforeUnmount() {
// 销毁播放器,避免内存泄漏
if (this.player) {
this.player.dispose();
}
},
methods: {
initPlayer() {
const videoElement = document.getElementById("videoPlayer");
// 播放地址(后端接口返回,支持MP4或HLS的.m3u8地址)
const videoUrl = "http://localhost:8080/api/video/play/test.mp4";
// 若为HLS格式,地址改为:http://localhost:8080/api/video/play/hls/xxx/index.m3u8
this.player = videojs(videoElement, {
autoplay: false, // 禁止自动播放(浏览器政策限制)
controls: true, // 显示控制栏
responsive: true, // 响应式布局
fluid: true, // 自适应容器大小
});
// 设置播放源
this.player.src({
src: videoUrl,
type: this.getVideoType(videoUrl), // 自动识别视频类型
});
},
getVideoType(url) {
if (url.endsWith(".m3u8")) return "application/x-mpegURL";
if (url.endsWith(".mp4")) return "video/mp4";
if (url.endsWith(".webm")) return "video/webm";
return "video/mp4";
},
},
};
</script>
<style scoped>
.video-player-container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
</style>四、功能优化与生产环境部署
1. 性能优化
视频转码:生产环境使用 FFmpeg 批量转码为 MP4(H.264 编码)和 HLS 格式,适配不同网络环境(HLS 支持弱网下自动切换码率)。
CDN 加速:将视频文件存储到云 OSS(如阿里云 OSS),并配置 CDN 加速,降低服务器带宽压力,提升播放速度。
缓存策略:在后端响应头中添加
Cache-Control,缓存视频分片(如max-age=86400),减少重复请求。限流防盗链:
防盗链:通过
Referer或Token验证,防止视频被非法嵌入。限流:使用 Spring Cloud Gateway 或 Nginx 限制单 IP 播放请求频率,避免恶意刷流量。
2. 生产环境部署注意事项
存储方案:放弃本地存储,使用云 OSS(阿里云 OSS、腾讯云 COS),支持无限扩容和高可用。
FFmpeg 部署:在 Linux 服务器上安装 FFmpeg(
yum install ffmpeg),通过定时任务或异步线程处理视频转码(避免阻塞接口)。权限升级:使用 JWT 令牌替代表单登录,前端请求时在 Header 中携带
Authorization: Bearer {token},后端验证令牌合法性。Nginx 反向代理:
用 Nginx 代理后端接口,处理静态资源(如视频分片),提升并发能力。
配置 Nginx 支持 Range 请求,进一步优化流式传输性能。
3. 常见问题排查
视频无法播放:检查视频格式(优先 MP4/HLS)、MIME 类型是否正确、跨域配置是否生效。
分片上传失败:检查服务器磁盘空间、分片大小是否超过配置上限、网络稳定性。
HLS 转码失败:确认 FFmpeg 已安装、命令行参数正确、服务器资源(CPU / 内存)充足。