Реализация докачивания файла HttpClient

Разрабатываю приложение, которое должно скачать файл с web адреса на компьютер и дальше его обработать.
Задачу вроде реализовал, все работает и тут ко мне обратился клиент и говорит:

У меня интернет медленный и имеет частые обрывы соединения, не можешь дать прямую ссылку, а то программа качает файл по новой.

И вот собственно я пытаюсь исправить эту проблему, а точнее надо сделать следующие 2 вещи:

  1. Возобновление загрузки файла, после обрыва соединения. - Частично решил при помощи буфера, кратковременные обрывы вроде как говорят гасит.
  2. Докачать уже имеющийся "Огрызок", который остался после предыдущего процесса неудачного скачивания.

Сейчас процесс скачивания файла выглядит примерно так:

private async Task ProcessContentStream(long? totalDownloadSize, Stream contentStream)
{
    var totalBytesRead = 0L;
    var readCount = 0L;
    var buffer = new byte[4096];
    var isMoreToRead = true;

    using (var fileStream = new FileStream(_destinationFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true))
    {
        do
        {
            var bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length);
            if (bytesRead == 0)
            {
                isMoreToRead = false;
                TriggerProgressChanged(totalDownloadSize, totalBytesRead);
                continue;
            }

            await fileStream.WriteAsync(buffer, 0, bytesRead);

            totalBytesRead += bytesRead;
            readCount += 1;

            if (readCount % 100 == 0)
                TriggerProgressChanged(totalDownloadSize, totalBytesRead);
        }
        while (isMoreToRead);
    }
}

То есть мы с сервера берем кусок (буфер) и записываем его в FileStream до тех пор, пока сервер не отдаст нам весь файл.

Я пробовал:

  • При запуске сдвигать позицию Stream'ов до fileStream.Length (текущего размера файла), но HttpClient отдает обычный Stream, который имеет CanSeek = false, то есть я не могу с сервера начать получать данные с определенной точки, только с самого начала.
  • Я пробовал пропускать запись в файл (await fileStream.WriteAsync(buffer, 0, bytesRead);) до тех пор, пока кол-во полученных с сервера данных не будет равно текущему размеру файла, то есть делаю нечто такое (набросок):

    if (totalBytesRead >= fileStream.Length)
        await fileStream.WriteAsync(buffer, 0, bytesRead);
    

    Но по ощущениям, из за await contentStream.ReadAsync(buffer, 0, buffer.Length); я данные все равно качаю с сервера, но только не записываю, а в результате получаю битый файл, который имеет разрыв ровно в том месте, где остановилась загрузка. Тут скорей всего происходит подобное из за того, что буфер был не до конца записан при закрытие приложения и при повторной загрузке мне надо файл начать записывать на 1 "буфер" раньше, но как это сделать так и не понял.

Короче, я в тупике и без понятия как докачать уже существующий файл на диске, посла обрыва/закрытия приложения. Было бы супер, если бы вы научили меня брать с сервера байты с определенной позиции, не затрагивая начало (Seek в Stream), тогда можно наверно после разрыва запросить эти байты и продолжить закачку, или есть другие варианты?


Ответы (1 шт):

Автор решения: EvgeniyZ

В общем да, спасибо всем, что помогли и натолкнули на Заголовок Range.

  • Сделал метод, который возвращает текущий размер файла с "откатом" на размер буфера:

    private long GetFileSize()
    {
        long? fileSize = null;
        if (_isExist)
        {
            fileSize = new FileInfo(_destinationFilePath)?.Length;
    
            var tail = fileSize % _bufferSize;
            if (tail != 0) fileSize -= _bufferSize;
        }
    
        return fileSize ?? 0L;
    }
    

    Результатом он выдаст либо 0, либо размер файла - размер буфера.

  • Дальше переписал отправку запроса на такое:

    var request = new HttpRequestMessage { RequestUri = new Uri(_downloadUrl) };
    request.Headers.Range = new RangeHeaderValue(_fileSize, null);
    using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
    
    • RangeHeaderValue(_fileSize, null); - отправляем серверу размер ранее полученного файла и берем весь остаток. Если 0 - сервер отдает все.
    • _httpClient.SendAsync(...) - отправляем на сервер GET запрос с Range заголовком.
  • У FileStream установил FileMode.OpenOrCreate, чтоб файл не перезписывало.

  • Перед чтением данных с сервера задаю позицию FileStream на размер файла, который ранее получили:

    fileStream.Seek(_fileSize, SeekOrigin.Begin);
    

