Перейти к содержимому

Блог

Создание надежных серверных систем с помощью Node.js и MySQL

Разработайте и внедрите масштабируемые и безопасные серверные устройства API с помощью Node.js, Express и MySQL.

  • 11 мин чтения
  • Updated

Node.js и MySQL остаются одними из самых надежных и проверенных комбинаций для создания серверных систем. Node.js обеспечивает управляемую событиями неблокирующую модель ввода-вывода, которая эффективно обрабатывает одновременные запросы, а MySQL обеспечивает целостность реляционных данных, необходимую бизнес-приложениям. Вместе они образуют основу, которая поддерживает все: от стартапов MVP до корпоративных платформ, обрабатывающих миллионы запросов в день.

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

Структура проекта Express.js

Node.js + Архитектура серверной части ExpressУровень промежуточного программного обеспеченияАутентификация (JWT)ПроверкаОграничение скоростиCORSHelmetВедение журналаКлиентБраузер/приложениеAPIШлюз/api/v1ExpressМаршрутыGET/POST/PUT/DELКонтроллерыОбработка запросовСервисыБизнес-логикаMySQLРеляционная БДHTTPмаршрутвызоввызовзапросФормат ответа{ успех: true,данные: [...],нумерация страниц: {...}}Конвейер обработки ошибокApiErrorasyncHandlererrorHandlerЦентрализованные, согласованные ответы на ошибки

Хорошо организованная структура проекта является основой поддерживаемой серверной части. Четко разделяйте проблемы и заранее устанавливайте соглашения.

project-root/
  src/
    config/
      database.js
      environment.js
      logger.js
    middleware/
      auth.js
      errorHandler.js
      rateLimiter.js
      validator.js
    models/
      User.js
      Product.js
      Order.js
      index.js
    routes/
      auth.routes.js
      users.routes.js
      products.routes.js
      orders.routes.js
      index.js
    services/
      auth.service.js
      user.service.js
      product.service.js
      email.service.js
    utils/
      ApiError.js
      asyncHandler.js
      pagination.js
    app.js
    server.js
  migrations/
  seeders/
  tests/
  .env
  .env.example
  package.json

Точка входа устанавливает Express с необходимым промежуточным программным обеспечением:

// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const { errorHandler } = require('./middleware/errorHandler');
const routes = require('./routes');

const app = express();

// Security middleware
app.use(helmet());
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
  credentials: true,
}));

// Request parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// Logging
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// API routes
app.use('/api/v1', routes);

// Error handling (must be last)
app.use(errorHandler);

module.exports = app;

Проектирование RESTful API

Проектируйте конечные точки API в соответствии с соглашениями REST. Используйте существительные для обозначения ресурсов, методы HTTP для обозначения действий и согласованные форматы ответов.

// src/routes/products.routes.js
const router = require('express').Router();
const { authenticate, authorize } = require('../middleware/auth');
const { validate } = require('../middleware/validator');
const { createProductSchema, updateProductSchema } = require('../validators/product');
const productController = require('../controllers/product.controller');

router.get('/', productController.getAll);
router.get('/:id', productController.getById);
router.post('/',
  authenticate,
  authorize('admin'),
  validate(createProductSchema),
  productController.create
);
router.put('/:id',
  authenticate,
  authorize('admin'),
  validate(updateProductSchema),
  productController.update
);
router.delete('/:id',
  authenticate,
  authorize('admin'),
  productController.delete
);

module.exports = router;

Контроллеры должны быть тонкими, делегируя бизнес-логику классам обслуживания:

// src/controllers/product.controller.js
const productService = require('../services/product.service');
const { asyncHandler } = require('../utils/asyncHandler');

exports.getAll = asyncHandler(async (req, res) => {
  const { page = 1, limit = 20, sort = 'created_at', order = 'DESC', search } = req.query;

  const result = await productService.findAll({
    page: parseInt(page),
    limit: Math.min(parseInt(limit), 100),
    sort,
    order,
    search,
  });

  res.json({
    success: true,
    data: result.products,
    pagination: {
      page: result.page,
      limit: result.limit,
      total: result.total,
      totalPages: result.totalPages,
    },
  });
});

