本地文件上传下载预览

这是新的尝试,打算把工作中的东西引入到公众号中,做技术记录和分享。

Java本地文件管理,主要是梳理 Java RaddomAccessFile 模块

话不多说,拿到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吗?

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支持移动到文件指定位置处开始读或写。

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
都是 java.io 包里面的内容

入口 controller 代码

@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

JNIEXPORT void JNICALL
Java_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");
}

jint
handleSetLength(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开始上传文件,继续。

文件下载

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等)

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等等。