文件上传思路梳理
最近项目里有很多关于图片上传、视频上传的功能,由于以前也只是会用个大概,总是去其他项目里找现成的代码,当 CV 工程师,完成需求的时候很没有成就感,改起来也很烦躁,就花了些时间,自己梳理了一下整个流程。
下面是项目里的一个关于视频上传的完整流程
html 页面
这里就用修改页面了,功能完善一些,像我当初回显的问题卡了好久,还是找前端大佬来帮忙解决的
html 页面
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org" >
<head>
<th:block th:include="include :: header('修改视频管理')" />
<th:block th:include="include :: bootstrap-fileinput-css" />
<th:block th:include="include :: select2-css" />
<th:block th:include="include :: bootstrap-select-css" />
</head>
<body class="white-bg">
<div class="wrapper wrapper-content animated fadeInRight ibox-content">
<form class="form-horizontal m" id="form-video-edit" th:object="${video}">
<input name="videoId" th:field="*{videoId}" type="hidden">
<div class="form-group">
<label class="col-sm-3 control-label">统一播放:</label>
<div class="col-sm-8" style="display: flex;align-items: center">
<div class="form-check" style="display: flex;align-items: center">
<input style="margin: 0 10px 0 0" type="radio" onchange="handleClick()" name="unified" id="exampleRadios1" value="1">
<label for="exampleRadios1" style="margin: 0" >
是
</label>
</div>
<div class="form-check" style="margin-left: 30px;display: flex;align-items: center" >
<input style="margin: 0 10px 0 0" type="radio" onchange="handleClick()" name="unified" id="exampleRadios2" value="0">
<label for="exampleRadios2" style="margin: 0" >
否
</label>
</div>
</div>
</div>
<div class="form-group" id="village">
<label class="col-sm-3 control-label">所在小区:</label>
<div class="col-sm-8">
<input name="village" th:field="*{village}" class="form-control" type="text" required>
</div>
</div>
<div class="form-group" id="place">
<label class="col-sm-3 control-label">小区内地点:</label>
<div class="col-sm-8">
<input name="place" th:field="*{place}" class="form-control" type="text" required>
</div>
</div>
<div class="form-group" id="uuId">
<label class="col-sm-3 control-label">硬件编码:</label>
<div class="col-sm-8">
<input name="uuId" th:field="*{uuId}" class="form-control" type="text" required>
</div>
</div>
<div class="form-group">
<div class="col-sm-8">
<input name="videoPath" id="videoPath" th:field="*{videoPath}" class="form-control" type="text" style="display: none">
</div>
</div>
<div class="form-group" >
<form id="form" action="system/video/upload" method="post" enctype="multipart/form-data">
<div class="file-loading">
<input id="input-id" name="files" multiple type="file">
</div>
</form>
</div>
</form>
</div>
<th:block th:include="include :: footer" />
<th:block th:include="include :: bootstrap-fileinput-js" />
<th:block th:include="include :: bootstrap-select-js" />
<th:block th:include="include :: select2-js" />
<script th:inline="javascript">
var prefix = ctx + "system/video";
var collType = [[${video.unified}]]
//回显视频
var myList = new Array(); //回显文件数组
var List = new Array(); //定义一个全局变量去接受文件名和id
var files = document.getElementById("videoPath").value; //获取后台传递的多个视频路径字符串
if (files != null && files != ""){
var fileList = files.substr(0, files.length - 1).split(";");
for (var i = 0; i<fileList.length; i++){
//myList[i]:获取单个视频播放路径
myList[i] = `<video controls src="http://${window.location.host}${fileList[i]}" style="width:100%;height:100%;"></video>`
List.push({ filePath: fileList[i] + ';', KeyID: 'init_'+i })
}
}
回显,根据 collType 确定单选按钮的选中项,控制其他输入框是否展示
$(function () {
if(collType==1){
$('#exampleRadios1').attr('checked',true)
$('#uuId').hide()
$('#place').hide()
$('#village').hide()
}else{
$('#exampleRadios2').attr('checked',true)
$('#uuId').show()
$('#place').show()
$('#village').show()
}
});
var videoPath = "";
$(function () {
initFileInput("input-id");
})
//点击事件,通过单选按钮的值,判断其他输入框是否展示
function handleClick() {
var type = $('input[type="radio"][name="unified"]:checked').val()
if(type==1){
$('#uuId').hide()
$('#place').hide()
$('#village').hide()
}else{
$('#uuId').show()
$('#place').show()
$('#village').show()
}
}
function initFileInput(ctrlName) {
var control = $('#' + ctrlName);
control.fileinput({
language: 'zh', //设置语言
uploadUrl: prefix + "/upload", //上传的地址
allowedFileExtensions: [ "mp4","mpeg","avi","navi","asf","wmv","mov","3gp","rmvb","rm","flv"],//接收的文件后缀
//uploadExtraData:{"id": 1, "fileName":'123.mp3'},
uploadAsync: true, //默认异步上传
showUpload: true, //是否显示上传按钮
showRemove : true, //显示移除按钮
showPreview : true, //是否显示预览
showCaption: false,//是否显示标题
browseClass: "btn btn-default", //按钮样式
dropZoneEnabled: true,//是否显示拖拽区域
maxFileSize: 0,//单位为kb,如果为0表示不限制文件大小
maxFileCount: 10, //表示允许同时上传的最大文件个数
initialPreviewAsData: false,
overwriteInitial: false,
enctype: 'multipart/form-data',
validateInitialCount:true,
previewFileIcon: "<i class='glyphicon glyphicon-king'></i>",
msgFilesTooMany: "选择上传的文件数量({n}) 超过允许的最大数值{m}!",
initialPreview: myList, //myList[i]:获取视频播放路径
}).on('filepreupload', function(event, data, previewId, index) { //上传中触发事件
}).on("fileuploaded", function (event, data, previewId, index) { //文件上传成功触发事件
if (data.response.code == 0){
var filePath = data.response.msg
List.push({ filePath: filePath, KeyID: previewId })
console.log('文件上传成功!');
}
}).on('fileerror', function(event, data, msg) { //一个文件上传失败触发事件
console.log('文件上传失败!');
}).on("filecleared",function(event, data, msg){ //清空文件触发事件
for (var i = 0; i < List.length; i++) {
delete List[i];
}
}).on("fileremoved", function (event, data, previewId, index) { //删除单个文件触发事件
console.log(event,'event', data,'data', previewId,'previewId', index,'index')
for (var i = 0; i < List.length; i++) {
if (List[i].KeyID== previewId || List[i].KeyID== data) {
List.splice(i,1);
}
}
console.log('文件删除!');
}).on("filesuccessremove",function (event, previewId, extra) { //删除上传成功视频的触发事件
for (var i = 0; i < List.length; i++) {
if (List[i].KeyID== previewId) {
List.splice(i,1);
}
}
//点击删除预览框中的删除按钮前触发( initialPreview中的文件除外,只针对于还未上传的文件 ) //id=文件容器的id , index=文件容器的index
}).on("filepreremove",function (event, previewId, extra) {
console.log(event, previewId, extra)
})
}
$("#form-video-edit").validate({
onkeyup: false,
focusCleanup: true,
rules:{
uuId:{ //硬件编码进行正则限制
isUUName:true
},
village:{ //小区字数限制
isDeptName:true
},
place:{ //地点字数限制
areaName:true
}
}
});
function submitHandler() {
//将数组中的文件路径拼接赋值给 videoPath ,并通过表单传递到后台
for (var i = 0; i < List.length; i++) {
videoPath = videoPath + List[i].filePath;
}
document.getElementById("videoPath").value = videoPath;
if ($.validate.form()) {
$.operate.save(prefix + "/edit", $('#form-video-edit').serialize());
}
}
</script>
</body>
</html>
这里用的 BootstrapFileInput 图片上传的插件,附上 fileinput 的属性作用以及详细说明。
直到最后,还有一个小问题,就是编辑的时候,直接删除回显的视频是没用的,删除事件无法触发,除非我们在文件上传框内添加一条新的视频。这个是确实找了很久也没解决掉,欢迎大佬指点🤞
controller 类
在我们选择了视频并点击上传后,视频会根据 uploadUrl 路径,找到 controller 里的上传方法:
controller 类
/**
* 视频上传
*/
@PostMapping("/upload")
@ResponseBody
public String picUpload(@RequestParam("files") MultipartFile[] files, HttpServletRequest request, HttpServletResponse response)
{
StringBuffer urlString=new StringBuffer();
AjaxResult ajaxResult = null;
try {
if (files != null && files.length > 0) {
for (int i = 0; i < files.length; i++) {
MultipartFile file = files[i];
//获取文件路径用";"拼接,返回前端
urlString.append(FileUploadUtils.uploadWithFileName(RuoYiConfig.getVersionAttachPath(), file)+";") ;
}
}
ajaxResult = AjaxResult.success(urlString.toString());
return ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
} catch (Exception e) {
ajaxResult = AjaxResult.error(e.getMessage());
return ServletUtils.renderString(response, JSONObject.toJSONString(ajaxResult));
}
}
我们从这里开始,一步步的看,首先是将上传的视频集 files 遍历。
① 这里的 RuoYiConfig 是一个关于项目相关配置的类,他的 getVersionAttachPath() 方法,其实是我们自定义的一个文件保存的路径名字段 profile 也是可以在项目的 application.yml 配置文件里进行更改的,我的本地环境路径是:D:/ruoyi/uploadPath
配置文件 RuoYiConfig
/** 上传路径 */
private static String profile;
public static String getProfile(){
return profile;
}
/**
* 获取版本附件上传路径
* @return
*/
public static String getVersionAttachPath() {
return getProfile() + "/versionAttach";
}
② 这里的 FileUploadUtils 是文件上传的工具类,
工具类 FileUploadUtils
package com.ruoyi.common.utils.file;
import java.io.File;
import java.io.IOException;
import org.apache.commons.io.FilenameUtils;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.exception.file.FileNameLengthLimitExceededException;
import com.ruoyi.common.exception.file.FileSizeLimitExceededException;
import com.ruoyi.common.exception.file.InvalidExtensionException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.Md5Utils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.config.RuoYiConfig;
/**
* 文件上传工具类
*
* @author ruoyi
*/
public class FileUploadUtils
{
/**
* 默认大小 50M
*/
public static final long DEFAULT_MAX_SIZE = 50 * 1024 * 1024;
/**
* 默认的文件名最大长度 100
*/
public static final int DEFAULT_FILE_NAME_LENGTH = 100;
/**
* 默认上传的地址
*/
private static String defaultBaseDir = RuoYiConfig.getProfile();
private static int counter = 0;
public static void setDefaultBaseDir(String defaultBaseDir)
{
FileUploadUtils.defaultBaseDir = defaultBaseDir;
}
public static String getDefaultBaseDir()
{
return defaultBaseDir;
}
/**
* 根据文件路径上传,文件名不变
*
* @param baseDir 相对应用的基目录
* @param file 上传的文件
* @return 文件名称
* @throws IOException
*/
public static final String uploadWithFileName(String baseDir, MultipartFile file) throws IOException
{
try
{
//通过基目录、上传文件和文件类型数组,获取文件路径
//baseDir 相对应用的基目录 --D:/ruoyi/uploadPath/versionAttach
//file 上传的文件
//MimeTypeUtils.DYNAMIC_ALLOWED_EXTENSION:自定义的文件类型数组
return uploadUseFileName(baseDir, file, MimeTypeUtils.DYNAMIC_ALLOWED_EXTENSION);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
}
/**
* 文件上传,文件名不变
*
* @param baseDir 相对应用的基目录
* @param file 上传的文件
* @param allowedExtension 上传文件类型
* @return 返回上传成功的文件名
* @throws FileSizeLimitExceededException 如果超出最大大小
* @throws FileNameLengthLimitExceededException 文件名太长
* @throws IOException 比如读写文件出错时
* @throws InvalidExtensionException 文件校验异常
*/
public static final String uploadUseFileName(String baseDir, MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
InvalidExtensionException
{
//获取上传的文件名长度
int fileNamelength = file.getOriginalFilename().length();
//判断文件名是否超过自定义的默认文件名最大长度
if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
{
throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
}
//通过上传的文件和文件类型数组,校验文件大小、文件类型是否符合
assertAllowed(file, allowedExtension);
// DateUtils 日期路径 即年/月/日 --2018/08/08
//拼接日期路径和文件名 --2018/08/08/视频.mp4
String fileName = DateUtils.datePath() + "/" + file.getOriginalFilename();
//通过基目录和文件名,拼接路径,创建文件
File desc = getAbsoluteFile(baseDir, fileName);
//保存文件
//使用此方法保存必须要绝对路径且文件夹必须已存在,否则报错
file.transferTo(desc);
//通过基目录和文件名,获取上传后文件的路径 --/profile/versionAttach/2018/08/08/视频.mp4
String pathFileName = getPathFileName(baseDir, fileName);
return pathFileName;
}
/**
* 拼接路径,创建文件
*
* @param baseDir 相对应用的基目录
* @param fileName 日期路径和文件名
*/
private static final File getAbsoluteFile(String uploadDir, String fileName) throws IOException
{
//拼接路径 --D:/ruoyi/uploadPath/versionAttach/视频.mp4
File desc = new File(uploadDir + File.separator + fileName);
//判断创建文件的时文件所在的 文件夹 是否存在
if (!desc.getParentFile().exists())
{
//避免文件创建失败(该文件所在的文件夹不存在所以创建它所在的文件目录)
//mkdirs()方法 ,如果文件夹已经存在,是不会再次创建的
desc.getParentFile().mkdirs();
}
//判断文件是否存在
if (!desc.exists())
{
//创建文件
desc.createNewFile();
}
return desc;
}
/**
* 拼接文件上传后的路径
*
* @param baseDir 相对应用的基目录
* @param fileName 日期路径和文件名
*/
private static final String getPathFileName(String uploadDir, String fileName) throws IOException
{
//获取系统自定义文件路径(D:/ruoyi/uploadPath)的长度 --int dirLastIndex = 20
int dirLastIndex = RuoYiConfig.getProfile().length() + 1;
//截取基目录(D:/ruoyi/uploadPath/versionAttach) --String currentDir = "versionAttach"
String currentDir = StringUtils.substring(uploadDir, dirLastIndex);
//Constants.RESOURCE_PREFIX:资源映射路径 --/profile
String pathFileName = Constants.RESOURCE_PREFIX + "/" + currentDir + "/" + fileName;
//String pathFileName = "/profile/versionAttach/2018/08/08/视频.mp4"
return pathFileName;
}
/**
* 文件大小校验
*
* @param file 上传的文件
* @return
* @throws FileSizeLimitExceededException 如果超出最大大小
* @throws InvalidExtensionException
*/
public static final void assertAllowed(MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, InvalidExtensionException
{
//获取文件的大小
long size = file.getSize();
//判断文件大小是否超过自定义默认文件大小
if (DEFAULT_MAX_SIZE != -1 && size > DEFAULT_MAX_SIZE)
{
throw new FileSizeLimitExceededException(DEFAULT_MAX_SIZE / 1024 / 1024);
}
//获取文件名称 --视频.mp4
String fileName = file.getOriginalFilename();
//获取文件后缀名 --mp4
String extension = getExtension(file);
//判断文件后缀名,是否在文件类型数组里
if (allowedExtension != null && !isAllowedExtension(extension, allowedExtension))
{
if (allowedExtension == MimeTypeUtils.IMAGE_EXTENSION)
{
throw new InvalidExtensionException.InvalidImageExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.FLASH_EXTENSION)
{
throw new InvalidExtensionException.InvalidFlashExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.MEDIA_EXTENSION)
{
throw new InvalidExtensionException.InvalidMediaExtensionException(allowedExtension, extension,
fileName);
}
else
{
throw new InvalidExtensionException(allowedExtension, extension, fileName);
}
}
}
/**
* 判断MIME类型是否是允许的MIME类型
*
* @param extension
* @param allowedExtension
* @return
*/
public static final boolean isAllowedExtension(String extension, String[] allowedExtension)
{
//判断文件后缀名,是否在文件类型数组里
for (String str : allowedExtension)
{
if (str.equalsIgnoreCase(extension))
{
return true;
}
}
return false;
}
/**
* 获取文件名的后缀
*
* @param file 表单文件
* @return 后缀名
*/
public static final String getExtension(MultipartFile file)
{
//通过FilenameUtils类,获取文件的后缀名
String extension = FilenameUtils.getExtension(file.getOriginalFilename());
if (StringUtils.isEmpty(extension))
{
extension = MimeTypeUtils.getExtension(file.getContentType());
}
return extension;
}
}
从 upload 方法跳转过来后,跟着代码一步一步的走,一行一行的添加注释,最后发现其实也没有太难,只是很多方法用的少、见的少,所以写的时候很不自信,很慌。这次浅显的流程梳理,也算是使我受益匪浅了。
附上一些其他要用到的工具类:
媒体类型工具类 MimeTypeUtils
package com.ruoyi.common.utils.file;
/**
* 媒体类型工具类
*
* @author ruoyi
*/
public class MimeTypeUtils
{
public static final String IMAGE_PNG = "image/png";
public static final String IMAGE_JPG = "image/jpg";
public static final String IMAGE_JPEG = "image/jpeg";
public static final String IMAGE_BMP = "image/bmp";
public static final String IMAGE_GIF = "image/gif";
public static final String[] IMAGE_EXTENSION = { "bmp", "gif", "jpg", "jpeg", "png" };
public static final String[] FLASH_EXTENSION = { "swf", "flv" };
public static final String[] MEDIA_EXTENSION = { "swf", "flv", "mp3", "wav", "wma", "wmv", "mid", "avi", "mpg",
"asf", "rm", "rmvb" };
public static final String[] DEFAULT_ALLOWED_EXTENSION = {
// 图片
"bmp", "gif", "jpg", "jpeg", "png",
// word excel powerpoint
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
// 压缩文件
"rar", "zip", "gz", "bz2",
// pdf
"pdf" };
public static final String[] DYNAMIC_ALLOWED_EXTENSION = {
// 图片
"bmp", "gif", "jpg", "jpeg", "png",
//视频
"mp4","mpeg","avi","navi","asf","wmv","mov","3gp","rmvb","rm","flv"
};
public static String getExtension(String prefix)
{
switch (prefix)
{
case IMAGE_PNG:
return "png";
case IMAGE_JPG:
return "jpg";
case IMAGE_JPEG:
return "jpeg";
case IMAGE_BMP:
return "bmp";
case IMAGE_GIF:
return "gif";
default:
return "";
}
}
}
时间工具类 DateUtils
/**
* 日期路径 即年/月/日 如2018/08/08
*/
public static final String datePath()
{
Date now = new Date();
return DateFormatUtils.format(now, "yyyy/MM/dd");
}
通用常量信息 Constants
/**
* 资源映射路径 前缀
*/
public static final String RESOURCE_PREFIX = "/profile";
配置文件 application.yml
# 项目相关配置
ruoyi:
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath)
profile: D:/ruoyi/uploadPath
好事定律:每件事最后都会是好事,如果不是好事,说明还没到最后。