Как загрузить файлы в Node и Nuxt

Как загрузить файлы в Node и Nuxt

30 марта 2023 г.

Вернемся к этой серии статей о загрузке файлов в Интернет.

  1. Загружать файлы в формате HTML

2. Загрузить файлы с помощью JavaScript

3. Получение загрузки файлов с помощью Node.js (Nuxt.js)

4. Оптимизация расходов на хранение с помощью Object Storage

5. Оптимизация доставки с помощью CDN

6. Защита загружаемых файлов с помощью сканирования на наличие вредоносных программ

В предыдущих сообщениях рассматривалась загрузка файлов с использованием HTML и JavaScript. Необходимые шаги:

* Использование элемента <input> с файловым атрибутом type для доступа к файлам.

* Создание HTTP-запросов с помощью <form> ; или с помощью API Fetch .

* Установка метода запроса «POST».

* Установка заголовка Content-Type запроса на multipart/form-data.

Сегодня мы перейдем к серверной части, чтобы получить эти запросы multipart/form-data и получить доступ к двоичным данным из этих файлов.

https://www.youtube.com/watch?v=34VJ1SPhtfk&embedable=true

Небольшая предыстория

Большинство понятий, изложенных в этом руководстве, должны широко применяться в разных платформах, средах выполнения и языках, но примеры кода будут более конкретными.

Я буду работать над проектом Nuxt.js, который работает в Node.js. В Nuxt есть несколько особых способов определения маршрутов API, которые требуют вызова глобальной функции. называется defineEventHandler.

/**
 * @see https://nuxt.com/docs/guide/directory-structure/server
 * @see https://nuxt.com/docs/guide/concepts/server-engine
 * @see https://github.com/unjs/h3
 */
export default defineEventHandler((event) => {
  return { ok: true };
});

Аргумент event обеспечивает доступ к работе непосредственно с базовым объектом запроса Node.js (он же IncomingMessage) через event.node.req.

Таким образом, мы можем написать наш специфичный для Node код в виде абстракции, например функцию с именем doSomethingWithNodeRequest, которая получает этот объект запроса Node и что-то с ним делает.

export default defineEventHandler((event) => {
  const nodeRequestObject = event.node.req;

  doSomethingWithNodeRequest(event.node.req);

  return { ok: true };
});

/**
 * @param {import('http').IncomingMessage} req
 */
function doSomethingWithNodeRequest(req) {
  // Do not specific stuff here
}

Работа напрямую с Node таким образом означает, что код и концепции должны применяться независимо от того, с какой высокоуровневой инфраструктурой вы работаете. В конце концов, завершите работу в Nuxt.js.

Работа с multipart/form-data в Node.js

В этом разделе мы рассмотрим некоторые низкоуровневые концепции, которые полезно понимать, но не обязательно. Не стесняйтесь пропустить этот раздел, если вы уже знакомы с чанками, потоками и буферами в Node.js.

Для загрузки файла необходимо отправить запрос multipart/form-data. В этих запросах браузер разделит данные на небольшие «фрагменты» и отправьте их через соединение, по одному фрагменту за раз. Это необходимо, потому что файлы могут быть слишком большими, чтобы их можно было отправить одним массивом полезной нагрузки.

Куски данных, отправляемых с течением времени, составляют так называемый «поток». Потоки довольно трудно понять с первого раза, по крайней мере, для меня. Они заслуживают отдельной статьи (или нескольких), поэтому я поделюсь прекрасным руководством от web.dev если вы хотите узнать больше.

По сути, поток похож на конвейерную ленту данных, где каждый фрагмент может обрабатываться по мере его поступления. С точки зрения HTTP-запроса, серверная часть будет получать части запроса, по одному биту за раз.

Node.js предоставляет нам API-интерфейс обработчика событий через метод on объекта запроса, который позволяет нам прослушивать события «данных», когда они передаются в серверную часть.

/**
 * @param {import('http').IncomingMessage} req
 */
