Java文件读写(二)网络传输大型文件的原理

青旬

目录

如何传输大型文件

前置知识

MD5是什么

RandomAccessFile详解

关于FileInputStream、FileOutputStream与RandomAccessFile的选择

环境配置

单机环境下模拟分块传输文件操作

原理图

大致过程

完整代码

在Web程序下传输大型文件

断点续传的原理

完整代码


 

如何传输大型文件

        看了第一章的小伙伴们其实已经知道基础的文件读写功能该如何写了,无非就是读取文件数据流再进行传输。还没明白的小伙伴请移步去另一篇文章学习 http://t.csdn.cn/yuIH1

        但如果需要传输大型文件呢?我们应该如何传输?

        在传输大型文件时,我们可能会遇到以下问题:

  1. 内存不足:如果一次性将整个文件读入内存,当文件很大时,可能会导致内存不足的问题。

  2. 网络传输速度慢:如果文件较大,传输时间会很长,可能会导致网络超时或者传输失败。

  3. 服务器设置限制:有些服务器可能会限制上传或下载文件的大小,这可能会导致无法传输大型文件。

        因此,我们不能一口气将大文件传输到服务器上。但是我们可以将大文件分成若干个小文件再进行传输。

 

        在传输过程中,可能会出现网络中断、传输错误等问题,导致接收方接收到的块顺序不正确或者块数据不完整。为了解决这些问题,最好使用RandomAccessFile

前置知识

MD5是什么

        MD5是一种常用的哈希算法,可以将任意长度的数据“压缩”为一个128位(16字节)的哈希值(hash value),通常用于数据完整性验证、密码验证等方面。在计算文件的MD5时,会通过对文件的所有字节进行哈希计算,得出唯一的MD5值,可以用于校验文件的完整性。

        MD5是不可逆的,即无法从MD5值反推出原始数据,这也保证了其在安全领域中的重要性。

        对比两个文件的md5值可以用来验证两个文件是否相同。如果两个文件的md5值相同,则可以认为它们的内容是相同的。这种比较通常用于文件传输或备份时,确保传输或备份的文件与原始文件内容一致。在文件校验时,如果两个文件的md5值不同,则说明它们的内容不同,可能是由于文件传输过程中出错或者备份失败导致的。因此,对比两个文件的md5值可以确保文件传输或备份的完整性和正确性。

RandomAccessFile详解

        RandomAccessFile是Java中对文件进行随机读写操作的类,相比其他文件类,它有以下优点和缺点。

优点:

  1. 支持对文件的随机访问,可以读取和修改文件的任意位置,而不必从头开始读取整个文件,提高了读写效率。
  2. 支持对文件进行读写操作,可以读取和写入各种类型的数据,如整数、浮点数、字符、字符串等。
  3. 可以通过设置文件指针来实现对文件的截取和插入操作,比如在文件的中间插入新的内容,或者截取文件的一部分进行操作。

缺点:

  1. 无法直接操作字符流,需要使用字节流来读取和写入数据,使用时需要注意字符编码的转换。
  2. 无法使用缓存机制来提高读写效率,需要自行实现缓存功能。
  3. 不支持多线程访问同一文件,需要手动进行同步处理,否则可能会出现文件内容不一致的情况。
  4. 不支持对文件进行任意长度的扩展或缩小,只能通过修改已有内容或在文件末尾追加内容来实现对文件的修改。

 

        看过前一章的小伙伴肯定会疑问,FileInputStreamFileOutputStream同样能对二进制文件、文本文件、图片、音视频等资源进行操作,为什么建议使用RandomAccessFile呢?

关于FileInputStreamFileOutputStream与RandomAccessFile的选择

        先来讲讲它们的区别:

  1. FileInputStream和FileOutputStream:这两个类是Java IO库中最基本的文件读写操作类。FileInputStream用于从文件中读取数据,而FileOutputStream用于将数据写入文件中。它们都是一次只能读取或写入一个字节或一个字节数组,因此不能用于读写大文件。而且,它们不能在文件中查找指定的位置。

  2. RandomAccessFile:RandomAccessFile也是Java IO库中的一个类,它提供了在文件中任意位置读写数据的功能。与FileInputStream和FileOutputStream不同的是,RandomAccessFile可以指定文件中的任意位置进行读写,并且支持对文件的随机访问,即可以向前或向后读写文件。因此,它适合于读写大文件或需要随机访问文件的情况。

        因此,如果需要对小文件进行读写操作,可以使用FileInputStream和FileOutputStream,而如果需要对大文件进行读写操作或需要随机访问文件,则应该使用RandomAccessFile。

