Безпечне завантаження файлів. Створюємо прототип
Декілька років тому я на співбесіді отримав тестове завдання — реалізувати безпечне завантаження файлів на сервер. Воно мені так сподобалось, що у вільний від роботи час я почав експериментувати над суттєвим розширенням його можливостей. Пізніше я не раз про це шкодував, адже кожна моя нова ідея призводила до зміни архітектури та значної частини коду.
Коли я прочитав завдання, то спершу припустив, що мова йде про звичайну HTML-форму для завантаження файлів на сервер. Пригадую, як дуже зрадів цьому, адже з такою легкою перевіркою на співбесіді, я однозначно отримаю роботу. Однак майже відразу мій внутрішній голос почав наполягати, що не може бути все так просто й інтуїція мене не підвела.
Дослідивши це питання в інтернеті, я знайшов низькорівневий метод Blob.slice() з File API. З його допомогою можна отримати частину обраного користувачем файлу, вказавши необхідні значення зміщення початку та кінця фрагменту відносно початку файлу. Це дає можливість, наприклад, організувати контрольовану передачу файлу порціями, поєднуючи їх на сервері.
Вимоги
Спершу необхідно реалізувати функцію повторного відправлення фрагменту файлу в разі виникнення проблем та функцію дозавантаження. Також обовʼязково додати можливість користувачу керувати процесом завантаження за допомогою стандартних кнопок «Завантажити», «Призупинити», «Продовжити», «Скасувати». Та й чому б не налагодити отримання вичерпної динамічної інформації про перебіг процесу завантаження для зручного інформування користувача.
Інформаційне вікно процесу завантаження файлів
Окреме важливе питання — це методологія визначення оптимального розміру частин файлу, на які його необхідно ділити під час завантаження на сервер. Файли малого розміру краще ділити на маленькі порції, а файли великого розміру — відповідно на великі. Також необхідно зважати на пропускну здатність каналу, яка може мінятися динамічно через інше одночасне паралельне завантаження.
У разі виникнення помилок, під час відправлення порцій файлу, їхній розмір бажано зменшити, щоби зменшити ймовірність виникнення повторної помилки. І взагалі поняття маленький та великий — дуже відносні та абстрактні, скільки конкретно це в байтах? Так у мене виникла ідея створити алгоритм динамічного адаптивного визначення оптимального розміру фрагменту файлу.
Виведення детальної інформації про фрагменти в консолі
Ще варто отримувати деталізовану інформацію про завантаження кожної порції файлу окремо для виводу її в консоль з метою зневадження. Окрім цього, в мене ще була ідея реалізувати підвищений контроль за фрагментами файлу за допомогою технології блокчейн. Але через використання захищеного протоколу HTTPS та за браком бажання зберігати додаткові проміжні дані завантаження на сервері, вирішив від неї відмовитись.
Реалізація
Для реалізації завантажувача необхідно створити скрипти, як на клієнті — зчитування та передача фрагментів файлу, так і на сервері — отримання та збереження фрагментів у файл. Клієнт ділить обраний користувачем файл на частини, відправляє їх, керує процесом на вимогу користувача та відображає інформацію про завантаження. Сервер виконує команди клієнта, отримує фрагменти файлу та поєднує їх, записуючи в тимчасовий файл, а після закінчення переміщує його в зазначену теку.
Схема файлів завантажувача
Для повноцінної роботи завантажувача нам необхідно 5 файлів, кожен із яких виконує свою певну роль. Я завантажив їх усіх на GitHub в теку SafeUpload.
index.htm | Приклад інтеграції завантажувача в HTML-сторінку |
index.js | Приклад інтеграції завантажувача в JS-скрипти |
Upload.js | Приклад реалізації прототипа завантажувача |
api.php | Приклад реалізації інтерфейсу для сервера |
File.php | Приклад реалізації операцій над файлом на сервері |
Файл index.htm
містить розмітку елемента вибору файлу та вікна індикації процесу завантаження, підключення зовнішніх скриптів завантажувача та стилів Bootstrap. У скрипті index.js
розміщені налаштування завантажувача, функції зворотного виклику, реакції на події завантажувача та допоміжні функції. Код файлу Upload.js
реалізовує функції поділу файлу на частини та відправлення їх на сервер, керування процесом завантаження, агрегації статистичної інформації та інше.
Файл api.php
, якщо не звертати увагу на допоміжні функції, виконує роль маршрутизатора та контролера для обʼєкта File
з однойменного файлу. А вже він зі свого боку містить увесь необхідний інструментарій для створення файлу, наповнення його фрагментами, переміщення та видалення в разі потреби. Далі я описуватиму лише основний файл Upload.js
, тому що опис решти файлів у статтю не поміститься і їх необхідно буде досліджувати самостійно.
Властивості та конструктор
Створімо клас Upload
та додамо в нього для початку тільки властивості з детальним описом та конструктор.
class Upload { /** * @property {object} #settings - Налаштування за замовчуванням * @property {string} #settings.api - Адреса API для завантаження файлу * @property {number} #settings.chunkSizeMinimum - Мінімальний розмір частини файлу, байти * @property {number} #settings.chunkSizeMaximum - Максимальний розмір частини файлу, байти * @property {number} #settings.fileSizeLimit - Максимальний розмір файлу, байти * @property {number} #settings.interval - Рекомендована тривалість запиту, секунди * @property {number} #settings.timeout - Максимальна тривалість запиту, секунди * @property {number} #settings.retryLimit - Максимальна кількість повторних запитів * @property {number} #settings.retryDelay - Тривалість паузи між повторними запитами, секунди */ #settings = { api: 'api', chunkSizeMinimum: 1024, chunkSizeMaximum: 1024 ** 2, fileSizeLimit: 1024 ** 2, interval: 1, timeout: 5, retryLimit: 5, retryDelay: 1 } /** * @property {object} #fileList - Перелік FileList з файлами File та додатковими параметрами * @property {object} #fileList.files - Перелік FileList * @property {object} #fileList.size - Дані про розмір всіх файлів * @property {number} #fileList.size.uploaded - Розмір завантажених частин файлів, байти * @property {number} #fileList.size.total - Загальний розмір всіх файлів, байти * @property {number} #fileList.current - Номер поточного файлу */ #fileList = {files: {}, size: {uploaded: 0, total: 0}, current: 0} /** * @property {object} #file - Файл File з даними та додатковими параметрами * @property {string} #file.name - Назва файлу * @property {string} #file.type - Тип файлу * @property {number} #file.size - Розмір файлу, байти * @property {string} #file.uuid - UUID файлу * @property {number} #file.lastModified - Дата останньої зміни файлу, мілісекунди */ #file = {name: '', type: '', size: 0, uuid: '', lastModified: 0} /** * @property {object} #chunk - Параметри частини файлу * @property {number} #chunk.number - Порядковий номер частини файлу * @property {number} #chunk.offset - Зміщення від початку файлу, байти * @property {object} #chunk.size - Дані розміру частини файлу, байти * @property {number} #chunk.size.base - Розмір бази частини файлу, байти * @property {number} #chunk.size.value - Запланований розмір частини файлу, байти * @property {number} #chunk.size.coefficient - Коефіцієнт розміру частини файлу (1|2) * @property {object} #chunk.value - Вміст частини файлу * @property {number} #chunk.value.size - Реальний розмір частини файлу, байти * @property {string} #chunk.value.type - Тип частини файлу */ #chunk = {number: 0, offset: 0, size: {base: 0, value: 0, coefficient: 1}, value: {size: 0, type: ''}} /** * @property {object} #request - Запит до сервера * @property {string} #request.action - Тип запиту * @property {object} #request.data - Дані запиту * @property {number} #request.time - Час виконання запиту, мс * @property {number} #request.speed - Швидкість виконання запиту, Б/с * @property {boolean} #request.retry - Ознака виконання повторних запитів */ #request = {action: '', data: {}, time: 0, speed: 0, retry: true} /** * @property {object} #events - Ознаки деяких дій * @property {boolean} #events.pause - Ознака призупинення завантаження * @property {boolean} #events.stop - Ознака зупинки завантаження */ #events = {pause: false, stop: false} /** * @property {object} #timers - Збережені часові мітки * @property {number} #timers.start - Часова мітка початку завантаження, секунди * @property {number} #timers.pause - Часова мітка призупинення завантаження, секунди */ #timers = {start: 0, pause: 0} /** * @property {object} #callbacks - Функції зворотного виклику * @property {function} #callbacks.pause - Дії в разі призупинення процесу завантаження файлу * @property {function} #callbacks.iteration - Дії в разі виконання кожного запита на сервер * @property {function} #callbacks.timeout - Дії в разі відсутності відповіді від сервера * @property {function} #callbacks.resolve - Дії в разі вдалого завершення процесу завантаження файлу * @property {function} #callbacks.reject - Дії в разі невдалого завершення процесу завантаження файлу * @property {function} #callbacks.finally - Дії в разі завершення процесу завантаження файлу */ #callbacks = { pause: () => {}, iteration: () => {}, timeout: () => {}, resolve: () => {}, reject: () => {}, finally: () => {} } /** * Конструктор * @param {object} files - Перелік FileList з файлами File * @param {object} [settings] - Налаштування * @param {object} [callbacks] - Функції зворотного виклику */ constructor(files, settings = {}, callbacks = {}) { this.#fileList.files = files; for(let i = 0; i < this.#fileList.files.length; i ++) this.#fileList.size.total += this.#fileList.files[i].size; this.#settings = {...this.#settings, ...settings}; this.#callbacks = {...this.#callbacks, ...callbacks}; this.#file = this.#fileList.files[0]; this.#callbacks.iteration(this.#getStatus()); } }
Зі всіх властивостей зміни можна вносити тільки в усталене налаштування (settings
), а решта для внутрішнього використання в методах. Конструктор отримує та зберігає файли для завантаження, користувацькі налаштування, які перезапишуть усталене налаштування, та функції зворотного виклику для різних подій.
Керування завантаженням
Тепер додамо методи керування процесом завантаження: start()
, pause()
, resume()
, та cancel()
.
/** * Починає процес завантаження файлу на сервер */ start() { this.#timers.start = this.#getTime(); this.#open().then(); } /** * Призупиняє процес завантаження файлу на сервер */ pause() { this.#request.speed = 0; this.#events.pause = true; this.#timers.pause = this.#getTime(); } /** * Продовжує процес завантаження файлу на сервер */ resume() { this.#events.pause = null; this.#chunk.size.coefficient = 1; this.#chunk.size.base = this.#settings.chunkSizeMinimum; this.#timers.start = this.#getTime() - (this.#timers.pause - this.#timers.start); switch (this.#request.data.get('action')) { case 'open': this.#open().then(); break; case 'append': this.#append().then(); break; case 'close': this.#close().then(); break; } } /** * Скасовує процес завантаження файлу на сервер */ cancel() { if (this.#events.pause) { this.#remove(); } else { this.#events.stop = true; } this.#callbacks.finally(); }
За допомогою цих публічних методів, які повʼязані з відповідними елементами на сторінці, користувачем здійснюються базові дії над завантажувачем. Загалом тут усе інтуїтивно зрозуміло й пояснювати немає сенсу, окрім використання мною оператора switch
в методі resume()
. Річ у тім, що я не зміг знайти спосіб динамічного формування необхідної назви приватного метода для відповідного продовження роботи завантажувача.
Керування файлом
А зараз додамо методи керування файлом open()
, append()
, close()
та remove()
, які управляються описаними вище методами. Вони виокремлені від попередніх подібних методів насамперед через те, що використовуються для рекурсії під час завантаження фрагментів файлу на сервер.
/** * Відкриває файл для запису на сервері * @see this.#request */ #open = async () => { if (this.#file.size > this.#settings.fileSizeLimit) return this.#error('Розмір файлу більше дозволеного'); this.#chunk.size.base = this.#settings.chunkSizeMinimum; this.#request.data = new FormData(); this.#request.data.set('action', 'open'); this.#request.data.set('name', this.#file.name); this.#file.uuid = await this.#send(); if (this.#file.uuid === undefined) return; this.#request.data.set('uuid', this.#file.uuid); await this.#append(); } /** * Додає частину файлу на сервер * @see this.#request * @see this.#chunk */ #append = async () => { if (this.#events.pause) {this.#callbacks.pause();return;} if (this.#events.stop) {this.#remove();return;} this.#chunk.number ++; this.#chunk.size.value = this.#chunk.size.base * this.#chunk.size.coefficient; this.#chunk.value = this.#file.slice(this.#chunk.offset, this.#chunk.offset + this.#chunk.size.value); this.#request.data.set('action', 'append'); this.#request.data.set('offset', this.#chunk.offset); this.#request.data.set('chunk', this.#chunk.value, this.#file.name); let response = await this.#send(); if (response === undefined) return; this.#chunk.offset = response; this.#callbacks.iteration(this.#getStatus()); let speed = Math.round(this.#chunk.value.size / this.#request.time); this.#fileList.size.uploaded += this.#chunk.value.size; if (this.#chunk.size.coefficient === 2) { if ((this.#request.time < this.#settings.interval) && (speed > this.#request.speed)) { if ((this.#chunk.size.base * 2) < this.#settings.chunkSizeMaximum) this.#chunk.size.base *= 2; } else { if ((this.#chunk.size.base / 2) > this.#settings.chunkSizeMinimum) this.#chunk.size.base /= 2; } } this.#request.speed = speed; this.#request.data.delete('chunk'); this.#chunk.size.coefficient = 3 - this.#chunk.size.coefficient; if (this.#chunk.offset < this.#file.size) { await this.#append(); } else { await this.#close(); } } /** * Закриває файл на сервері * @see this.#request */ #close = async () => { this.#request.data.set('action', 'close'); this.#request.data.set('time', this.#file.lastModified); let size = await this.#send(); if (size === undefined) return; if (size !== this.#file.size) return this.#error('Неправильний розмір завантаженого файлу'); console.debug(`Файл ${this.#file.name} завантажено`); this.#fileList.current ++; if (this.#fileList.current < this.#fileList.files.length) { this.#file = this.#fileList.files[this.#fileList.current]; await this.#open(); } else { this.#callbacks.resolve(); this.#callbacks.finally(); } } /** * Видаляє файл на сервері * @see this.#request */ #remove = () => { this.#request.data.set('action', 'remove'); this.#request.retry = false; this.#send(); }
А ось з-поміж цих методів варто звернути особливу увагу на append()
, як мінімум через використання в ньому згаданої раніше функції slice()
. Але найцікавіше в ньому це реалізація автоматичного визначення оптимального розміру фрагменту файлу, запозичена мною з автомобільної сфери. Хто знає як формується суміш для інжекторних двигунів за допомогою лямбда-зонда знайде в моєму алгоритмі дещо спільне.
Алгоритм спочатку відправляє фрагмент із поточним розміром, який вказаний у налаштуваннях, або з мінімальним для першої ітерації («тік»). Потім відправляє фрагмент розміром удвічі більшим за попередній («так») та порівнює його швидкість зі швидкістю попередньої ітерації. Якщо швидкість другої ітерації більша, значення поточного розміру фрагменту в налаштуваннях збільшується удвічі й навпаки.
Після цього пара ітерацій «тік-так» повторюється, постійно підбираючи оптимальний розмір порцій, доки файл не завантажиться повністю. Мінімальний та максимальний розміри частин файлу обмежені за допомогою відповідних налаштувань та за допомогою рекомендованої тривалості виконання операції. Експериментально я прийшов до висновку, що занадто великий період виконання запиту незручний і бажано дотримуватись оптимального інтервалу (~ 1 секунда).
Пересилання даних
Ну й нарешті додамо серце завантажувача, яке формує та здійснює пересилання даних. Щоби реалізувати механізм повторного запиту (retry
), відправлення даних на сервер здійснюється у два етапи.
/** * Формує запит до сервера та витягує дані з відповіді * @returns {object|void} - Відформатована відповідь сервера * @see this.#request */ #send = async () => { let url = this.#settings.api; let body = {method: 'POST', body: this.#request.data}; let retry = (this.#request.retry) ? 1 : 0; this.#request.time = (new Date()).getTime(); let response = await this.#fetchExtended(url, body, retry); this.#request.time = ((new Date()).getTime() - this.#request.time) / 1000; if (response === undefined) return; let responseText = await response.text(); if (response.status === 200) return /^\d+$/.test(responseText) ? +responseText : responseText; return this.#error(responseText); } /** * Відправляє запит на сервер з timeout та retry в разі необхідності * @param {string} url - Адреса запиту * @param {object} body - Дані запиту * @param {number} [retry=1] - Номер повторного запиту, 0 - без повторів * @returns {Response|void} - Відповідь сервера при наявності */ #fetchExtended = async (url, body, retry = 1) => { try { let fetchPromise = fetch(url, body); let timeoutPromise = new Promise(resolve => (setTimeout(resolve, this.#settings.timeout * 1000)) ); let response = await Promise.race([fetchPromise, timeoutPromise]); if (response) return response; console.warn(`Перевищено час виконання запиту (${this.#settings.timeout}c)`); } catch (e) { console.warn('Під час виконання запиту виникла помилка: ' + e.message); } if ((retry === 0) || this.#events.stop) return; if (retry <= this.#settings.retryLimit) { console.warn('Повторний запит #' + retry); await new Promise(resolve => { setTimeout(resolve, this.#settings.retryDelay * 1000) }); return this.#fetchExtended(url, body, ++retry); } else { this.pause(); this.#callbacks.pause(); this.#callbacks.timeout(); } }
Метод send()
здійснює підготовку запиту до сервера та опрацювання відповіді від нього, а метод fetchExtended()
безпосередньо здійснює запит. Якщо запит здійснено невдало, автоматично його повторює з попередньо заданою в налаштуваннях кількістю разів та інтервалами між ними. У разі, якщо цього виявиться не достатньо завантаження не скасовується, а призупиняється, щоби його можна було продовжити пізніше, після розвʼязання проблеми.
Додаткові методи
Під кінець додамо декілька допоміжних методів.
/** * Вираховує та повертає дані про статус процесу завантаження файлу * @returns {object} * @property {number} chunk.number - Номер частини файлу * @property {number} chunk.size - Розмір частини файлу, байти * @property {number} chunk.time - Час завантаження частини файлу, секунди * @property {number} chunk.speed - Швидкість завантаження частини файлу, байти/секунду * @property {number} current.number - Номер поточного файлу * @property {string} current.name - Назва поточного файлу * @property {number} current.size.uploaded - Розмір завантаженої частини поточного файлу, байти * @property {number} current.size.total - Загальний розмір поточного файлу, байти * @property {number} total.numbers - Загальна кількість файлів * @property {number} total.size.uploaded - Розмір завантаженої частини всіх файлів, байти * @property {number} total.size.total - Загальний розмір всіх файлів, байти * @property {number} total.time.elapsed - Час з початку завантаження файлів, секунди * @property {number} total.time.estimate - Прогнозований час до завершення завантаження файлів, секунди */ #getStatus = () => { let status = {}; status.chunk = { number: this.#chunk.number, size: this.#chunk.value.size, time: this.#request.time, speed: this.#request.speed } status.current = { number: this.#fileList.current + 1, name: this.#file.name, size: { uploaded: this.#chunk.offset, total: this.#file.size } } status.total = { number: this.#fileList.files.length, size: { uploaded: this.#fileList.size.uploaded, total: this.#fileList.size.total, }, time: {elapsed: 0, estimate: 0} } if (this.#timers.start > 0) status.total.time.elapsed = this.#getTime() - this.#timers.start; if (status.total.size.uploaded > 0) { status.total.time.estimate = Math.round(status.total.size.total / (status.total.size.uploaded / status.total.time.elapsed)) status.total.time.estimate -= status.total.time.elapsed; } return status; } /** * Повертає поточну мітку часу * @returns {number} - Мітка часу, секунди */ #getTime = () => { return Math.round((new Date()).getTime() / 1000); } /** * Виконує дії при помилці * @param {string} message - Текст помилки */ #error = (message) => { this.#callbacks.reject(message); this.#callbacks.finally(); console.error(message); }
Метод getStatus()
повертає вичерпні дані про статус процесу завантаження файлів на цю мить, щоби вивести її в інформаційне вікно для користувача. Метод getTime()
є обгорткою для однойменної функції JS, але на відміну від неї для зручності повертає час у секундах. Метод error()
викликається в разі виникнення помилки, адже перехоплення помилок за допомогою try ... catch
не спрацьовують для асинхронних викликів.
Висновки
У підсумку ми створили завантажувач підвищеної надійності з деталізованою індикацією, який легко впорається з завантаженням на сервер файлів великого розміру. Під час розроблення я тестував його надійність у разі вимкнення мережі в системі, втраті звʼязку (timeout
) та симуляції внутрішніх проблем на сервері. Але в будь-якому разі це лише прототип, який потребує адаптації та додаткового тестування під великим навантаженням і різноманітними позаштатними ситуаціями.
Сумісність з інтернет-оглядачами перевіряв на Chrome 83 та Opera 68 для ОС Linux, на Chrome 83, Opera 58, Edge 45 та Samsung Internet 11 для ОС Android. На момент розроблення завантажувач не працював на інтернет-оглядачах Mozilla Firefox 76 (Linux) та 68 (Android) через непідтримання приватних властивостей та методів обʼєкта. Серверна частина завантажувача розроблялася та тестувалася на PHP 7.2 (Apache 2.4, ОС Linux).
Наостанок хочу звернути вашу увагу на те, що обмеження на максимальний розмір файлу на сервері не спрацює через те, що файл передається частинами, а не цілком, як зазвичай. Також адаптуйте, в разі необхідності, такі параметри налаштувань PHP як post_max_size
та upload_max_filesize
, які безпосередньо повʼязані з максимальним розміром фрагмента файлу. Додатково варто приділити увагу безпеці, адже в завантажувачі немає системи авторизації між клієнтом та сервером, що дає можливість для несанкціонованого втручання.
7 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів