Загрузка файлов - тривиальная и довольно распространенная задача для веб-разработки. Об этом есть множество библиотек, статей, руководств и т. Д., Но в большинстве из них не говорится об основной проблеме загрузки файлов - ошибках. Думаю, каждый из нас знает, как больно, когда закачка останавливается на 90% из-за ошибки подключения, и нам снова приходится закачивать весь файл с самого начала.

Сегодня я хочу поговорить о том, как решить проблемы с подключением к загрузке и сделать ваше приложение более быстрым и удобным для пользователя. Ну что ж, приступим!

Итак, как с этим справиться?

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

Но… Мы же можем наблюдать за процессом загрузки, верно? А еще у нас есть удобный метод Blob.slice (). Давайте разрежем наш файл по последнему загруженному байту, загрузим остальную часть и прикрепим части на сервер. И мы действительно можем это сделать! .. кроме проблемы - статус выполнения не совсем точен.

Что ж, в целом идея хороша, может, доработаем? Что, если мы попросим сервер сообщить последний загруженный байт? И это действительно решает проблему точности.

И мы уже можем писать код, но я не буду говорить об этом подходе :-) Нет, это хорошая идея, и она отлично работает, но я хочу предложить вам другой метод. Что, если мы разрежем файл на множество мелких частей и отправим каждую часть отдельно? Если мы это сделаем, мы сможем отправлять части параллельно и, вероятно, улучшить скорость загрузки вместе с резюме.

В этой статье я хочу показать вам, как кодировать многопоточную загрузку файлов, а в следующей я сравню это с классическим подходом к загрузке.

Подготовка файла

Прежде чем мы начнем, давайте определим chunkSize как размер одной части файла. Затем у нас есть Math.ceil(file.size / chunkSize) детали. Давайте также определим его как массив индексов частей, а затем перевернем его.

const chunksQueue = new Array(chunksQuantity).fill().map((_, index) => index).reverse();

Я отменил это, потому что я хочу отправлять детали последовательно и брать идентификатор следующей детали с конца.

Теперь мы готовы разделить файл:

function sendNext() {
    if (!chunksQueue.length) {
        console.log("All parts uploaded");
        return;
    }

    const chunkId = chunksQueue.pop();
    const begin = chunkId * chunkSize;
    const chunk = file.slice(begin, begin + chunkSize);

    upload(chunk, chunkId)
        .then(() => {
            sendNext();
        })
        .catch(() => {
            chunksQueue.push(chunkId);
        });
}

Надеюсь, вы заметили, что я возвращаю chunkId в очереди, если загрузка чанка не удалась. Это важно, потому что нам нужно успешно отправить каждую часть, и, делая это, я могу просто повторять sendNext, пока chunksQueue не станет пустым.

Отправка деталей

Начнем с одной части отправки - в основном она мало чем отличается от классической загрузки файлов:

function upload(chunk, chunkId) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.open("post", "/upload");

        xhr.setRequestHeader("Content-Type", "application/octet-stream");
        xhr.setRequestHeader("X-Chunk-Id", chunkId);
        xhr.setRequestHeader("X-Content-Id", fileId);
        xhr.setRequestHeader("Content-Length", chunk.size);
        // Size and real name of whole file, not just a chunk
        xhr.setRequestHeader("X-Content-Length", file.size);
        xhr.setRequestHeader("X-Content-Name", file.name);

        xhr.onreadystatechange = () => {
            if (xhr.readyState === 4 && xhr.status === 200) {
                resolve();
            }
        };

        xhr.onerror = reject;

        xhr.send(chunk);
    });
}

Для передачи служебной информации я использую кастомные заголовки - это заголовки, начинающиеся с X-. Это довольно хороший способ сообщить вашему серверу что-то о входящем запросе. В этом случае я отправляю chunkId как заголовок X-Chunk-Id для экземпляра.

Вы также могли заметить, что я установил Content-Type как application/octet-stream , хотя вы могли ожидать multipart/form-data. На самом деле, вы можете использовать multipart, но это немного усложняет ваш серверный код, потому что вам также нужно правильно анализировать multipart. И библиотек для этого, конечно же, предостаточно, но, на самом деле, вам не нужно multipart, потому что мы отправляем только файлы и разделяем их сами по себе, поэтому нам не нужно переносить границу и так далее.

