[ファイルシステム] ファイル検証、マージ、再開を最適化するアップローダー (2)


(1) 質問:

  • ファイル名を使用して一時フォルダーを作成することを選択しますが、フォルダー名は一意である必要があり、一意の識別子の文字列を使用する方が明らかに優れた解決策であるため、これは明らかに最適な解決策ではありません。フラグメントの検証は、フラグメントの一意の検証を実現するために完全なファイルの md5+ 番号であり、完全なファイルの検証は md5 によって直接実現されます。
  • 今回の即時転送では完全なファイルの即時転送しか解決できず、同時実行数を 1 に設定することにより転送効率が大幅に低下します。(http の同時実行許可を使用して) 大量の同時送信を解決し、順序付けされていないフラグメントを再送信できるソリューションを提案する必要があります。ここで提案されているのは、バックエンドがファイルをチェックするときに、各フラグメントの番号を配列に入れてフロントエンドに返し、フロントエンドがデータを走査して、送信のために未送信のフラグメントを見つけることです。数秒での完全なファイル送信 + 高い同時実行 + 数秒での断片化された送信 + 断片化された再送信を実現します。

最適化 1: md5 検証

md5は情報の暗号化の一種で、内容がどんなに長くても一定の長さの文字列に加工することができます。ファイルのバイトが同じ、つまり内容が同じ場合、md5 値は同じでなければなりません。そして、異なる文字がある限り、md5 値は大きく異なる可能性があります (異なる必要があります)。

フラグメント検証やファイル検証を含むファイル検証を実装するには

  • 断片化の検証。一時フォルダーはファイルの md5 値に基づいて名前が付けられているため、断片化は対応するフォルダーに自動的に割り当てられ、断片化の検証が実現されます。
  • ファイル検証を完了します。ファイルをマージするときに、マージされたファイルのバックエンドで md5 エンコードを実行し、一時フォルダーのファイル名と比較します。それらが同じであれば、検証は成功です。それらが異なる場合は、ファイルが破損しているかエラーがある場合は、直接破棄し、すべてのファイルの再送信を要求するメッセージを返します。
  • シャードは必ずしも順序付けされているわけではないため、継続プロセスを最適化する必要があります。アップロードを続行する必要がある場合は、デフォルトで最新の番号が順番に直接返されるのではなく、既存のシャード番号の配列が返される必要があります。

1.1 フロントエンド

バインドフック関数

<uploader :options="this.options" 
          @file-added="this.fileAdded" 
          @file-success="this.fileSuccess">

    <uploader-unsupport></uploader-unsupport>
    <uploader-btn class="uploader-btn">
        点击上传
    </uploader-btn>
</uploader>

file は公式コンポーネントによって定義されたファイル オブジェクトです

  1. 公式はフック関数 fileAdded を提供しています。つまり、ファイルがアップロード キューに追加された後、ファイルの md5 を計算できます。
  2. md5 を計算するには、まずファイル ストリームが必要で、ファイルの内容を読み取り、md5 計算を実行します。
  3. まず file.pause() ファイルアップロードの処理を一時停止し、計算後にリクエストパラメータの識別子パラメータと file.resume() を上書きしてアップロードを続行します。
  4. 次の公式の計算では、ファイル IO とファイルに対する md5 値の計算を非同期で実行し、コールバック関数を使用して md5 値の結果をマージします。つまり、最初にファイルをスライスし (単一スライスのサイズは chunkSize パラメーターです)、それを非同期計算用のチャンクに分割します。非同期により、md5 計算の速度が向上します。これは、md5 値が非同期で計算されるため、非同期操作の結果を表すには Promise オブジェクトが必要になるためです。

以下のコードは公式に提供されています

