Как стать автором
Обновить

Минималистичный загрузчик файлов

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров5.6K

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

Ну а пока я застрял здесь и вынужден как-то скинуть фотки со своего телефона, у которого почему-то отвалился MTP. В работе у меня - страница фидбэка для полностью статического сайта и я подумал - О! А ведь там загрузчик файлов будет очень кстати, заодно и фотки скину. И как только я начал его делать, в одном из списков рассылки Devuan вижу сообщение: как достали эти грёбаные телефоны, как скинуть файлы помогите пожалуйста.

Ну, думаю, раз не я такой один, значит оно того стоит. Когда-то я делал подобное, давным-давно, ещё во времена jQuery, но тогда был какой-то готовый компонент. А сейчас я ничего стороннего не хотел. Полез в MDN. Осмысленно всё скопипастил, и вот докладываю о результатах. Кое-что, конечно, повычистил, например, отслеживание файлов с одинаковыми имёнами, если они из разных каталогов, показ миниатюр, но это исключительно ради простоты изложения. Такие мелочи вы и сами легко сделаете.

Итак, базовый модуль загрузчика на Javascript. Модулем его назвать можно с большой натяжкой, так как весь Javascript и CSS у меня собирается в один HTML файл, упаковывается в gz, и дальше nginx раздаёт его налево и направо максимально быстро. Внутри HTML модуль Javascript становится анонимным без возможности экспорта чего-либо, поэтому приходится использовать старые недобрые методы.