exports.create = asyncHandler(async (req, res) => {
  const product = await productService.create(req.body);
  res.status(201).json({
    success: true,
    data: product,
  });
});

MySQL Пул соединений с mysql2

Архитектура пула соединений MySQLПриложениеПараллельные запросыПоток запроса 1Поток запроса 2Поток запроса 3Поток запроса 4...Поток запроса NПул соединений (mysql2)АктивенНеактивенСоединение 1 — АктивноеСоединение 2 — АктивноеСоединение 3 — АктивноеСоединение 4 — ОжиданиеСоединение 5 — НеактивноеСоединениеОграничение: 10 | waitForConnections: trueEnableKeepAlive: true | Ограничение очереди: 0MySQLСервер базы данныхХранилище данныхИндексыМеханизм InnoDBПул повторно использует соединения, избегая накладных расходов на создание новых для каждого запроса.

Пул соединений имеет решающее значение для производительности. Пакетmysql2предоставляет API на базе Promise с подготовленными операторами и пулом соединений «из коробки».

// src/config/database.js
const mysql = require('mysql2/promise');
const logger = require('./logger');

const pool = mysql.createPool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT) || 3306,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: parseInt(process.env.DB_POOL_SIZE) || 10,
  queueLimit: 0,
  enableKeepAlive: true,
  keepAliveInitialDelay: 30000,
  timezone: '+00:00',
  typeCast: function (field, next) {
    if (field.type === 'TINY' && field.length === 1) {
      return field.string() === '1';
    }
    return next();
  },
});

// Test connection on startup
pool.getConnection()
  .then(conn => {
    logger.info('MySQL connected successfully');
    conn.release();
  })
  .catch(err => {
    logger.error('MySQL connection failed:', err.message);
    process.exit(1);
  });

module.exports = pool;

Всегда используйте параметризованные запросы, чтобы предотвратить внедрение SQL:

// NEVER do this
const query = `SELECT * FROM users WHERE email = '${email}'`;

// ALWAYS use parameterized queries
const [rows] = await pool.execute(
  'SELECT id, email, first_name, last_name FROM users WHERE email = ?',
  [email]
);

Sequelize ORM

Для приложений со сложными связями данных Sequelize предоставляет полнофункциональный ORM с определениями моделей, ассоциациями, миграциями и построением запросов.

// src/models/Product.js
const { DataTypes } = require('sequelize');
const sequelize = require('../config/sequelize');

const Product = sequelize.define('Product', {
  id: {
    type: DataTypes.UUID,
    defaultValue: DataTypes.UUIDV4,
    primaryKey: true,
  },
  name: {
    type: DataTypes.STRING(255),
    allowNull: false,
    validate: {
      notEmpty: true,
      len: [2, 255],
    },
  },
  description: {
    type: DataTypes.TEXT,
    allowNull: true,
  },
  price: {
    type: DataTypes.DECIMAL(10, 2),
    allowNull: false,
    validate: {
      min: 0,
    },
  },
  sku: {
    type: DataTypes.STRING(100),
    unique: true,
    allowNull: false,
  },
  stock_quantity: {
    type: DataTypes.INTEGER,
    defaultValue: 0,
    validate: {
      min: 0,
    },
  },
  is_active: {
    type: DataTypes.BOOLEAN,
    defaultValue: true,
  },
}, {
  tableName: 'products',
  timestamps: true,
  underscored: true,
  paranoid: true, // Soft deletes
  indexes: [
    { fields: ['sku'], unique: true },
    { fields: ['is_active'] },
    { fields: ['price'] },
    { fields: ['created_at'] },
  ],
});

// Associations
Product.associate = (models) => {
  Product.belongsTo(models.Category, { foreignKey: 'category_id' });
  Product.hasMany(models.OrderItem, { foreignKey: 'product_id' });
  Product.belongsToMany(models.Tag, { through: 'product_tags' });
};

module.exports = Product;

Аутентификация с помощью JWT

Внедрите аутентификацию без отслеживания состояния с использованием веб-токенов JSON. Используйте токены доступа для запросов API и токены обновления для управления сеансами.

// src/services/auth.service.js
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { User } = require('../models');
const ApiError = require('../utils/ApiError');

const SALT_ROUNDS = 12;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';

