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

Приложения Express могут использовать пакет express-rate-limit, чтобы ограничить количество запросов, принимаемых серверным приложением. Очень просто использовать. Нам просто нужно указать лимит скорости в течение некоторого времени, в течение которого запрос будет принят.

Например, чтобы ограничить запрос приемом 5 запросов в минуту с одного IP-адреса, мы помещаем:

const rateLimit = require("express-rate-limit");
 
 
const limiter = rateLimit({
  windowMs: 60 * 1000, 
  max: 5
});
 
app.use("/api/", limiter, (req, res) => {...});

limiter - это промежуточное ПО, которое добавляется перед обратным вызовом маршрута и выполняется перед вызовом обратного вызова маршрута, если предел скорости не достигнут.

В этой статье мы создадим конвертер видео для преобразования исходного видео в формат по выбору пользователя. Мы будем использовать пакет fluent-ffmpeg для выполнения преобразований. Поскольку задания выполняются долго, мы также создадим очередь заданий, чтобы они работали в фоновом режиме. Ограничение скорости в минуту может быть установлено нами в переменной окружения.

FFMPEG - это программа для обработки видео и аудио из командной строки, которая имеет множество возможностей. Он также поддерживает множество форматов для преобразования видео.

Разработчики проделали за нас тяжелую работу, создав оболочку Node.js для FFMPEG. Пакет называется fluent-ffmpeg. gIt находится по адресу https://github.com/fluent-ffmpeg/node-fluent-ffmpeg. Этот пакет позволяет нам запускать команды FFMPEG, вызывая встроенные функции.

Мы будем использовать Express для серверной части и React для передней части.

Back End

Для начала создайте папку проекта и папку backend внутри нее. В папке backend запустите npx express-generator, чтобы сгенерировать файлы для платформы Express.

Затем запустите npm i в папке backend, чтобы загрузить пакеты в package.json.

Затем мы должны установить наши собственные пакеты. Нам нужен Babel, чтобы использовать import в нашем приложении. Кроме того, мы будем использовать пакет Bull для фоновых заданий, пакет CORS для междоменных запросов с интерфейсом, fluent-ffmpeg для преобразования видео, Multer для загрузки файлов, Dotenv для управления переменными среды, express-rate-limit для ограничения запросов к нашему app, Sequelize для ORM и SQLite3 для базы данных или.

Запустите npm i @babel/cli @babel/core @babel/node @babel/preset-env bull cors dotenv fluent-ffmpeg multer sequelize sqlite3 express-rate-limit, чтобы установить все пакеты.

Затем добавьте файл .babelrc в папку backend и добавьте:

{
    "presets": [
        "@babel/preset-env"
    ]
}

чтобы включить новейшие функции JavaScript, и в разделе scripts документа package.json замените существующий код на:

"start": "nodemon --exec npm run babel-node --  ./bin/www",
"babel-node": "babel-node"

для работы с Babel вместо обычной среды выполнения Node.

Затем мы создаем код Sequelize, запустив npx sequelize-cli init.

Затем в config.json, который только что был создан путем выполнения приведенной выше команды, мы заменяем существующий код на:

{
  "development": {
    "dialect": "sqlite",
    "storage": "development.db"
  },
  "test": {
    "dialect": "sqlite",
    "storage": "test.db"
  },
  "production": {
    "dialect": "sqlite",
    "storage": "production.db"
  }
}

Далее нам нужно создать нашу модель и выполнить миграцию. Мы бежим:

npx sequelize-cli --name VideoConversion --attributes filePath:string,convertedFilePath:string,outputFormat:string,status:enum

Для создания модели и миграции для VideoConversions таблицы.

Во вновь созданном файле миграции замените существующий код на:

