最近工作剛好遇到大檔案(> 2GB)上傳的需求,大檔案的上傳方式與平常處裡小檔案上傳當方式有些不同,對於大檔案上傳我們需要額外考量三個問題 :
1. 上傳逾時。
2 IIS 不支援超過2BG 的檔案上傳
3. 檔案在上傳過程中中斷。
上述這三個問題可以透過multipart/form-data和chuck 的方式,將檔案分割成無數個chunk,且給予每個chunk編號,如果傳送失敗就重新傳送chunk ,進而實現斷點續傳的功能。
目前市面三個有支援大檔案與斷點續傳的JavaScript Library分別有Resumable.js、Dropzone.js、jQuery Ajax File Upload,且它們底層皆是使用HTML5 File API,此外它們的作者都有提供Server Side Implementation Sample,而且連TypeScript的定義檔都有,真的都是佛心來著。
接著我們開始介紹如何使用Resumable.js 上傳檔案至Web API(.NET) :
Server Side (Web API)的主要流程:
1. 首先,在Web API需要使用MultipartFormDataStreamProvider來取得每一次Resumable.js 所傳過來的chunk,而chunk由FormData和FileData組成,其中FormData記載Resumable.js所定義的chuck欄位,而FileData則存放chunk的檔案內容。
2. 接著從FormData得到這些Resumable.js所定義的chuck欄位resumableFilename、resumableIdentifier、resumableTotalChunks、resumableChunkNumber欄位,並且根據這些欄位我們將chunk儲存在Upload 目錄下。
3. 等待所有chunk都到位,將合併這些chunk並組成完整的檔案。
FileStorageController Class
[RoutePrefix("api/FileStorage")]
public class FileStorageController : ApiController
{
private Resumable _Resumable = new Resumable(HttpContext.Current.Server.MapPath("~/Upload"));
[Route("Upload"), HttpOptions]
public object UploadFileOptions()
{
return Request.CreateResponse(HttpStatusCode.OK);
}
[Route("Upload"), HttpGet]
public object Upload(int resumableChunkNumber, string resumableIdentifier)
{
return _Resumable.ChunkIsHere(resumableChunkNumber, resumableIdentifier) ? Request.CreateResponse(HttpStatusCode.OK) : Request.CreateResponse(HttpStatusCode.NoContent);
}
[Route("Upload"), HttpPost]
public async Task<object> Upload()
{
try
{
if (!Request.Content.IsMimeMultipartContent())throw new HttpResponseException(HttpStatusCode.UnsupportedMediaType);
string root = _Resumable.GetRoot();
if (!Directory.Exists(root)) Directory.CreateDirectory(root);
var provider = new MultipartFormDataStreamProvider(root);
if (await ReadPart(provider))
{
ResumableConfig configuration = _Resumable.GetUploadConfiguration(provider);
return Request.CreateResponse(HttpStatusCode.OK);
}
else
{
var message = _Resumable.DeleteInvalidChunkData(provider) ? "Cannot read multi part file data." : "Cannot delete temporary file chunk data.";
return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, message);
}
}
catch (Exception ex)
{
return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, ex.ToString());
}
}
public async Task<bool> ReadPart(MultipartFormDataStreamProvider provider)
{
try
{
await Request.Content.ReadAsMultipartAsync(provider);
ResumableConfig configuration = _Resumable.GetUploadConfiguration(provider);
int chunkNumber = _Resumable.GetChunkNumber(provider);
MultipartFileData chunk = provider.FileData[0]; // Only one file in multipart message
_Resumable.RenameChunk(chunk, chunkNumber, configuration.Identifier);
_Resumable.TryAssembleFile(configuration);
return true;
}
catch
{
throw;
}
}
}
Resumable Class
public class ResumableConfig
{
public int Chunks { get; set; }
public string Identifier { get; set; }
public string FileName { get; set; }
public static ResumableConfig Create(string identifier, string filename, int chunks)
{
return new ResumableConfig { Identifier = identifier, FileName = filename, Chunks = chunks };
}
}
public class Resumable
{
private string Root { get; set; }
public Resumable(string root) {
this.Root = root;
}
public string GetRoot() {
return this.Root;
}
public bool DeleteInvalidChunkData(MultipartFormDataStreamProvider provider)
{
try
{
var localFileName = provider.FileData[0].LocalFileName;
if (File.Exists(localFileName)) File.Delete(localFileName);
return true;
}
catch
{
return false;
}
}
#region Get configuration
public ResumableConfig GetUploadConfiguration(MultipartFormDataStreamProvider provider)
{
return ResumableConfig.Create(identifier: GetId(provider), filename: GetFileName(provider), chunks: GetTotalChunks(provider));
}
public string GetFileName(MultipartFormDataStreamProvider provider)
{
var filename = provider.FormData["resumableFilename"];
return !String.IsNullOrEmpty(filename) ? filename : provider.FileData[0].Headers.ContentDisposition.FileName.Trim('\"');
}
public string GetId(MultipartFormDataStreamProvider provider)
{
var id = provider.FormData["resumableIdentifier"];
return !String.IsNullOrEmpty(id) ? id : Guid.NewGuid().ToString();
}
public int GetTotalChunks(MultipartFormDataStreamProvider provider)
{
var total = provider.FormData["resumableTotalChunks"];
return !String.IsNullOrEmpty(total) ? Convert.ToInt32(total) : 1;
}
public int GetChunkNumber(MultipartFormDataStreamProvider provider)
{
var chunk = provider.FormData["resumableChunkNumber"];
return !String.IsNullOrEmpty(chunk) ? Convert.ToInt32(chunk) : 1;
}
#endregion
#region Chunk methods
public string GetChunkFileName(int chunkNumber, string identifier)
{
return Path.Combine(this.Root, string.Format("{0}_{1}", identifier, chunkNumber.ToString()));
}
public void RenameChunk(MultipartFileData chunk, int chunkNumber, string identifier)
{
string generatedFileName = chunk.LocalFileName;
string chunkFileName = GetChunkFileName(chunkNumber, identifier);
if (File.Exists(chunkFileName)) File.Delete(chunkFileName);
File.Move(generatedFileName, chunkFileName);
}
public string GetFilePath(ResumableConfig configuration)
{
return Path.Combine(this.Root, configuration.Identifier);
}
public bool ChunkIsHere(int chunkNumber, string identifier)
{
string fileName = GetChunkFileName(chunkNumber, identifier);
return File.Exists(fileName);
}
public bool AllChunksAreHere(ResumableConfig configuration)
{
for (int chunkNumber = 1; chunkNumber <= configuration.Chunks; chunkNumber++)
if (!ChunkIsHere(chunkNumber, configuration.Identifier)) return false;
return true;
}
public void TryAssembleFile(ResumableConfig configuration)
{
if (AllChunksAreHere(configuration))
{
var path = ConsolidateFile(configuration);
// Rename consolidated with original name of upload
RenameFile(path, Path.Combine(this.Root, configuration.FileName));
DeleteChunks(configuration);
}
}
public void DeleteChunks(ResumableConfig configuration)
{
for (int chunkNumber = 1; chunkNumber <= configuration.Chunks; chunkNumber++)
{
var chunkFileName = GetChunkFileName(chunkNumber, configuration.Identifier);
File.Delete(chunkFileName);
}
}
public string ConsolidateFile(ResumableConfig configuration)
{
var path = GetFilePath(configuration);
using (var destStream = File.Create(path, 15000))
{
for (int chunkNumber = 1; chunkNumber <= configuration.Chunks; chunkNumber++)
{
var chunkFileName = GetChunkFileName(chunkNumber, configuration.Identifier);
using (var sourceStream = File.OpenRead(chunkFileName))
{
sourceStream.CopyTo(destStream);
}
}
destStream.Close();
}
return path;
}
#endregion
public string RenameFile(string sourceName, string targetName)
{
targetName = Path.GetFileName(targetName); // Strip to filename if directory is specified (avoid cross-directory attack)
string realFileName = Path.Combine(this.Root, targetName);
if (File.Exists(realFileName)) File.Delete(realFileName);
File.Move(sourceName, realFileName);
return targetName;
}
}
Client Side (TypeScript) 的主要流程:1. 首先,import Resumable.js。
2. 建立Resumable物件並設定target與chunkSize,其中target需填入file upload url。
3. 註冊fileAdded、complete、progress以及fileSuccess等常用事件。
import * as Resumable from 'resumablejs/resumable.js';
private UploaderOnInit(url:string): void {
const r = new Resumable({
target: url,
chunkSize: 3 * 1024 * 1024, //3 MB
});
r.assignBrowse(document.getElementById('Uploader'), false);
r.assignDrop(document.getElementById('Uploader'));
r.on('fileAdded', function (file, event) {
r.upload();
// to do ...
});
r.on('complete', function () {
r.files.pop();
// to do ...
});
r.on('progress', function () {
// to do ...
});
r.on('fileSuccess', function (file, message) {
// to do ...
});
}
參考文獻1. https://github.com/23/resumable.js
留言
張貼留言