Проблема с памятью Chrome - File API + AngularJS

У меня есть веб-приложение, которое должно загружать большие файлы в хранилище Azure BLOB. Мое решение использует API-интерфейс HTML5 для разбивки на куски, которые затем помещаются как блочные блоки, идентификаторы блоков хранятся в массиве, а затем блоки фиксируются как blob.

Решение отлично работает в IE. В 64-битном Chrome я успешно загрузил 4Gb файлы, но вижу очень интенсивное использование памяти (2Gb +). В 32-битном Chrome конкретный процесс chrome достигнет 500-550 МБ, а затем сбой.

Я не вижу никаких явных утечек памяти или вещей, которые я могу изменить, чтобы помочь сборку мусора. Я храню идентификаторы блоков в массиве, поэтому, очевидно, будет некоторая память, но это не должно быть массивным. Это почти так, как будто File API содержит весь файл, который он кладет в память.

Он написан как служба Angular, вызванная с контроллера, я думаю, что только служебный код уместен:

(function() {
    'use strict';

    angular
    .module('app.core')
    .factory('blobUploadService',
    [
        '$http', 'stringUtilities',
        blobUploadService
    ]);

function blobUploadService($http, stringUtilities) {

    var defaultBlockSize = 1024 * 1024; // Default to 1024KB
    var stopWatch = {};
    var state = {};

    var initializeState = function(config) {
        var blockSize = defaultBlockSize;
        if (config.blockSize) blockSize = config.blockSize;

        var maxBlockSize = blockSize;
        var numberOfBlocks = 1;

        var file = config.file;

        var fileSize = file.size;
        if (fileSize < blockSize) {
            maxBlockSize = fileSize;
        }

        if (fileSize % maxBlockSize === 0) {
            numberOfBlocks = fileSize / maxBlockSize;
        } else {
            numberOfBlocks = parseInt(fileSize / maxBlockSize, 10) + 1;
        }

        return {
            maxBlockSize: maxBlockSize,
            numberOfBlocks: numberOfBlocks,
            totalBytesRemaining: fileSize,
            currentFilePointer: 0,
            blockIds: new Array(),
            blockIdPrefix: 'block-',
            bytesUploaded: 0,
            submitUri: null,
            file: file,
            baseUrl: config.baseUrl,
            sasToken: config.sasToken,
            fileUrl: config.baseUrl + config.sasToken,
            progress: config.progress,
            complete: config.complete,
            error: config.error,
            cancelled: false
        };
    };

    /* config: {
      baseUrl: // baseUrl for blob file uri (i.e. http://<accountName>.blob.core.windows.net/<container>/<blobname>),
      sasToken: // Shared access signature querystring key/value prefixed with ?,
      file: // File object using the HTML5 File API,
      progress: // progress callback function,
      complete: // complete callback function,
      error: // error callback function,
      blockSize: // Use this to override the defaultBlockSize
    } */
    var upload = function(config) {
        state = initializeState(config);

        var reader = new FileReader();
        reader.onloadend = function(evt) {
            if (evt.target.readyState === FileReader.DONE && !state.cancelled) { // DONE === 2
                var uri = state.fileUrl + '&comp=block&blockid=' + state.blockIds[state.blockIds.length - 1];
                var requestData = new Uint8Array(evt.target.result);

                $http.put(uri,
                        requestData,
                        {
                            headers: {
                                'x-ms-blob-type': 'BlockBlob',
                                'Content-Type': state.file.type
                            },
                            transformRequest: []
                        })
                    .success(function(data, status, headers, config) {
                        state.bytesUploaded += requestData.length;

                        var percentComplete = ((parseFloat(state.bytesUploaded) / parseFloat(state.file.size)) * 100
                        ).toFixed(2);
                        if (state.progress) state.progress(percentComplete, data, status, headers, config);

                        uploadFileInBlocks(reader, state);
                    })
                    .error(function(data, status, headers, config) {
                        if (state.error) state.error(data, status, headers, config);
                    });
            }
        };

        uploadFileInBlocks(reader, state);

        return {
            cancel: function() {
                state.cancelled = true;
            }
        };
    };

    function cancel() {
        stopWatch = {};
        state.cancelled = true;
        return true;
    }

    function startStopWatch(handle) {
        if (stopWatch[handle] === undefined) {
            stopWatch[handle] = {};
            stopWatch[handle].start = Date.now();
        }
    }

    function stopStopWatch(handle) {
        stopWatch[handle].stop = Date.now();
        var duration = stopWatch[handle].stop - stopWatch[handle].start;
        delete stopWatch[handle];
        return duration;
    }

    var commitBlockList = function(state) {
        var uri = state.fileUrl + '&comp=blocklist';

        var requestBody = '<?xml version="1.0" encoding="utf-8"?><BlockList>';
        for (var i = 0; i < state.blockIds.length; i++) {
            requestBody += '<Latest>' + state.blockIds[i] + '</Latest>';
        }
        requestBody += '</BlockList>';

        $http.put(uri,
                requestBody,
                {
                    headers: {
                        'x-ms-blob-content-type': state.file.type
                    }
                })
            .success(function(data, status, headers, config) {
                if (state.complete) state.complete(data, status, headers, config);
            })
            .error(function(data, status, headers, config) {
                if (state.error) state.error(data, status, headers, config);
                // called asynchronously if an error occurs
                // or server returns response with an error status.
            });
    };

    var uploadFileInBlocks = function(reader, state) {
        if (!state.cancelled) {
            if (state.totalBytesRemaining > 0) {

                var fileContent = state.file.slice(state.currentFilePointer,
                    state.currentFilePointer + state.maxBlockSize);
                var blockId = state.blockIdPrefix + stringUtilities.pad(state.blockIds.length, 6);

                state.blockIds.push(btoa(blockId));
                reader.readAsArrayBuffer(fileContent);

                state.currentFilePointer += state.maxBlockSize;
                state.totalBytesRemaining -= state.maxBlockSize;
                if (state.totalBytesRemaining < state.maxBlockSize) {
                    state.maxBlockSize = state.totalBytesRemaining;
                }
            } else {
                commitBlockList(state);
            }
        }
    };

    return {
        upload: upload,
        cancel: cancel,
        startStopWatch: startStopWatch,
        stopStopWatch: stopStopWatch
    };
};
})();