Собственно и все, что необходимо для продолжения скачивания файла. Сам код сейчас примерно такой (черновик):

public class DownloadManager : IDisposable
{
    private readonly HttpClient _httpClient;

    private readonly string _downloadUrl;
    private readonly string _destinationFilePath;
    private long _fileSize;
    private long? _totalFileSize;
    private bool _isExist;
    private const int _bufferSize = 8192;

    private int _connections;

    public delegate void ProgressChangedHandler(long? totalFileSize, long totalBytesDownloaded, double? progressPercentage);
    public event ProgressChangedHandler ProgressChanged;

    public DownloadManager(string downloadUrl, string destinationFilePath)
        => (_downloadUrl, _destinationFilePath, _httpClient)
        = (downloadUrl, destinationFilePath, new HttpClient());

    public async Task StartDownload()
    {
        bool isConnect = false;
        do
        {
            try
            {
                using var sizeRequest = await _httpClient.GetAsync(_downloadUrl, HttpCompletionOption.ResponseHeadersRead);
                _totalFileSize = sizeRequest.Content.Headers.ContentLength;
                isConnect = true;
                _connections = 0;
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                _connections++;
                await Task.Delay(TimeSpan.FromSeconds(5));
            }

        } while (!isConnect && _connections <= 15);

        if (!isConnect) return;

        _isExist = File.Exists(_destinationFilePath);
        _fileSize = GetFileSize();

        var request = new HttpRequestMessage { RequestUri = new Uri(_downloadUrl) };
        request.Headers.Range = new RangeHeaderValue(_fileSize, null);
        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        await DownloadFileFromHttpResponseMessage(response);
    }

    private async Task DownloadFileFromHttpResponseMessage(HttpResponseMessage response)
    {
        Console.WriteLine($"[{response.StatusCode}]: {response.RequestMessage}");

        if (response.IsSuccessStatusCode)
        {
            using var contentStream = await response.Content.ReadAsStreamAsync();
            await ProcessContentStream(_totalFileSize, contentStream);
        }
        else
        {
            if (_fileSize != _totalFileSize)
                response.EnsureSuccessStatusCode();
        }
    }

    private async Task ProcessContentStream(long? totalDownloadSize, Stream contentStream)
    {
        var buffer = new byte[_bufferSize];
        var totalBytesRead = _fileSize;
        var isMoreToRead = true;
        var readCount = 0L;

        using (var fileStream = new FileStream(_destinationFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, _bufferSize, true))
        {
            fileStream.Seek(_fileSize, SeekOrigin.Begin);

            do
            {
                int bytesRead = 0;

                try
                {
                    bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length);
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message);

                    await fileStream.FlushAsync();
                    fileStream.Close();
                    fileStream.Dispose();
                    await StartDownload();
                    return;
                }


                if (bytesRead == 0)
                {
                    isMoreToRead = false;
                    TriggerProgressChanged(totalDownloadSize, totalBytesRead);
                    continue;
                }

                await fileStream.WriteAsync(buffer, 0, bytesRead);

                totalBytesRead += bytesRead;
                readCount += 1;

                if (readCount % 100 == 0)
                    TriggerProgressChanged(totalDownloadSize, totalBytesRead);
            }
            while (isMoreToRead);
        }

        Console.WriteLine(_destinationFilePath.GetMD5());
    }

    private void TriggerProgressChanged(long? totalDownloadSize, long totalBytesRead)
    {
        if (ProgressChanged == null)
            return;

        double? progressPercentage = null;
        if (totalDownloadSize.HasValue)
            progressPercentage = Math.Round((double)totalBytesRead / totalDownloadSize.Value * 100, 2);

        ProgressChanged(totalDownloadSize, totalBytesRead, progressPercentage);
    }

    private long GetFileSize()
    {
        long? fileSize = null;
        if (_isExist)
        {
            fileSize = new FileInfo(_destinationFilePath)?.Length;

            var tail = fileSize % _bufferSize;
            if (tail != 0) fileSize -= _bufferSize;
        }

        return fileSize ?? 0L;
    }

    public void Dispose() => _httpClient?.Dispose();
}

Издевательства по типу:

  • Открытие/закрытие окна каждые 3-5%.
  • Отключение интернета.
  • Заморозки процесса на длительное время.

код успешно прошел, файл всегда докачивался и имел всегда верный Md5 и размер.
Осталось подчистить, привести все в порядок, пару проверок добавить, событий и можно использовать)

→ Ссылка