要使用 HttpClient 下載數百 MB 甚至數 GB 的大檔,若使用一般寫法,檔案下載完成前,程式會陷入無止盡的等待,使用者不知下載進度,程式是不是當掉,沒法後悔中斷,是種糟糕的操作體驗。另一方面,數百 MB 數 GB 的檔案先存進記憶體再寫成檔案,對電腦的記憶體也會造成負擔。

var url = "https://github.com/microsoft/vscode-mssql/releases/download/v1.40.0/mssql-1.40.0-win-x64.vsix";
var process = System.Diagnostics.Process.GetCurrentProcess();
Action<string> showMemoryUsage = (title) =>
{
    process.Refresh();
    var memoryUsage = process.WorkingSet64;
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {title}: {memoryUsage / (1024.0 * 1024.0):F2} MB");
};
showMemoryUsage("Before GetAsync()");
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(url);
showMemoryUsage("After GetAsync()");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsByteArrayAsync();
showMemoryUsage("After ReadAsByteArrayAsync()");
Console.WriteLine($"共下載 {content.Length:n0} bytes");

結果不太理想,下載 96M 的檔案,GetAsync() 後記憶體由 19.62 上升到 145.47 MB,ReadAsByteArrayAsync() 後再飆上 237.92 MB,差不多是在記憶體中硬塞了兩份 96MB 檔案內容:

思考了一下,下載內容如果要寫成檔案或轉送其他地方,沒必要一次轉成 byte[],可改用 ReadAsStreamAsync(),再以串流方式讀取:

showMemoryUsage("Before GetAsync()");
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(url);
showMemoryUsage("After GetAsync()");
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync();
showMemoryUsage("After ReadAsStreamAsync()");
var buffer = new byte[1024 * 1024];
var len = 0;
var count = 0;
while ((len = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
    count += len;
}
Console.WriteLine($"共下載 {count:n0} bytes");
showMemoryUsage("After ReadAsync()");

經過這番改良,記憶體用量由 240 下降到 146MB。但,等待下載完成的這 30 秒,使用者完全看不到下載進度也無法放棄下載(除非 Ctrl-C 暴力中止整個程序),不符合大家對友善程式的期待。

所以,我們來改寫一下,讓程式可以即時回報下載進度,並且允許使用者按下任何鍵放棄下載。(輔助說明寫在註解)

using System.Runtime.CompilerServices;

var url = "https://github.com/microsoft/vscode-mssql/releases/download/v1.40.0/mssql-1.40.0-win-x64.vsix";
var process = System.Diagnostics.Process.GetCurrentProcess();
Action<string> showMemoryUsage = (title) =>
{
    process.Refresh();
    var memoryUsage = process.WorkingSet64;
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {title}: {memoryUsage / (1024.0 * 1024.0):F2} MB");
};

// 使用 CancellationTokenSource 管理取消下載請求
using var cts = new CancellationTokenSource();
// 註冊 Ctrl+C 事件,當使用者按下 Ctrl+C 時取消下載
Console.CancelKeyPress += (_, e) =>
{
    // 阻止應用程式被中止
    e.Cancel = true;
    Console.Write("取消下載中(Ctrl+C)...");
    cts.Cancel();
};
var fileName = string.Empty;
var downloadTask = Task.Run(async () =>
{
    showMemoryUsage("Before GetAsync()");
    using var httpClient = new HttpClient();
    var response = await httpClient.GetAsync(url,
        // 改為 HttpCompletionOption.ResponseHeadersRead,讓 HttpClient 讀完 HTTP 標頭後就返回
        // 預設值為 ResponseContentRead,會等到整個內容下載回傳
        HttpCompletionOption.ResponseHeadersRead, cts.Token);
    showMemoryUsage("After GetAsync()");
    response.EnsureSuccessStatusCode();

    // 取得存檔名稱
    fileName = Path.GetFileName(url);
    if (response.Content.Headers.ContentDisposition != null && 
        !string.IsNullOrEmpty(response.Content.Headers.ContentDisposition.FileName))
    {
        fileName = response.Content.Headers.ContentDisposition.FileName.Trim('"');
    }

    using var fileStream = new FileStream(fileName, FileMode.Create, FileAccess.Write, FileShare.None);
    var totalBytes = response.Content.Headers.ContentLength ?? 0;
    var bytesRead = 0L;
    // 所有 Async 方法都傳入 CancellationToken,以便儘快中止動作
    using var stream = await response.Content.ReadAsStreamAsync(cts.Token);
    var buffer = new byte[1024 * 1024];
    Console.Write("Downloading... ");
    var cursorLeft = Console.CursorLeft;
    try
    {
        Console.CursorVisible = false; // 隱藏游標,避免顯示進度時閃爍
        while (true)
        {
            var bytes = await stream.ReadAsync(buffer, cts.Token);
            if (bytes == 0) break;
            await fileStream.WriteAsync(buffer.AsMemory(0, bytes), cts.Token);
            bytesRead += bytes;
            Console.CursorLeft = cursorLeft; // 將游標移回進度數字起始位置覆寫前一次的值
            if (totalBytes > 0)
                Console.Write($"{(double)bytesRead / totalBytes:P1}  ");
            else
                Console.Write($"{bytesRead:n0} bytes   ");
        }
    }
    finally
    {
        Console.CursorVisible = true;
        Console.WriteLine();
    }
    fileStream.Dispose(); // 結束寫入檔案,釋放 FileStream
    showMemoryUsage("After File Saved");
    Console.WriteLine($"共下載 {bytesRead:n0} bytes");
}, cts.Token);

Console.WriteLine("按任意鍵取消下載...");
while (!downloadTask.IsCompleted)
{
    if (Console.KeyAvailable)
    {
        Console.Write("取消下載中(按任意鍵)...");
        Console.ReadKey(true);
        cts.Cancel();
        break;
    }
    await Task.Delay(100);
}

await downloadTask.ContinueWith(t =>
{
    showMemoryUsage("After Task Completed");
    if (t.IsCompletedSuccessfully)
    {
        Console.WriteLine($"下載完成,檔案已儲存為 {fileName}");
        return;
    }
    if (t.IsCanceled)
    {
        Console.WriteLine("下載已取消");
    }
    else if (t.IsFaulted)
    {
        Console.WriteLine($"下載失敗: {t.Exception?.GetBaseException().Message}");
    }
    if (!string.IsNullOrEmpty(fileName) && File.Exists(fileName))
    {
        try
        {
            File.Delete(fileName);
            Console.WriteLine("已刪除未完成的檔案");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"無法刪除檔案: {ex.Message}");
        }
    }
});

分別試了三次,第一次按任意鍵中止,第二次按 Ctrl-C 中止,第三次等待下載完成,下載過程可以即時看到完成百分比,中止時也會優雅結束,並刪除沒下載完的殘缺檔案。

很優雅吧?

thumbnail

Demonstrates how to download large files in .NET efficiently using streaming, progress reporting, and cancellation. Compares memory usage pitfalls of ReadAsByteArrayAsync() and shows a more elegant, user‑friendly solution with HttpClient, streams, and CancellationToken.


Comments

# by Tim

感謝分享

Post a comment