"use strict";
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable("VideoConversions", {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      filePath: {
        type: Sequelize.STRING
      },
      convertedFilePath: {
        type: Sequelize.STRING
      },
      outputFormat: {
        type: Sequelize.STRING
      },
      status: {
        type: Sequelize.ENUM,
        values: ["pending", "done", "cancelled"],
        defaultValue: "pending"
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable("VideoConversions");
  }
};

чтобы добавить константы для нашего перечисления.

Затем в models/videoconversion.js замените существующий код на:

"use strict";
module.exports = (sequelize, DataTypes) => {
  const VideoConversion = sequelize.define(
    "VideoConversion",
    {
      filePath: DataTypes.STRING,
      convertedFilePath: DataTypes.STRING,
      outputFormat: DataTypes.STRING,
      status: {
        type: DataTypes.ENUM("pending", "done", "cancelled"),
        defaultValue: "pending"
      }
    },
    {}
  );
  VideoConversion.associate = function(models) {
    // associations can be defined here
  };
  return VideoConversion;
};

чтобы добавить в модель константы перечисления.

Затем запустите npx sequelize-init db:migrate, чтобы создать нашу базу данных.

Затем создайте папку files во внутренней папке для хранения файлов.

Затем мы создаем нашу очередь заданий на обработку видео. Создайте папку queues и внутри нее создайте файл videoQueue.js и добавьте:

const Queue = require("bull");
const videoQueue = new Queue("video transcoding");
const models = require("../models");
var ffmpeg = require("fluent-ffmpeg");
const fs = require("fs");
const convertVideo = (path, format) => {
  const fileName = path.replace(/\.[^/.]+$/, "");
  const convertedFilePath = `${fileName}_${+new Date()}.${format}`;
  return new Promise((resolve, reject) => {
    ffmpeg(`${__dirname}/../files/${path}`)
      .setFfmpegPath(process.env.FFMPEG_PATH)
      .setFfprobePath(process.env.FFPROBE_PATH)
      .toFormat(format)
      .on("start", commandLine => {
        console.log(`Spawned Ffmpeg with command: ${commandLine}`);
      })
      .on("error", (err, stdout, stderr) => {
        console.log(err, stdout, stderr);
        reject(err);
      })
      .on("end", (stdout, stderr) => {
        console.log(stdout, stderr);
        resolve({ convertedFilePath });
      })
      .saveToFile(`${__dirname}/../files/${convertedFilePath}`);
  });
};
videoQueue.process(async job => {
  const { id, path, outputFormat } = job.data;
  try {
    const conversions = await models.VideoConversion.findAll({ where: { id } });
    const conv = conversions[0];
    if (conv.status == "cancelled") {
      return Promise.resolve();
    }
    const pathObj = await convertVideo(path, outputFormat);
    const convertedFilePath = pathObj.convertedFilePath;
    const conversion = await models.VideoConversion.update(
      { convertedFilePath, status: "done" },
      {
        where: { id }
      }
    );
    Promise.resolve(conversion);
  } catch (error) {
    Promise.reject(error);
  }
});
export { videoQueue };

В функции convertVideo мы используем fluent-ffmpeg для получения видеофайла, а затем устанавливаем пути FFMPEG и FFProbe из переменных среды. Затем мы вызываем toFormat, чтобы преобразовать его в указанный формат. Мы регистрируем обработчики start, error и end, чтобы увидеть выходные данные и выполнить наше обещание в конечном событии. Когда преобразование завершено, мы сохраняем его в новый файл.

videoQueue - это очередь Bull, которая последовательно обрабатывает задания в фоновом режиме. Redis необходим для запуска очереди, нам понадобится установка Ubuntu Linux. Мы запускаем следующие команды в Ubuntu для установки и запуска Redis:

$ sudo apt-get update
$ sudo apt-get upgrade
$ sudo apt-get install redis-server
$ redis-server

В обратном вызове функции videoQueue.process мы вызываем функцию convertVideo и обновляем путь к преобразованному файлу и статус данного задания, когда задание выполнено.

Далее мы создаем наши маршруты. Создайте файл conversions.js в папке routes и добавьте:

var express = require("express");
var router = express.Router();
const models = require("../models");
var multer = require("multer");
const fs = require("fs").promises;
const path = require("path");
import { videoQueue } from "../queues/videoQueue";
const rateLimit = require("express-rate-limit");
const limiter = rateLimit({
  windowMs: 60000,
  max: process.env.CALL_PER_MINUTE || 10,
  message: {
    error: "Too many requests"
  }
});
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "./files");
  },
  filename: (req, file, cb) => {
    cb(null, `${+new Date()}_${file.originalname}`);
  }
});
const upload = multer({ storage });
router.get("/", async (req, res, next) => {
  const conversions = await models.VideoConversion.findAll();
  res.json(conversions);
});
router.post("/", limiter, upload.single("video"), async (req, res, next) => {
  const data = { ...req.body, filePath: req.file.path };
  const conversion = await models.VideoConversion.create(data);
  res.json(conversion);
});
router.delete("/:id", async (req, res, next) => {
  const id = req.params.id;
  const conversions = await models.VideoConversion.findAll({ where: { id } });
  const conversion = conversions[0];
  try {
    await fs.unlink(`${__dirname}/../${conversion.filePath}`);
    if (conversion.convertedFilePath) {
      await fs.unlink(`${__dirname}/../files/${conversion.convertedFilePath}`);
    }
  } catch (error) {
  } finally {
    await models.VideoConversion.destroy({ where: { id } });
    res.json({});
  }
});
router.put("/cancel/:id", async (req, res, next) => {
  const id = req.params.id;
  const conversion = await models.VideoConversion.update(
    { status: "cancelled" },
    {
      where: { id }
    }
  );
  res.json(conversion);
});
router.get("/start/:id", limiter, async (req, res, next) => {
  const id = req.params.id;
  const conversions = await models.VideoConversion.findAll({ where: { id } });
  const conversion = conversions[0];
  const outputFormat = conversion.outputFormat;
  const filePath = path.basename(conversion.filePath);
  await videoQueue.add({ id, path: filePath, outputFormat });
  res.json({});
});
module.exports = router;