GlobalUploader.vue - shady-xia/vue-uploader-solutions - GitHub1s

	methods: {
    
    
        fileSuccess() {
    
    
            //fileSuccess钩子是在所有分片上传成功的时候激活
            //我们请求一次检索当前文件夹内容的接口去更新当前的展示
            //使用this.$emit是因为当前uploader被封装成一个子组件,需要emit与父组件通信
            //父组件提供一个额外的方法给子组件访问,用于更新父组件的数据。【emit是事件通信,详细看我父子组件通信的博客】
            this.$http.post("/file/showAllFiles", {
    
    
                curUrl: this.$store.state.file.curUrl,
            }).then((res) => {
    
    
                this.$emit("uploadSuccess",res.data.list)
            });
        },
            
        fileAdded(file) {
    
    
            // 添加文件进行MD5校验,并覆盖identify参数
            this.computeMD5(file)
        },
            
        computeMD5(file) {
    
    
            //建立Reader流,通过file内容建立md5的内容
            let fileReader = new FileReader()
            let time = new Date().getTime()
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
            let currentChunk = 0
            const chunkSize = 10 * 1024 * 1000
            let chunks = Math.ceil(file.size / chunkSize)
            let spark = new SparkMD5.ArrayBuffer()
            //暂停文件上传
            file.pause()
            loadNext()
            //返回值是一个Promise对象
            //Promise对象用于表示一个异步操作的最终完成(或失败)及其结果值,是js中的一个对象
            return new Promise((resolve, reject) => {
    
    
                fileReader.onload = (e) => {
    
    
                    spark.append(e.target.result)
                    if (currentChunk < chunks) {
    
    
                        currentChunk++
                        loadNext()
                    } else {
    
    
                        let md5 = spark.end()
                        // md5计算完毕
                        this.startUpload({
    
    md5,file})
                    }
                }

                //出现error异常的时候取消上传
                fileReader.onerror = function () {
    
    
                    this.error(`文件${
      
      file.name}读取出错,请检查该文件`)
                    file.cancel()
                    reject()
                }
            })

            //方法内定义一个方法,便于区分模块
            function loadNext() {
    
    
                let start = currentChunk * chunkSize
                let end = start + chunkSize >= file.size ? file.size : start + chunkSize

                fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
            }
        },

        // md5计算完毕,开始上传
        startUpload({
     
     md5,file}) {
    
    
            //覆盖文件原来的文件标识
            file.uniqueIdentifier = md5
            //resume是表示文件继续上传
            file.resume()
        }
	}

1.2 バックエンド

1.2.1 コントローラー

  • 他のものは変更されません。ここではフォルダーの名前に識別子を使用します。
/**
     * @Author Nineee
     * @Date 2022/8/16 23:47
     * @Description : 上传文件
     * @param file: 需要上传的文件
     * @param request: 请求体获取当前对象
     * @return void
     */
    @PostMapping("/uploadFile")
    @ResponseBody
    public void uploadFile( @RequestParam("file") MultipartFile file,
                            @RequestParam("chunkNumber") Integer chunkNumber,
                            @RequestParam("totalChunks") Integer totalChunks,
                            @RequestParam("totalSize") String totalSize,
                            @RequestParam("identifier") String identifier,
                            @RequestParam("filename") String filename,
                            @RequestParam("curUrl") String curUrl,
                            HttpServletRequest request) {
    
    
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();

        String[] strs = filename.split("\\.");
        //其他不变,这里使用identifier命名文件夹
        String localUrl = store + uid + curUrl + "\\" + identifier;
        //不是最后一个分片,不需要合并
        fileService.uploadFile(file, localUrl, strs[1]+chunkNumber, false);
        if(chunkNumber == totalChunks) {
    
    
            //否则发起合并服务,merge合并然后校验md5
            fileService.uploadFile(file, localUrl, filename, true);
        }
    }

1.2.2 サービス

  • md5 エンコーディング、フロントエンドで確認してください