exports.register = async ({ email, password, firstName, lastName }) => {
  const existingUser = await User.findOne({ where: { email } });
  if (existingUser) {
    throw new ApiError(409, 'Email already registered');
  }

  const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
  const user = await User.create({
    email,
    password: hashedPassword,
    first_name: firstName,
    last_name: lastName,
  });

  const tokens = generateTokens(user);
  return { user: sanitizeUser(user), ...tokens };
};

exports.login = async ({ email, password }) => {
  const user = await User.findOne({ where: { email } });
  if (!user || !(await bcrypt.compare(password, user.password))) {
    throw new ApiError(401, 'Invalid email or password');
  }

  const tokens = generateTokens(user);
  return { user: sanitizeUser(user), ...tokens };
};

function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  );

  const refreshToken = jwt.sign(
    { userId: user.id, tokenType: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );

  return { accessToken, refreshToken };
}

function sanitizeUser(user) {
  const { password, ...userData } = user.toJSON();
  return userData;
}

Промежуточное программное обеспечение аутентификации проверяет токены на защищенных маршрутах:

// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const ApiError = require('../utils/ApiError');

exports.authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    throw new ApiError(401, 'Access token required');
  }

  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new ApiError(401, 'Access token expired');
    }
    throw new ApiError(401, 'Invalid access token');
  }
};

exports.authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      throw new ApiError(403, 'Insufficient permissions');
    }
    next();
  };
};

Проверка ввода с помощью Joi

Проверяйте все входящие данные до того, как они достигнут вашей бизнес-логики. Joi предоставляет мощную библиотеку проверки на основе схемы.

// src/validators/product.js
const Joi = require('joi');

exports.createProductSchema = Joi.object({
  name: Joi.string().min(2).max(255).required(),
  description: Joi.string().max(5000).optional(),
  price: Joi.number().positive().precision(2).required(),
  sku: Joi.string().alphanum().max(100).required(),
  stock_quantity: Joi.number().integer().min(0).default(0),
  category_id: Joi.string().uuid().required(),
  tags: Joi.array().items(Joi.string().uuid()).optional(),
  is_active: Joi.boolean().default(true),
});

exports.updateProductSchema = Joi.object({
  name: Joi.string().min(2).max(255),
  description: Joi.string().max(5000).allow(null),
  price: Joi.number().positive().precision(2),
  stock_quantity: Joi.number().integer().min(0),
  category_id: Joi.string().uuid(),
  is_active: Joi.boolean(),
}).min(1);

// src/middleware/validator.js
exports.validate = (schema) => {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,
      stripUnknown: true,
    });

    if (error) {
      const errors = error.details.map(detail => ({
        field: detail.path.join('.'),
        message: detail.message,
      }));
      return res.status(400).json({
        success: false,
        message: 'Validation failed',
        errors,
      });
    }

    req.body = value;
    next();
  };
};

Промежуточное программное обеспечение обработки ошибок

Централизованная обработка ошибок обеспечивает согласованное реагирование на ошибки и предотвращает утечку конфиденциальной информации клиентам.

