使用 C# 下載檔案的十八般武藝

Soar、毅發表於2021-08-20

檔案下載是一個軟體開發中的常見需求。本文從最簡單的下載方式開始步步遞進,講述了檔案下載過程中的常見問題並給出瞭解決方案。並展示瞭如何使用多執行緒提升 HTTP 的下載速度以及呼叫 aria2 實現非 HTTP 協議的檔案下載。

簡單下載

在 .NET 程式中下載檔案最簡單的方式就是使用 WebClient 的 DownloadFile 方法:

    var url = "https://www.coderbusy.com";
    var save = @"D:\1.html";
    using (var web = new WebClient())
    {
        web.DownloadFile(url,save);
    }

非同步下載

該方法也提供非同步的實現:

    var url = "https://www.coderbusy.com";
    var save = @"D:\1.html";
    using (var web = new WebClient())
    {
        await web.DownloadFileTaskAsync(url, save);
    }

下載檔案的同時向伺服器傳送自定義請求頭

如果需要對檔案下載請求進行定製,可以使用 HttpClient :

    var url = "https://www.coderbusy.com";
    var save = @"D:\1.html";
    var http = new HttpClient();
    var request = new HttpRequestMessage(HttpMethod.Get,url);
    //增加 Auth 請求頭
    request.Headers.Add("Auth","123456");
    var response = await http.SendAsync(request);
    response.EnsureSuccessStatusCode();
    using (var fs = File.Open(save, FileMode.Create))
    {
        using (var ms = response.Content.ReadAsStream())
        {
            await ms.CopyToAsync(fs);
        }
    }

如何解決下載檔案不完整的問題

以上所有程式碼在應對小檔案的下載時沒有特別大的問題,在網路情況不佳或檔案較大時容易引入錯誤。以下程式碼在開發中很常見:

    var url = "https://www.coderbusy.com";
    var save = @"D:\1.html";
    if (!File.Exists(save))
    {
        Console.WriteLine("檔案不存在,開始下載...");
        using (var web = new WebClient())
        {
            await web.DownloadFileTaskAsync(url, save);
        }
        Console.WriteLine("檔案下載成功");
    }
    Console.WriteLine("開始處理檔案");
    //TODO:對檔案進行處理

如果在 DownloadFileTaskAsync 方法中發生了異常(通常是網路中斷或網路超時),那麼下載不完整的檔案將會保留在本地系統中。在該任務重試執行時,因為檔案已存在(雖然它不完整)所以會直接進入處理程式,從而引入異常。

一個簡單的修復方式是引入異常處理,但這種方式對應用程式意外終止造成的檔案不完整無效:

    var url = "https://www.coderbusy.com";
    var save = @"D:\1.html";
    if (!File.Exists(save))
    {
        Console.WriteLine("檔案不存在,開始下載...");
        using (var web = new WebClient())
        {
            try
            {
                await web.DownloadFileTaskAsync(url, save);
            }
            catch
            {
                if (File.Exists(save))
                {
                    File.Delete(save);
                }
                throw;
            }
        }
        Console.WriteLine("檔案下載成功");
    }
    Console.WriteLine("開始處理檔案");
    //TODO:對檔案進行處理

筆者更喜歡的方式是引入一個臨時檔案。下載操作將資料下載到臨時檔案中,當確定下載操作執行完畢時將臨時檔案改名:

    var url = "https://www.coderbusy.com";
    var save = @"D:\1.html";
    if (!File.Exists(save))
    {
        Console.WriteLine("檔案不存在,開始下載...");
        //先下載到臨時檔案
        var tmp = save + ".tmp";
        using (var web = new WebClient())
        {
            await web.DownloadFileTaskAsync(url, tmp);
        }
        File.Move(tmp, save, true);
        Console.WriteLine("檔案下載成功");
    }
    Console.WriteLine("開始處理檔案");
    //TODO:對檔案進行處理

使用 Downloader 進行 HTTP 多執行緒下載

在網路頻寬充足的情況下,單執行緒下載的效率並不理想。我們需要多執行緒和斷點續傳才可以拿到更好的下載速度。

Downloader 是一個現代化的、流暢的、非同步的、可測試的和可移植的 .NET 庫。這是一個包含非同步進度事件的多執行緒下載程式。Downloader 與 .NET Standard 2.0 及以上版本相容,可以在 Windows、Linux 和 macOS 上執行。

GitHub 開源地址: https://github.com/bezzad/Downloader

NuGet 地址:https://www.nuget.org/packages/Downloader