В маршруте POST / мы принимаем загрузку файла с помощью пакета Multer. Мы добавляем задание и сохраняем файл в папку files, которую мы создали ранее. Мы сохраняем его с исходным именем файла в функции filename в объекте, который мы передали в функцию diskStorage, и указали, что файл будет сохранен в папке files в функции destination.

Маршрут GET / добавляет рабочие места. DELETE / удаляет задание с данным идентификатором вместе с исходным файлом задания. Маршрут PUT /cancel/:id устанавливает status в cancelled.

И маршрут GET /start/:id добавляет задание с заданным идентификатором в очередь, которую мы создали ранее.

Мы добавили здесь объект limiter, чтобы использовать express-rate-limit для ограничения количества вызовов API для маршрута POST / и маршрута GET /start/:id, чтобы предотвратить добавление и запуск слишком большого количества заданий соответственно.

В app.js мы заменяем существующий код на:

require("dotenv").config();
var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var cors = require("cors");
var indexRouter = require("./routes/index");
var conversionsRouter = require("./routes/conversions");
var app = express();
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(express.static(path.join(__dirname, "files")));
app.use(cors());
app.use("/", indexRouter);
app.use("/conversions", conversionsRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
  res.status(err.status || 500);
  res.render("error");
});
module.exports = app;

чтобы добавить надстройку CORS для обеспечения междоменного взаимодействия, сделать папку files общедоступной и опубликовать conversions маршруты, которые мы создали ранее.

Чтобы добавить переменные среды, создайте файл .env в папке backend и добавьте:

FFMPEG_PATH='c:\ffmpeg\bin\ffmpeg.exe'
FFPROBE_PATH='c:\ffmpeg\bin\ffprobe.exe'
CALL_PER_MINUTE=5

Измените пути к путям FFMPEG и FFProbe на вашем компьютере и настройте CALL_PER_MINUTE на все, что вам нравится, если оно больше нуля.

Внешний интерфейс

Закончив бэкенд, мы можем перейти к переднему краю. В корневой папке проекта запустите npx create-react-app frontend, чтобы создать файлы интерфейса.

Затем мы устанавливаем несколько пакетов. Нам нужны Axios для выполнения HTTP-запросов, Formik для обработки значений формы, MobX для управления состоянием, React Router для маршрутизации URL-адресов на наши страницы и Bootstrap для стилизации.

