Привет, чувак. Это я. То есть ты, только из будущего. Увы, тут у нас в 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/
Добавить комментарий