// src/utils/ApiError.js
class ApiError extends Error {
  constructor(statusCode, message, errors = []) {
    super(message);
    this.statusCode = statusCode;
    this.errors = errors;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = ApiError;

// src/utils/asyncHandler.js
exports.asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// src/middleware/errorHandler.js
const logger = require('../config/logger');

exports.errorHandler = (err, req, res, next) => {
  let statusCode = err.statusCode || 500;
  let message = err.message || 'Internal Server Error';

  // Sequelize validation errors
  if (err.name === 'SequelizeValidationError') {
    statusCode = 400;
    message = 'Validation error';
  }

  // Sequelize unique constraint
  if (err.name === 'SequelizeUniqueConstraintError') {
    statusCode = 409;
    message = 'Resource already exists';
  }

  // Log server errors
  if (statusCode >= 500) {
    logger.error({
      message: err.message,
      stack: err.stack,
      url: req.originalUrl,
      method: req.method,
      ip: req.ip,
    });
  }

  res.status(statusCode).json({
    success: false,
    message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
    ...(err.errors?.length && { errors: err.errors }),
  });
};

Оптимизация и индексирование запросов

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

  • Использовать индексы для часто запрашиваемых столбцов.— добавлять индексы для столбцов, используемых в предложениях WHERE, условиях JOIN и операторах ORDER BY. Составные индексы должны следовать правилу крайнего левого префикса.
  • Избегайте SELECT *— всегда указывайте нужные столбцы. Это сокращает передачу данных и позволяет MySQL использовать индексы покрытия.
  • Используйте EXPLAIN для анализа запросов.— запускайтеEXPLAINперед запросами, чтобы понять план выполнения. Ищите полное сканирование таблиц, операции сортировки файлов и временные таблицы.
  • Оптимизация нумерации страниц— для больших наборов данных используйте нумерацию страниц на основе курсора (пагинацию набора ключей) вместо OFFSET, которая становится медленной при большом количестве страниц.
// Inefficient OFFSET pagination
const [rows] = await pool.execute(
  'SELECT * FROM products ORDER BY created_at DESC LIMIT ? OFFSET ?',
  [limit, (page - 1) * limit]
);

// Efficient cursor-based pagination
const [rows] = await pool.execute(
  `SELECT id, name, price, created_at FROM products
   WHERE created_at < ?
   ORDER BY created_at DESC
   LIMIT ?`,
  [cursor, limit]
);

Миграция базы данных

Никогда не изменяйте рабочие базы данных вручную. Используйте миграцию Sequelize для изменений схемы с контролем версий.

// migrations/20250101000000-create-products-table.js
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('products', {
      id: {
        type: Sequelize.UUID,
        defaultValue: Sequelize.UUIDV4,
        primaryKey: true,
      },
      name: {
        type: Sequelize.STRING(255),
        allowNull: false,
      },
      price: {
        type: Sequelize.DECIMAL(10, 2),
        allowNull: false,
      },
      sku: {
        type: Sequelize.STRING(100),
        unique: true,
        allowNull: false,
      },
      category_id: {
        type: Sequelize.UUID,
        references: {
          model: 'categories',
          key: 'id',
        },
        onUpdate: 'CASCADE',
        onDelete: 'SET NULL',
      },
      created_at: {
        type: Sequelize.DATE,
        defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
      },
      updated_at: {
        type: Sequelize.DATE,
        defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
      },
    });

    await queryInterface.addIndex('products', ['sku']);
    await queryInterface.addIndex('products', ['category_id']);
    await queryInterface.addIndex('products', ['created_at']);
  },

  down: async (queryInterface) => {
    await queryInterface.dropTable('products');
  },
};

Ограничение скорости

Защитите свой API от злоупотреблений с помощью ограничения скорости. Используйтеexpress-rate-limitс хранилищем Redis для распределенных развертываний.

// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('../config/redis');

exports.apiLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: {
    success: false,
    message: 'Too many requests, please try again later',
  },
  standardHeaders: true,
  legacyHeaders: false,
});

exports.authLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: {
    success: false,
    message: 'Too many login attempts, please try again later',
  },
  skipSuccessfulRequests: true,
});

Ведение журналов с помощью Winston

Производственные приложения нуждаются в структурированном журналировании с несколькими транспортами и уровнями журналирования.

// src/config/logger.js
const winston = require('winston');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'api-server' },
  transports: [
    new winston.transports.File({
      filename: 'logs/error.log',
      level: 'error',
      maxsize: 5242880, // 5MB
      maxFiles: 5,
    }),
    new winston.transports.File({
      filename: 'logs/combined.log',
      maxsize: 5242880,
      maxFiles: 10,
    }),
  ],
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.combine(
      winston.format.colorize(),
      winston.format.simple()
    ),
  }));
}

module.exports = logger;

Развертывание Docker

Контейнеризируйте свое приложение для единообразного развертывания в различных средах.

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:20-alpine
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
COPY migrations/ ./migrations/
COPY package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]
# docker-compose.yml
version: '3.8'
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=mysql
      - DB_USER=app_user
      - DB_PASSWORD_FILE=/run/secrets/db_password
      - DB_NAME=myapp
    depends_on:
      mysql:
        condition: service_healthy
    restart: unless-stopped

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
      MYSQL_DATABASE: myapp
      MYSQL_USER: app_user
      MYSQL_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  mysql_data:

Заключение

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