Запустите npm i axios bootstrap formik mobx mobx-react react-bootstrap react-router-dom , чтобы установить пакеты.

Затем мы заменяем существующий код в App.js на:

import React from "react";
import { Router, Route } from "react-router-dom";
import "./App.css";
import { createBrowserHistory as createHistory } from "history";
import HomePage from "./HomePage";
import { ConversionsStore } from "./store";
import TopBar from "./TopBar";
const conversionsStore = new ConversionsStore();
const history = createHistory();
function App() {
  return (
    <div className="App">
      <TopBar />
      <Router history={history}>
        <Route
          path="/"
          exact
          component={props => (
            <HomePage {...props} conversionsStore={conversionsStore} />
          )}
        />
      </Router>
    </div>
  );
}
export default App;

Мы добавляем верхнюю панель и маршруты в этот файл.

В App.css мы заменяем существующий код на:

.page {
  padding: 20px;
}
.button {
  margin-right: 10px;
}

для добавления отступов и полей на нашу страницу и кнопки.

Затем создайте HomePage.js в папке src и добавьте:

import React from "react";
import Table from "react-bootstrap/Table";
import Button from "react-bootstrap/Button";
import ButtonToolbar from "react-bootstrap/ButtonToolbar";
import { observer } from "mobx-react";
import {
  getJobs,
  addJob,
  deleteJob,
  cancel,
  startJob,
  APIURL
} from "./request";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
function HomePage({ conversionsStore }) {
  const fileRef = React.createRef();
  const [file, setFile] = React.useState(null);
  const [fileName, setFileName] = React.useState("");
  const [initialized, setInitialized] = React.useState(false);
  const onChange = event => {
    setFile(event.target.files[0]);
    setFileName(event.target.files[0].name);
  };
  const openFileDialog = () => {
    fileRef.current.click();
  };
  const handleSubmit = async evt => {
    if (!file) {
      return;
    }
    let bodyFormData = new FormData();
    bodyFormData.set("outputFormat", evt.outputFormat);
    bodyFormData.append("video", file);
    try {
      await addJob(bodyFormData);
    } catch (error) {
      alert(error.response.statusText);
    } finally {
      getConversionJobs();
    }
  };
  const getConversionJobs = async () => {
    const response = await getJobs();
    conversionsStore.setConversions(response.data);
  };
  const deleteConversionJob = async id => {
    await deleteJob(id);
    getConversionJobs();
  };
  const cancelConversionJob = async id => {
    await cancel(id);
    getConversionJobs();
  };
  const startConversionJob = async id => {
    await startJob(id);
    getConversionJobs();
  };
  React.useEffect(() => {
    if (!initialized) {
      getConversionJobs();
      setInitialized(true);
    }
  });
  return (
    <div className="page">
      <h1 className="text-center">Convert Video</h1>
      <Formik onSubmit={handleSubmit} initialValues={{ outputFormat: "mp4" }}>
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group
                as={Col}
                md="12"
                controlId="outputFormat"
                defaultValue="mp4"
              >
                <Form.Label>Output Format</Form.Label>
                <Form.Control
                  as="select"
                  value={values.outputFormat || "mp4"}
                  onChange={handleChange}
                  isInvalid={touched.outputFormat && errors.outputFormat}
                >
                  <option value="mov">mov</option>
                  <option value="webm">webm</option>
                  <option value="mp4">mp4</option>
                  <option value="mpeg">mpeg</option>
                  <option value="3gp">3gp</option>
                </Form.Control>
                <Form.Control.Feedback type="invalid">
                  {errors.outputFormat}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="video">
                <input
                  type="file"
                  style={{ display: "none" }}
                  ref={fileRef}
                  onChange={onChange}
                  name="video"
                />
                <ButtonToolbar>
                  <Button
                    className="button"
                    onClick={openFileDialog}
                    type="button"
                  >
                    Upload
                  </Button>
                  <span>{fileName}</span>
                </ButtonToolbar>
              </Form.Group>
            </Form.Row>
            <Button type="submit">Add Job</Button>
          </Form>
        )}
      </Formik>
      <br />
      <Table>
        <thead>
          <tr>
            <th>File Name</th>
            <th>Converted File</th>
            <th>Output Format</th>
            <th>Status</th>
            <th>Start</th>
            <th>Cancel</th>
            <th>Delete</th>
          </tr>
        </thead>
        <tbody>
          {conversionsStore.conversions.map((c, i) => {
            return (
              <tr key={i}>
                <td>{c.filePath}</td>
                <td>{c.status}</td>
                <td>{c.outputFormat}</td>
                <td>
                  {c.convertedFilePath ? (
                    <a href={`${APIURL}/${c.convertedFilePath}`}>Open</a>
                  ) : (
                    "Not Available"
                  )}
                </td>
                <td>
                  <Button
                    className="button"
                    type="button"
                    onClick={startConversionJob.bind(this, c.id)}
                  >
                    Start
                  </Button>
                </td>
                <td>
                  <Button
                    className="button"
                    type="button"
                    onClick={cancelConversionJob.bind(this, c.id)}
                  >
                    Cancel
                  </Button>
                </td>
                <td>
                  <Button
                    className="button"
                    type="button"
                    onClick={deleteConversionJob.bind(this, c.id)}
                  >
                    Delete
                  </Button>
                </td>
              </tr>
            );
          })}
        </tbody>
      </Table>
    </div>
  );
}
export default observer(HomePage);

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

