使用分区上传大文件到 MVC / WebAPI






4.88/5 (58投票s)
C# MVC 中上传大文件的解决方案。
引言
将大文件发送到 MVC/Web-API 服务器可能会出现问题 - 本文将介绍一种替代方法。所采用的方法是将大文件分解成小块,上传这些块,然后在服务器上将它们合并在一起 - 通过分区进行文件传输。本文将介绍如何使用 JavaScript 从网页发送文件到 MVC 服务器,以及如何使用 httpclient 的 Web 表单发送文件,并且可以使用 MVC 或 WebAPI 实现。
根据我的经验,你需要上传到网站/ API 的文件越大,遇到的潜在问题就越大。即使你设置了正确的配置,调整了 web.config,确保使用了正确的 maxRequestLength
和 maxAllowedContentLength
的乘数,当然,别忘了 executionTimeout
(哎呀!),事情仍然可能出错。在文件*几乎*传输完成时连接可能会失败,服务器可能会意外(墨菲定律)耗尽空间,等等,不一而足。下图演示了本文讨论的基本概念。
背景
这个解决方案的概念非常简单。附带的代码可以工作(我已经投入生产),并且你可以从许多方面对其进行改进。例如,在本文中,原始大文件被分解成大约 1MB 的块,并逐个顺序上传到服务器。例如,可以通过多线程来提高效率,并行发送块。还可以通过添加容错、自动恢复到 RESTful API 架构等来提高健壮性。如果你需要这些功能,我将留给你自己实现。
代码包含两部分 - 最初的文件分割/分区成块,以及最后将块合并回原始文件。我将演示如何使用 C# 在 Web 表单中进行文件分割,以及如何使用 JavaScript 进行分割,以及如何使用 C# 在服务器端进行文件合并。
文件分割
分割文件的概念非常基础。我们通过二进制流遍历文件,从位置零到文件的最后一个字节,沿途复制二进制数据块并进行传输。通常,我们设置一个任意的(或经过仔细考虑的!)块大小来提取,并以此作为一次接收的数据量。最后剩余的部分就是最后一个块。
在下面的示例中,设置的块大小为 128b。对于所示文件,这给了我们 3 个 128b 的块,以及 1 个 32b 的块。在此示例中,分割后有四个文件块需要传输到服务器。

