65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (58投票s)

2015年9月29日

CPOL

6分钟阅读

viewsIcon

160515

downloadIcon

9892

C# MVC 中上传大文件的解决方案。

引言

将大文件发送到 MVC/Web-API 服务器可能会出现问题 - 本文将介绍一种替代方法。所采用的方法是将大文件分解成小块,上传这些块,然后在服务器上将它们合并在一起 - 通过分区进行文件传输。本文将介绍如何使用 JavaScript 从网页发送文件到 MVC 服务器,以及如何使用 httpclient 的 Web 表单发送文件,并且可以使用 MVC 或 WebAPI 实现。

根据我的经验,你需要上传到网站/ API 的文件越大,遇到的潜在问题就越大。即使你设置了正确的配置,调整了 web.config,确保使用了正确的 maxRequestLengthmaxAllowedContentLength 的乘数,当然,别忘了 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
© . All rights reserved.