文件分片上传

概述:
最近有个文件上传的优化需求,由于文件比较大,在网络比较差的时候,文件上传经常超时,故改为将文件分片上传。

说明:
1.由于文件服务很简单,没有任何引入任何类型的数据库,所以不支持断点续传(断点续传需要使用数据库等方式保存已经上传的分片索引,最后再合并文件并校验)
2.若是多实例部署,需要实时的文件同步工具,或者网关转发请求时可以根据客户端IP(的Hash)进行转发,保证文件在一次上传过程中,处理该条请求的服务器不会发生变化
3.未对文件的大小做严格的限制,目前仅依据前端传入的参数进行文件尺寸的前置校验,若文件大小超出限制且前端参数被篡改,需要在达到限制后才能发现
4.要实现对资源的访问,还需要配置nginx等反向代理工具,对资源进行映射

Nginx反向代理配置(监听80端口,如果使用https,则需要监听443端口):

        location / {
            alias   D://upload/file/;
        }

依赖:

        
            com.squareup.okhttp3
            okhttp
            3.11.0
            
                
                    com.google.android
                    android
                
            
        
        
            org.springframework.boot
            spring-boot-starter-validation
        

代码:

VelificationUtil

package com.example.study.util;

import lombok.extern.slf4j.Slf4j;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

@Slf4j
public class VelificationUtil {
    private static final String MD5 = "MD5";
    private static final String EMPTY = "";
    private static final byte[] HEX = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
    private static final byte LOW = 0xf;

    /**
     * 获取byte数组的md5
     *
     * @param bytes byte数组
     * @return md5
     */
    public static String getMD5Hex(byte[] bytes) {
        try {
            MessageDigest md5 = MessageDigest.getInstance(MD5);
            md5.update(bytes);
            byte[] digest = md5.digest();
            return byte2Hex(digest);
        } catch (NoSuchAlgorithmException e) {
            log.error("加密方法{}不支持:{}", MD5, e);
        }
        return EMPTY;
    }

    /**
     * byte数组转16进制字符串
     *
     * @param bytes byte数组
     * @return 16进制字符串
     */
    private static String byte2Hex(byte[] bytes) {
        int length = bytes.length;
        byte[] hexs = new byte[length << 1];
        for (int index = 0; index < length; index++) {
            hexs[index << 1] = HEX[bytes[index] >>> 4 & LOW];
            hexs[(index << 1) + 1] = HEX[bytes[index] & LOW];
        }
        return new String(hexs);
    }
}

FileSplitUploadStatusEnum

package com.example.study.enums;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 文件分片上传状态码
 * http状态码见:http://tools.jb51.net/table/http_status_code
 */
@Getter
@AllArgsConstructor
public enum FileSplitUploadStatusEnum {
    SUCCESS(200, 1000, "整个文件上传完成"),
    SPLIT_SUCCESS(100, 1001, "分片上传完成,请继续上传后续分片"),
    PARAM_VALID_FAIL(400, 1002, "文件参数校验失败"),
    FILE_INFO_CONFLICT(409, 1003, "当前上传人数较多,文件冲突,请修改文件名或稍后上传"),
    OVER_MAX_SIZE(424, 1004, "文件大小超出限制"),
    FILE_MODIFIED_SIZE(424, 1005, "文件被篡改-文件长度不符"),
    FILE_MODIFIED_MD5(424, 1006, "文件被篡改-md5校验失败"),
    SYSTEM_ERROR(500, 1007, "系统异常");

    /**
     * 通用的http状态码
     */
    Integer code;

    /**
     * 上传文件分片的内部状态码
     */
    Integer status;

    /**
     * 状态描述
     */
    String msg;
}

SplitFileInfoRequest

package com.example.study.vo;

import lombok.Data;

import javax.validation.constraints.*;

/**
 * 注:这里我使用的Spring Boot的版本为2.4.0,以下对参数的注解的message属性值,均不会返回给前端,若需要返回,请使用其他版本的javax.validation
 */
@Data
public class SplitFileInfoRequest {
    /**
     * 文件名称
     */
    @NotBlank(message = "无效的文件名,请重新上传")
    private String fileName;

    /**
     * 整个文件的md5
     */
    @NotBlank(message = "无效的文件md5,请重新上传")
    private String fileMd5;

