【Java】 Springboot+Vue 大文件断点续传
同事在重构老系统的项目时用到了这种大文件上传
第一篇文章是简书的这个:
1 | https: //www.jianshu.com/p/b59d7dee15a6 |
是夏大佬写的vue-uploader组件:
1 | https: //www.cnblogs.com/xiahj/p/15950975.html |
然后晚上看完才发现,没有后台接口...
然后我找了下网上的资料也不多,然后参考的是这位来实现:
1 | https: //github.com/LuoLiangDSGA/spring-learning/tree/bc60e349b4c573fb624230d068f7bb66a9e64736/boot-uploader |
我的Springboot接口实现:
1、Chuck分块对象实体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | package cn.cloud9.server.struct.file.dto; import com.alibaba.fastjson.annotation.JSONField; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; import org.springframework.data.annotation.Transient; import org.springframework.web.multipart.MultipartFile; import java.io.Serializable; /** * @author OnCloud9 * @description * @project tt-server * @date 2022年11月18日 下午 11:02 */ @Data @EqualsAndHashCode (callSuper = true ) @TableName ( "chuck" ) public class Chuck extends ChuckFile implements Serializable { /** * 当前文件块,从1开始 */ @TableField ( "CHUNK_NUMBER" ) private Integer chunkNumber; /** * 分块大小 */ @TableField ( "CHUNK_SIZE" ) private Long chunkSize; /** * 当前分块大小 */ @TableField ( "CURRENT_CHUNK_SIZE" ) private Long currentChunkSize; /** * 相对路径 */ @TableField ( "RELATIVE_PATH" ) private String relativePath; /** * 总块数 */ @TableField ( "TOTAL_CHUNKS" ) private Integer totalChunks; /** * form表单的file对象,为了不让Fastjson序列化,注解设置false */ @Transient @JSONField (serialize = false ) @TableField (exist = false ) private MultipartFile file; } |
2、分块文件实体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | package cn.cloud9.server.struct.file.dto; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import org.springframework.data.annotation.Id; import java.io.Serializable; /** * @author OnCloud9 * @description * @project tt-server * @date 2022年11月18日 下午 11:00 */ @Data @TableName ( "chuck_file" ) public class ChuckFile implements Serializable { @TableId (value = "ID" , type = IdType.AUTO) protected Long id; @TableField ( "FILENAME" ) protected String filename; @TableField ( "IDENTIFIER" ) protected String identifier; @TableField ( "TOTAL_SIZE" ) protected Long totalSize; @TableField ( "TYPE" ) protected String type; @TableField ( "LOCATION" ) protected String location; } |
3、保存分块信息,判断分块是否上传了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | package cn.cloud9.server.struct.file.service.impl; import cn.cloud9.server.struct.file.dto.Chuck; import cn.cloud9.server.struct.file.mapper.ChuckMapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; /** * @author OnCloud9 * @description * @project tt-server * @date 2022年11月18日 下午 11:08 */ @Slf4j @Service public class ChuckService extends ServiceImpl<ChuckMapper, Chuck> { /** * 保存分块信息 * @param chuck */ @Transactional (propagation = Propagation.REQUIRED, rollbackFor = Exception. class ) public Chuck saveChuck(Chuck chuck) { final int insert = baseMapper.insert(chuck); return 1 == insert ? chuck : null ; } /** * 判断该分块是否上传过 * @param chuck * @return */ public boolean checkHasChucked(Chuck chuck) { final List<Chuck> chucks = lambdaQuery() .eq(Chuck::getIdentifier, chuck.getIdentifier()) .eq(Chuck::getChunkNumber, chuck.getChunkNumber()) .list(); return CollectionUtils.isNotEmpty(chucks); } } |
4、分块文件服务Bean就两个作用:合并分块,写入合并后的文件信息:
这里原封不动照抄作者的逻辑,就是排序作者写错了用倒序,害我检查半天哪没写对,文件一直合成是坏的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | package cn.cloud9.server.struct.file.service.impl; import cn.cloud9.server.struct.file.dto.ChuckFile; import cn.cloud9.server.struct.file.mapper.ChuckFileMapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; /** * @author OnCloud9 * @description * @project tt-server * @date 2022年11月18日 下午 11:08 */ @Slf4j @Service public class ChuckFileService extends ServiceImpl<ChuckFileMapper, ChuckFile> { public void mergeChuckFile(String storagePath, ChuckFile chuckFile) { try { final String finalFile = storagePath + File.separator + chuckFile.getFilename(); Files.createFile(Paths.get(finalFile)); Files.list(Paths.get(storagePath)) /* 1、过滤非合并文件 */ .filter(path -> path.getFileName().toString().contains( "-" )) /* 2、升序排序 0 - 1 - 2 。。。 */ .sorted((o1, o2) -> { String p1 = o1.getFileName().toString(); String p2 = o2.getFileName().toString(); int i1 = p1.lastIndexOf( "-" ) + 1 ; int i2 = p2.lastIndexOf( "-" ) + 1 ; return Integer.valueOf(p1.substring(i1)).compareTo(Integer.valueOf(p2.substring(i2))); }) /* 3、合并文件 */ .forEach(path -> { try { /* 以追加的形式写入文件 */ Files.write(Paths.get(finalFile), Files.readAllBytes(path), StandardOpenOption.APPEND); /* 合并后删除该块 */ Files.delete(path); } catch (IOException e) { e.printStackTrace(); } }); } catch (Exception e) { log.error( "{}合并写入异常:{}" , chuckFile.getFilename(), e.getMessage()); } } public void addChuckFile(ChuckFile chuckFile) { baseMapper.insert(chuckFile); } } |
5、Controller的三个接口
这里Chuck接口的逻辑就没封装到服务了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | package cn.cloud9.server.struct.file.controller; import cn.cloud9.server.struct.controller.BaseController; import cn.cloud9.server.struct.file.FileProperty; import cn.cloud9.server.struct.file.FileUtil; import cn.cloud9.server.struct.file.dto.Chuck; import cn.cloud9.server.struct.file.dto.ChuckFile; import cn.cloud9.server.struct.file.service.impl.ChuckFileService; import cn.cloud9.server.struct.file.service.impl.ChuckService; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; import java.io.File; /** * @author OnCloud9 * @description 大型文件上传 * @project tt-server * @date 2022年11月18日 下午 10:58 */ @Slf4j @RestController @RequestMapping ( "/chuck-file" ) public class ChuckFileController extends BaseController { private static final String CHUCK_DIR = "chuck" ; @Resource private FileProperty fileProperty; @Resource private ChuckFileService chuckFileService; @Resource private ChuckService chuckService; /** * 上传分块文件 * @param chuck 分块文件 * @return 响应结果 */ @PostMapping (value = "/chuck" , consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Object uploadFileChuck( @ModelAttribute Chuck chuck) { final MultipartFile mf = chuck.getFile(); log.info( "收到文件块 文件块名:{} 块编号:{}" , mf.getOriginalFilename(), chuck.getChunkNumber()); try { /* 1、准备存储位置 根目录 + 分块目录 + 无类型文件名的目录 */ final String pureName = FileUtil.getFileNameWithoutTypeSuffix(mf.getOriginalFilename()); String storagePath = fileProperty.getBaseDirectory() + File.separator + CHUCK_DIR + File.separator + pureName; final File storePath = new File(storagePath); if (!storePath.exists()) storePath.mkdirs(); /* 2、准备分块文件的规范名称 [无类型文件名 -分块号] */ String chuckFilename = pureName + "-" + chuck.getChunkNumber(); /* 3、向存储位置写入文件 */ final File targetFile = new File(storePath, chuckFilename); mf.transferTo(targetFile); log.debug( "文件 {} 写入成功, uuid:{}" , chuck.getFilename(), chuck.getIdentifier()); chuck = chuckService.saveChuck(chuck); } catch (Exception e) { log.error( "文件上传异常:{}, {}" , chuck.getFilename(), e.getMessage()); return e.getMessage(); } return chuck; } /** * 检查该分块文件是否上传了 * @param chuck 分块文件 * @return 304 | 分块文件 has-chucked */ @GetMapping ( "/chuck" ) public Object checkHasChucked( @ModelAttribute Chuck chuck) { final boolean hasChucked = chuckService.checkHasChucked(chuck); if (hasChucked) response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); return chuck; } /** * 文件合并 * @param chuckFile 分块文件合并信息 */ @PostMapping ( "/merge" ) public void mergeChuckFile( @ModelAttribute ChuckFile chuckFile) { /* 获取存储位置 */ final String pureName = FileUtil.getFileNameWithoutTypeSuffix(chuckFile.getFilename()); String storagePath = fileProperty.getBaseDirectory() + File.separator + CHUCK_DIR + File.separator + pureName; chuckFileService.mergeChuckFile(storagePath, chuckFile); chuckFile.setLocation(storagePath); chuckFileService.addChuckFile(chuckFile); } } |
6、根目录的配置bean:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | package cn.cloud9.server.struct.file; import lombok.Data; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; /** * @author OnCloud9 * @description * @project tt-server * @date 2022年11月15日 下午 11:34 */ @Data @Configuration @ConfigurationProperties (prefix = "file" ) public class FileProperty { /* 文件基础目录位置 */ private String baseDirectory; } |
配置文件写法:
1 2 | file: base-directory: F:\\tt-file |
文件工具类代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | package cn.cloud9.server.struct.file; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.tika.Tika; import sun.misc.BASE64Encoder; import javax.servlet.http.HttpServletResponse; import java.io.File; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.Month; /** * @author OnCloud9 * @description * @project tt-server * @date 2022年11月15日 下午 11:27 */ @Slf4j public class FileUtil { /** * 根据当前年月创建目录 * @return yyyy_mm */ public static String createDirPathByYearMonth() { final LocalDateTime now = LocalDateTime.now(); final int year = now.getYear(); final Month month = now.getMonth(); final int monthValue = month.getValue(); return year + "_" + monthValue; } /** * 获取文件类型后缀 * @param filename 文件名称 * @return 文件类型 */ public static String getFileTypeSuffix(String filename) { final int pointIndex = filename.lastIndexOf( "." ); final String suffix = filename.substring(pointIndex + 1 ); return StringUtils.isBlank(suffix) ? "" : suffix; } /** * 获取不带类型后缀的文件名 * @param filename 文件名称 * @return 不带类型后缀的文件名 */ public static String getFileNameWithoutTypeSuffix(String filename) { final int pointIndex = filename.lastIndexOf( "." ); final String pureName = filename.substring( 0 , pointIndex); return StringUtils.isBlank(pureName) ? "" : pureName; } /** * 设置文件下载的响应信息 * @param response 响应对象 * @param file 文件 * @param originFilename 源文件名 */ public static void setDownloadResponseInfo(HttpServletResponse response, File file, String originFilename) { try { response.setCharacterEncoding( "UTF-8" ); /* 文件名 */ final String fileName = StringUtils.isBlank(originFilename) ? file.getName() : originFilename; /* 获取要下载的文件类型, 设置文件类型声明 */ String mimeType = new Tika().detect(file); response.setContentType(mimeType); /* 设置响应头,告诉该文件用于下载而非展示 attachment;filename 类型:附件,文件名称 */ final String header = response.getHeader( "User-Agent" ); if (StringUtils.isNotBlank(header) && header.contains( "Firefox" )){ /* 对火狐浏览器单独设置 */ response.setHeader( "Content-Disposition" , "attachment;filename==?UTF-8?B?" + new BASE64Encoder().encode(fileName.getBytes(StandardCharsets.UTF_8))+ "?=" ); } else response.setHeader( "Content-Disposition" , "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8" )); } catch (Exception e) { log.info( "设置响应信息异常:{}" , e.getMessage()); } } } |
获取MimeType类型需要这个Tika组件支持:
1 2 3 4 5 6 | <!-- https://mvnrepository.com/artifact/org.apache.tika/tika-core --> < dependency > < groupId >org.apache.tika</ groupId > < artifactId >tika-core</ artifactId > < version >2.6.0</ version > </ dependency > |
Web接口组件:
直接克隆作者的项目
1、把接口改成你写的接口地址 一个chuck 一个merge (chuck会被组件用Post和Get区分请求好像,post就传分块,get检查分块上传了没有)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | <template> <uploader :options= "options" :file-status-text= "statusText" class = "uploader-example" ref= "uploader" @file-complete= "fileComplete" @complete= "complete" ></uploader> </template> <script> import axios from 'axios' import qs from 'qs' export default { data () { return { options: { target: 'http://localhost:8080/chuck-file/chuck' , // '//jsonplaceholder.typicode.com/posts/' , testChunks: false }, attrs: { accept: 'image/*' }, statusText: { success: '成功了' , error: '出错了' , uploading: '上传中' , paused: '暂停中' , waiting: '等待中' } } }, methods: { complete () { console.log( 'complete' , arguments) }, fileComplete () { console.log( 'file complete' , arguments) const file = arguments[0].file axios.post( 'http://localhost:8080/chuck-file/merge' , qs.stringify({ filename: file.name, identifier: arguments[0].uniqueIdentifier, totalSize: file.size, type: file.type })).then( function (response) { console.log(response) }). catch ( function (error) { console.log(error) }) } }, mounted () { this .$nextTick(() => { window.uploader = this .$refs.uploader.uploader }) } } </script> <style> .uploader-example { width: 880px; padding: 15px; margin: 40px auto 0; font-size: 12px; box-shadow: 0 0 10px rgba(0, 0, 0, .4); } .uploader-example .uploader-btn { margin-right: 4px; } .uploader-example .uploader-list { max-height: 440px; overflow: auto; overflow-x: hidden; overflow-y: auto; } </style> |
2、区分后台的端口:
在config / index.js 里面重新配置web的端口:
8080 改成 8081
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | // see http://vuejs-templates.github.io/webpack for documentation. var path = require( 'path' ) module.exports = { build: { env: require( './prod.env' ), index: path.resolve(__dirname, '../dist/index.html' ), assetsRoot: path.resolve(__dirname, '../dist' ), assetsSubDirectory: '' , assetsPublicPath: './' , productionSourceMap: true , // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin productionGzip: false , productionGzipExtensions: [ 'js' , 'css' ], // Run the build command with an extra argument to // View the bundle analyzer report after build finishes: // `npm run build --report` // Set to `true` or `false` to always turn it on or off bundleAnalyzerReport: process.env.npm_config_report }, dev: { env: require( './dev.env' ), port: 8081, autoOpenBrowser: true , assetsSubDirectory: '' , assetsPublicPath: '/' , proxyTable: {}, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README // (https://github.com/webpack/css-loader#sourcemaps) // In our experience, they generally work as expected, // just be aware of this issue when enabling this option. cssSourceMap: false } } |
这里可以看到就两个依赖:
1 2 3 4 | "dependencies" : { "axios" : "^1.1.3" , "simple-uploader.js" : "^0.5.6" }, |
分块文件表记录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | mysql> SELECT * FROM chuck_file; + ----+------------------------------------------+----------------------------------------------+------------+--------------------+--------------------------------------------------------+ | ID | FILENAME | IDENTIFIER | TOTAL_SIZE | TYPE | LOCATION | + ----+------------------------------------------+----------------------------------------------+------------+--------------------+--------------------------------------------------------+ | 1 | charles中文破解版.rar | 135963354-charlesrar | 135963354 | | F:\\tt-file\chuck\charles中文破解版 | | 2 | charles中文破解版.rar | 135963354-charlesrar | 135963354 | | F:\\tt-file\chuck\charles中文破解版 | | 3 | charles中文破解版.rar | 135963354-charlesrar | 135963354 | | F:\\tt-file\chuck\charles中文破解版 | | 4 | charles中文破解版.rar | 135963354-charlesrar | 135963354 | | F:\\tt-file\chuck\charles中文破解版 | | 5 | charles中文破解版.rar | 135963354-charlesrar | 135963354 | | F:\\tt-file\chuck\charles中文破解版 | | 6 | charles中文破解版.rar | 135963354-charlesrar | 135963354 | | F:\\tt-file\chuck\charles中文破解版 | | 7 | mysql-8.0.29-1.el8.x86_64.rpm-bundle.tar | 793722880-mysql-8029-1el8x86_64rpm-bundletar | 793722880 | application/x-tar | F:\\tt-file\chuck\mysql-8.0.29-1.el8.x86_64.rpm-bundle | | 8 | phoenix-hbase-2.4-5.1.2-bin.tar.gz | 207440936-phoenix-hbase-24-512-bintargz | 207440936 | application/x-gzip | F:\\tt-file\chuck\phoenix-hbase-2.4-5.1.2-bin.tar | + ----+------------------------------------------+----------------------------------------------+------------+--------------------+--------------------------------------------------------+ 8 rows in set (0.02 sec) |
表结构:
1 2 3 4 5 6 7 8 9 | CREATE TABLE `chuck_file` ( `ID` int NOT NULL AUTO_INCREMENT, `FILENAME` varchar (128) COLLATE utf8mb4_general_ci DEFAULT NULL , `IDENTIFIER` varchar (128) COLLATE utf8mb4_general_ci DEFAULT NULL , `TOTAL_SIZE` bigint DEFAULT NULL , `TYPE` varchar (24) COLLATE utf8mb4_general_ci DEFAULT NULL , `LOCATION` varchar (128) COLLATE utf8mb4_general_ci DEFAULT NULL , PRIMARY KEY (`ID`) ) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE =utf8mb4_general_ci; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | CREATE TABLE `chuck` ( `ID` int NOT NULL AUTO_INCREMENT, `FILENAME` varchar (128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL , `IDENTIFIER` varchar (128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL , `TOTAL_SIZE` bigint DEFAULT NULL , `TYPE` varchar (24) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL , `LOCATION` varchar (128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL , `CHUNK_NUMBER` int DEFAULT NULL , `CHUNK_SIZE` int DEFAULT NULL , `CURRENT_CHUNK_SIZE` int DEFAULT NULL , `RELATIVE_PATH` varchar (128) COLLATE utf8mb4_general_ci DEFAULT NULL , `TOTAL_CHUNKS` int DEFAULT NULL , PRIMARY KEY (`ID`) ) ENGINE=InnoDB AUTO_INCREMENT=1734 DEFAULT CHARSET=utf8mb4 COLLATE =utf8mb4_general_ci; |
最后几点事项:
1、分块文件的命名规则最好是严谨点,影响合并操作,一般中文无特殊符号文件名不会有影响
2、不建议立即删除分块,可以先对合并后的文件进行校验,如果不存在或者大小不一致,可以继续拿分块重新合并
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术