(() => {

class FileUploader
{
    constructor(settings)
    {
        const default_settings = {
            url: '/',
            chunk_size: 512 * 1024,  // последний chunk может быть в полтора раза больше,
                                     // не забываем про лимиты request body на сервере
                                     // (у NGINX по умолчанию 1M)
            file_name_header: 'File-Name'  // что-нибудь стандартное типа Content-Disposition
                                           // было бы лучше, но его сложнее парсить
        };

        this.settings = Object.assign({}, default_settings, settings);
        this.upload_queue = [];
    }

    upload(file, params)
    /*
     * Добавляем файл в очередь, и если загрузка ещё не в процессе - тогда начинаем.
     */
    {
        const start_upload = this.upload_queue.length == 0;

        // Создаём file_item и добавляем в начало очереди
        const file_item = new FileItem(this, file, params);
        this.upload_queue.push(file_item);

        if(start_upload) {
            // если вызываем асинхронную функцию без await, получим promise,
            // либо fulfilled, либо pending. Но он нам всё равно не нужен.
            this._async_upload_files().then();
        }
    }

    progress(file, params, chunk_start_percentage, chunk_end_percentage, percentage)
    /*
     * Этот метод вызывается для отображения прогресс-бара.
     * Реализуем его в производном классе.
     */
    {
    }

    async upload_complete(file, params)
    /*
     * Этот метод вызывается по завершении загрузки.
     * Реализуем его в производном классе.
     */
    {
    }

    async _async_upload_files()
    {
        // обрабатываем очередь загрузки
        while(this.upload_queue.length != 0) {
            await this.upload_queue[0].upload();
            this.upload_queue.shift();
        }
    }
}

class FileItem
/*
 * Элемент очереди загрузки.
 */
{
    constructor(uploader, file, params)
    {
        this.uploader = uploader;
        this.file = file;
        this.params = params;
    }

    async upload()
    {
        var chunk_start = 0;
        var chunk_size;
        while(chunk_start < this.file.size) {
            const remaining_size = this.file.size - chunk_start;

            // загружаем кусками default_chunk_size, последний кусок допускается
            // в полтора раза больше, чем default_chunk_size
            if(remaining_size < 1.5 * this.uploader.settings.chunk_size) {
                chunk_size = remaining_size;
            } else {
                chunk_size = this.uploader.settings.chunk_size;
            }

            const chunk = this.file.slice(chunk_start, chunk_start + chunk_size);
            // XXX сохранять (start, end) в слайсе - грязный хак, а что делать?
            chunk.start = chunk_start;
            chunk.end = chunk_start + chunk_size;
            while(true) {
                try {
                    await this._upload_chunk(chunk);
                    break;
                } catch(error) {
                    console.log(`${this.file.name} upload error, retry in 5 seconds`);
                    await new Promise(resolve => setTimeout(resolve, 5000));
                }
            }

            chunk_start += chunk_size;
        }
        await this.uploader.upload_complete(this.file, this.params);
    }

    _upload_chunk(chunk)
    {
        // Эта функция использует non-awaitable XMLHttpRequest, поэтому не может быть async.
        // Но мы вызываем её с await, так что должны вернуть promise.

        const self = this;

        return new Promise((resolve, reject) => {

            const reader = new FileReader();
            const xhr = new XMLHttpRequest();

            xhr.upload.addEventListener(
                "progress",
                (e) => {
                    if(e.lengthComputable) {
                        const percentage = Math.round((e.loaded * 100) / e.total);
                        self._update_progress(chunk, percentage);
                    }
                },
                false
            );

            xhr.onreadystatechange = () => {
                if(xhr.readyState === xhr.DONE) {
                    if(xhr.status === 200) {
                        self._update_progress(chunk, 100);
                        resolve(xhr.response);
                    } else {
                        reject({
                            status: xhr.status,
                            statusText: xhr.statusText
                        });
                    }
                }
            };

            xhr.onerror = () => {
                reject({
                    status: xhr.status,
                    statusText: xhr.statusText
                });
            };

            xhr.open('POST', this.uploader.settings.url);

            const content_range = `bytes ${chunk.start}-${chunk.end - 1}/${this.file.size}`;
            xhr.setRequestHeader("Content-Range", content_range);
            xhr.setRequestHeader("Content-Type", "application/octet-stream");
            xhr.setRequestHeader(this.uploader.settings.file_name_header, this.file.name);

            reader.onload = (e) => {
                xhr.send(e.target.result);
            };

            reader.readAsArrayBuffer(chunk);
            self._update_progress(chunk, 0);
        });
    }

    _update_progress(chunk, percentage)
    {
        // считаем проценты и вызываем метод progress
        const chunk_start_percentage = chunk.start * 100 / this.file.size;
        const chunk_end_percentage = chunk.end * 100 / this.file.size;
        const upload_percentage = chunk_start_percentage + chunk.size * percentage / this.file.size;
        this.uploader.progress(
            this.file,
            this.params,
            chunk_start_percentage.toFixed(2),
            chunk_end_percentage.toFixed(2),
            upload_percentage.toFixed(2)
        );
    }
}

// типа экспортируем FileUploader
window.FileUploader = FileUploader;

})();

HTML и остальной Javascript:

<h3>Upload Files</h3>
<p>
    <button id="file-select">Choose Files</button> or drag and drop to the table below
</p>
<table id="file-list">
    <thead>
        <tr><th>File name</th><th>Size</th></tr>
    </thead>
    <tbody>
    </tbody>
</table>
<template id="file-row">
    <tr><td></td><td></td></tr>
</template>
<input type="file" id="files-input" multiple style="display:none">