Есть ли способ, которым я могу перемещать область объектов, чтобы помочь с Chrome GC? Я видел, как другие люди упоминали подобные проблемы, но понимали, что Хром решил некоторые.

Я должен сказать, что мое решение в значительной степени основано на блоге Gaurav Mantri здесь:

http://gauravmantri.com/2013/02/16/uploading-large-files-in-windows-azure-blob-storage-using-shared-access-signature-html-and-javascript/#comment-47480

Ответ 1

Я не вижу явных утечек памяти или вещей, которые я могу изменить, чтобы помочь вывоз мусора. Я храню идентификаторы блоков в массиве, очевидно будет некоторая память, но это не должно быть массивным. Это почти так, как будто File API содержит весь файл, который он разрезает на память.

Вы правы. Новый Blob, созданный .slice(), хранится в памяти.

Решение заключается в вызове Blob.prototype.close() в ссылке Blob при завершении обработки объекта Blob или File.

Обратите внимание, что в javascript в Question также создается новый экземпляр FileReader, если функция upload вызывается более одного раза.

4.3.1. Метод среза

Метод slice() возвращает новый объект Blob с размером байтов от необязательного параметра start до, но не включая необязательный параметр end и атрибут type, который является значение необязательного параметра contentType.

Blob экземпляры существуют в течение document. Хотя Blob следует собирать мусор после удаления из Blob URL Store

9.6. Время жизни URL-адресов Blob

Примечание. Пользовательские агенты могут мусор собирать ресурсы, удаленные из Blob URL Store.

Каждый Blob должен иметь внутреннее состояние StructuredClone. Дальнейшее нормативное определение состояния моментального снимка может для File s.

4.3.2. Метод close

Метод close() называется close a Blob и должен действовать как следующим образом:

  • Если readability state контекстного объекта CLOSED, прервите этот алгоритм.
  • В противном случае установите readability state в context object на CLOSED.
  • Если объект контекста имеет запись в Blob URL Store, удалите запись, соответствующую context object.

Если объект Blob передан в URL.createObjectURL(), вызовите URL.revokeObjectURL() на Blob или File объект, затем вызовите .close().

revokeObjectURL(url) статический метод

Отменяет Blob URL, указанный в строке url, удалив соответствующую запись из Хранилища URL-адресов Blob. Этот метод должен действовать   следующим образом:   1. Если url относится к Blob, у которого есть readability state of CLOSED ИЛИ, если значение, предоставленное для Аргумент url  а не Blob URL, ИЛИ, если значение, предоставленное для аргумента url,   не имеет записи в Blob URL Store, этот вызов метода делает   ничего. Пользовательские агенты могут отображать сообщение на консоли ошибок.   2. В противном случае пользовательские агенты должны remove the entry из Blob URL Store для url.

Вы можете просмотреть результат этих вызовов, открыв

chrome://blob-internals 

просмотр деталей до и после вызовов, которые создают Blob и закрыть Blob.

Например, из

xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Refcount: 1
Content Type: text/plain
Type: data
Length: 3

to

xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Refcount: 1
Content Type: text/plain

после вызова .close(). Аналогично из