    /**
     * 文件总大小
     * 由于需要读取整个文件到byte数组中以获取md5值,所以文件大小不能超过byte数组大小的限制(即Integer.MAX_VALUE)
     */
    @NotNull(message = "文件总大小不能为空")
    @Min(value = 1, message = "无效的文件总大小,请重新上传")
    @Max(value = Integer.MAX_VALUE, message = "文件总大小超出限制")
    private Integer totalSize;

    /**
     * 总分片数
     */
    @NotNull(message = "总分片数不能为空")
    @Min(value = 1, message = "无效的总分片数,请重新上传")
    private Integer splitTotal;

    /**
     * 当前为第几分片(从1开始)
     */
    @NotNull(message = "分片索引不能为空")
    @Min(value = 1, message = "无效的分片索引,请重新上传")
    private Integer splitIndex;

    /**
     * 当前分片大小
     */
    @NotNull(message = "当前分片大小不能为空")
    @Min(value = 1, message = "文件分片大小为1B-512KB")
    @Max(value = 524288, message = "文件分片大小为1B-512KB")
    private Integer splitSize;

    /**
     * 当前文件分片内容(最大512k)
     */
    @NotNull(message = "当前文件分片内容不能为空")
    @Size(min = 1, max = 524288, message = "文件分片大小限制为1B-512KB")
    private byte[] splitData;

    public boolean isFirstSplit() {
        return splitIndex.equals(1);
    }

    public boolean isLastSplit() {
        return splitIndex.equals(splitTotal);
    }
}

FileSplitUploadResponse

package com.example.study.vo;

import com.example.study.enums.FileSplitUploadStatusEnum;
import lombok.*;

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FileSplitUploadResponse {
    private static final String SPLIT_SUCCESS_MSG = "第%d分片上传完成,共%d分片。请继续上传后续分片";
    private Integer code;
    private Integer status;
    private String msg;
    private T data;

    public FileSplitUploadResponse(FileSplitUploadStatusEnum statusEnum) {
        this.code = statusEnum.getCode();
        this.status = statusEnum.getStatus();
        this.msg = statusEnum.getMsg();
    }

    public FileSplitUploadResponse(Integer splitIndex, Integer splitTotal) {
        FileSplitUploadStatusEnum statusEnum = FileSplitUploadStatusEnum.SPLIT_SUCCESS;
        this.code = statusEnum.getCode();
        this.status = statusEnum.getStatus();
        this.msg = String.format(SPLIT_SUCCESS_MSG, splitIndex, splitTotal);
    }
}

CustomConfig

package com.example.study.config;

import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Getter
@Configuration
public class CustomConfig {
    /**
     * 服务的url,用于拼接访问上传后的资源
     */
    @Value("${file.upload.server.url:http://localhost/}")
    private String fileUploadServerUrl;

    /**
     * 本地文件存放路径(Linux)
     */
    @Value("${file.upload.path.linux:/upload/file/}")
    private String fileUploadInLinux;

    /**
     * 本地文件存放路径(Windows)
     */
    @Value("${file.upload.path.windows:D:/upload/file/}")
    private String fileUploadInWindows;

    /**
     * 文件上传最大时间(毫秒)
     * 默认十分钟
     */
    @Value("${file.upload.max.time:600000}")
    private long maxUploadTime;

    /**
     * 文件上传大小限制
     * 默认100M
     */
    @Value("${file.upload.max.size:104857600}")
    private long maxFileSize;
}

SplitFileParam

package com.example.study.param;

import com.example.study.vo.SplitFileInfoRequest;
import lombok.Getter;
import lombok.Setter;

/**
 * 用于保存临时文件的参数信息
 */
@Getter
@Setter
public class SplitFileParam {
    /**
     * 临时文件的文件名
     */
    private String tmpFileName;

    /**
     * 临时文件的本地存储路径
     */
    private String tmpLocalPath;

    /**
     * 最终的文件名
     */
    private String fileName;

    /**
     * 文件本地存储路径
     */
    private String localPath;

    /**
     * 文件给外部访问的url
     */
    private String webUrl;

    /**
     * 前端传入的请求体(文件参数)
     */
    private SplitFileInfoRequest splitFileInfo;
}

 FileSplitUploadService

package com.example.study.service;

import com.example.study.vo.FileSplitUploadResponse;
import com.example.study.vo.SplitFileInfoRequest;