從 NuGet 安裝 Downloader 之後,建立一個下載配置:

    var downloadOpt = new DownloadConfiguration()
    {
        BufferBlockSize = 10240, // 通常,主機最大支援8000位元組,預設值為8000。
        ChunkCount = 8, // 要下載的檔案分片數量,預設值為1
        MaximumBytesPerSecond = 1024 * 1024, // 下載速度限制為1MB/s,預設值為零或無限制
        MaxTryAgainOnFailover = int.MaxValue, // 失敗的最大次數
        OnTheFlyDownload = false, // 是否在記憶體中進行快取? 預設值是true
        ParallelDownload = true, // 下載檔案是否為並行的。預設值為false
        TempDirectory = "C:\\temp", // 設定用於緩衝大塊檔案的臨時路徑,預設路徑為Path.GetTempPath()。
        Timeout = 1000, // 每個 stream reader  的超時(毫秒),預設值是1000
        RequestConfiguration = // 定製請求標頭檔案
        {
            Accept = "*/*",
            AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
            CookieContainer =  new CookieContainer(), // Add your cookies
            Headers = new WebHeaderCollection(), // Add your custom headers
            KeepAlive = false,
            ProtocolVersion = HttpVersion.Version11, // Default value is HTTP 1.1
            UseDefaultCredentials = false,
            UserAgent = $"DownloaderSample/{Assembly.GetExecutingAssembly().GetName().Version.ToString(3)}"
        }
    };

建立一個下載服務:

var downloader = new DownloadService(downloadOpt);

配置事件處理器(該步驟可以省略):

    // Provide `FileName` and `TotalBytesToReceive` at the start of each downloads
    // 在每次下載開始時提供 "檔名 "和 "要接收的總位元組數"。
    downloader.DownloadStarted += OnDownloadStarted;

    // Provide any information about chunker downloads, like progress percentage per chunk, speed, total received bytes and received bytes array to live streaming.
    // 提供有關分塊下載的資訊,如每個分塊的進度百分比、速度、收到的總位元組數和收到的位元組陣列,以實現實時流。
    downloader.ChunkDownloadProgressChanged += OnChunkDownloadProgressChanged;

    // Provide any information about download progress, like progress percentage of sum of chunks, total speed, average speed, total received bytes and received bytes array to live streaming.
    // 提供任何關於下載進度的資訊,如進度百分比的塊數總和、總速度、平均速度、總接收位元組數和接收位元組陣列的實時流。
    downloader.DownloadProgressChanged += OnDownloadProgressChanged;

    // Download completed event that can include occurred errors or cancelled or download completed successfully.
    // 下載完成的事件,可以包括髮生錯誤或被取消或下載成功。
    downloader.DownloadFileCompleted += OnDownloadFileCompleted;

接著就可以下載檔案了:

    string file = @"D:\1.html";
    string url = @"https://www.coderbusy.com";
    await downloader.DownloadFileTaskAsync(url, file);

下載非 HTTP 協議的檔案

除了 WebClient 可以下載 FTP 協議的檔案之外,上文所示的其他方法只能下載 HTTP 協議的檔案。

aria2 是一個輕量級的多協議和多源命令列下載工具。它支援 HTTP/HTTPS、FTP、SFTP、BitTorrent 和 Metalink。aria2 可以通過內建的 JSON-RPC 和 XML-RPC 介面進行操作。

我們可以呼叫 aria2 實現檔案下載功能。

GitHub 地址:https://github.com/aria2/aria2

下載地址:https://github.com/aria2/aria2/releases

將下載好的 aria2c.exe 複製到應用程式目錄,如果是其他系統則可以下載對應的二進位制檔案。

    public static async Task Download(string url, string fn)
    {
        var exe = "aria2c";
        var dir = Path.GetDirectoryName(fn);
        var name = Path.GetFileName(fn);

        void Output(object sender, DataReceivedEventArgs args)
        {
            if (string.IsNullOrWhiteSpace(args.Data))
            {
                return;
            }
            Console.WriteLine("Aria:{0}", args.Data?.Trim());
        }

        var args = $"-x 8 -s 8 --dir={dir} --out={name} {url}";
        var info = new ProcessStartInfo(exe, args)
        {
            UseShellExecute = false,
            CreateNoWindow = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
        };
        if (File.Exists(fn))
        {
            File.Delete(fn);
        }

        Console.WriteLine("啟動 aria2c: {0}", args);
        using (var p = new Process { StartInfo = info, EnableRaisingEvents = true })
        {
            if (!p.Start())
            {
                throw new Exception("aria 啟動失敗");
            }
            p.ErrorDataReceived += Output;
            p.OutputDataReceived += Output;
            p.BeginOutputReadLine();
            p.BeginErrorReadLine();
            await p.WaitForExitAsync();
            p.OutputDataReceived -= Output;
            p.ErrorDataReceived -= Output;
        }

        var fi = new FileInfo(fn);
        if (!fi.Exists || fi.Length == 0)
        {
            throw new FileNotFoundException("檔案下載失敗", fn);
        }
    }

以上程式碼通過命令列引數啟動了一個新的 aria2c 下載程式,並對下載進度資訊輸出在了控制檯。呼叫方式如下:

    var url = "https://www.coderbusy.com";
    var save = @"D:\1.html";
    await Download(url, save);

相關文章