在做的項目中,有需求是在HDFS文件系統中支持管理視頻數據,並在前端支持在線預覽。其中后端使用的是Spring boot框架,前端是React框架。
總體分析
大概需要實現以下的核心功能
- 視頻上傳
- 視頻下載
- 視頻預覽
一.視頻上傳
后端提供一個上傳接口,將二進制流存到HDFS中。這里使用的是 MultipartFile,直接接受一個二進制流。
前端在用戶點擊上傳按鈕的時候,把要上傳的文件轉化為二進制流並調用后端接口
后端簡單實現
@PostMapping("/file")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file,
@RequestParam("dirPath") String dirPath) {
return uploadSingleFile(file, dirPath);
}
public ResponseEntity<String> uploadSingleFile(MultipartFile file, String dirPath) {
String fileName = file.getOriginalFilename();
String filePath = dirPath + "/" + fileName;
try {
uploadFileToHdfs(file, dirPath);
return new ResponseEntity<>(HttpStatus.OK);
} catch (PathNotFoundException | PathIsDirectoryException e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
} catch (IOException e) {
logger.error("Upload " + filePath + " failed.", e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
public void uploadFileToHdfs(MultipartFile file, String dirPath) throws IOException {
// 連接hdfs,實際項目中需要放到構造函數中,防止重復連接
conf = new Configuration();
conf.set("dfs.client.use.datanode.hostname", "true");
conf.set("fs.defaultFS", hdfs://mirage-cluster-01:8020);
fs = FileSystem.get(conf);
String fileName = file.getOriginalFilename();
// 這里只是簡單實現,需要調用hdfs接口判斷 文件是否已經存在/文件夾路徑是否存在等等
Path fullDirPath = new Path("hdfs://mirage-cluster-01:8020/dev" + dirPath));
Path fullFilePath = new Path(fullDirPath.toString() + "/" + fileName);
FSDataOutputStream outputStream = fs.create(fullFilePath);
outputStream.write(file.getBytes());
outputStream.close();
}
前端簡單實現(假設已經用表單選擇好了要上傳的文件(實際我們使用antd的Upload組件獲取本地的文件),文件數據存在state中,點擊確定觸發 handleOnOk() 函數)
handleOnOk = async e => {
// 假設已經驗證過沒有同名文件/文件夾
// 選擇的參數存在了組件的state中
const { fileName, path, uploadFile } = this.state;
// 驗證上傳文件是否為空
if (uploadFile === null) {
message.error('請先選擇上傳的文件');
this.setState({
confirmLoading: false,
})
return;
}
// 構造上傳文件表單
let file = null;
if (fileName !== '') {
file = new File([uploadFile],
fileName,
{
'type': uploadFile.type,
});
}
const formData = new FormData();
formData.append('file', file);
formData.append('dirPath', path);
// 發送請求
const init = {
method: 'POST',
mode: 'cors',
body: formData,
}
const url = `http://ip:port/file`; //后端接口地址
const response = await fetch(url, init);
... // 下面根據response的狀態碼判斷是否上傳成功
}
二. 視頻下載
后端提供一個下載接口,直接返回一個二進制流。
前端點擊下載按鈕,調用接口,下載文件到本地。
后端簡單實現
@GetMapping("/file")
public ResponseEntity<InputStreamResource> download(@RequestParam("filePath") String filePath) {
return downloadSingleFile(filePath);
}
public ResponseEntity<InputStreamResource> downloadSingleFile(String filePath) {
// 假設已經做完了路徑異常判斷
String fileName = pathSplit[pathSplit.length - 1];
try {
InputStream in = getHdfsFileInputStream(filePath);
InputStreamResource resource = new InputStreamResource(in);
// 設置一些協議參數
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"))
.body(resource);
} catch {
...
}
}
/**
* 獲取 HDFS 文件的 IO 流
*/
public InputStream getHdfsFileInputStream(@NotNull String filePath) throws IOException {
// 連接hdfs,實際項目中需要放到構造函數中,防止重復連接
conf = new Configuration();
conf.set("dfs.client.use.datanode.hostname", "true");
conf.set("fs.defaultFS", hdfs://mirage-cluster-01:8020);
fs = FileSystem.get(conf);
// 讀取文件流
Path fullFilePath = new Path("hdfs://mirage-cluster-01:8020/dev" + filePath));
InputStream in = fs.open(fullFilePath);
return in;
}
前端簡單實現 (假設已經選中了要下載的文件,點擊按鈕觸發了 handleOnOk() 函數)
handleOnOK = async (e) => {
e.stopPropagation();
const { filePathUpload, file } = this.props;
const { name, path } = file;
// 直接請求后端接口
const url = `http://ip:port/file?filePath=${path}`;
const response = await fetchTool(url, init);
if (response && response.status === 200) {
// 讀取文件流
const data = await response.blob();
// 創建下載鏈接
let blobUrl = window.URL.createObjectURL(data);
// 創建一個a標簽用於下載
const aElement = document.createElement('a');
document.body.appendChild(aElement);
aElement.style.display = 'none';
aElement.href = blobUrl;
// 設置下載后文件名
aElement.download = name;
// 觸發點擊鏈接,開始下載
aElement.click();
// 下載完成,移除對象
document.body.removeChild(aElement);
}
}
三.視頻預覽
后端提供一個預覽接口,返回文件流。
前端通過video標簽播放。
后端簡單實現
@GetMapping("/video-preview")
public ResponseEntity<InputStreamResource> preview(@RequestParam String filePath,
@RequestHeader String range) {
// 前端使用video標簽發起的請求會在header里自帶的range參數,對應視頻進度條請求視頻內容
return videoPreview(filePath, range);
}
public ResponseEntity<InputStreamResource> videoPreview(String filePath, String range) {
try {
// 獲取文件流
InputStream in = getHdfsFileInputStream(filePath);
InputStreamResource resource = new InputStreamResource(in);
// 計算一些需要在response里設置的參數
long fileLen = getHdfsFileStatus(filePath).getLen();
long videoRange = Long.parseLong(range.substring(range.indexOf("=") + 1, range.indexOf("-")));
String[] pathSplit = filePath.split("/");
String fileName = pathSplit[pathSplit.length - 1];
// 這里設置的參數都很關鍵,不然前端播放視頻不能拖動進度條
return ResponseEntity.ok()
.header("Content-type","video/mp4")
.header("Content-Disposition", "attachment; filename="+fileName)
.header("Content-Range", String.valueOf(videoRange + (fileLen-1)))
.header("Accept-Ranges", "bytes")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(fileLen)
.body(resource);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
前端簡單實現(假設點擊播放按鈕,在一個Modal內部,生成播放視頻的代碼)
buildVideoShowData = () => {
const { file } = this.props;
const { path } = file;
const url = `http://ip:port/video-preview?filePath=${path}`;
return (
<video width="840" height="630"
controls='controls'
preload='auto'
autoPlay={true}
loop={true}
>
<source src={url} type="video/mp4"/>
</video>
)
}
如果前端播放的video的src是一個指向視頻文件的路徑,比如將一些視頻存放在部署了前端的同一台服務器的本地硬盤上,src='./video/xxx.mp4'。這樣的話不需要后端接口,可以在前端直接播放,並且可以拖動視頻進度條控制進度。
但這樣相當於把視頻在前端寫死,如果要支持播放用戶上傳的視頻,就不好搞。
所以提供了后端接口從HDFS中讀文件流,並將src設置為接口地址獲取文件流。在我一開始寫這個后端接口時,並沒有設置好這些header參數,導致前端播放視頻時無法拖動進度條,只能從頭往后看。在設置了這些參數之后,后端就可以根據前端傳來的視頻的range,返回視頻進度條對應的內容,做到真正的在線預覽。