2021年05月19日 18:42
原创作品,转载时请务必以超链接形式标明文章原始出处,否则将追究法律责任。

最近迷恋上了登山,游山玩水,我当时给同事们算了一下,一个夏季从5月开始到8月底,总共四个月,一个月休8天,总共是可以休32天,刨去下雨有事不能出去玩,其实一个夏季能出去玩的时间也就是20天左右,好好珍惜,现在年纪大了,每一天都很宝贵。

微信图片_20210519210842.jpg

好了,废话不多说,要实现大文件上传,首先要考虑不要超时,上传的时候给用户展示进度,上传中途突然断网,需要支持断点续传,不至于500M的文件上传了499M了,结果最后一M失败了,前功尽弃。

OK,今天就给大家看一下在Angular中如何实现这个上传功能,首先我们选择Kendo UI for angular。组件采用Kendo Upload,请参考:https://www.telerik.com/kendo-angular-ui/components/uploads/upload/chunk-upload/


首先我们看一下angular端,在app.module中引入组件,整个代码如下

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { AWSUploadService } from '../services/aws-upload-service';
import { HttpClientModule, HTTP_INTERCEPTORS  } from '@angular/common/http';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { UploadsModule } from "@progress/kendo-angular-upload";
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { UploadInterceptor } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    InputsModule,
    UploadsModule,
    BrowserAnimationsModule
  ],
  providers: [AWSUploadService, {
    provide: HTTP_INTERCEPTORS,
    useClass: UploadInterceptor,
    multi: true,
  }],
  bootstrap: [AppComponent]
})
export class AppModule { }

我们需要引入UploadsModule,UploadInterceptor,并提供拦截器UploadInterceptor。

接下来我们看一下UI,UI上我们只放一个Upload组件实现上传。

<div class="content" role="main">
  <kendo-upload [saveUrl]="uploadSaveUrl"
                [removeUrl]="uploadRemoveUrl"
                [withCredentials]="false"
                [concurrent]="false"
                [chunkable]="chunkSettings">
  </kendo-upload>
</div>

这里我们需要设置几个属性,一个是SaveUrl,就是接收上传文件的api地址。withCredentials设置为false,是为了跨域的时候不验证凭证。concurrent为false意思是当上传多个文件的时候,是否是并发上传。chunkable设置的是分片上传时的一些参数,如下

image.png


OK,我们看一下js逻辑

import { Component, Injectable } from '@angular/core';
import { AWSUploadService } from '../services/aws-upload-service';
import { HttpEventType, HttpClient, HttpParams } from '@angular/common/http';
import { ChunkSettings } from '@progress/kendo-angular-upload';

import {
  SelectEvent,
  RemoveEvent,
  FileRestrictions,
} from "@progress/kendo-angular-upload";

import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpProgressEvent,
  HttpResponse,
} from "@angular/common/http";

import { Observable, of, concat } from "rxjs";
import { delay } from "rxjs/operators";
import { ChunkMetadata } from "@progress/kendo-angular-upload";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'aws-upload-app';

  constructor(private awsUploadService: AWSUploadService
    , private http: HttpClient) {

  }

  public chunkSettings: ChunkSettings = {
    size: 1024000,
    resumable: true,
    maxAutoRetries: 2
  };

  ngOnInit() {
  }

  uploadSaveUrl = "http://localhost:60923/api/Upload";
  uploadRemoveUrl = "removeUrl";
}

@Injectable()
export class UploadInterceptor implements HttpInterceptor {
  public fileMap: any = [];

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    if (req.url === "saveUrl") {

      // Parsing the file metadata.
      const metadata: ChunkMetadata = JSON.parse(req.body.get("metadata"));

      // Retrieving the uid of the uploaded file.
      const fileUid = metadata.fileUid;

      if (metadata.chunkIndex === 0) {
        this.fileMap[fileUid] = [];
      }

      // Storing the chunks of the file.
      this.fileMap[fileUid].push(req.body.get("files"));

      // Checking if this is the last chunk of the file
      if (metadata.chunkIndex === metadata.totalChunks - 1) {
        // Reconstructing the initial file
        // const completeFile = new Blob(
        //     this.fileMap[metadata.fileUid],
        //     { type: metadata.contentType }
        // );
      }


      const events: Observable<HttpEvent<any>>[] = [30, 60, 100].map((x) =>
        of(<HttpProgressEvent>{
          type: HttpEventType.UploadProgress,
          loaded: x,
          total: 100,
        })
      );

      const success = of(new HttpResponse({ status: 200 })).pipe(delay(1000));
      events.push(success);

      return concat(...events);
    }