环境配置

因为计算文件的Md5值需要使用到DigestUtils工具类,所以需要在pom. xml文件 中导入以下依赖

  1. <!--DigestUtils工具包的依赖-->
  2. <dependency>
  3. <groupId>commons-codec</groupId>
  4. <artifactId>commons-codec</artifactId>
  5. <version>1.15</version>
  6. </dependency>

单机环境下模拟分块传输文件操作

原理图

6ad277faee1844f39df5c32faeacba80.png

大致过程

        在上传分块文件过程中,我们需要将分块文件暂存在chunk文件夹中,所以得临时创建一个chunk文件夹,上传完毕之后便会将chunk里的所有分块文件合并成一个文件。检测完文件完整性之后便会将chunk文件夹删除。

        验证文件完整性需要先计算好原文件的Md5值再与新文件的Md5值来对比判断。有的小伙伴会提问:“为什么需要用原文件来计算Md5值,我直接用流来计算好不就可以了吗?” 因为文件在传输过程中可能会出现失真、丢包等现象,文件流有可能会与原文件不一致。

        文件存放路径为 文件的Md5值的第一位+Md5值的第二位+Md5值,例如:"C:\Users\uploadfile\9\8\9856ac885f252fd8839167dec95e484b"。这可以提高程序搜索文件的效率,这种优化方式其实类似HashMap的散列表。

        这里将 文件分块+上传分块 和 排序、合并分块+校验文件完整性 功能分为两部分代码

  1. void chunkFile(File file, String sourcefileMd5) throws IOException; 根据文件的Md5值(sourcefileMd5)在指定路径下创建chunk文件夹,将文件(file)分块保存至chunk文件夹中
  2. void mergeFile(String fileName, String sourceFileMd5) throws IOException;  根据文件的Md5值(sourcefileMd5)将指定路径下的分块文件合并至上一级目录中,再用原文件名(fileName)为新文件命名

