Node.js และ MySQL ยังคงเป็นหนึ่งในการผสมผสานที่เชื่อถือได้และผ่านการทดสอบการต่อสู้แล้วสำหรับการสร้างระบบแบ็กเอนด์ Node.js มอบโมเดล I/O ที่ขับเคลื่อนด้วยเหตุการณ์และไม่มีการบล็อก ซึ่งจัดการคำขอที่เกิดขึ้นพร้อมกันได้อย่างมีประสิทธิภาพ ในขณะที่ 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 Design
ออกแบบจุดสิ้นสุด 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 Connection Pooling with mysql2
การรวมการเชื่อมต่อมีความสำคัญต่อประสิทธิภาพการทำงาน แพ็คเกจmysql2มอบ API ตามสัญญาพร้อมคำสั่งที่เตรียมไว้และการรวมการเชื่อมต่อทันที
// 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 Web Tokens ใช้โทเค็นการเข้าถึงสำหรับคำขอ 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 Migration สำหรับการเปลี่ยนแปลงสคีมาที่ควบคุมเวอร์ชัน
// 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 Deployment
จัดคอนเทนเนอร์แอปพลิเคชันของคุณเพื่อการปรับใช้ที่สอดคล้องกันในทุกสภาพแวดล้อม
# 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 ที่แข็งแกร่งต้องอาศัยความใส่ใจในสถาปัตยกรรม ความปลอดภัย ประสิทธิภาพ และข้อกังวลด้านการปฏิบัติงาน ด้วยการสร้างโครงสร้างโปรเจ็กต์ใหม่ทั้งหมด การใช้การรับรองความถูกต้องและการตรวจสอบที่เหมาะสม การเพิ่มประสิทธิภาพการสืบค้นฐานข้อมูล และการจัดคอนเทนเนอร์การใช้งานของคุณ คุณจะสร้างแบ็กเอนด์ที่ปลอดภัย มีประสิทธิภาพ และบำรุงรักษาได้ เริ่มต้นด้วยพื้นฐานที่ระบุไว้ในคู่มือนี้ วัดประสิทธิภาพของแอปพลิเคชันของคุณภายใต้ภาระงานจริง และย้ำจุดคอขวดที่คุณค้นพบ รูปแบบที่นำเสนอในที่นี้ได้รับการพิสูจน์แล้วในแอปพลิเคชันที่ใช้งานจริงหลายพันรายการ และจะทำหน้าที่เป็นรากฐานที่มั่นคงสำหรับระบบแบ็กเอนด์ของคุณ