C#的流传输文件和断点下载
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System;
using Microsoft.Extensions.Logging;
namespace qqbot.Services // 请确保命名空间正确
{
public record DownloadProgress(long BytesDownloaded, long TotalBytes);
public class FileCacheHttpService : IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<FileCacheHttpService> _logger;
public int Retries { get; set; } = 5;
public FileCacheHttpService(HttpClient httpClient, ILogger<FileCacheHttpService> logger)
{
_logger = logger;
_httpClient = httpClient;
_httpClient.Timeout = TimeSpan.FromHours(1); // 保持一个较长的超时
}
public async Task DownlaodFileAsync(string url, string destinationPath,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default
)
{
for (int i = 0; i < Retries; i++)
{
try
{
_logger.LogInformation("开始缓存文件 {Url}", url);
await PerformDownloadAsync(url, destinationPath, progress, cancellationToken);
_logger.LogInformation("文件 {DestinationPath} 下载成功。", destinationPath);
// ✅ 关键修正 1: 成功后立即返回,不再继续循环。
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "第 {Attempt} 次下载失败。将在5秒后重试...", i + 1);
if (i == Retries - 1)
{
_logger.LogError(ex, "已达到最大重试次数,下载文件 {Url} 失败。", url);
throw; // 达到最大次数后,将异常抛出
}
await Task.Delay(5000, cancellationToken);
}
}
}
private async Task PerformDownloadAsync(
string url, string destinationPath, // 修正了参数名的拼写错误
IProgress<DownloadProgress>? progress,
CancellationToken cancellationToken
)
{
long existingFileSize = 0;
if (File.Exists(destinationPath))
{
existingFileSize = new FileInfo(destinationPath).Length;
}
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (existingFileSize > 0)
{
request.Headers.Range = new RangeHeaderValue(existingFileSize, null);
_logger.LogInformation("文件已存在,从 {Bytes} 字节处继续下载。", existingFileSize);
}
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.OK)
{
existingFileSize = 0;
}
else if (response.StatusCode != System.Net.HttpStatusCode.PartialContent && existingFileSize > 0)
{
throw new InvalidOperationException($"服务器不支持断点续传 (状态码: {response.StatusCode})");
}
response.EnsureSuccessStatusCode();
long totalBytes = response.Content.Headers.ContentLength ?? 0;
if (existingFileSize > 0 && response.Content.Headers.ContentRange != null)
{
totalBytes = response.Content.Headers.ContentRange.Length ?? 0;
}
_logger.LogInformation("开始下载,总大小 {TotalBytes} 字节。", totalBytes);
using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var fileStream = new FileStream(destinationPath, existingFileSize == 0 ? FileMode.Create : FileMode.Append, FileAccess.Write, FileShare.None);
var buffer = new byte[81920];
long totalBytesRead = existingFileSize;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken);
totalBytesRead += bytesRead;
progress?.Report(new DownloadProgress(totalBytesRead, totalBytes));
}
}
public void Dispose()
{
// ✅ 关键修正 2: 正确实现 Dispose
_httpClient?.Dispose();
}
}
}
检测文件的下载点,是否存在文件
long existingFileSize = 0;
// 检查下载点
if (File.Exists(desinationPath)){
existingFileSize = new FileInfo(desinationPath).Length;
}
在检查完是否已经存在了之后,开始建立一个请求,尝试从断点开始下载:
var request = new HttpRequestMessage(HttpMethod.Get, url); // 创建Url的http请求
if (existingFileSize > 0){
request.Headers.Range = new RangeHeaderValue(existingFileSize, null);
_logger.LogInformation("文件已经存在,开始从{Bytes字节下载}", existingFileSize);
}
之后获取响应头:为了避免内存爆炸和实现真正的流式处理。
// 确保先获取响应头,而不会缓冲整个响应体
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.OK){ // 不支持范围请求的情况
existingFileSize = 0; //
}
_httpClient 在后台会执行以下操作:
- 发送 GET 请求。
- 接收到响应头。
- 开始接收响应体(文件内容),并将其全部缓冲到一个内部的内存流中。
- 当整个文件都下载到内存中之后,
await操作才会完成,您的代码才会继续执行。
后果: 如果下载一个 1GB 的文件,程序会瞬间消耗 1GB 的内存。这对于服务器应用或同时进行多个下载的客户端来说是致命的。
因此我们使用ResponseHeadersRead 模式
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.OK){ // 不支持范围请求的情况
existingFileSize = 0; //
}
else if (response.StatusCode != System.Net.HttpStatusCode.PartialContent && existingFileSize > 0) {
throw new InvalidOperationException($"服务器不支持断点续传 (状态码: {response.StatusCode})");
}
response.EnsureSuccessStatusCode();
_httpClient 的行为会完全改变:
- 发送请求。
- 一旦接收到响应头,
await操作立即完成,您的代码就可以继续执行。 - 此时,响应体(文件内容)还停留在底层的网络套接字缓冲区中,几乎没有占用您的应用程序内存。
- 然后,您可以从
response.Content中获取一个网络流 (Stream)。 - 接下来,您就可以在一个循环中,用一个小小的缓冲区(例如 80KB),一块一块地从这个网络流中读取数据,并直接写入到磁盘的文件流中。
好处:
- 极低的内存占用: 无论您下载的文件是 1GB 还是 10GB,您的程序内存占用始终都非常低(只有那个小缓冲区的大小)。
- 更快的“首字节”响应: 您的代码可以非常快地拿到响应头,从而可以立即检查服务器的状态码 (
response.StatusCode)、文件总大小 (response.Content.Headers.ContentLength) 等信息,并根据这些信息来决定下一步怎么做(例如,我们代码中的断点续传逻辑就需要根据状态码是200 OK还是206 PartialContent来做不同处理)。
断点续传的进度条实现
long totalBytes = response.Content.Headers.ContentLength ?? 0;
if ( existingFileSize >0 && response.Content.Headers.ContentRange != null){
totalBytes = response.Content.Headers.ContentRange.Length ?? 0;
}
_logger.LogInformation("开始下载,总大小{TotalBytes}字节", totalBytes);
为了计算下载进度百分比,我们需要两个数:已下载字节数 和 文件总字节数。这段代码就是为了准确地获得第二个值。
含义: “首先,我假设这是一次全新的下载。请尝试从服务器响应的 Content-Length 头部信息中,获取文件的总大小。”
工作场景: 当我们发起一个普通的、从头开始的下载请求时,服务器会返回一个 200 OK 状态码,并在响应头中包含一个 Content-Length 字段,它的值就是文件的完整大小。
?? 0: 这是一个空值合并运算符,意思是如果服务器没有提供 Content-Length,就默认总大小为 0。
特殊情况 (断点续传)
if ( existingFileSize > 0 && response.Content.Headers.ContentRange != null){
totalBytes = response.Content.Headers.ContentRange.Length ?? 0;
}
existingFileSize > 0: 这个条件确认了我们本地确实已经存在一个未下载完的文件。
response.Content.Headers.ContentRange != null: 这个条件确认了服务器的回应是一个 206 Partial Content(部分内容)响应。这种响应必须包含一个 Content-Range 头部。
totalBytes = response.Content.Headers.ContentRange.Length ?? 0;:
这是最关键的一步! 当服务器响应一个“部分内容”时,它的 Content-Length 头部只会包含本次传输的数据块的大小,而不是整个文件的总大小。
而 Content-Range 头部则包含了更丰富的信息,它的格式通常像这样:bytes 1048576-2097151/5242880 这句话的意思是:“我正在向你发送从第 1048576 字节到第 2097151 字节的数据,这个文件的完整总大小是 5242880 字节。”response.Content.Headers.ContentRange.Length 正是用来精确地提取出那个 / 后面的完整文件总大小。
为什么要覆盖: 所以,如果这是一次断点续传,我们必须用 Content-Range 中的总大小,来覆盖掉 Content-Length 中那个不准确的“部分大小”。
// 异步读写数据流
using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
// append模式追踪数据
using var fileStream = new FileStream(desinationPath,existingFileSize == 0 ? FileMode.Create : FileMode.Append , FileAccess.Write, FileShare.None);
var buffer = new byte[81920]; // 80KB 缓冲区
long totalBytesRead = existingFileSize;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) {
await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken); //
totalBytesRead += bytesRead;
progress?.Report(new DownloadProgress(totalBytesRead, totalBytes));
}
在我们通过 ResponseHeadersRead 获取到响应头之后,这行代码会从 response.Content 中获取一个网络流 (Stream)。此时,真正的文件数据还在服务器端或网络缓冲区中,并没有被下载到您的程序内存里。contentStream 就是我们用来从网络上“抽水”的管子。
using var fileStream = new FileStream(...);这行代码会创建一个指向本地磁盘文件的文件流 (FileStream)。这是我们将要写入数据的目标。如果 existingFileSize 是 0(表示这是一次全新的下载),就使用 FileMode.Create 来创建一个新文件(如果已存在则覆盖)。如果 existingFileSize 大于 0(表示这是一次断点续传),就使用 FileMode.Append,它会打开现有文件并将写入位置自动定位到文件的末尾,准备追加新数据。
**while ((bytesRead = await contentStream.ReadAsync(...)) > 0)**作用: 这是下载的核心循环,意思是“只要网络水管里还有水,就一直抽”。
await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken): 这是一个异步操作。它会尝试从网络流 (contentStream) 中读取数据,最多读取buffer.Length那么多字节(在我们的例子中是 80KB),然后将数据放入buffer数组中。bytesRead变量会得到实际读取到的字节数。while (... > 0): 当服务器发送完所有数据后,下一次ReadAsync会返回0,这个循环就会自然结束。
await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken);:作用: 将刚刚从网络“抽”到的一桶水(buffer 里的数据),异步地倒入“本地水桶”(写入磁盘文件)。我们只写入 bytesRead 那么多,因为这才是本次实际读取到的数据量。
totalBytesRead += bytesRead;:
- 作用: 更新我们的“已下载总量”计数器。
progress?.Report(new DownloadProgress(totalBytesRead, totalBytes));:
- 作用: 向外界报告当前的下载进度。
progress?是一种空条件运算符,意思是“如果progress对象不是null(即调用者关心进度),才执行后面的代码”。Report(...): 这个方法会触发您在调用DownloadFileAsync时传入的那个IProgress<T>回调,将当前的“已下载量”和“总量”传递出去,从而让 UI 界面可以更新进度条。