<script>
    const upload_complete_color = 'rgb(0,192,0,0.2)';
    const chunk_complete_color = 'rgb(0,255,0,0.1)';

    class Uploader extends FileUploader
    {
        constructor()
        {
            super({url: '/api/feedback/upload'});

            this.elem = {
                file_select:  document.getElementById("file-select"),
                files_input:  document.getElementById("files-input"),
                file_list:    document.getElementById("file-list"),
                row_template: document.getElementById('file-row')
            };
            this.elem.tbody = this.elem.file_list.getElementsByTagName('tbody')[0];

            this.row_index = 0;

            this.set_event_handlers();
        }

        set_event_handlers()
        {
            const self = this;
            this.elem.file_select.addEventListener(
                "click",
                () => { self.elem.files_input.click(); },
                false
            );
            this.elem.files_input.addEventListener(
                "change",
                () => { self.handle_files(self.elem.files_input.files) },
                false
            );

            function consume_event(e)
            {
                e.stopPropagation();
                e.preventDefault();
            }

            function drop(e)
            {
                consume_event(e);
                self.handle_files(e.dataTransfer.files);
            }

            this.elem.file_list.addEventListener("dragenter", consume_event, false);
            this.elem.file_list.addEventListener("dragover", consume_event, false);
            this.elem.file_list.addEventListener("drop", drop, false);
        }

        progress(file, params, chunk_start_percentage, chunk_end_percentage, percentage)
        {
            params.progress_container.style.background = 'linear-gradient(to right, '
                + `${upload_complete_color} 0 ${percentage}%, `
                + `${chunk_complete_color} ${percentage}% ${chunk_end_percentage}%, `
                + `transparent ${chunk_end_percentage}%)`;
        }

        async upload_complete(file, params)
        {
            // красим зелёным всю строку
            params.progress_container.style.background = upload_complete_color;
            params.progress_container.nextSibling.style.background = upload_complete_color;
        }

        handle_files(files)
        /*
         * обрабатываем здесь файлы от drag'n'drop или диалога выбора
         */
        {
            for(const file of files) {
                const cols = this.append_file(file.size);
                this.upload(file, {progress_container: cols[0]});
            }
        }

        append_file(size)
        /*
         * Добавляем файл в таблицу, возвращаем список ячеек.
         */
        {
            const rows = this.elem.tbody.getElementsByTagName("tr");
            var row;
            if(this.row_index >= rows.length) {
                row = this.append_row();
            } else {
                row = rows[this.row_index];
            }
            this.row_index++;

            const cols = row.getElementsByTagName("td");
            cols[1].textContent = size.toString();
            return cols;
        }

        append_row()
        /*
         * Добавляем пустую строку к таблице.
         */
        {
            const tbody = this.elem.file_list.getElementsByTagName('tbody')[0];
            const row = this.elem.row_template.content.firstElementChild.cloneNode(true);
            tbody.appendChild(row);
            return row;
        }


    const uploader = new Uploader();

    // инициализируем таблицу - добавляем пять пустых строк
    for(let i = 0; i < 5; i++) uploader.append_row();

</script>

И, наконец, кусочек серверной части:

import os.path
import re
from starlette.responses import Response
import aiofiles.os

# Ничего этого в aiofiles нет. На момент написания, по крайней мере.
aiofiles.os.open = aiofiles.os.wrap(os.open)
aiofiles.os.close = aiofiles.os.wrap(os.close)
aiofiles.os.lseek = aiofiles.os.wrap(os.lseek)
aiofiles.os.write = aiofiles.os.wrap(os.write)

re_content_range = re.compile(r'bytes\s+(\d+)-(\d+)/(\d+)')

@expose(methods='POST')
async def upload(self, request):
    '''
    Ловим и записываем кусок файла.
    '''
    data = await request.body()
    filename = os.path.basename(request.headers['File-Name'])
    start, end, size = [int(n) for n in re_content_range.search(request.headers['Content-Range']).groups()]

    fd = await aiofiles.os.open(filename, os.O_CREAT | os.O_RDWR, mode=0o666)
    try:
        await aiofiles.os.lseek(fd, start, os.SEEK_SET)
        await aiofiles.os.write(fd, data)
    finally:
        await aiofiles.os.close(fd)
    return Response()

Что сказать в заключение? Мы бегаем по кругу. Процессоры мощнее, памяти больше, а загрузить все файлы за один раз мне не удалось. Браузер вылетал с OOM после 20-30 загруженных фоток, и это без показа миниатюр. Или я где-то накосячил?

Теги:
Хабы:
Всего голосов 10: ↑7 и ↓3+4
Комментарии61

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань