大文件上传系统开发指南(基于原生JS+SpringBoot)
项目概述
大家好,我是一个在浙江奋斗的Java程序员,最近接了个"刺激"的外包项目 - 开发一个支持20G大文件上传下载的系统,还要兼容IE9这种上古浏览器。客户要求使用原生JS实现(不能用jQuery等库),支持文件夹上传(保留层级结构),还要加密传输和断点续传。预算只有100元,但需要提供完整技术支持和源代码。
这活儿听着就刺激,但咱是谁?是打不死的小强!下面我就分享一下我的解决方案和部分代码实现。
技术选型
- 前端:Vue3 CLI + 原生JavaScript(兼容IE9)
- 后端:SpringBoot + Tomcat
- 数据库:MySQL(主要存用户信息和文件元数据)
- 文件存储:服务器本地文件系统
- 加密:SM4(国密)和AES
系统架构
浏览器(IE9+) ←HTTP→ SpringBoot后端 ←本地IO→ 文件存储 ↑ MySQL(元数据)前端实现(关键代码)
1. 文件夹上传组件(兼容IE9)
export default { data() { return { fileList: [], chunkSize: 5 * 1024 * 1024, // 5MB分片 concurrent: 3 // 并发上传数 } }, methods: { triggerFileInput() { document.getElementById('fileInput').click(); }, handleFileChange(e) { const files = e.target.files; if (!files.length) return; // 处理文件夹结构 const fileTree = this.buildFileTree(files); this.prepareUpload(fileTree); }, // 构建文件树结构(保留文件夹层级) buildFileTree(files) { const tree = {}; for (let i = 0; i < files.length; i++) { const file = files[i]; const path = file.webkitRelativePath || file.relativePath || file.name; const parts = path.split('/'); // 添加文件信息 const fileName = parts[parts.length - 1]; current[fileName] = { file: file, relativePath: path, size: file.size, chunks: Math.ceil(file.size / this.chunkSize), uploadedChunks: 0, progress: 0, status: '等待上传' }; } return tree; }, // 准备上传队列 prepareUpload(tree) { const flattenFiles = []; const traverse = (node, path = '') => { for (const key in node) { if (key === '__files__' || key === '__dirs__') continue; const newPath = path ? `${path}/${key}` : key; if (node[key].file) { // 是文件 flattenFiles.push({ ...node[key], relativePath: newPath }); } else { // 是目录,继续遍历 traverse(node[key].__dirs__, newPath); } } }; traverse(tree); this.fileList = flattenFiles; this.startUpload(); }, // 开始上传 startUpload() { const activeUploads = 0; const uploadNext = () => { if (activeUploads >= this.concurrent) return; const file = this.fileList.find(f => f.status === '等待上传'); if (!file) { if (this.fileList.every(f => f.status === '上传完成')) { this.$emit('upload-complete'); } return; } }; uploadNext(); }, // 分片上传文件 async uploadFile(fileEntry) { const file = fileEntry.file; const fileId = this.generateFileId(file); // 检查断点续传信息 const resumeInfo = this.getResumeInfo(fileId); let startChunk = resumeInfo ? resumeInfo.uploadedChunks : 0; for (let i = startChunk; i < fileEntry.chunks; i++) { const start = i * this.chunkSize; const end = Math.min(start + this.chunkSize, file.size); const chunk = file.slice(start, end); // 读取分片内容(兼容IE9) const chunkData = await this.readFileAsArrayBuffer(chunk); // 加密处理(这里简化,实际应该用Web Crypto API或polyfill) const encryptedData = this.encryptData(chunkData, 'AES'); // 实际应该用SM4 const formData = new FormData(); formData.append('fileId', fileId); formData.append('chunkIndex', i); formData.append('totalChunks', fileEntry.chunks); formData.append('relativePath', fileEntry.relativePath); formData.append('fileSize', file.size); formData.append('chunk', new Blob([encryptedData])); formData.append('fileName', file.name); } // 所有分片上传完成,通知服务器合并 await this.mergeFile(fileId, fileEntry.relativePath, file.size, file.name); fileEntry.status = '上传完成'; this.clearResumeInfo(fileId); }, // 以下是兼容IE9的工具方法 readFileAsArrayBuffer(file) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); reader.readAsArrayBuffer(file); }); }, encryptData(data, algorithm) { // 实际项目中应该使用Web Crypto API或polyfill // 这里简化处理,实际加密代码会更复杂 if (typeof data === 'string') { return btoa(data); // 简单base64模拟加密 } else { const bytes = new Uint8Array(data); let result = ''; for (let i = 0; i < bytes.length; i++) { result += String.fromCharCode(bytes[i]); } return btoa(result); } }, async uploadChunk(formData) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk', true); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error('上传失败')); } }; xhr.onerror = () => reject(new Error('网络错误')); xhr.send(formData); }); }, async mergeFile(fileId, relativePath, fileSize, fileName) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/merge', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error('合并失败')); } }; xhr.onerror = () => reject(new Error('网络错误')); xhr.send(JSON.stringify({ fileId, relativePath, fileSize, fileName })); }); }, // 断点续传相关方法(使用localStorage存储) generateFileId(file) { // 简单生成文件ID,实际应该更可靠 return `${file.name}-${file.size}-${file.lastModified}`; }, getResumeInfo(fileId) { const info = localStorage.getItem(`upload_resume_${fileId}`); return info ? JSON.parse(info) : null; }, saveResumeInfo(fileId, fileEntry) { localStorage.setItem(`upload_resume_${fileId}`, JSON.stringify({ uploadedChunks: fileEntry.uploadedChunks, relativePath: fileEntry.relativePath, fileSize: fileEntry.size, fileName: fileEntry.file.name })); }, clearResumeInfo(fileId) { localStorage.removeItem(`upload_resume_${fileId}`); }, formatSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } } }后端实现(SpringBoot关键代码)
1. 文件上传控制器
@RestController@RequestMapping("/api/upload")publicclassFileUploadController{privatestaticfinalStringUPLOAD_DIR="/var/bigfileupload/";// 实际应该从配置读取privatestaticfinalintCHUNK_SIZE=5*1024*1024;// 5MB@PostMapping("/chunk")publicResponseEntityuploadChunk(@RequestParam("fileId")StringfileId,@RequestParam("chunkIndex")intchunkIndex,@RequestParam("totalChunks")inttotalChunks,@RequestParam("relativePath")StringrelativePath,@RequestParam("fileSize")longfileSize,@RequestParam("fileName")StringfileName,@RequestParam("chunk")MultipartFilechunk)throwsIOException{// 创建临时目录StringtempDir=UPLOAD_DIR+"temp/"+fileId+"/";FiletempDirFile=newFile(tempDir);if(!tempDirFile.exists()){tempDirFile.mkdirs();}// 保存分片(实际应该先解密)StringchunkPath=tempDir+chunkIndex;chunk.transferTo(newFile(chunkPath));// 记录上传进度(实际应该用数据库)UploadProgressprogress=newUploadProgress();progress.setFileId(fileId);progress.setUploadedChunks(chunkIndex+1);progress.setTotalChunks(totalChunks);progress.setRelativePath(relativePath);progress.setFileSize(fileSize);progress.setFileName(fileName);// saveToDatabase(progress); // 实际应该存数据库returnResponseEntity.ok().body(Map.of("status","success","chunkIndex",chunkIndex,"fileId",fileId));}@PostMapping("/merge")publicResponseEntitymergeFile(@RequestBodyMergeRequestrequest)throwsIOException,NoSuchAlgorithmException{StringfileId=request.getFileId();StringtempDir=UPLOAD_DIR+"temp/"+fileId+"/";FiletempDirFile=newFile(tempDir);if(!tempDirFile.exists()){returnResponseEntity.badRequest().body(Map.of("error","临时文件不存在"));}// 创建目标目录结构StringrelativePath=request.getRelativePath();StringtargetPath=UPLOAD_DIR+relativePath;FiletargetFile=newFile(targetPath);}// 下载文件接口(非打包方式)@GetMapping("/download")publicResponseEntitydownloadFile(@RequestParamStringfilePath,HttpServletResponseresponse)throwsIOException{// 设置响应头HttpHeadersheaders=newHttpHeaders();headers.add(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=\""+file.getName()+"\"");headers.add(HttpHeaders.CONTENT_TYPE,Files.probeContentType(file.toPath()));headers.add(HttpHeaders.CONTENT_LENGTH,String.valueOf(file.length()));returnResponseEntity.ok().headers(headers).body(resource);}// 文件夹下载(递归下载)@GetMapping("/download/folder")publicvoiddownloadFolder(@RequestParamStringfolderPath,HttpServletResponseresponse)throwsIOException{// 实际实现应该递归遍历文件夹,生成zip或逐个文件下载// 这里简化处理,实际项目中需要更复杂的实现response.setContentType("application/zip");response.setHeader("Content-Disposition","attachment; filename=\""+newFile(folderPath).getName()+".zip\"");// 实际应该使用ZipOutputStream打包// 这里只是示例,实际不会这样实现try(ServletOutputStreamout=response.getOutputStream()){out.write("这不是真正的zip文件,实际应该递归打包文件夹".getBytes());}}}2. 数据库实体类
@Entity@Table(name="file_metadata")publicclassFileMetadata{@Id@GeneratedValue(strategy=GenerationType.IDENTITY)privateLongid;privateStringfileId;privateStringfilePath;privateStringfileName;privatelongfileSize;privateDateuploadTime;// getters and setters}兼容IE9的注意事项
- XMLHttpRequest:IE9不支持FormData,但支持XMLHttpRequest上传文件
- FileReader:IE10+才完全支持,IE9需要polyfill
- Blob:IE10+支持,IE9需要使用BlobBuilder
- 加密:Web Crypto API在IE9不可用,需要使用第三方库如CryptoJS
IE9兼容的加密方案示例
// 在index.html中引入CryptoJS//// 修改encryptData方法encryptData(data,algorithm){if(algorithm==='AES'){// 使用CryptoJS进行AES加密constkey=CryptoJS.enc.Utf8.parse('1234567890123456');// 实际应该从安全配置读取constiv=CryptoJS.enc.Utf8.parse('1234567890123456');letdataToEncrypt;if(typeofdata==='string'){dataToEncrypt=data;}else{// 如果是ArrayBuffer,转换为字符串constbytes=newUint8Array(data);letstr='';for(leti=0;i<bytes.length;i++){str+=String.fromCharCode(bytes[i]);}dataToEncrypt=str;}constencrypted=CryptoJS.AES.encrypt(dataToEncrypt,key,{iv:iv,mode:CryptoJS.mode.CBC,padding:CryptoJS.pad.Pkcs7});returnencrypted.toString();}// 其他算法...returndata;// 未加密}部署说明
前端构建:
npminstallnpmrun build将生成的
dist目录内容部署到Tomcat的webapps/ROOT目录后端配置:
- 修改
application.properties:server.port=8080 spring.servlet.multipart.max-file-size=21GB spring.servlet.multipart.max-request-size=21GB file.upload-dir=/var/bigfileupload/ - 确保上传目录存在且有写入权限:
mkdir-p /var/bigfileupload/tempchmod777/var/bigfileupload
- 修改
数据库初始化:
- 创建MySQL数据库并执行SQL脚本:
CREATETABLEfile_metadata(idBIGINTAUTO_INCREMENTPRIMARYKEY,file_idVARCHAR(255)NOTNULL,file_pathTEXTNOTNULL,file_nameVARCHAR(255)NOTNULL,file_sizeBIGINTNOTNULL,upload_timeDATETIMENOTNULL);
- 创建MySQL数据库并执行SQL脚本:
开发文档要点
系统功能:
- 大文件分片上传(支持20GB+)
- 文件夹上传(保留层级结构)
- 断点续传(基于localStorage)
- 加密传输和存储(AES/SM4)
- 兼容IE9+等主流浏览器
API文档:
POST /api/upload/chunk- 上传文件分片POST /api/upload/merge- 合并文件分片GET /api/upload/download- 下载单个文件GET /api/upload/download/folder- 下载整个文件夹
部署文档:
- 环境要求:JDK 8+, Node.js, MySQL, Tomcat 8+
- 配置文件说明
- 初始化脚本
总结
这个项目确实挑战不小,但通过合理的分片上传、断点续传机制和兼容性处理,我们还是能够实现客户的需求。关键点在于:
- 前端使用原生JS实现文件夹结构解析和上传队列管理
- 后端提供分片上传和合并接口
- 使用localStorage存储上传进度实现断点续传
- 通过CryptoJS等库实现兼容IE9的加密
由于预算有限,我省略了一些高级功能如:
- 完整的SM4加密实现(实际需要引入Bouncy Castle等库)
- 分布式存储支持
- 详细的权限控制
- 完善的错误处理和日志
如果需要更完整的实现,建议考虑:
- 使用WebUploader等成熟库(但需要处理兼容性问题)
- 增加预算购买商业组件
- 分阶段开发,先实现核心功能
最后,欢迎加入我们的QQ群374992201,一起交流技术,合作接单!群里经常有红包和技术分享,还有项目合作机会哦!
导入项目
导入到Eclipse:点南查看教程
导入到IDEA:点击查看教程
springboot统一配置:点击查看教程
工程
NOSQL
NOSQL示例不需要任何配置,可以直接访问测试
创建数据表
选择对应的数据表脚本,这里以SQL为例
修改数据库连接信息
访问页面进行测试
文件存储路径
up6/upload/年/月/日/guid/filename
效果预览
文件上传
文件刷新续传
支持离线保存文件进度,在关闭浏览器,刷新浏览器后进行不丢失,仍然能够继续上传
文件夹上传
支持上传文件夹并保留层级结构,同样支持进度信息离线保存,刷新页面,关闭页面,重启系统不丢失上传进度。
下载示例
点击下载完整示例