@Override
    /**
     * @Author Nineee
     * @Date 2022/8/16 23:07
     * @Description : 上传文件
     * @param file: multipart二进制文件流,也就是目标文件
     * @param curUrl: 上传的目标地址
     * @return Integer 1表示成功,0表示失败
     */
    public Integer uploadFile(MultipartFile file, String localUrl, String filename, boolean merge) {
    
    
        if(!merge) {
    
    
            MultipartFileUtil.addFile(file, localUrl, filename);
        }else {
    
    
            //合并分片
            MultipartFileUtil.mergeFileByRandomAccessFile(localUrl, filename);
            //校验完整文件,localUrl是xxx/xxx/md5值,完整文件在xxx/xxx/filename
            String target = localUrl.substring(0, localUrl.lastIndexOf("\\")+1)+filename;
            //文件夹名就是前端上传的文件的md5
            String oriMd5 = localUrl.substring(localUrl.lastIndexOf("\\")+1);
            String md5 = "";
            
            //通过spring的工具类获取合并后文件的md5
            try (FileInputStream inputStream = new FileInputStream(target)) {
    
    
                md5 = DigestUtils.md5DigestAsHex(inputStream);
            } catch (IOException e) {
    
    
                e.printStackTrace();
                log.error("文件md5值计算出错!path:{}; err: {}", target, e.getMessage());
            }
            
            System.out.println("前端发送的md5:"+oriMd5);
            System.out.println("后端校验的md5:"+md5);
            if(!oriMd5.equals(md5)) {
    
    
                //如果不相等,重传
                return -1;
            }
            //合并并且也校验后删除tmp文件夹
            try {
    
    
                MultipartFileUtil.deleteDirByNio(localUrl);
            } catch (Exception e) {
    
    
                e.printStackTrace();
            }
        }
        return 1;
    }

1.3 デモ

1.3.1 アップロード

ここに画像の説明を挿入

1.3.2 md5 にちなんで名付けられた一時フォルダーを自動的に作成する

ここに画像の説明を挿入

1.3.3 ファイルのマージが完了し、md5 値が検証されます。
ここに画像の説明を挿入
1.3.4 検証が成功すると、フォルダーが自動的に削除されます。

ここに画像の説明を挿入

小さなエピソード: 大きなファイルは破損しやすい [解決済み]

しかし!小さなファイルをアップロードする場合、フロントエンドとバックエンドのMD5を一致させることができることがわかりました

大きなファイルをアップロードすると、md5 値が一致しません
[テスト画像、pdf、rar 圧縮ファイルなどを転送できます]
ここに画像の説明を挿入

テストした結果、ファイル形式の問題ではなく、ファイル サイズの問題であることがわかりました。

[40M 以下では問題ありませんが、90M を超えるとマージの問題が発生し始めます]

ここに画像の説明を挿入

問題の説明!

× 推測 1: 合併のタイミングが間違っている。フロントエンドを通じてマージが決定される場合にもマージエラーが発生する可能性があるため、覆されました。

シャードの順序の問題かと思いますが、シャードが少ない場合は順序を保証しやすいですが、シャードが多い場合は順序を保証することが難しいため、(1)の同時実行数が1、順番が間違っているはずです。ネットワーク帯域幅が不安定な場合、各フラグメントの到着時間は一定ではなくなります。最後のシャードが到着したという理由だけでマージを許可することは現実的ではありません。

√ 推測 2: バックエンドのマージ コードに問題があります。つまり、シャードの数が 2 桁を超えると、ディクショナリの順序のデフォルトのソート (1) が原因で問題が発生します。その後に 2 ではなく 10 が続きます

后端合并文件的方法中自定义比较器
保证合并的file[]有序

Arrays.sort(files, 
			(o1,o2) -> 
			Integer.parseInt(o1.getName()) -
			Integer.parseInt(o2.getName()));

ここに画像の説明を挿入

解決済み: 辞書編集上の順序の問題であることが判明しました