完整代码

  1. import org.apache.commons.codec.digest.DigestUtils;
  2. import java.io.File;
  3. import java.io.FileInputStream;
  4. import java.io.IOException;
  5. import java.io.RandomAccessFile;
  6. import java.util.Arrays;
  7. import java.util.Collections;
  8. import java.util.Comparator;
  9. import java.util.List;
  10. public class FileUtils {
  11. /**
  12. * 文件分块传输
  13. * @param file 文件
  14. * @param sourceFileMd5 原文件的Md5值
  15. */
  16. public static void uploadBigFile(File file, String sourceFileMd5) {
  17. try {
  18. // 将文件分块传输
  19. chunkFile(file, sourceFileMd5);
  20. // 合并分块文件
  21. mergeFile(file.getName(), sourceFileMd5);
  22. } catch (IOException e) {
  23. e.printStackTrace();
  24. }
  25. }
  26. /**
  27. * 将文件分块传输
  28. * @param file 文件
  29. * @param sourcefileMd5 原文件的Md5值
  30. * @throws IOException
  31. */
  32. private static void chunkFile(File file, String sourcefileMd5) throws IOException {
  33. File sourceFile = file;
  34. String chunkFilePath = getChunkFilePath(sourcefileMd5);
  35. // 判断chunk文件夹是否存在,不存在则创建
  36. File chunkFolder = new File(chunkFilePath);
  37. if (!chunkFolder.exists()) {
  38. chunkFolder.mkdirs();
  39. }
  40. // 分块大小
  41. int chunkSize = 1024 * 1024 * 5;
  42. // 分块数量
  43. int chunkNum = (int) Math.ceil(sourceFile.length()*1.0 / chunkSize);
  44. // 使用RandomAccessFile访问文件
  45. RandomAccessFile raf_r = new RandomAccessFile(sourceFile, "r");
  46. // 缓存区大小
  47. byte[] bytes = new byte[1024];
  48. for (int i = 0; i < chunkNum; i++) {
  49. // 创建分块文件
  50. File chunkFile = new File(chunkFilePath + i);
  51. if(chunkFile.exists()){
  52. chunkFile.delete();
  53. }
  54. chunkFile.createNewFile();
  55. RandomAccessFile raf_rw = new RandomAccessFile(chunkFile, "rw");
  56. int len = -1;
  57. while((len = raf_r.read(bytes)) != -1) {
  58. // 每次从缓存区中取出数据存入文件中
  59. raf_rw.write(bytes, 0, len);
  60. if(chunkFile.length() >= chunkSize) {
  61. break;
  62. }
  63. }
  64. raf_rw.close();
  65. }
  66. raf_r.close();
  67. }
  68. /**
  69. * 文件合并
  70. * @param fileName 文件名称
  71. * @param sourceFileMd5 原文件的Md5值
  72. * @throws IOException
  73. */
  74. private static void mergeFile(String fileName, String sourceFileMd5) throws IOException {
  75. // 分块文件目录
  76. File chunkFolder = new File(getChunkFilePath(sourceFileMd5));
  77. // 合并文件
  78. File mergeFile = new File(getFilePath(sourceFileMd5) + fileName);
  79. if (mergeFile.exists()) {
  80. mergeFile.delete();
  81. }
  82. // 创建新的合并文件
  83. mergeFile.createNewFile();
  84. // 用于写文件
  85. RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
  86. // 指针指向文件顶端
  87. raf_write.seek(0);
  88. // 缓冲区
  89. byte[] b = new byte[1024];
  90. // 分块列表
  91. File[] fileArray = chunkFolder.listFiles();
  92. // 转成集合,便于排序
  93. List<File> fileList = Arrays.asList(fileArray);
  94. // 从小到大排序
  95. Collections.sort(fileList, new Comparator<File>() {
  96. @Override
  97. public int compare(File o1, File o2) {
  98. return Integer.parseInt(o1.getName()) - Integer.parseInt(o2.getName());
  99. }
  100. });
  101. // 合并文件
  102. for (File chunkFile : fileList) {
  103. RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "rw");
  104. int len = -1;
  105. while ((len = raf_read.read(b)) != -1) {
  106. raf_write.write(b, 0, len);
  107. }
  108. raf_read.close();
  109. }
  110. raf_write.close();
  111. // 校验文件
  112. try (FileInputStream mergeFileStream = new FileInputStream(mergeFile)) {
  113. // 取出合并文件的md5进行比较
  114. String mergeFileMd5 = DigestUtils.md5Hex(mergeFileStream);
  115. if (sourceFileMd5.equals(mergeFileMd5)) {
  116. System.out.println("合并文件成功");
  117. // 删除chunk文件夹
  118. deleteChunkFile(sourceFileMd5);
  119. } else {
  120. System.out.println("合并文件失败");
  121. }
  122. }
  123. }
  124. /**
  125. * 删除chunk文件
  126. * @param fileMd5 原文件的Md5值
  127. */
  128. private static void deleteChunkFile(String fileMd5) {
  129. String chunkFilePath = getChunkFilePath(fileMd5);
  130. File directory = new File(chunkFilePath);
  131. if (directory.isDirectory()) {
  132. File[] files = directory.listFiles();
  133. // 遍历删除chunk里所有的分块文件
  134. for(File file : files) {
  135. file.delete();
  136. }
  137. }
  138. // 删除chunk文件夹
  139. directory.delete();
  140. }
  141. /**
  142. * 根据Md5计算文件存放路径
  143. * @param fileMd5 文件的Md5值
  144. * @return 文件存放路径,例如: "C:\Users\uploadfile\9\8\9856ac885f252fd8839167dec95e484b\"
  145. */
  146. private static String getFilePath(String fileMd5) {
  147. return "C:\\Users\\uploadfile\\" + fileMd5.substring(0, 1) + "\\" + fileMd5.substring(1, 2) + "\\" + fileMd5 + "\\";
  148. }
  149. /**
  150. * 根据Md5计算分块文件存放路径
  151. * @param fileMd5 文件的Md5值
  152. * @return 文件存放路径,例如: "C:\Users\uploadfile\9\8\9856ac885f252fd8839167dec95e484b\chunk\"
  153. */
  154. private static String getChunkFilePath(String fileMd5) {
  155. return getFilePath(fileMd5) + "chunk\\";
  156. }
  157. }