    if (req.url === "removeUrl") {
      return of(new HttpResponse({ status: 200 }));
    }

    return next.handle(req);
  }
}

前端这里其实官方demo已经讲得很清楚了,我就不再赘述。我们看一下官方没有的后端api是如何实现的。

[HttpPost]
[Route("upload/chunk")]
public HttpResponseMessage UploadByChunk()
{
    try
    {
        if (HttpContext.Current.Request.Files.AllKeys.Length > 0)
        {
            var metaData = HttpContext.Current.Request.Form["metadata"];
            var fileMetaData = JsonConvert.DeserializeObject<FileMetaData>(metaData);

            var httpPostedChunkFile = HttpContext.Current.Request.Files[0];
            if (httpPostedChunkFile != null)
            {
                var saveFile = HttpContext.Current.Server.MapPath("~/uploaded");
                var saveFilePath = Path.Combine(saveFile, fileMetaData.FileUid + ".part");
                var chunkIndex = fileMetaData.ChunkIndex;
                if (chunkIndex == 0)
                {
                    if (File.Exists(saveFilePath))
                    {
                        File.Delete(saveFilePath);
                    }

                    httpPostedChunkFile.SaveAs(saveFilePath);
                }
                else
                {
                    MergeChunkFile(saveFilePath, httpPostedChunkFile.InputStream);
                    var totalChunk = fileMetaData.TotalChunks;
                    if (Convert.ToInt32(chunkIndex) == (Convert.ToInt32(totalChunk) - 1))
                    {
                        var savedFile = HttpContext.Current.Server.MapPath("~/uploaded");
                        var originalFilePath = Path.Combine(savedFile, fileMetaData.FileName);
                        
                        if (File.Exists(originalFilePath))
                        {
                            File.Delete(originalFilePath);
                        }
                        File.Move(saveFilePath, originalFilePath);
                    }
                }
            }
        }

        return Request.CreateResponse(HttpStatusCode.OK);
    }
    catch (Exception e)
    {
        return Request.CreateResponse(HttpStatusCode.InternalServerError);
    }
}

public void MergeChunkFile(string fullPath, Stream chunkContent)
{
    try
    {
        using (FileStream stream = new FileStream(fullPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
        {
            using (chunkContent)
            {
                chunkContent.CopyTo(stream);
            }
        }
    }
    catch (IOException ex)
    {
        throw ex;
    }
}

大家可以看到我们首先获取到metadata,因为文件被切分后,不是一个完整的文件,所以每一个分片文件发送到服务端都有一个元数据,这个元数据信息是放在form中的,所以每次我们拿到后,先进行反序列化拿到元数据信息。那么元数据将到底有哪些信息呢,看官网说明,有如下一些元数据信息,英文已经写得很明白了。

image.png

所以结合上面所述,整个流程是这样的,客户端将文件进行切割,比如一个文件500M,根据chunkSettings配置,比如切片大小为10M,那么这个文件就会被分成50个小块,然后一块块的顺序往api发送。发送的时候怎么保证文件的片能够在服务端顺序拼起来,其实这里就要用到元数据。元数据其实是一个json字符串,包含了文件名,文件大小,文件唯一标识uid,分片索引,总分片数。有了这些元数据,我们就能判断当前数据片是什么位置的。当第一个分片到达api的时候,我们在服务端先存一个临时文件uid.part。当后面的分片到达时,只是把文件流追加到临时文件中。当最后一片文件内容到达时,我们对文件进行重命名,完成文件的最终合并。

好了,整个流程就是这么简单。我们看一下效果吗,选择文件上传,会显示进度,以及暂停按钮,暂停上传后,可以重新resume继续上传,对于客户端,肯定有记录文件分片上传到哪个位置,否则无法继续上传。这里请大家参考UploadInterceptor代码。


Capture.PNG

在文件还没有彻底上传到服务器之前,会在服务端产生临时文件,如下


Capture1.PNG

ok,最后再强调一下,上传的大小限制我们要改一下,一个是system.web下

<httpRuntime targetFramework="4.5.2" maxRequestLength="102400000" />

另外一个节点是system.webserver下面

 <security>
    <requestFiltering>
    	<requestLimits maxAllowedContentLength="2147483648" />
    </requestFiltering>
</security>


设置这两个是为了我们运行时和部署IIS的时候上传文件的大小不要受太大限制。最后,如果要支持跨域,安装Microsoft.Asp.Net.WebApi.Cors,在api config中设置

 config.EnableCors(new EnableCorsAttribute("*", "*", "*"));

发表评论
匿名  
用户评论
暂无评论