Я думаю, что самое загадочное в функции для вас - это fileId. На самом деле это просто уникальный идентификатор файла (а не чанка!). И мы должны его отправить, потому что серверу нужно знать, кусок какого файла он получает сейчас. Я надеюсь, вы согласитесь, что если сервер объединит куски двух разных файлов, это будет ужасно!

Но как определить fileId? Хороший вопрос. Если у вас есть уникальный идентификатор клиента, такой как сеанс, вы можете просто определить идентификатор как временную метку и проверит сеанс и идентификатор на вашем сервере. Но если вы этого не сделали, я рекомендую определить идентификатор на стороне сервера до начала загрузки первого фрагмента. В этом случае вы также можете отправить file.name и file.size перед загрузкой и больше не отправлять их.

Многопоточность

До сих пор мы отправляли куски в одном потоке. Пришло время его улучшить!

let activeConnections = 0;
function sendNext() {
    if (activeConnections >= threadsQuantity) {
        return;
    }

    if (!chunksQueue.length) {
        if (!activeConnections) {
            console.log("All parts uploaded");
        }
        return;
    }

    const chunkId = chunksQueue.pop();
    const begin = chunkId * chunkSize;
    const chunk = file.slice(begin, begin + chunkSize);
    activeConnections += 1;
    upload(chunk, chunkId)
        .then(() => {
            activeConnections -= 1;
            sendNext();
        })
        .catch((error) => {
            activeConnections -= 1;
            chunksQueue.push(chunkId);
        });

    sendNext();
}

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

Сервер

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

Прежде всего, нам нужен метод, который получает файл и куда-то его записывает. В случае, когда я говорю «файл», я имею в виду кусок - одну небольшую часть большого файла, но для сервера особой разницы нет. Затем мы должны выполнить конкатенацию всех фрагментов, когда все фрагменты будут получены. И последнее, но не менее важное: мы должны игнорировать фрагмент и не включать его в файл целиком, если что-то пошло не так и мы не получили полный фрагмент.

app.post("/upload", (request, response) => {
    const chunk = [];
    request.on("data", (part) => {
        chunk.push(part); // It is parts of chunk, NOT chunks of a file
    }).on("end", () => {
        response.setHeader("Content-Type", "application/json");
        response.write(JSON.stringify({status: 200}));
        response.end();
    });
}));

В приведенном выше коде я использую экспресс для создания http-сервера, но я делаю это только для меньшего количества кода! Нет необходимости использовать экспресс, и вы можете использовать все, что захотите.

Итак, первая часть почти готова - мы получили chunk, но прежде чем писать, давайте подумаем, где? Помните, что фрагменты можно отправлять в любом порядке, но мы должны объединять их последовательно - в том же порядке, в котором они были в файле. И похоже, что мы действительно можем хранить куски в массиве и использовать chunkId в качестве индекса.