Web程序 下传输大型文件

        在这种环境下传输大文件的过程跟单机情况有一些区别。因为用户不可能自己编写代码计算文件的Md5值再将文件分块好给后端传输数据,所以这些活都得让前端人干。因此后端需要编写三个接口。

  1. boolean checkChunk(String fileMd5, Integer fileId) ; 根据文件的Md5值(fileMd5)和分块文件的编号(fileId)来检查服务器是否存在这个编号的分块文件
  2. boolean uploadFile(MultipartFile multipartFile, String fileMd5, Integer fileId); 根据文件的Md5值(fileMd5)和分块文件的编号(fileId)来找到文件存放路径,然后将分块文件(multipartFile)保存到路径中
  3. boolean mergeFile(String fileName, String sourceFileMd5) throws IOException; 根据原文件名称(fileName)为合并后的新文件命名,然后将前端计算好的原文件的Md5值(sourceFileMd5)与保存好的新文件的Md5值对比判断文件完整性

断点 续传的原理

  1. 前端计算好完整文件的Md5值,并将文件分块。
  2. 前端使用多线程一块一块上传,在这之前先调用checkChunk接口判断该分块是否存在,如果存在则不上传;如果不存在,调用uploadFile接口上传分块。
  3. 当所有分块文件上传完毕,则调用mergeFile接口通知服务器合并所有分块文件。
  4. 合并完成之后服务器将前端计算好的Md5值与服务器合并后的文件的Md5值对比来判断文件完整性。