blob:http://example.com/c2823f75-de26-46f9-a4e5-95f57b8230bd
Uuid: 29e430a6-f093-40c2-bc70-2b6838a713bc

Альтернативным подходом может быть отправка файла в виде ArrayBuffer или фрагментов буферов массивов. Затем повторно соберите файл на сервере.

Или вы можете вызывать FileReader конструктор, FileReader.prototype.readAsArrayBuffer() и load событие FileReader каждый раз.

В load событие FileReader пройдет ArrayBuffer до Uint8Array, используйте ReadableStream, TypedArray.prototype.subarray(), .getReader(), .read(), чтобы получить N фрагменты ArrayBuffer как TypedArray at pull от Uint8Array. Когда обработаны N фрагменты, равные .byteLength of ArrayBuffer, передайте массив конструктора Uint8Array в Blob для рекомбинации частей файла в один файл в браузере; затем отправьте Blob на сервер.

<!DOCTYPE html>
<html>

<head>
</head>

<body>
  <input id="file" type="file">
  <br>
  <progress value="0"></progress>
  <br>
  <output for="file"><img alt="preview"></output>
  <script type="text/javascript">
    const [input, output, img, progress, fr, handleError, CHUNK] = [
      document.querySelector("input[type='file']")
      , document.querySelector("output[for='file']")
      , document.querySelector("output img")
      , document.querySelector("progress")
      , new FileReader
      , (err) => console.log(err)
      , 1024 * 1024
    ];

    progress.addEventListener("progress", e => {
      progress.value = e.detail.value;
      e.detail.promise();
    });

    let [chunks, NEXT, CURR, url, blob] = [Array(), 0, 0];

    input.onchange = () => {
      NEXT = CURR = progress.value = progress.max = chunks.length = 0;
      if (url) {
        URL.revokeObjectURL(url);
        if (blob.hasOwnProperty("close")) {
          blob.close();
        }
      }

      if (input.files.length) {
        console.log(input.files[0]);
        progress.max = input.files[0].size;
        progress.step = progress.max / CHUNK;
        fr.readAsArrayBuffer(input.files[0]);
      }

    }

    fr.onload = () => {
      const VIEW = new Uint8Array(fr.result);
      const LEN = VIEW.byteLength;
      const {type, name:filename} = input.files[0];
      const stream = new ReadableStream({
          pull(controller) {
            if (NEXT < LEN) {
              controller
              .enqueue(VIEW.subarray(NEXT, !NEXT ? CHUNK : CHUNK + NEXT));
               NEXT += CHUNK;
            } else {
              controller.close();
            }
          },
          cancel(reason) {
            console.log(reason);
            throw new Error(reason);
          }
      });

      const [reader, processData] = [
        stream.getReader()
        , ({value, done}) => {
            if (done) {
              return reader.closed.then(() => chunks);
            }
            chunks.push(value);
            return new Promise(resolve => {
              progress.dispatchEvent(
                new CustomEvent("progress", {
                  detail:{
                    value:CURR += value.byteLength,
                    promise:resolve
                  }
                })
              );                
            })
            .then(() => reader.read().then(data => processData(data)))
            .catch(e => reader.cancel(e))
        }
      ];

      reader.read()
      .then(data => processData(data))
      .then(data => {
        blob = new Blob(data, {type});
        console.log("complete", data, blob);
        if (/image/.test(type)) {
          url = URL.createObjectURL(blob);
          img.onload = () => {
            img.title = filename;
            input.value = "";
          }
          img.src = url;
        } else {
          input.value = "";
        }             
      })
      .catch(e => handleError(e))

    }
  </script>

</body>

</html>

plnkr http://plnkr.co/edit/AEZ7iQce4QaJOKut71jk?p=preview


Вы также можете использовать утилиту fetch()

fetch(new Request("/path/to/server/", {method:"PUT", body:blob}))

Кому передать тело для requestзапросите, запустите эти шаги:

  • Пусть тело будет запросом body.
  • Если тело равно null, тогда очередь запроса на выборку по запросу обрабатывать запрос конца тела для запроса и прервать эти шаги.

  • Пусть чтение будет результатом чтения фрагмента из потока bodys.

    • Когда чтение выполняется с объектом, чье свойство done является ложным и чье свойство value является объектом Uint8Array, запустите эти субшаги:

      • Пусть байты представляют собой последовательность байтов, представленную объектом Uint8Array.
      • Передача байтов.

      • Увеличить количество переданных байтов байтов по длине байтов.

      • Повторите вышеуказанный шаг.

    • Когда чтение выполняется с объектом, чье свойство done равно true, очередь на задание выборки по запросу для обработки запроса на конец тела для запроса.

    • Когда чтение выполняется со значением, которое не соответствует ни одному из вышеприведенных шаблонов, или чтение отклонено, прекратите текущую fetch с фатальной причиной.

См. также