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

от автора

Привет, чувак. Это я. То есть ты, только из будущего. Увы, тут у нас в 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 загруженных фоток, и это без показа миниатюр. Или я где-то накосячил?


ссылка на оригинал статьи https://habr.com/ru/articles/749746/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *