0

15 Phút "Đóng Gạch" Node.js: Setup Từ Zero Đến Production (Clean MVC, MySQL + Flyway, Redis, Docker & CI/CD)

Chào anh em Viblo,

Khi bắt đầu một dự án Node.js mới, việc cấu trúc thư mục, khởi tạo Express, kết nối Database, quản lý Migration, cấu hình Redis, viết Dockerfile và thiết lập CI/CD thường tốn rất nhiều thời gian "đóng gạch" ban đầu.

Hôm nay, mình sẽ hướng dẫn các bạn cách Setup từ con số 0 đến môi trường Production cho một service Node.js sử dụng kiến trúc MVC, ngôn ngữ TypeScript, kết nối MySQL, quản lý Database Migration với Flyway, sử dụng Redis Caching, chạy ứng dụng trên Docker Compose và tự động hóa với GitHub Actions.

Hãy cùng bắt tay vào làm nhé!

🎯 Mã nguồn "Bấm-Là-Chạy" dành cho bạn: Thay vì copy-paste từng dòng, mình đã đóng gói toàn bộ Source Code của bài viết này thành một Template chuẩn chỉnh trên GitHub.

🔗 Repo: paudang/nodejs-service

(Bạn chỉ việc git clone, gõ nhẹ docker-compose up -d và tận hưởng thành quả nhé! Đừng quên Star ⭐ ủng hộ mình nếu thấy hữu ích nha)


Bước 1: Khởi tạo dự án & Cài đặt Dependencies

Trước tiên, chúng ta tạo một thư mục mới cho dự án:

mkdir nodejs-service
cd nodejs-service
npm init -y

Cài đặt các thư viện cần thiết cho Express, Database, Caching và Security:

npm install express cors helmet hpp express-rate-limit dotenv morgan swagger-ui-express pug sequelize mysql2 ioredis

Cài đặt các gói hỗ trợ phát triển (DevDependencies):

npm install -D typescript @types/node @types/express @types/cors @types/morgan @types/swagger-ui-express ts-node typescript tsconfig-paths

Khởi tạo file tsconfig.json cho TypeScript và cấu hình Path Alias (@/*):

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*"]
}

Tiếp theo, mở file package.json và thêm các Scripts quan trọng sau đây để khởi chạy và build dự án:

"scripts": {
  "start": "node dist/index.js",
  "dev": "ts-node -r tsconfig-paths/register src/index.ts",
  "build": "tsc && tsc-alias"
}

(Bạn nhớ cài thêm npm i -D tsconfig-paths tsc-alias để hỗ trợ build path alias nhé!)


Bước 2: Xây cấu trúc (MVC) & Quản lý Migration (Flyway)

1. Phân chia thư mục mã nguồn

Với kiến trúc MVC (Model - View - Controller), chúng ta sẽ chia mã nguồn bên trong thư mục src một cách gọn gàng:

mkdir src
cd src
mkdir config controllers models routes utils views
  • config: File cấu hình kết nối DB (MySQL), Redis, Swagger.
  • controllers: Xử lý HTTP Request & Response, điều phối logic.
  • models: Định nghĩa schema/thực thể database trong Code.
  • routes: Cấu hình danh sách lệnh định tuyến API endpoints.
  • utils: Chứa các hàm hỗ trợ chung (Logger chuẩn, formatter).
  • views: Chứa file .pug render giao diện Server-side (nếu có).

2. Sự "Bá Đạo" Của Flyway Trong Production 🚀

Trong một team có nhiều Developer cùng làm hoặc khi deploy lên Production, việc "đồng bộ Database Schema" (bảng, cột, data mồi ban đầu) cực kỳ nhức nhối. Chúng ta không thể thò tay vào từng con server để gõ tay lệnh tạo hay sửa bảng được.

Đó là lý do Flyway xuất hiện! Flyway là công cụ kiểm soát phiên bản Database (Database Version Control) hàng đầu thế giới bằng file SQL thuần.

  • Cách hoạt động: Ta định nghĩa trước các file SQL có quy ước đánh số chặt chẽ (VD: V1__Initial_Setup.sql, V2__Add_Users_Table.sql).
  • Trong thực tế: Khi hệ thống khởi động, Flyway sẽ tự động "nhìn" vào DB xem thiếu những file migration nào rồi chạy chúng một cách tuần tự và chính xác nhất. Không ai có thể can thiệp sai cấu trúc Database nữa!

Tạo thư mục chưa file SQL cho Flyway ở root project:

mkdir -p flyway/sql

Bên trong đó, bạn có thể tạo ngay file V1__Initial_Setup.sql chức các câu query khởi tạo Table đầu tiên.


Bước 3: Cấu hình Kết nối (Database & Redis)

1. File biến môi trường .env

Tạo file .env ở thư mục gốc:

PORT=3000
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=root
DB_NAME=demo
REDIS_HOST=localhost
REDIS_PORT=6379

2. Kết nối MySQL (Sequelize)

Tại src/config/database.ts:

import { Sequelize } from 'sequelize';
import dotenv from 'dotenv';

dotenv.config();

const dialect = 'mysql';
const sequelize = new Sequelize(
  process.env.DB_NAME || 'demo',
  process.env.DB_USER || 'root',
  process.env.DB_PASSWORD || 'root',
  {
    host: process.env.DB_HOST || '127.0.0.1',
    dialect: dialect,
    logging: false,
    port: parseInt(process.env.DB_PORT || '3306')
  }
);

export default sequelize;

3. Cấu hình Redis & Viết Cache Service

Tại src/config/redisClient.ts, chúng ta khởi tạo kết nối và viết luôn một Singleton Service (cacheService) chứa các hàm thao tác chung (get, set, delete) cực kỳ tiện lợi:

import Redis from 'ioredis';
import dotenv from 'dotenv';

dotenv.config();

class RedisService {
    private client: Redis;
    private static instance: RedisService;

    private constructor() {
        this.client = new Redis({
            host: process.env.REDIS_HOST || 'localhost',
            port: Number(process.env.REDIS_PORT) || 6379,
        });
        this.client.on('connect', () => console.log('Redis connected'));
    }

    public static getInstance(): RedisService {
        if (!RedisService.instance) {
            RedisService.instance = new RedisService();
        }
        return RedisService.instance;
    }

    // Tiện ích tự động check Cache, nếu MISS thì tự gọi DB rồi lưu lại Cache
    public async getOrSet<T>(key: string, fetcher: () => Promise<T>, ttl: number = 3600): Promise<T> {
        const cached = await this.client.get(key);
        if (cached) return JSON.parse(cached);

        const data = await fetcher();
        if (data) await this.client.set(key, JSON.stringify(data), 'EX', ttl);
        return data;
    }

    public async del(key: string): Promise<void> {
        await this.client.del(key);
    }
}

// Export trực tiếp instance (Bí danh là cacheService)
export default RedisService.getInstance();

Bước 4: Viết File Entry Point (src/index.ts)

File index.ts sẽ là điểm chạm tích hợp toàn bộ:

import express, { Request, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import dotenv from 'dotenv';
import apiRoutes from '@/routes/api'; // 🔥 Import file routes ở Bước 5

dotenv.config();

const app = express();
const port = process.env.PORT || 3000;

app.use(helmet());
app.use(cors({ origin: '*' }));
app.use(rateLimit({ windowMs: 10 * 60 * 1000, max: 100 }));
app.use(express.json());

// Đăng ký toàn bộ API Routes vào tiền tố /api
app.use('/api', apiRoutes);

app.get('/health', (req: Request, res: Response) => {
  res.json({ status: 'UP', message: 'Hệ thống hoạt động bình thường' });
});

// Hàm khởi động chuẩn chỉnh
const startServer = async () => {
    console.log(`Server running on port ${port}`);
};

// Retry kết nối DB phòng khi DB khởi động chậm trong docker network
const syncDatabase = async () => {
    let retries = 30;
    while (retries) {
        try {
            const sequelize = (await import('@/config/database')).default;
            await sequelize.sync();
            console.log('Database synced & connected!');
            app.listen(port, startServer);
            break;
        } catch (error) {
            console.error('Error syncing database:', error);
            retries -= 1;
            await new Promise(res => setTimeout(res, 5000));
        }
    }
};

syncDatabase();

Bước 5: Xây dựng REST API (Thực hành với bảng Users)

Để hệ thống thực sự chạy được và phản hồi dữ liệu, chúng ta hãy thử viết một luồng API CRUD cơ bản cho thực thể User nhé.

1. Định nghĩa Model (Bảng users)

Tại src/models/User.ts, chúng ta dùng Sequelize để map code với MySQL:

import { DataTypes, Model } from 'sequelize';
import sequelize from '@/config/database';

class User extends Model {
  public id!: number;
  public name!: string;
  public email!: string;
}

User.init({
    id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
    name: { type: DataTypes.STRING, allowNull: false },
    email: { type: DataTypes.STRING, allowNull: false, unique: true },
  }, { sequelize, tableName: 'users', underscored: true }
);

export default User;

2. Viết logic tại Controller (Có tích hợp Redis Cache)

Tại src/controllers/userController.ts, ta xử lý việc lấy dữ liệu (kèm cache) và tạo mới user:

import { Request, Response } from 'express';
import User from '@/models/User';
import cacheService from '@/config/redisClient';

export class UserController {
    async getUsers(req: Request, res: Response) {
        try {
            // Lấy từ Cache, nếu không có thì Query DB và Cache lại 60s
            const users = await cacheService.getOrSet('users:all', async () => {
                return await User.findAll();
            }, 60);
            res.json(users);
        } catch (error) {
            res.status(500).json({ error: 'Internal Server Error' });
        }
    }

    async createUser(req: Request, res: Response) {
        try {
            const { name, email } = req.body;
            const user = await User.create({ name, email });
            // Nhớ xóa cache sau khi có data mới!
            await cacheService.del('users:all'); 
            res.status(201).json(user);
        } catch (error) {
            res.status(500).json({ error: 'Internal Server Error' });
        }
    }
}

3. Gắn Route và public API

Tại src/routes/api.ts, ta kết nối controller với các endpoint:

import { Router, Request, Response } from 'express';
import { UserController } from '@/controllers/userController';

const router = Router();
const userController = new UserController();

// Test bằng cách call thư HTTP GET /api/users hoặc POST /api/users
router.get('/users', (req: Request, res: Response) => userController.getUsers(req, res));
router.post('/users', (req: Request, res: Response) => userController.createUser(req, res));

export default router;

(Lưu ý: Bạn nhớ import và app.use('/api', apiRoutes) vào file src/index.ts nhé!)


Bước 6: Đóng gói "All In One" với Docker-Compose

Production Server nào cũng cần Docker.

1. Dockerfile (Multi-stage build tối ưu hóa)

# Stage 1: Builder
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY tsconfig*.json ./
RUN npm ci || npm ci || npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:18-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --only=production --ignore-scripts || npm ci --only=production --ignore-scripts || npm ci --only=production --ignore-scripts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/views ./dist/views
COPY --from=builder /app/public ./public
EXPOSE 3000
RUN mkdir -p logs && chown -R node:node logs
USER node
CMD ["npm", "start"]

2. docker-compose.yml (App + MySQL + Flyway + Redis)

Đây là "combo thần thánh". Khi chạy lên, Container db sẽ tạo MySQL. Sau đó flyway tự động lao vào tạo các Table trong DB, và app kết nối, redis cũng sẵn sàng Cache:

services:
  app:
    build: .
    ports:
      - "${PORT:-3000}:3000"
    depends_on:
      - db
    environment:
      - PORT=3000
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - DB_HOST=db
      - DB_USER=root
      - DB_PASSWORD=root
      - DB_NAME=demo
  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: demo
    ports:
      - "${DB_PORT:-3306}:3306"
    volumes:
      - ./flyway/sql:/docker-entrypoint-initdb.d
  flyway:
    image: flyway/flyway
    command: -connectRetries=60 migrate
    volumes:
      - ./flyway/sql:/flyway/sql
    environment:
      FLYWAY_URL: jdbc:mysql://db:3306/demo
      FLYWAY_USER: root
      FLYWAY_PASSWORD: root
    depends_on:
      - db
  redis:
    image: redis:alpine
    restart: always
    ports:
      - "${REDIS_PORT:-6379}:6379"
volumes:
  mysql_data:

Bước 7: Tự động hóa CI/CD với GitHub Actions

File .github/workflows/ci.yml:

name: Node.js CI

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18.x, 20.x]
    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v3
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
    - name: Install Dependencies
      run: npm ci
    - name: Lint Code
      run: npm run lint
    - name: Run Tests
      run: npm test
    - name: Build
      run: npm run build --if-present

Luồng CI này đảm bảo Pull Request nào cũng phải đi qua đánh giá Lint code / Test trước.


Điều Bất Ngờ Nhất Ở Cuối Bài... 🤫

Bạn nghĩ bạn đang đọc một bài hướng dẫn build Repo thông thường?

👉 Sự Thật Là: Toàn bộ Base Code Repo này, những cấu hình chằng chịt giữa Express, Sequelize, Docker, Redis, Flyway (Migration file), Eslint và các workflow GitHub Actions... đều được MỘT TOOL AUTOMATION TẠO RA TRONG CHƯA TỚI 1 PHÚT ĐỒNG HỒ!

Tốc độ phát triển Product là vàng! Nếu bạn muốn chấm dứt chuỗi ngày hì hục setup đi setup lại một cục base code giống hệt nhau khi bắt đầu dự án mới, hãy dùng luôn Tool mình đã chế tác và tối ưu hoá.

Nếu bạn muốn tự động sinh ra một repo "xịn" y hệt (hoặc chọn dùng luôn Clean Architecture, Kafka thay thế,...), hãy sử dụng Tool này (kèm hướng dẫn cực chi tiết) ngay tại đường link Viblo dưới đây:

🔗 Tool tự động hoá việc tạo project NodeJS chuẩn chỉnh chỉ trong 1 phút với nodejs-quickstart-structure

Hy vọng cả bài phân tích kiến trúc trên lẫn công cụ gen code này sẽ làm hành trình ra mắt Sản phẩm phần mềm của bạn "Dễ thở" và tăng tốc hơn. Nhớ Upvote để đồng đạo cùng lan toả nhé! 🔥


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí