全栈实战
从零搭一个可上线的全栈应用,需要打通前端、后端、数据库、部署整条链路。本文以"Vue/React 前端 + Node/Express 后端 + MySQL + Docker 部署"为主线,串起架构设计、环境管理、联调、容器化、CI/CD 与生产运维,让应用真正跑在线上。
一、全栈架构概览
浏览器 ──HTTPS──> Nginx(静态资源 + 反向代理)
│
├──> Node API(Express)──> MySQL / Redis
└──> 前端静态文件(dist/)| 层 | 职责 | 技术选型 |
|---|---|---|
| 前端 | 页面渲染、交互、状态管理 | Vue 3 / React + Vite |
| 后端 | 业务逻辑、数据校验、鉴权 | Node.js + Express / NestJS |
| 数据库 | 持久化存储 | MySQL / PostgreSQL |
| 缓存/队列 | 加速与异步 | Redis |
| 网关 | 静态托管、反向代理、HTTPS | Nginx |
| 部署 | 容器化与编排 | Docker + docker-compose |
二、项目结构设计(前后端分离)
text
fullstack-app/
├── client/ # 前端
│ ├── src/
│ │ ├── api/ # 接口封装(axios 实例 + 各模块接口)
│ │ ├── components/
│ │ ├── pages/
│ │ └── main.ts
│ ├── package.json
│ └── vite.config.ts # dev 代理配置
├── server/ # 后端
│ ├── src/
│ │ ├── controllers/ # 控制器:解析请求、调服务
│ │ ├── services/ # 服务层:业务逻辑
│ │ ├── models/ # 数据模型(ORM)
│ │ ├── middleware/ # 鉴权、校验、错误处理
│ │ ├── routes/ # 路由定义
│ │ ├── config/ # 配置读取
│ │ └── app.js
│ ├── tests/
│ ├── Dockerfile
│ └── package.json
├── nginx/
│ └── default.conf # 反向代理与静态资源配置
├── docker-compose.yml
└── .env.example # 环境变量模板分层的价值:controllers 只做参数解析与响应,services 承载业务,models 只碰数据。接口层、业务层、数据层互不越界,测试与重构都轻松。
三、环境配置:开发/测试/生产
| 环境 | NODE_ENV | 用途 | 典型配置 |
|---|---|---|---|
| 开发 | development | 本地联调 | 本地数据库、开启调试日志 |
| 测试 | test | 自动化测试 | 内存/SQLite 库、测试密钥 |
| 生产 | production | 线上运行 | 真实库、限流、压缩日志 |
bash
# .env 模板(.env 不进 git 仓库,.env.example 提交)
NODE_ENV=development
PORT=3000
DATABASE_URL=mysql://root:123456@localhost:3306/shop
JWT_SECRET=please-change-me
REDIS_URL=redis://localhost:6379javascript
// server/src/config/index.js:集中读取并校验环境变量
require("dotenv").config();
const config = {
env: process.env.NODE_ENV || "development",
port: Number(process.env.PORT) || 3000,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
isProd: process.env.NODE_ENV === "production",
};
// 生产环境启动时校验关键配置缺失
if (config.isProd && (!config.databaseUrl || !config.jwtSecret)) {
throw new Error("缺少必要环境变量,请检查 .env");
}
module.exports = config;原则:代码里不出现任何环境差异化的硬编码;密钥、连接串全部走环境变量;.env 加入 .gitignore,用 .env.example 标注需要的变量。
四、后端 API 与前端联调
4.1 开发代理(Vite dev server)
开发时前端跑在 5173,后端跑在 3000,跨域问题用代理解决,浏览器请求同源:
typescript
// client/vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
});4.2 生产 CORS 配置
生产环境前后端若不同源(如前端 app.example.com、后端 api.example.com),后端需配置 CORS:
javascript
const cors = require("cors");
const allowedOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(",")
: ["http://localhost:5173"];
app.use(cors({
origin: allowedOrigins, // 白名单,不要用 *
credentials: true, // 允许携带 Cookie
allowedHeaders: ["Content-Type", "Authorization"],
}));| 方式 | 场景 | 要点 |
|---|---|---|
| Vite 代理 | 开发环境 | 浏览器无跨域,天然解决 |
| CORS 白名单 | 前后端不同域部署 | 明确列出来源,禁止 * 配 credentials |
| Nginx 同源 | 静态资源与 API 同域 | 路径转发,无跨域问题(见第六节) |
4.3 接口约定
- 统一前缀:
/api,版本:/api/v1。 - 统一响应结构:
{ data, pagination }与{ error: { code, message } }。 - 前端
axios实例统一封装:基础 URL、token 注入、401 跳登录、错误提示。
javascript
// client/src/api/http.js
import axios from "axios";
const http = axios.create({ baseURL: "/api/v1", timeout: 10000 });
// 请求拦截:自动携带 token
http.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 响应拦截:统一处理错误
http.interceptors.response.use(
(res) => res.data,
(error) => {
if (error.response?.status === 401) {
location.href = "/login"; // 未登录跳登录页
}
return Promise.reject(error);
}
);五、Docker 化部署
5.1 后端 Dockerfile
dockerfile
# server/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build --if-present
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev # 只装生产依赖,镜像更小
COPY --from=builder /app/dist ./dist
USER node # 以非 root 运行
EXPOSE 3000
CMD ["node", "dist/app.js"]5.2 前端 Dockerfile(构建后由 Nginx 托管)
dockerfile
# client/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # 产出 dist/
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 805.3 docker-compose 编排
yaml
# docker-compose.yml
services:
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: shop
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
retries: 10
api:
build: ./server
environment:
NODE_ENV: production
DATABASE_URL: mysql://root:${DB_PASSWORD}@db:3306/shop
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy # 数据库就绪后再启动 API
ports:
- "3000:3000"
web:
build: ./client
ports:
- "80:80"
depends_on:
- api
volumes:
db-data:bash
docker compose up -d --build # 一键启动全部服务
docker compose logs -f api # 查看 API 日志
docker compose down # 停止| 要点 | 说明 |
|---|---|
| 服务名即主机名 | 容器内用 db、api 访问彼此 |
| healthcheck | 用健康检查控制启动顺序 |
| 数据卷 | 数据库文件挂载卷,容器删了数据不丢 |
| 环境变量注入 | compose 从宿主机 .env 读取 |
六、Nginx 反向代理与静态资源
nginx
# nginx/default.conf
server {
listen 80;
server_name example.com;
# 前端静态资源
root /usr/share/nginx/html;
index index.html;
# 前端路由回退(history 模式)
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理到 Node
location /api/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 静态资源缓存
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}bash
# 浏览器访问 /api/v1/users → Nginx 转发 → api:3000/api/v1/users
# 前后端同域,没有跨域问题;HTTPS 证书挂在 Nginx 层| Nginx 职责 | 说明 |
|---|---|
| 静态托管 | 前端 dist 直接由 Nginx 提供,比 Node 托管快 |
| 反向代理 | /api 转发到 Node,隐藏内部服务 |
| 负载均衡 | upstream 配置多个 Node 实例轮询 |
| 缓存/压缩 | gzip 压缩、静态资源长缓存 |
| HTTPS | 证书终止于 Nginx,后端保持 HTTP |
七、CI/CD 流程(GitHub Actions)
yaml
# .github/workflows/deploy.yml
name: CI/CD
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
working-directory: server
- run: npm test # 单元/集成测试
working-directory: server
- run: npm run build # 前端构建验证
working-directory: client
deploy:
needs: test # 测试通过才部署
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & push images
run: |
docker compose build
docker push registry.example.com/myapp:latest
- name: Deploy to server
uses: appleboy/ssh-action@v1 # SSH 到服务器
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/myapp
docker compose pull
docker compose up -d --force-recreate| 阶段 | 动作 | 作用 |
|---|---|---|
| CI:test | 安装依赖 → 跑测试 → 构建 | 合并前拦住问题 |
| CI:lint | ESLint / 类型检查 | 代码质量门禁 |
| CD:build | 构建镜像并推送到仓库 | 产物不可变 |
| CD:deploy | SSH 到服务器拉镜像重启 | 零停机发布 |
密钥(服务器地址、SSH Key、镜像仓库凭证)存放在仓库的 Secrets 中,绝不出现在 workflow 文件里。
八、Node 应用生产环境考量
| 维度 | 做法 |
|---|---|
| 进程守护 | PM2 集群模式多核运行,崩溃自动重启 |
| 内存限制 | --max-old-space-size=1024,OOM 自动重启 |
| 日志 | 结构化日志 + 按天轮转 + 集中采集 |
| 优雅退出 | 捕获 SIGTERM,关闭连接池与监听后再退出 |
| 安全 | HTTPS、helmet、限流、密钥走环境变量 |
| 监控 | 内存/CPU 指标采集 + 告警 |
javascript
// 优雅退出:收到 SIGTERM(docker stop / PM2 stop 时触发)
async function shutdown(signal) {
console.log(`收到 ${signal},开始优雅退出`);
server.close(async () => {
await pool.end(); // 关闭数据库连接池
await redis.quit();
process.exit(0);
});
// 兜底:10 秒强制退出
setTimeout(() => process.exit(1), 10000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));json
// PM2 配置 ecosystem.config.js
{
"apps": [{
"name": "my-api",
"script": "dist/app.js",
"instances": "max",
"exec_mode": "cluster",
"max_memory_restart": "300M",
"env": { "NODE_ENV": "production" }
}]
}九、上线检查清单
| 类别 | 检查项 |
|---|---|
| 配置 | 密钥已从代码移出;.env 生产值正确;NODE_ENV=production |
| 数据库 | 迁移已执行;连接池参数合理;备份与恢复演练过 |
| 安全 | HTTPS 生效;helmet 开启;登录限流;CORS 白名单收紧 |
| 日志监控 | 日志可检索;错误上报(Sentry)连通;内存/CPU 告警配置 |
| 部署 | Dockerfile 精简;健康检查就绪探针;Nginx 缓存与代理正确 |
| CI/CD | 测试门禁通过;构建产物可复现;回滚方案就绪 |
| 性能 | 静态资源已缓存压缩;慢接口有缓存兜底;压测过预期峰值 |
| 流程 | 启动脚本一键部署;pm2 save / systemd 开机自启;域名解析生效 |
对照清单逐项勾选,应用才能从"本地能跑"变成"线上稳定跑"。全栈能力正是这样一点点沉淀下来的:架构分层、环境隔离、容器化、自动化,每一环都是生产质量的组成部分。