/**
 * 文件分片上传
 */
public interface FileSplitUploadService {
    /**
     * 文件分片上传
     *
     * @param splitFileInfo 文件信息
     * @return 上传结果
     */
    FileSplitUploadResponse splitUploadFile(SplitFileInfoRequest splitFileInfo);
}

 FileSplitUploadServiceImpl

package com.example.study.service.impl;

import com.example.study.config.CustomConfig;
import com.example.study.enums.FileSplitUploadStatusEnum;
import com.example.study.param.SplitFileParam;
import com.example.study.service.FileSplitUploadService;
import com.example.study.util.VelificationUtil;
import com.example.study.vo.FileSplitUploadResponse;
import com.example.study.vo.SplitFileInfoRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.UUID;

@Slf4j
@Service
public class FileSplitUploadServiceImpl implements FileSplitUploadService {
    private static final String PROPERTY_OS_NAME = "os.name";
    private static final String OS_LINUX = "linux";
    private static final String FILE_NAME_SEPARATOR = "-";
    private static final String TMP_FILE_NAME_SUFFIX = ".tmp";

    @Autowired
    private CustomConfig config;

    @Override
    public FileSplitUploadResponse splitUploadFile(SplitFileInfoRequest splitFileInfo) {
        FileSplitUploadResponse response = null;
        SplitFileParam param = getParam(splitFileInfo);
        try {
            // 只有第一个分片时需要校验目录、文件是否存在。若请求有问题,一开始就从非第一分片开始传,直接抛出异常即可
            if (splitFileInfo.isFirstSplit()) {
                File tmpFile = new File(param.getTmpLocalPath());
                // 建立文件目录
                if (!tmpFile.getParentFile().exists()) {
                    tmpFile.getParentFile().mkdirs();
                }
                if (tmpFile.exists() && isConfilct(param.getTmpLocalPath())) {
                    log.warn("文件冲突,文件名:{}", param.getTmpFileName());
                    response = new FileSplitUploadResponse<>(FileSplitUploadStatusEnum.FILE_INFO_CONFLICT);
                    return response;
                }
            }
            writeFile(param.getSplitFileInfo().getSplitData(), param.getTmpLocalPath());
            if (splitFileInfo.isLastSplit()) {
                int checkResult = checkAndRenameFile(param);
                switch (checkResult) {
                    case 0:
                        response = new FileSplitUploadResponse(FileSplitUploadStatusEnum.SUCCESS);
                        response.setData(param.getWebUrl());
                        break;
                    case 1:
                        response = new FileSplitUploadResponse(FileSplitUploadStatusEnum.FILE_MODIFIED_SIZE);
                        break;
                    case 2:
                        response = new FileSplitUploadResponse(FileSplitUploadStatusEnum.FILE_MODIFIED_MD5);
                        break;
                    default:
                        log.error("check result:{}", checkResult);
                }
            } else {
                log.info("保存文件{}的第{}分片成功", param.getTmpFileName(), param.getSplitFileInfo().getSplitIndex());
                response = new FileSplitUploadResponse(splitFileInfo.getSplitIndex(), splitFileInfo.getSplitTotal());
            }
        } catch (Exception e) {
            log.error("文件上传失败: ", e);
            deleteFile(new File(param.getTmpLocalPath()));
            if (e.getMessage().equals(FileSplitUploadStatusEnum.OVER_MAX_SIZE.getCode().toString())) {
                response = new FileSplitUploadResponse(FileSplitUploadStatusEnum.OVER_MAX_SIZE);
            } else {
                response = new FileSplitUploadResponse(FileSplitUploadStatusEnum.SYSTEM_ERROR);
            }
        }
        return response;
    }

    /**
     * 校验文件并重命名
     * 为了减少对象的频繁创建与销毁,把校验和重命名放在一起
     *
     * @param param 文件参数
     * @return 0-成功/1-文件大小不符/2-文件md5不符
     * @throws IOException IO异常
     */
    private int checkAndRenameFile(SplitFileParam param) throws IOException {
        File file = new File(param.getTmpLocalPath());
        // check lenth
        if (param.getSplitFileInfo().getTotalSize() - file.length() != 0) {
            log.warn("文件大小不符:{}", param.getTmpFileName());
            deleteFile(file);
            return 1;
        }
        // check md5
        byte[] fileBytes = new byte[(int) file.length()];
        InputStream is = new FileInputStream(file);
        is.read(fileBytes);
        String md5Hex = VelificationUtil.getMD5Hex(fileBytes);
        is.close();
        if (!md5Hex.equals(param.getSplitFileInfo().getFileMd5())) {
            log.warn("文件md5不符:{}", param.getTmpFileName());
            deleteFile(file);
            return 2;
        }
        log.info("文件校验成功:{}", param.getTmpFileName());
        if (file.renameTo(new File(param.getLocalPath()))) {
            log.info("文件重命名成功:{} -> ", param.getTmpFileName(), param.getFileName());
        }
        return 0;
    }