完整代码

  1. import com.example.spingboottestprogram.service.FileService;
  2. import org.apache.commons.codec.digest.DigestUtils;
  3. import org.springframework.stereotype.Service;
  4. import org.springframework.web.multipart.MultipartFile;
  5. import java.io.File;
  6. import java.io.FileInputStream;
  7. import java.io.IOException;
  8. import java.io.RandomAccessFile;
  9. import java.util.Arrays;
  10. import java.util.Collections;
  11. import java.util.Comparator;
  12. import java.util.List;
  13. @Service("fileService")
  14. public class FileServiceImpl implements FileService {
  15. /**
  16. * 检查分块文件是否存在
  17. * @param fileMd5 文件Md5值
  18. * @param fileId 分块文件编号
  19. * @return 块文件是否存在
  20. */
  21. public boolean checkChunk(String fileMd5, Integer fileId) {
  22. //定义分块文件存放路径
  23. String chunkFilePath = getChunkFilePath(fileMd5);
  24. //新建一个目录(文件夹)
  25. File dest = new File(chunkFilePath + fileId);
  26. // 判断文件是否存在
  27. if (!dest.exists()){
  28. // 不存在返回false
  29. return false;
  30. }
  31. // 存在则返回true
  32. return true;
  33. }
  34. /**
  35. * 下载分块文件
  36. * @param multipartFile 分块文件
  37. * @param fileMd5 文件Md5值
  38. * @param fileId 分块文件编号
  39. * @return 下载是否成功
  40. */
  41. public boolean uploadFile(MultipartFile multipartFile, String fileMd5, Integer fileId) {
  42. //判断文件是否为空 isEmpty
  43. if (multipartFile == null || multipartFile.isEmpty()){
  44. return false;
  45. }
  46. //定义分块文件存放路径
  47. String chunkFilePath = getChunkFilePath(fileMd5);
  48. //新建一个目录(文件夹)
  49. File dest = new File(chunkFilePath + fileId);
  50. if (!dest.getParentFile().canExecute()){
  51. dest.getParentFile().mkdirs();
  52. }
  53. try {
  54. //文件输出
  55. multipartFile.transferTo(dest);
  56. }
  57. catch (Exception e) {
  58. e.printStackTrace();
  59. //拷贝失败要有提示
  60. return false;
  61. }
  62. return true;
  63. }
  64. /**
  65. * 合并分块
  66. * @param fileName 新文件名字
  67. * @param sourceFileMd5 前端计算好的原文件的Md5值
  68. * @return 合并是否成功
  69. * @throws IOException
  70. */
  71. public boolean mergeFile(String fileName, String sourceFileMd5) throws IOException {
  72. // 分块文件目录
  73. File chunkFolder = new File(getChunkFilePath(sourceFileMd5));
  74. // 合并文件
  75. File mergeFile = new File(getFilePath(sourceFileMd5) + fileName);
  76. if (mergeFile.exists()) {
  77. mergeFile.delete();
  78. }
  79. // 创建新的合并文件
  80. mergeFile.createNewFile();
  81. // 用于写文件
  82. RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
  83. // 指针指向文件顶端
  84. raf_write.seek(0);
  85. // 缓冲区
  86. byte[] b = new byte[1024];
  87. // 分块列表
  88. File[] fileArray = chunkFolder.listFiles();
  89. // 转成集合,便于排序
  90. List<File> fileList = Arrays.asList(fileArray);
  91. // 从小到大排序
  92. Collections.sort(fileList, new Comparator<File>() {
  93. @Override
  94. public int compare(File o1, File o2) {
  95. return Integer.parseInt(o1.getName()) - Integer.parseInt(o2.getName());
  96. }
  97. });
  98. // 合并文件
  99. for (File chunkFile : fileList) {
  100. RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "rw");
  101. int len = -1;
  102. while ((len = raf_read.read(b)) != -1) {
  103. raf_write.write(b, 0, len);
  104. }
  105. raf_read.close();
  106. }
  107. raf_write.close();
  108. // 校验文件
  109. try (FileInputStream mergeFileStream = new FileInputStream(mergeFile)) {
  110. // 取出合并文件的md5进行比较
  111. String mergeFileMd5 = DigestUtils.md5Hex(mergeFileStream);
  112. if (sourceFileMd5.equals(mergeFileMd5)) {
  113. System.out.println("合并文件成功");
  114. deleteChunk(sourceFileMd5);
  115. return true;
  116. } else {
  117. System.out.println("合并文件失败");
  118. return false;
  119. }
  120. }
  121. }
  122. /**
  123. * 删除桶
  124. */
  125. private void deleteChunk(String fileMd5) {
  126. String chunkFilePath = getChunkFilePath(fileMd5);
  127. File directory = new File(chunkFilePath);
  128. if (directory.isDirectory()) {
  129. File[] files = directory.listFiles();
  130. // 遍历删除chunk里所有的分块文件
  131. for(File file : files) {
  132. file.delete();
  133. }
  134. }
  135. // 删除chunk文件夹
  136. directory.delete();
  137. }
  138. /**
  139. * 根据Md5计算文件存放路径
  140. * @param fileMd5 文件的Md5值
  141. * @return 文件存放路径,例如: "C:\Users\uploadfile\9\8\9856ac885f252fd8839167dec95e484b\"
  142. */
  143. private static String getFilePath(String fileMd5) {
  144. return "C:\\Users\\uploadfile\\" + fileMd5.substring(0, 1) + "\\" + fileMd5.substring(1, 2) + "\\" + fileMd5 + "\\";
  145. }
  146. /**
  147. * 根据Md5计算分块文件存放路径
  148. * @param fileMd5 文件的Md5值
  149. * @return 文件存放路径,例如: "C:\Users\uploadfile\9\8\9856ac885f252fd8839167dec95e484b\chunk\"
  150. */
  151. private static String getChunkFilePath(String fileMd5) {
  152. return getFilePath(fileMd5) + "chunk\\";
  153. }
  154. }

        看到这里,小伙伴们其实就已经发现这串代码跟上面的代码的实现原理差不多。

        如果想深入了解代码的实现可以前往 Git仓库 克隆代码学习  GitHub - QingXun123/fileio-2


Java文件读写(一)基础文件读写的详细操作:http://t.csdn.cn/yuIH1

Java文件读写(二)网络传输大型文件的原理:http://t.csdn.cn/xyJpo (所在位置)

后面我会持续更新有关文件读写的文章。

关注作者,获取更多精彩内容!