话不多说,拿到 Java 项目,跑起来。这是前后端分离的项目,前端比较简单,直接打开 html 文件。
仓库地址:https://gitee.com/hicey/file-manager
提供:分片上传、断点续传、秒传功能 另外的下载、删除功能
开发环境:JDK8,SpringBoot2.x,MySQL5.5,web-uploader
秒传
上传完成后再次选择这个文件就会启动秒传功能,在百度网盘等应用里可以看到类似的功能
基本判断逻辑是记录文件的 md5,通过文件 md5 来判断文件是否已经存在,如果已存在则直接使用。
代码逻辑可以有多种,比如
1、使用 redis 或者 mysql 记录 文件记录,根据唯一的值 md5,如果文件在,则说明文件已经上传,直接增加一条文件记录,文件地址指向已存在的那个文件地址。前端可以选择对应的库,比如说 spark-md5.js,快速计算文件的 md5。
2、根据文件名和地址,找到磁盘中是否有一样的文件,如果有 conf 配置文件,也需要一起判断。
那什么是 md5 呢?一个文件只有对应唯一的 md5 吗?
MD5 即 Message-Digest Algorithm 5(信息-摘要算法 5),用于确保信息传输完整一致。是计算机广泛使用的杂凑算法之一(又译摘要算法、哈希算法),主流编程语言普遍已有 MD5 实现。
MD5 算法具有以下特点:
1、压缩性:任意长度的数据,算出的 MD5 值长度都是固定的,是一个 32 位长度的 16 进制字符串。
2、容易计算:从原数据计算出 MD5 值很容易。
3、抗修改性:对原数据进行任何改动,哪怕只修改 1 个字节,所得到的 MD5 值都有很大区别。
4、强抗碰撞:已知原数据和其 MD5 值,想找到一个具有相同 MD5 值的数据(即伪造数据)是非常困难的。
md5 是一种常见不可逆加密算法,使用简单,计算速度快,在很多场景下都会用到,比如:给用户上传的文件命名,数据库中保存的用户密码,下载文件后检验文件是否正确等。
图床、上传组件
前端不是特别熟悉,可以选用的组件库有:webuploader.js vue-simple-uploader
webuploader 链接地址:
http://fex.baidu.com/webuploader/getting-started.html
这里选用的是 webuploader.js,需要理解 init 函数和各种 event & callback,init 的时候需要给后端的断点续传接口,其他的都有默认值,同时,fileUpload 用的是 FormData,不需要字段校验,因为前后端库函数的不同,一定需要的字段是当前片段、总片段、md5 name 等等
这里有个疑问,前后端都使用库函数,那字段是怎么做到这么匹配的?因为前端不太熟,所以从后端开始改造,可以考虑自定义的 class FormData,再字段匹配到后端库函数的 UploadFileParam
普通文件上传
用 postman 来测试,就是在 body 中选择 form-data, 选择 file,然后上传文件,在 Java 后端的入参,得到是的 MultipartFile 接口,这个是 springframework 封装好的,在这里的实现类是 StandardMultipartFile,是在 org.springframework.web.multipart.support.StandardMultipartHttpServletRequest 类下的。
普通文件上传比较简单,就是在 http 的 header 头中去 Content-Disposition 字段,在后端可以看成是继承自 InputStream ,文件就是个输入流。
分片上传
所谓的分片,前端可以对文件进行分割,比如 前端利用 h5 的 File api 读文件进行分割(啊,前端不太熟悉了,好多都模糊了)
对于 Java 来说,后端处理就是使用了 RandomAccessFile 来收集分片上传的文件。
上传 test.mp4 文件
1、创建 test.mp4.conf 配置文件,RandomAccessFile,可读可写,用来存放所有的分片(chunks)、当前分片(chunk)、当前分片的 flag,每上传一份则更新 flag,表示已上传。
2、创建 test.mp4.temp 临时文件,可读可写,每次都在这个临时文件 append(追加分片的文件),前端 N 次调用 API 上传,一点一点累积,当最后一个分片完成后,重命名为 test.mp4 文件。
3、「可选」前端调用 add 接口,表示要插入一条文件记录到 mysql 中,其实也可以在 最后一个分片完成后,后端调用 callback 来完成。
最重要的是 RandomAccessFile,相比于 FileInputStream 固定使用 O_RDONLY,FileOutputStream 固定使用 O_WRONLY | O_CREAT,RandomAccessFile 提供了在 Java 中指定打开模式的能力,RandomAccessFile 相当于是 FileInputStream 与 FileOutputStream 的封装结合,即可以读也可以写,并且 RandomAccessFile 支持移动到文件指定位置处开始读或写。
代码语言:javascript复制import java.io.File;import java.io.IOException;import java.io.RandomAccessFile;
复制代码
都是 java.io 包里面的内容
入口 controller 代码
代码语言:javascript复制@PostMapping(value = "/breakpoint-upload", consumes = "multipart/*", headers = "content-type=multipart/form-data",produces = "application/json;charset=UTF-8")public RestResponse<Object> breakpointResumeUpload(UploadFileParam param, HttpServletRequest request) { return RestResponses. newResponseFromResult(fileService.breakpointResumeUpload(param, request));} consumes:指定处理请求的提交内容类型(Content-Type),例如application/json, text/html;produces: 指定返回的内容类型,仅当request请求头中的(Accept)类型中包含该指定类型才返回;
复制代码
在 RandomAccessFile 比较重要的方法有
setLength
设置文件长度,本案例中是设置 conf 的 chunks 的,用来记录所有分片
在 openjdk 的 方法是:Java_java_io_RandomAccessFile_setLength
代码语言:javascript复制JNIEXPORT void JNICALLJava_java_io_RandomAccessFile_setLength(JNIEnv *env, jobject this, jlong newLength){ FD fd; jlong cur; fd = getFD(env, this, raf_fd); if (fd == -1) { JNU_ThrowIOException(env, "Stream Closed"); return; } if ((cur = IO_Lseek(fd, 0L, SEEK_CUR)) == -1) goto fail; if (IO_SetLength(fd, newLength) == -1) goto fail; if (cur > newLength) { if (IO_Lseek(fd, 0L, SEEK_END) == -1) goto fail; } else { if (IO_Lseek(fd, cur, SEEK_SET) == -1) goto fail; } return; fail: JNU_ThrowIOExceptionWithLastError(env, "setLength failed");}
jinthandleSetLength(FD fd, jlong length){ int result; RESTARTABLE(ftruncate64(fd, length), result); return result;}
最终调用在操作系统层面,调用 ftruncate 来设置文件长度
复制代码
seek
设置文件指针的偏移量,从该文件开始计算,在此位置发生下一个读或写操作。偏移量可以设置在文件末尾之外。设置超出文件结尾的偏移量不会改变文件长度。只有在设置偏移量超过文件末尾后,文件长度才会被写入更改。
在 openjdk 中是 seek0 函数。
seek 方法:在 linux、unix 操作系统下就是调用系统的 lseek 函数。
若 lseek 成功执行,则返回新的偏移量,因此可用以下方法确定一个打开文件的当前偏移量:
write
在 openjdk 是 writeBytes(b, off, len)
这三个 write 方法实现与 FileOutputStream 相同,可以参考 JDK 源码阅读: FileOutputStream(下次再说。)
在 io 说明有输入输出,Java 运行在用户态,需要切换到内核态。这些都是需要走系统调用的。
用户态切换到内核态,都有以下一些方式。
系统调用
这是用户态进程主动要求切换到内核态的一种方式。
系统调用(System Call)是操作系统为在用户态运行的进程与硬件设备(如 CPU、磁盘、打印机等)进行交互提供的一组接口。
当用户进程需要发生系统调用时,CPU 通过软中断切换到内核态开始执行内核系统调用函数。用户态进程通过系统调用申请使 用操作系统提供的服务程序完成工作,比如 print()实际上就是执行了一个输出的系统调用。
异常
当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
外围设备的中断
当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会 暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到 内核态的切换。
比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
断点续传
上传过程如果中断,下次再上传该文件将只会上传剩下的分片
设计逻辑大概就是:
1、判断 conf 文件是否存在,如果存在再读取 conf,确认当前 chunk,并返回给前端。
2、前端直接从当前 chunk 开始上传文件,继续。
文件下载
代码语言:javascript复制String filename = (!EmptyUtils.basicIsEmpty(isSource) && isSource) ? fileDetails.getData().getFileName() : fileDetails.getData().getFilePath();inputStream = fileService.getFileInputStream(id);response.setHeader("Content-Disposition", "attachment;filename=" EncodingUtils.convertToFileName(request, filename));// 获取输出流outputStream = response.getOutputStream();IOUtils.copy(inputStream, outputStream);
复制代码
文件预览(图片、PDF 等)
代码语言:javascript复制controller
@GetMapping(value = "/view/{id}", produces = MediaType.IMAGE_PNG_VALUE)public ResponseEntity<byte[]> viewFilesImage(@PathVariable String id)
需要转化为 byte[]
public static byte[] inputStreamToByte(InputStream inputStream) throws IOException { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); byte[] buff = new byte[1024]; int rc = 0; while ((rc = inputStream.read(buff, 0, 1024)) > 0) { byteArrayOutputStream.write(buff, 0, rc); } return byteArrayOutputStream.toByteArray(); }
复制代码
总结
Java 的 IO 包,内容很多,不过万变不离其宗。
从 JDK 来看,就是对于操作系统文件的封装;
从应用层 Java 来看,就是处理输入输出、格式的转化,并且由于场景比较多,而划分了很多的类,以供开发者使用。其中适用于大文件上传的就是 RandomAccessFile,其他还有最普通的 File 等等。