C# 文件分割
附带的演示程序 "WinFileUpload
" 是一个简单的 Windows 窗体应用程序。它的唯一功能是演示如何用 C# 分割一个大文件(50MB),并使用 HTTPClient
将文件发布到 Web 服务器(在本例中为 MVC 服务器)。
对于这个 C# 示例,我有一个名为 Utils
的类,它接收一些输入变量,如最大文件块大小、临时文件夹位置以及要分割的文件名。要将文件分割成块,我们调用 "SplitFile
" 方法。SplitFile
会遍历输入文件并将其分解成单独的文件块。然后,我们使用 "UploadFile
" 上传每个文件块。
Utils ut = new Utils();
ut.FileName = "hs-2004-15-b-full_tif.bmp"; // hard coded for demo
ut.TempFolder = Path.Combine(CurrentFolder, "Temp");
ut.MaxFileSizeMB = 1;
ut.SplitFile();
foreach (string File in ut.FileParts)
{
UploadFile(File);
}
MessageBox.Show("Upload complete!");
文件上传方法接收一个输入文件名,并使用 HTTPClient
上传文件。请注意,我们发送的是 MultiPartFormData
来承载有效负载。
public bool UploadFile(string FileName)
{
bool rslt = false;
using (var client = new HttpClient())
{
using (var content = new MultipartFormDataContent())
{
var fileContent = new ByteArrayContent(System.IO.File.ReadAllBytes(FileName));
fileContent.Headers.ContentDisposition = new
ContentDispositionHeaderValue("attachment")
{
FileName = Path.GetFileName(FileName)
};
content.Add(fileContent);
var requestUri = "https://:8170/Home/UploadFile/";
try
{
var result = client.PostAsync(requestUri, content).Result;
rslt = true;
}
catch (Exception ex)
{
// log error
rslt = false;
}
}
}
return rslt;
}
那么,配套代码就完成了。接下来需要注意的关键事项之一是使用的文件名约定。它由原始文件名加上一个代码可解析的后缀 "_part
." 组成,服务器端将使用它来将不同的文件块合并回一个连续的文件。这只是我设计的一个约定 - 你可以根据自己的需求进行更改,但请确保保持一致。
此示例的约定是
Name = original name + ".part_N.X" (N = file part number, X = total files)
这是一个图片文件被分割成三部分的示例
- MyPictureFile.jpg.part_1.3
- MyPictureFile.jpg.part_2.3
- MyPictureFile.jpg.part_3.3
文件块以什么顺序发送到服务器并不重要。重要的是使用某种约定,如上所示,以便服务器知道(a)它正在处理哪个文件部分以及(b)何时收到了所有部分,可以将其合并回一个大的原始文件。
接下来,这是扫描文件、创建多个可传输块文件的 C# 代码的核心部分。
public bool SplitFile()
{
bool rslt = false;
string BaseFileName = Path.GetFileName(FileName);
// set the size of file chunk we are going to split into
int BufferChunkSize = MaxFileSizeMB * (1024 * 1024);
// set a buffer size and an array to store the buffer data as we read it
const int READBUFFER_SIZE = 1024;
byte[] FSBuffer = new byte[READBUFFER_SIZE];
// open the file to read it into chunks
using (FileStream FS = new FileStream(FileName, FileMode.Open,
FileAccess.Read, FileShare.Read))
{
// calculate the number of files that will be created
int TotalFileParts = 0;
if (FS.Length < BufferChunkSize)
{
TotalFileParts = 1;
}
else
{
float PreciseFileParts = ((float)FS.Length / (float)BufferChunkSize);
TotalFileParts = (int)Math.Ceiling(PreciseFileParts);
}
int FilePartCount = 0;
// scan through the file, and each time we get enough data to fill a chunk,
// write out that file
while (FS.Position < FS.Length)
{
string FilePartName = String.Format("{0}.part_{1}.{2}",
BaseFileName, (FilePartCount + 1).ToString(), TotalFileParts.ToString());
FilePartName = Path.Combine(TempFolder, FilePartName);
FileParts.Add(FilePartName);
using (FileStream FilePart = new FileStream(FilePartName, FileMode.Create))
{
int bytesRemaining = BufferChunkSize;
int bytesRead = 0;
while (bytesRemaining > 0 && (bytesRead = FS.Read(FSBuffer, 0,
Math.Min(bytesRemaining, READBUFFER_SIZE))) > 0)
{
FilePart.Write(FSBuffer, 0, bytesRead);
bytesRemaining -= bytesRead;
}
}
// file written, loop for next chunk
FilePartCount++;
}
}
return rslt;
}
至此,客户端 C# 代码部分已完成 - 我们将在文章后面看到结果以及如何在服务器端处理。接下来,让我们看看如何从 Web 浏览器中使用 JavaScript 完成同样的操作。
JavaScript 文件分割
注意:JavaScript 代码和 C# 合并代码包含在附带的演示文件 "MVCServer
" 中。
在浏览器中,我们有一个类型为 "file
" 的输入控件和一个按钮,用于调用启动文件分割和数据传输的方法。
<input type="file" id="uploadFile" name="file" /> <a class="btn btn-primary" href="#" id="btnUpload">Upload file</a>
在 document ready
时,我们将按钮的 click
事件绑定到调用主方法
$(document).ready(function () {
$('#btnUpload').click(function () {
UploadFile($('#uploadFile')[0].files);
}
)
});
我们的 UploadFile
方法负责将文件分割成块,并像在 C# 示例中一样,将块传递给另一个方法进行传输。这里的主要区别在于,在 C# 中,我们创建了单独的文件,而在我们的 JavaScript 示例中,我们是从数组中获取块。
function UploadFile(TargetFile)
{
// create array to store the buffer chunks
var FileChunk = [];
// the file object itself that we will work with
var file = TargetFile[0];
// set up other initial vars
var MaxFileSizeMB = 1;
var BufferChunkSize = MaxFileSizeMB * (1024 * 1024);
var ReadBuffer_Size = 1024;
var FileStreamPos = 0;
// set the initial chunk length
var EndPos = BufferChunkSize;
var Size = file.size;
// add to the FileChunk array until we get to the end of the file
while (FileStreamPos < Size)
{
// "slice" the file from the starting position/offset, to the required length
FileChunk.push(file.slice(FileStreamPos, EndPos));
FileStreamPos = EndPos; // jump by the amount read
EndPos = FileStreamPos + BufferChunkSize; // set next chunk length
}
// get total number of "files" we will be sending
var TotalParts = FileChunk.length;
var PartCount = 0;
// loop through, pulling the first item from the array each time and sending it
while (chunk = FileChunk.shift())
{
PartCount++;
// file name convention
var FilePartName = file.name + ".part_" + PartCount + "." + TotalParts;
// send the file
UploadFileChunk(chunk, FilePartName);
}
}
UploadFileChunk
接收上一个方法传递的文件部分,并以与 C# 示例类似的方式将其发布到服务器。
function UploadFileChunk(Chunk, FileName)
{
var FD = new FormData();
FD.append('file', Chunk, FileName);
$.ajax({
type: "POST",
url: 'https://:8170/Home/UploadFile/',
contentType: false,
processData: false,
data: FD
});
}
文件合并
注意:JavaScript 代码和 C# Merge
代码包含在附带的演示文件 "MVCServer
" 中。
在服务器端,无论是 MVC 还是 Web-API,我们都会接收到单个文件块,并需要将它们合并回原始文件。
我们首先设置一个标准的 POST
处理程序来接收上传到服务器的文件块。此代码读取输入流,并使用客户端(C# 或 JavaScript)创建的文件名将其保存到 *temp* 文件夹。文件保存后,代码会调用 "MergeFile
" 方法,该方法会检查是否已拥有足够的文件块来合并文件。请注意,这只是本文使用的简单方法。你可能会决定以不同的方式处理合并触发器,例如,每隔几分钟运行一个定时作业,将其传递给另一个进程等。应根据你的具体实现需求进行更改。
[HttpPost]
public HttpResponseMessage UploadFile()
{
foreach (string file in Request.Files)
{
var FileDataContent = Request.Files[file];
if (FileDataContent != null && FileDataContent.ContentLength > 0)
{
// take the input stream, and save it to a temp folder using
// the original file.part name posted
var stream = FileDataContent.InputStream;
var fileName = Path.GetFileName(FileDataContent.FileName);
var UploadPath = Server.MapPath("~/App_Data/uploads");
Directory.CreateDirectory(UploadPath);
string path = Path.Combine(UploadPath, fileName);
try
{
if (System.IO.File.Exists(path))
System.IO.File.Delete(path);
using (var fileStream = System.IO.File.Create(path))
{
stream.CopyTo(fileStream);
}
// Once the file part is saved, see if we have enough to merge it
Shared.Utils UT = new Shared.Utils();
UT.MergeFile(path);
}
catch (IOException ex)
{
// handle
}
}
}
return new HttpResponseMessage()
{
StatusCode = System.Net.HttpStatusCode.OK,
Content = new StringContent("File uploaded.")
};
}
每次调用 MergeFile
方法时,它首先会检查是否已获得合并原始文件所需的所有文件块。它通过解析文件名来确定这一点。如果所有文件都存在,该方法会将它们排序成正确的顺序,然后将它们一个接一个地追加,直到分割的原始文件恢复完整。
/// <summary>
/// original name + ".part_N.X" (N = file part number, X = total files)
/// Objective = enumerate files in folder, look for all matching parts of
/// split file. If found, merge and return true.
/// </summary>
/// <param name="FileName"></param>
/// <returns></returns>
public bool MergeFile(string FileName)
{
bool rslt = false;
// parse out the different tokens from the filename according to the convention
string partToken = ".part_";
string baseFileName = FileName.Substring(0, FileName.IndexOf(partToken));
string trailingTokens = FileName.Substring(FileName.IndexOf(partToken) + partToken.Length);
int FileIndex = 0;
int FileCount = 0;
int.TryParse(trailingTokens.Substring(0, trailingTokens.IndexOf(".")), out FileIndex);
int.TryParse(trailingTokens.Substring(trailingTokens.IndexOf(".") + 1), out FileCount);
// get a list of all file parts in the temp folder
string Searchpattern = Path.GetFileName(baseFileName) + partToken + "*";
string[] FilesList = Directory.GetFiles(Path.GetDirectoryName(FileName), Searchpattern);
// merge .. improvement would be to confirm individual parts are there / correctly in
// sequence, a security check would also be important
// only proceed if we have received all the file chunks
if (FilesList.Count() == FileCount)
{
// use a singleton to stop overlapping processes
if (!MergeFileManager.Instance.InUse(baseFileName))
{
MergeFileManager.Instance.AddFile(baseFileName);
if (File.Exists(baseFileName))
File.Delete(baseFileName);
// add each file located to a list so we can get them into
// the correct order for rebuilding the file
List<SortedFile> MergeList = new List<SortedFile>();
foreach (string File in FilesList)
{
SortedFile sFile = new SortedFile();
sFile.FileName = File;
baseFileName = File.Substring(0, File.IndexOf(partToken));
trailingTokens = File.Substring(File.IndexOf(partToken) + partToken.Length);
int.TryParse(trailingTokens.
Substring(0, trailingTokens.IndexOf(".")), out FileIndex);
sFile.FileOrder = FileIndex;
MergeList.Add(sFile);
}
// sort by the file-part number to ensure we merge back in the correct order
var MergeOrder = MergeList.OrderBy(s => s.FileOrder).ToList();
using (FileStream FS = new FileStream(baseFileName, FileMode.Create))
{
// merge each file chunk back into one contiguous file stream
foreach (var chunk in MergeOrder)
{
try
{
using (FileStream fileChunk =
new FileStream(chunk.FileName, FileMode.Open))
{
fileChunk.CopyTo(FS);
}
}
catch (IOException ex)
{
// handle
}
}
}
rslt = true;
// unlock the file from singleton
MergeFileManager.Instance.RemoveFile(baseFileName);
}
}
return rslt;
}
通过在客户端使用文件分割,在服务器端使用文件合并,我们现在拥有了一个非常可行的解决方案,可以比直接发送一个大的数据块更安全地上传大文件。在测试中,我使用了从哈勃望远镜图片(此处)转换成 BMP 的一些大图片文件。如果本文对您有所帮助,请在页面顶部给它点赞!:)
历史
- 29/09/2015 - 版本 1