У нас также есть кнопки для запуска, отмены и удаления каждого задания.

Чтобы добавить загрузку файла, у нас есть скрытый ввод файла, и в обработчике onChange ввода файла мы устанавливаем файл. Обработчик onClick кнопки "Загрузить" щелкнет входной файл, чтобы открыть диалоговое окно загрузки файла.

Мы получаем последние задания, вызывая getConversionJobs, мы сначала загружаем страницу, а когда запускаем, отменяем и удаляем задания. Данные о вакансиях хранятся в магазине MobX, который мы создадим позже. Мы заключаем observer в наш HomePage в последней строке, чтобы всегда получать самые свежие значения из магазина.

Затем создайте request.js и папку src и добавьте:

const axios = require("axios");
export const APIURL = "http://localhost:3000";
export const getJobs = () => axios.get(`${APIURL}/conversions`);
export const addJob = data =>
  axios({
    method: "post",
    url: `${APIURL}/conversions`,
    data,
    config: { headers: { "Content-Type": "multipart/form-data" } }
  });
export const cancel = id => axios.put(`${APIURL}/conversions/cancel/${id}`, {});
export const deleteJob = id =>
  axios.delete(`${APIURL}/conversions/${id}`);
export const startJob = id => axios.get(`${APIURL}/conversions/start/${id}`);

Все HTTP-запросы, которые мы отправляем в серверную часть, находятся здесь. Они использовались на HomePage.

Затем создайте магазин MobX, создав файл store.js в папке src. Там добавьте:

import { observable, action, decorate } from "mobx";
class ConversionsStore {
  conversions = [];
setConversions(conversions) {
    this.conversions = conversions;
  }
}
ConversionsStore = decorate(ConversionsStore, {
  conversions: observable,
  setConversions: action
});
export { ConversionsStore };

Это простое хранилище, в котором хранятся контакты. В массиве conversions мы храним контакты для всего приложения. Функция setConversions позволяет нам устанавливать контакты из любого компонента, в который мы передаем объект this store.

Затем создайте TopBar.js в папке src и добавьте:

import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
function TopBar() {
  return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">Video Converter</Navbar.Brand>
      <Navbar.Toggle aria-controls="basic-navbar-nav" />
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav className="mr-auto">
          <Nav.Link href="/">Home</Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}
export default TopBar;

Он содержит React Bootstrap Navbar для отображения верхней панели со ссылкой на домашнюю страницу и названием приложения.

В index.html мы заменяем существующий код на:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>Video Converter</title>
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

чтобы добавить CSS Bootstrap и изменить заголовок.

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

Затем запустите серверную часть, запустив npm start в папке backend и npm start в папке frontend, затем выберите «да», если вас попросят запустить его с другого порта.

В итоге получаем: