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