Node.js и MySQL остаются одними из самых надежных и проверенных комбинаций для создания серверных систем. Node.js обеспечивает управляемую событиями неблокирующую модель ввода-вывода, которая эффективно обрабатывает одновременные запросы, а MySQL обеспечивает целостность реляционных данных, необходимую бизнес-приложениям. Вместе они образуют основу, которая поддерживает все: от стартапов MVP до корпоративных платформ, обрабатывающих миллионы запросов в день.
В этом руководстве описывается создание серверной части API промышленного уровня, включая структуру проекта, проектирование базы данных, аутентификацию, обработку ошибок и развертывание.
Структура проекта Express.js
Хорошо организованная структура проекта является основой поддерживаемой серверной части. Четко разделяйте проблемы и заранее устанавливайте соглашения.
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
Пул соединений имеет решающее значение для производительности. Пакет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 требует внимания к архитектуре, безопасности, производительности и эксплуатации. Создавая чистую структуру проекта, реализуя правильную аутентификацию и проверку, оптимизируя запросы к базе данных и помещая в контейнер развертывание, вы создаете безопасную, производительную и удобную в обслуживании серверную часть. Начните с основ, изложенных в этом руководстве, измерьте производительность вашего приложения при реальной нагрузке и исправьте обнаруженные вами узкие места. Представленные здесь шаблоны были проверены в тысячах производственных приложений и послужат прочной основой для ваших серверных систем.