const fileChunks = [];
/* Not changed code */
}).on("end", () => {
    const chunkId = request.headers["x-chunk-id"];
    fileChunks[chunkId] = Buffer.concat(chunk);

Давайте также помнить, что сервер может получать множество файлов одновременно, и было бы здорово не испортить их. Наконец, мы можем использовать наш идентификатор файла!

Окончательный код сервера выглядит так:

const fileStorage = {};

app.post("/upload", (request, response) => {
    const fileId = request.headers["x-content-id"];
    const chunkSize = Number(request.headers["content-length"]);
    const chunkId = request.headers["x-chunk-id"];
    const chunksQuantity = request.headers["x-chunks-quantity"];
    const fileName = request.headers["x-content-name"];
    const fileSize = Number(request.headers["x-content-length"]);
    const file = fileStorage[fileId] = fileStorage[fileId] || [];
    const chunk = [];

    request.on("data", (part) => {
        chunk.push(part);
    }).on("end", () => {
        const completeChunk = Buffer.concat(chunk);
        
        if (completeChunk.length !== chunkSize) {
            sendBadRequest(response);
            return;
        }
        file[chunkId] = completeChunk;
        
        const fileCompleted = file.filter(chunk => !chunk).length === chunksQuantity;

        if (fileCompleted) {
            const completeFile = Buffer.concat(file);
            
            if (completeFile.length !== fileSize) {
                sendBadRequest(response);
                return;
            }
            
            const fileStream = fs.createWriteStream(__dirname + '/files/' + fileName);
            
            fileStream.write(completeFile);
            fileStream.end();

            delete fileStorage[fileId];
        }

        response.setHeader("Content-Type", "application/json");
        response.write(JSON.stringify({status: 200}));
        response.end();
    });
});

function sendBadRequest(response) {
    response.setHeader("Content-Type", "application/json");
    response.write(JSON.stringify({status: 400}));
    response.end();
}

Если во время загрузки какого-либо фрагмента сервер ответит со статусом неверный запрос (400), мы отправим его снова и перезапишем старый фрагмент в fileStorage.

Расчет прогресса

Мы почти закончили здесь. Последняя часть - это подсчет прогресса загрузки файла.

Фактически, мы можем посчитать только процент загруженных блоков. Это легко и, может быть, действительно методично, но давайте посчитаем весь реальный прогресс для науки (или, по крайней мере, для развлечения).

Таким образом, хотя подсчет выполнения полностью загруженных фрагментов не является проблемой, подсчет части «в процессе» немного сложен. Как незавершенные фрагменты на сервере, мы должны игнорировать процесс загрузки фрагментов, который был выполнен с ошибкой. Я сделал это с внедрением кеша прогресса - там будет весь прогресс активных соединений.

const progressCache = {};
let uploadedSize = 0;

function onProgressCallback({loaded, total}) {
    const percent = Math.round(loaded / total * 100 * 100) / 100;
    console.log("Uploaded", percent);
}

function onProgress(chunkId, event) {
    if (event.type === "progress" || event.type === "error" || event.type === "abort") {
        progressCache[chunkId] = event.loaded;
    }

    if (event.type === "loadend") {
        uploadedSize += progressCache[chunkId] || 0;
        delete progressCache[chunkId];
    }

    const inProgress = Object.keys(progressCache).reduce((memo, id) => memo + progressCache[id], 0);
    const sentLength = Math.min(uploadedSize + inProgress, file.size);

    onProgressCallback({
        loaded: sentLength,
        total: this.file.size
    })
}

Как видите, я также проверяю тип события прогресса. Я делаю это, потому что события error и abort будут записывать ноль в процессе, и мы не будем считать кешированный прогресс неудачного фрагмента как прогресс загрузки. Для этих событий вам нужно добавить несколько слушателей в функцию upload:

function upload(chunk, chunkId) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        const progressListener = onProgress.bind(null, chunkId);
        xhr.upload.addEventListener("progress", progressListener);

        xhr.addEventListener("error", progressListener);
        xhr.addEventListener("abort", progressListener);
        xhr.addEventListener("loadend", progressListener);

        xhr.open("post", "/upload");
        /* Not changed code */

Вот и все

Итак, теперь вы можете подключить весь приведенный выше код и попробовать его! Или вы можете просто клонировать мою тестовую реализацию с github.

git push origin master

Прежде чем вы начнете использовать это в производстве, я должен предупредить вас о нескольких вещах.

Во-первых, этот код не тестировался в производственной среде. Как видите, меня не волновала поддержка старых браузеров (например, Internet Explorer). На самом деле, вы можете перенести код на ES5, но я тестировал его только в Chrome и Firefox, так что будьте осторожны.

Во-вторых, помните, что все незавершенные файлы хранятся в памяти сервера. Вероятно, лучше будет создавать пустой файл для каждого нового fileId и добавлять каждый фрагмент файла. У этого подхода есть одна большая проблема - правильно вычислить начальный байт, но вы можете решить ее с помощью индекса блока и общего размера блока. И, пожалуйста, не забывайте, что последний кусок может быть меньше других.

В-третьих, нужно добавить больше проверок и обработки ошибок. Некоторые из них (но не все) я добавил в тестовое репо.

И, наконец, эта концепция особенно хороша с очередью запросов, о которой я говорил в прошлый раз. Так что, если вы его пропустили, рекомендую проверить.

Несколько последних слов

Так что теперь вы можете немного упростить жизнь своим пользователям. Надеюсь, статья была вам полезна. Спасибо за чтение!