    /**
     * 写入分片的数据到文件中
     *
     * @param splitData   分片数据
     * @param tmpFilePath 临时文件路径
     * @throws IOException IO异常
     */
    private void writeFile(byte[] splitData, String tmpFilePath) throws IOException {
        RandomAccessFile randomAccessFile = new RandomAccessFile(tmpFilePath, "rw");
        if (randomAccessFile.length() + splitData.length > config.getMaxFileSize()) {
            randomAccessFile.close();
            throw new RuntimeException(FileSplitUploadStatusEnum.OVER_MAX_SIZE.getCode().toString());
        }
        randomAccessFile.seek(randomAccessFile.length());
        randomAccessFile.write(splitData);
        randomAccessFile.close();
    }

    /**
     * 获取保存文件需要的参数
     *
     * @param splitFileInfo 前端上传的文件信息
     * @return 保存文件需要的参数
     */
    private SplitFileParam getParam(SplitFileInfoRequest splitFileInfo) {
        SplitFileParam param = new SplitFileParam();
        // 文件保存路径(OS下的路径)
        String localPathPre = System.getProperty(PROPERTY_OS_NAME).equalsIgnoreCase(OS_LINUX)
                ? config.getFileUploadInLinux() : config.getFileUploadInWindows();
        // 临时文件名
        String tmpFileName = splitFileInfo.getFileMd5()
                .concat(FILE_NAME_SEPARATOR)
                .concat(splitFileInfo.getFileName())
                .concat(TMP_FILE_NAME_SUFFIX);
        param.setTmpFileName(tmpFileName);
        param.setTmpLocalPath(localPathPre.concat(tmpFileName));
        // 最终的文件名
        if (splitFileInfo.isLastSplit()) {
            String fileName = UUID.randomUUID().toString()
                    .concat(FILE_NAME_SEPARATOR)
                    .concat(splitFileInfo.getFileName());
            param.setFileName(fileName);
            param.setLocalPath(localPathPre.concat(fileName));
            String webUrl = config.getFileUploadServerUrl().concat(fileName);
            param.setWebUrl(webUrl);
        }
        param.setSplitFileInfo(splitFileInfo);
        return param;
    }

    /**
     * 文件是否冲突
     * 如果md5和文件名都相同,就会产生冲突
     * 根据文件最大上传时间来判断到底是冲突还是脏数据,如果是脏数据,删除脏数据
     *
     * @param filePath 文件路径
     * @return false-脏数据/true-文件冲突
     */
    private boolean isConfilct(String filePath) {
        Long fileCreateTimeInMills = getFileCreateTimeInMills(filePath);
        if (System.currentTimeMillis() - fileCreateTimeInMills > config.getMaxUploadTime()) {
            deleteFile(new File(filePath));
            return false;
        }
        return true;
    }

    /**
     * 删除文件
     *
     * @param file 待删除文件
     */
    private void deleteFile(File file) {
        boolean delete = file.delete();
        if (!delete) {
            log.warn("文件删除失败:{}", file.getPath());
        }
    }

    /**
     * 获取文件创建时间
     *
     * @param filePath 文件路径
     * @return 文件创建时间戳
     */
    private static Long getFileCreateTimeInMills(String filePath) {
        try {
            Path path = Paths.get(filePath);
            BasicFileAttributeView basicview = Files.getFileAttributeView(path, BasicFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
            BasicFileAttributes attr = basicview.readAttributes();
            return attr.creationTime().toMillis();
        } catch (Exception e) {
            log.warn("获取文件创建时间出错:filePath:{}, errorMsg:{}", filePath, e);
            File file = new File(filePath);
            return file.lastModified();
        }
    }
}

SpiltUploadController 

package com.example.study.controller;

import com.example.study.enums.FileSplitUploadStatusEnum;
import com.example.study.service.FileSplitUploadService;
import com.example.study.vo.FileSplitUploadResponse;
import com.example.study.vo.SplitFileInfoRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;


/**
 * @Author: java_t_t
 * @Description: 文件分片上传
 */
@Slf4j
@RestController
@RequestMapping("/file")
public class SpiltUploadController {
    @Autowired
    FileSplitUploadService fileSplitUploadService;