function doSomethingWithNodeRequest(req) {
  req.on("data", (data) => {
    console.log(data);
  }
}

Например, когда я загружаю фотографию, на которой Наггет мило улыбается, я смотрю в консоли сервера я увижу некоторые странные вещи, которые выглядят примерно так:

Screenshot of a terminal with two logs of text that begin with "<Buffer", then a long list of two digit hex values, and end with a large number and "... more bytes>"

Я использовал здесь снимок экрана, чтобы вспомогательные технологии не могли читать эту тарабарщину вслух. Могли бы вы представить?

Эти два фрагмента искаженной чепухи называются «buffers» и они представляют собой два фрагмента данных, из которых состоит поток запросов, содержащий милую фотографию Наггета.

<цитата>

Буфер — это хранилище в физической памяти, используемое для временного хранения данных во время их переноса из одного места в другое.

MDN

Буферы — еще одна странная низкоуровневая концепция, которую я должен объяснить, говоря о рабочих файлах в JavaScript. JavaScript не работает напрямую с двоичными данными, поэтому мы узнаем о буферах. Также нормально, если эти понятия все еще кажутся вам немного расплывчатыми.

Полное понимание всего этого сейчас не является важной частью, и по мере того, как вы будете продолжать изучать передачу файлов, вы лучше поймете, как все это работает вместе.

Работа с одним частичным фрагментом данных не очень полезна. Вместо этого мы можем переписать нашу функцию во что-то, с чем мы сможем работать:

  1. Возвращайте Promise, чтобы упростить работу с асинхронным синтаксисом.

2. Предоставьте массив для хранения фрагментов данных для последующего использования.

3. Прислушивайтесь к событию «данные» и добавляйте фрагменты в нашу коллекцию по мере их поступления.

4. Прослушайте событие «конец» и преобразуйте фрагменты во что-то, с чем мы сможем работать.

5. Разрешите Promise с помощью полезной нагрузки окончательного запроса.

6. Мы также должны помнить об обработке событий «ошибки».

/**
 * @param {import('http').IncomingMessage} req
 */
function doSomethingWithNodeRequest(req) {
  return new Promise((resolve, reject) => {
    /** @type {any[]} */
    const chunks = [];
    req.on('data', (data) => {
      chunks.push(data);
    });
    req.on('end', () => {
      const payload = Buffer.concat(chunks).toString()
      resolve(payload);
    });
    req.on('error', reject);
  });
}

И каждый раз, когда запрос получает какие-то данные, он помещает эти данные в массив фрагментов.

Таким образом, после настройки этой функции мы фактически можем ожидать, вернувшего Promise, пока запрос не завершит получение всех данных из потока запросов, и записать полученное значение в консоль. .

export default defineEventHandler((event) => {
  const nodeRequestObject = event.node.req;

  const body = await doSomethingWithNodeRequest(event.node.req);
  console.log(body)

  return { ok: true };
});

Это тело запроса. Разве это не красиво?

Screenshot of a terminal containing a long string of unintelligible text including alphanumerical values as well as symbols and characters that cannot be rendered. It legitimately looks like alien writing

Честно говоря, я даже не знаю, что бы сделал скринридер, если бы это был обычный текст.

Если вы загрузите файл изображения, это, вероятно, будет выглядеть так, будто инопланетянин взломал ваш компьютер. Не волнуйтесь, это не так. Буквально так выглядит текстовое содержимое этого файла. Вы даже можете попробовать открыть файл изображения в обычном текстовом редакторе и увидеть то же самое.

Если я загружу более простой пример, например файл .txt с простым текстом, тело может выглядеть так:

Content-Disposition: form-data; name="file"; filename="dear-nugget.txt"
Content-Type: text/plain

I love you!
------WebKitFormBoundary4Ay52hDeKB5x2vXP--

Обратите внимание, что запрос разбит на разные разделы для каждого поля формы. Разделы разделены «границей формы», которую браузер вставит по умолчанию. Я не буду вдаваться в подробности, поэтому, если вы хотите узнать больше, ознакомьтесь с Content-Disposition на MDN.

Важно знать, что запросы multipart/form-data намного сложнее, чем просто пары ключ/значение.

Большинство серверных фреймворков предоставляют встроенные инструменты для доступа к телу запроса. Таким образом, мы фактически заново изобрели колесо. Например, Nuxt предоставляет глобальную функцию readBody. Таким образом, мы могли бы сделать то же самое, не написав собственный код:

export default defineEventHandler((event) => {
  const nodeRequestObject = event.node.req;

  const body = await readBody(event.node.req);
  console.log(body)

  return { ok: true };
});

Это прекрасно работает для других типов содержимого, но для multipart/form-data возникают проблемы. Все тело запроса считывается в память как одна гигантская строка текста. Сюда входит информация Content-Disposition, границы формы, а также поля и значения формы.

Не говоря уже о том, что файлы даже не записываются на диск. Большая проблема здесь заключается в том, что если загружен очень большой файл, он может занять всю память приложения и привести к его сбою.

Решение снова заключается в работе с потоками.

Когда наш сервер получает фрагмент данных из потока запросов, вместо того, чтобы хранить его в памяти, мы можем направить его в другой поток. В частности, мы можем отправить его в поток, который записывает данные в файловую систему, используя createWriteStream.

По мере поступления фрагментов по запросу эти данные записываются в файловую систему, а затем освобождаются из памяти.

Это примерно то, что я хочу углубиться в низкоуровневые концепции. Вернемся к решению проблемы, не изобретая велосипед.

Использование библиотеки для потоковой передачи данных на диск

Вероятно, мой лучший совет по обработке загрузки файлов – найти библиотеку, которая сделает всю эту работу за вас:

* Разбирать запросы multipart/form-data.

* Отделите файлы от других полей формы.

* Поток данных файла в файловую систему.

* Предоставить вам данные полей формы, а также полезные данные о файлах.

Сегодня я буду использовать эту библиотеку под названием formidable. Вы можете установить его с помощью npm install formidable, а затем импортировать в свой проект.

import formidable from 'formidable';

Formidable работает напрямую с объектом запроса Node, который мы удобно уже захватили из события Nuxt («Вау, какая поразительная предусмотрительность!!!» 🤩).

Таким образом, мы можем изменить нашу функцию doSomethingWithNodeRequest, чтобы вместо нее использовать грозный. Он по-прежнему должен возвращать промис, потому что грозный использует обратные вызовы, но с промисами удобнее работать.

В противном случае мы можем в основном заменить содержимое функции на грозное. Нам нужно создать внушительный экземпляр, использовать его для разбора объекта запроса, и пока нет ошибки, мы можем выполнить обещание с помощью одного объекта, который содержит как поля формы, так и файлы.

/**
 * @param {import('http').IncomingMessage} req
 */
function doSomethingWithNodeRequest(req) {
  return new Promise((resolve, reject) => {
    /** @see https://github.com/node-formidable/formidable/ */
    const form = formidable({ multiples: true })
    form.parse(req, (error, fields, files) => {
      if (error) {
        reject(error);
        return;
      }
      resolve({ ...fields, ...files });
    });
  });
}

Это дает нам удобную функцию для анализа multipart/form-data с помощью промисов и доступа к обычным полям формы запроса, а также к информации о файлах, которые были записаны на диск с помощью потоков.

Теперь мы можем изучить тело запроса:

export default defineEventHandler((event) => {
  const nodeRequestObject = event.node.req;

  const body = await doSomethingWithNodeRequest(event.node.req);
  console.log(body)

  return { ok: true };
});

Мы должны увидеть объект, содержащий все поля формы и их значения, но для каждого входного файла мы увидим объект, представляющий загруженный файл, а не сам файл. Этот объект содержит всевозможную полезную информацию, включая его путь на диске, имя, mimetype и многое другое.

{
  file-input-name: PersistentFile {
    _events: [Object: null prototype] { error: [Function (anonymous)] },
    _eventsCount: 1,
    _maxListeners: undefined,
    lastModifiedDate: 2023-03-21T22:57:42.332Z,
    filepath: '/tmp/d53a9fd346fcc1122e6746600',
    newFilename: 'd53a9fd346fcc1122e6746600',
    originalFilename: 'file.txt',
    mimetype: 'text/plain',
    hashAlgorithm: false,
    size: 13,
    _writeStream: WriteStream {
      fd: null,
      path: '/tmp/d53a9fd346fcc1122e6746600',
      flags: 'w',
      mode: 438,
      start: undefined,
      pos: undefined,
      bytesWritten: 13,
      _writableState: [WritableState],
      _events: [Object: null prototype],
      _eventsCount: 1,
      _maxListeners: undefined,
      [Symbol(kFs)]: [Object],
      [Symbol(kIsPerformingIO)]: false,
      [Symbol(kCapture)]: false
    },
    hash: null,
    [Symbol(kCapture)]: false
  }
}

Вы также заметите, что newFilename является хешированным значением. Это делается для того, чтобы при загрузке двух файлов с одинаковым именем вы не потеряли данные. Конечно, вы можете изменить способ записи файлов на диск.

Обратите внимание, что в стандартном приложении рекомендуется хранить часть этой информации в постоянном месте, например в базе данных, чтобы вы могли легко найти все загруженные файлы. Но суть поста не в этом.

Теперь есть еще одна вещь, которую я хочу исправить. Я хочу обрабатывать запросы multipart/form-data только с грозным. Все остальное может быть обработано встроенным парсером тела, подобным тому, который мы видели выше.

Итак, я сначала создам переменную «тело», затем проверю заголовки запроса и назначу значение тела на основе «типа содержимого». Я также переименую свою функцию в parseMultipartNodeRequest, чтобы более подробно описать, что она делает.

Вот как все это выглядит (обратите внимание, что getRequestHeaders — это еще одна встроенная функция Nuxt):

import formidable from 'formidable';

/**
 * @see https://nuxt.com/docs/guide/concepts/server-engine
 * @see https://github.com/unjs/h3
 */
export default defineEventHandler(async (event) => {
  let body;
  const headers = getRequestHeaders(event);

  if (headers['content-type']?.includes('multipart/form-data')) {
    body = await parseMultipartNodeRequest(event.node.req);
  } else {
    body = await readBody(event);
  }
  console.log(body);

  return { ok: true };
});

/**
 * @param {import('http').IncomingMessage} req
 */
function parseMultipartNodeRequest(req) {
  return new Promise((resolve, reject) => {
    /** @see https://github.com/node-formidable/formidable/ */
    const form = formidable({ multiples: true })
    form.parse(req, (error, fields, files) => {
      if (error) {
        reject(error);
        return;
      }
      resolve({ ...fields, ...files });
    });
  });
}

Таким образом, у нас есть API, который достаточно надежен, чтобы принимать multipart/form-data, обычный текст или запросы с кодировкой URL.

📯📯📯 Завершение

Рэйв-рога со смайликами нет, поэтому придется использовать их. Мы многое рассмотрели, так что давайте подведем небольшой итог.

Когда мы загружаем файл с помощью запроса multipart/form-data, браузер будет отправлять данные по одному фрагменту за раз, используя поток. Это потому, что мы не можем сразу поместить весь файл в объект запроса.

В Node.js мы можем прослушивать событие «данные» запроса, чтобы работать с каждым фрагментом данных по мере их поступления. Это дает нам доступ к потоку запросов.

Хотя мы могли бы захватить все эти данные и сохранить их в памяти, это плохая идея, поскольку загрузка большого файла может занять всю память сервера, что приведет к его сбою.

Вместо этого мы можем направить этот поток куда-то еще, чтобы каждый фрагмент был получен, обработан, а затем освобожден из памяти. Одним из вариантов является использование fs.createWriteStream для создания WritableStream, который может записывать в файловую систему.

Вместо того, чтобы писать собственный низкоуровневый синтаксический анализатор, мы должны использовать такой инструмент, как formidable. Но нам нужно подтвердить, что данные поступают из запроса multipart/form-data. В противном случае мы можем использовать стандартный парсер тела.

Мы рассмотрели множество низкоуровневых концепций и остановились на высокоуровневом решении. Надеюсь, все это имело смысл, и вы нашли это полезным.

Если у вас есть какие-либо вопросы или если что-то непонятно, пожалуйста, свяжитесь со мной. Я всегда рад помочь.

Мне очень весело работать над этой серией, и я надеюсь, что вам она тоже понравится. Оставайтесь с нами до конца :D

  1. Загружать файлы в формате HTML

2. Загрузить файлы с помощью JavaScript

3. Получение загрузки файлов с помощью Node.js (Nuxt.js)

4. Оптимизация расходов на хранение с помощью Object Storage

5. Оптимизация доставки с помощью CDN

6. Защита загружаемых файлов с помощью сканирования на наличие вредоносных программ

Большое спасибо за прочтение. Если вам понравилась эта статья и вы хотите поддержать меня, лучший способ сделать это — поделиться ею< /strong>, подпишитесь на мою рассылку и подпишитесь на меня в Twitter.


Первоначально опубликовано на austingil.com.


Оригинал