HttpClient 下載超大檔案的優雅做法 - 串流讀取、進度回報及允許中止
| | | 1 | |
要使用 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 中止,第三次等待下載完成,下載過程可以即時看到完成百分比,中止時也會優雅結束,並刪除沒下載完的殘缺檔案。

很優雅吧?

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
感謝分享