最適化 2: 同時実行性を向上させ、マージのタイミングを最適化する

  • フロントエンド テスト リクエストが送信され、バックエンドがファイルをチェックするときに、各フラグメント番号を配列に入れてフロントエンドに返します。フロントエンドはデータを走査して、送信用の未送信のフラグメントを見つけます。 。数秒での完全なファイル送信 + 高い同時実行 + 数秒での断片化された送信 + 断片化された再送信を実現します。
  • フロントエンドは応答コードを通じてすべてのフラグメントが正常にアップロードされたかどうかを知ることができるため、マージのタイミングはフロントエンドによって決定される必要があります。
  • 上記を実行した後、フロントエンドの同時実行数を 3 に設定して、結果が影響を受けるかどうかを確認します。

2.1 結合タイミングの最適化

2.1.1 フロントエンド

アップローダーがファイルをバインドする成功イベント

<uploader :options="this.options" @file-added="this.fileAdded"
    @file-error="this.fileError" @file-success="this.fileSuccess" >

fileSuccess イベント メソッドでリクエストを開始します。パラメータがわからない場合は、console.log を出力することで内容を知ることができます。

このイベントは、すべてのフラグメントが正常にアップロードされた後にトリガーされます [判断基準は、バックエンドがステータス コード 200 を返すことです]。

fileSuccess(rootFile, file, chunk) {
    
    
    //这是所有分片都上传成功的钩子,发送合并请求
    //通过输出,得到chunk为最后一个分片实例
    //file为完整文件实例,和rootFile好像
    //打印file去找变量
    this.$http.post("/file/mergeFile", {
    
    
        //传送我们需要的参数即可
        curUrl: this.$store.state.file.curUrl,
        identifier: file.uniqueIdentifier,
        filename: file.name,
    }).then((res) => {
    
    
        //更新数据
        this.$emit("uploadSuccess", res.data.list)
    });
},

2.1.2 バックエンド

コントローラー層のインターフェースを追加するだけで、サービスを変更する必要はありません。同時に、他の 2 つの関連するインターフェイスのコード ロジックも少し変更されました。

  • バックエンドが最後のフラグメント番号に遭遇したときにマージするコードを削除し、uploadFile インターフェイスがマージされないようにします。
  • フロントエンドが mergeFile リクエストを開始すると、同時にリスト ファイルをマージして返し、showAllFiles へのアクセスを節約します。
/**
     * @Author Nineee
     * @Date 2022/9/22 13:07
     * @Description : 用于test快传 秒传 和 续传 的接口
     * @param chunkNumber: 分片编号
     * @param totalChunks: 总分片数
     * @param totalSize: 总大小
     * @param filename: 文件名
     * @param curUrl: 当前位置
     * @return Map
     */
    @GetMapping("/uploadFile")
    @ResponseBody
    public Map uploadFile( @RequestParam("chunkNumber") String chunkNumber,
                           @RequestParam("totalChunks") String totalChunks,
                           @RequestParam("totalSize") String totalSize,
                           @RequestParam("identifier") String identifier,
                           @RequestParam("filename") String filename,
                           @RequestParam("curUrl") String curUrl,
                           HttpServletRequest request, HttpServletResponse response) {
    
    
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();
        Map map = new HashMap();

        boolean isTotalFileExist = Files.exists(Paths.get(store + uid + curUrl + "\\" + filename));
        if(isTotalFileExist) {
    
    
            //存在文件,秒传文件
            map.put("skipUpload", true);
        }else {
    
    
            //未存在完整文件
            map.put("skipUpload", false);
            String[] strs = filename.split("\\.");
            String localUrl = store + uid + curUrl + "\\" + identifier +"\\";
            long count = fileService.findShards(localUrl);
            map.put("position", count);
        }
        return map;
    }

    /**
     * @Author Nineee
     * @Date 2022/8/16 23:47
     * @Description : 上传文件
     * @param file: 需要上传的文件
     * @param request: 请求体获取当前对象
     * @return void
     */
    @PostMapping("/uploadFile")
    @ResponseBody
    public void uploadFile( @RequestParam("file") MultipartFile file,
                            @RequestParam("chunkNumber") Integer chunkNumber,
                            @RequestParam("totalChunks") Integer totalChunks,
                            @RequestParam("totalSize") String totalSize,
                            @RequestParam("identifier") String identifier,
                            @RequestParam("filename") String filename,
                            @RequestParam("curUrl") String curUrl,
                            HttpServletRequest request) {
    
    
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();

        //对于localUrl,如果不是末尾分片,我们应该加上一个tmp文件夹避免文件混乱。
        //只有发起合并请求的时候再合并到源路径后删除tmp文件夹。
        //注意,.需要转义
        String[] strs = filename.split("\\.");
        String localUrl = store + uid + curUrl + "\\" + identifier;
        //不是最后一个分片,不需要合并
        fileService.uploadFile(file, localUrl, ""+chunkNumber, false);
    }

    /**
     * @Author Nineee
     * @Date 2022/9/24 21:45
     * @Description : 合并文件分片的请求
     * @param params:
     * @param request:
     * @return Map
     */
    @PostMapping("/mergeFile")
    @ResponseBody
    public Map mergeFile(@RequestBody Map<String, String> params,
                         HttpServletRequest request) {
    
    
        String curUrl = params.get("curUrl");
        String identifier = params.get("identifier");
        String filename = params.get("filename");
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();

        //对于localUrl,如果不是末尾分片,我们应该加上一个tmp文件夹避免文件混乱。
        //只有发起合并请求的时候再合并到源路径后删除tmp文件夹。
        //注意,.需要转义
        String[] strs = filename.split("\\.");
        String localUrl = store + uid + curUrl + "\\" + identifier;
        int res = fileService.uploadFile(null, localUrl, filename, true);

        List<FileInfo> list = fileService.showAllFiles(store + uid + curUrl);
        Map map = new HashMap();
        map.put("list", list);
        map.put("user", user);
        return map;
    }

2.1.3 効果

フロントエンドがいつマージするかを決定する

ここに画像の説明を挿入
バックエンドファイルのマージは正常に機能します
ここに画像の説明を挿入

2.2 同時実行性の向上

2.2.1 アップローダーで直接設定する

//options中设置
simultaneousUploads: 3,

2.2.2 効果

一度に 3 つずつ見ると、まだステータス コードがないことがわかります。
ここに画像の説明を挿入

その後、最終ファイルも通常どおりマージされます
ここに画像の説明を挿入

最適化 3: 最適化を再開する

テスト インターフェイスを最適化するだけで済みます。

  • テストインターフェイスはすべてのフラグメントの数をチェックし、それらを int[] に入れて返します。

3.1 フロントエンド

  • テスト インターフェイスから返される SkipUpload が false であるため、この関数はフラグメントが送信されるたびに開始されます。
  • 現在のスライスの番号がアップロードされた配列で見つかる場合 [番号は 1 から始まるため +1]、アップロードする必要がないことを意味し、true を返します。
  • テストリクエストによって返されたアップロードされた配列で現在のセグメント番号が見つからない場合は、false が返されます。つまり、現在のセグメントをバックエンドに送信する必要があります。