    /**
     * 上传文件到本地
     *
     * @param splitFileInfo 文件
     * @return
     */
    @PostMapping("/upload")
    public FileSplitUploadResponse uploadFileToLocal(@RequestBody @Valid SplitFileInfoRequest splitFileInfo) {
        if (splitFileInfo.getFileMd5().length() != 32
                || splitFileInfo.getSplitSize() - splitFileInfo.getSplitData().length != 0
                || splitFileInfo.getSplitIndex() > splitFileInfo.getSplitTotal()) {
            recordValidFailReason(splitFileInfo);
            FileSplitUploadResponse response = new FileSplitUploadResponse(FileSplitUploadStatusEnum.PARAM_VALID_FAIL);
            return response;
        }
        return fileSplitUploadService.splitUploadFile(splitFileInfo);
    }

    private void recordValidFailReason(SplitFileInfoRequest splitFileInfo) {
        log.info("文件信息校验失败:" +
                        "\nfileMd5:{}" +
                        "\ntotalSize:{}" +
                        "\nsplitTotal:{}" +
                        "\nsplitIndex:{}" +
                        "\nsplitSize:{}" +
                        "\nsplitData.size:{}",
                splitFileInfo.getFileMd5(),
                splitFileInfo.getTotalSize(),
                splitFileInfo.getSplitTotal(),
                splitFileInfo.getSplitIndex(),
                splitFileInfo.getSplitSize(),
                splitFileInfo.getSplitData().length);
    }
}

 

 

 

 

测试类-TestUpload

package com.example.study.common;

import com.alibaba.fastjson.JSON;
import com.example.study.util.VelificationUtil;
import com.example.study.vo.SplitFileInfoRequest;
import okhttp3.*;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.concurrent.TimeUnit;

public class TestUpload {
    private static OkHttpClient client;
    private static final String url = "http://localhost:8080/file/upload";
    private static MediaType mediaType = MediaType.parse("application/json; charset=utf-8");

    public static void main(String[] args) throws IOException {
        client = new OkHttpClient.Builder()
                .connectTimeout(20, TimeUnit.SECONDS)
                .readTimeout(20, TimeUnit.SECONDS)
                .build();


        String filePath = "E:\\360安全浏览器下载\\HUAWEICLOUDMeeting_Win.exe";
        File file = new File(filePath);
        RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
        byte[] splitData = new byte[524288];
        long totalSize = randomAccessFile.length();
        long splitTotal = totalSize / 524288;
        if (totalSize % 524288 != 0) {
            splitTotal++;
        }
        SplitFileInfoRequest body = new SplitFileInfoRequest();
        body.setFileName(file.getName());
        byte[] wholeData = new byte[(int) file.length()];
        new FileInputStream(file).read(wholeData);
        body.setFileMd5(VelificationUtil.getMD5Hex(wholeData));
        body.setTotalSize((int) file.length());
        body.setSplitTotal((int) splitTotal);

        int index = 1;
        int readSize;
        while ((readSize = randomAccessFile.read(splitData)) != -1) {
            body.setSplitSize(readSize);
            body.setSplitIndex(index);
            body.setSplitData(splitData);

            if (readSize != 524288) {
                byte[] lastSplitData = new byte[readSize];
                System.arraycopy(splitData, 0, lastSplitData, 0, readSize);
                body.setSplitData(lastSplitData);
            }
            Request request = new Request.Builder()
                    .url(url)
                    .post(RequestBody.create(mediaType, JSON.toJSONString(body)))
                    .build();
            Response execute = client.newCall(request).execute();
            System.out.println(execute.body().string());
            index++;
        }
        System.out.println("finished");
    }
}

 

 

测试结果:

文件分片上传_第1张图片

反向代理结果:

文件分片上传_第2张图片

 

你可能感兴趣的:(JAVA,Spring相关技术,实用功能,java)