checkChunkUploadedByResponse: function (chunk, message) {
    
    
    let objMessage = JSON.parse(message);
    if (objMessage.skipUpload) {
    
    
        return true;
    }

    return (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0
},

3.2 バックエンド

コントローラ

/**
     * @Author Nineee
     * @Date 2022/9/22 13:07
     * @Description : 用于test快传 秒传 和 续传 的接口
     * @param filename: 文件名
     * @param curUrl: 当前位置
     * @return Map
     */
    @GetMapping("/uploadFile")
    @ResponseBody
    public Map uploadFile( @RequestParam("identifier") String identifier,
                           @RequestParam("filename") String filename,
                           @RequestParam("curUrl") String curUrl,
                           HttpServletRequest request) {
    
    
        User user = (User)request.getAttribute("user");
        int uid = user.getUid();
        Map map = new HashMap();

        boolean isTotalFileExist = Files.exists(Paths.get(store + uid + curUrl + "\\" + filename));
        if(isTotalFileExist) {
    
    
            //存在文件,秒传文件
            map.put("skipUpload", true);
        }else {
    
    
            //未存在完整文件
            map.put("skipUpload", false);
            String[] strs = filename.split("\\.");
            String localUrl = store + uid + curUrl + "\\" + identifier +"\\";
            
            //多了这里,获取分片数组
            int[] uploaded = fileService.findShards(localUrl);
            map.put("uploaded", uploaded);
        }
        return map;
    }

サービス

  • それでもフォルダーを作成する必要がある場合は、アップロードがまだ開始されていないことを意味します
  • フォルダーがある場合は、そのリストのすべてのファイル名、つまり番号を取得します
/**
     * @Author Nineee
     * @Date 2022/9/22 16:41
     * @Description : 找到已有的分片编号
     * @param localUrl:  临时文件夹位置
     * @return int[]
     */
    @Override
    public int[] findShards(String localUrl) {
    
    

        File tempDir = new File(localUrl);
        if (!tempDir.exists()) {
    
    
            tempDir.mkdirs();
            return new int[]{
    
    };
        }
        //应该检查目前到第几个分片,默认分片是有序的
        int[] uploaded = new int[]{
    
    };
        try {
    
    
            File[] files = tempDir.listFiles();
            uploaded = new int[files.length];
            for(int i = 0; i < files.length; ++i) {
    
    
                uploaded[i] = Integer.parseInt(files[i].getName());
            }
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
        return uploaded;
    }

3.3 デモンストレーション

転送されていないファイルの場合
ここに画像の説明を挿入
部分的に断片のあるファイルの場合

  • 順序付けされていないフラグメントは 7 つだけです [Web ページを更新するとアップロードが中断されます]
    ここに画像の説明を挿入

  • 7つのフラグメント番号を取得する
    ここに画像の説明を挿入

  • 正常にマージされました
    ここに画像の説明を挿入

  • この場所をもう一度見てみると、再送信時に 16 個のフラグメントに対して 9 回のリクエストしか行われていないため、前の 7 個のフラグメントは再アップロードされずに送信され続けていることがわかります。

ここに画像の説明を挿入

概要とプレビュー

最適化:

  1. md5 はファイルを一意に識別し、フラグメントの所有権を確認します。そして、マージされたファイルをmd5 で再エンコードして検証し、ファイルが完全であることを確認します
  2. マージ リクエストのタイミングは、バックエンドにマージを通知する前にすべてのフラグメントが正常にアップロードされたことを確認するために、[コールバック関数を使用して] フロントエンドによって決定されます。
  3. 同時実行数は 3 に増やすかカスタマイズでき、送信プロセスで順序を保証する必要はありません。
  4. バックエンドでファイルをマージするときの断片化の順序を解決します。これは、辞書編集的な並べ替えを回避するためにFile[] 配列の順序を変更することに依存しています(つまり、1 の後に 2 ではなく 10 が続きます)。
  5. テストインターフェイスは最適化されています. テストインターフェイスは完全なファイルがあるかどうかと既存のフラグメントの数を返します. フロントエンドはバックエンドによって返されたアップロードされた配列に従って現在のフラグメントをアップロードする必要があるかどうかを判断します.信頼性が高く効率的な継続アップロード重複したパーツのアップロードを削減します。

未実装:

  1. ユーザーのブレークポイント (一時停止、またはネットワークの問題によるブレークポイント) を保存します。
  2. ファイルアップロード一覧表示、アップロード進行状況表示、アップロード制御(一時停止と開始)を実現

おすすめ

転載: blog.csdn.net/NineWaited/article/details/127040220