简介:基于Node.js、Express与MySQL的快速开发脚手架,面向需要快速搭建后端项目的Node开发者,尤其适合API服务、管理后台等常见场景。压缩包仅37KB,共31个文件,以17个js主逻辑文件为核心,辅以2个md说明文档、2个html演示页面、2个json配置以及yml、license等辅助文件,整体轻量清晰。项目内预设了数据库连接、CRUD示例、Redis与ES工具封装等基础模块,目录划分涵盖lib、src、config、utils等,便于按照既有约定扩展业务。目前已有31人学习下载,适合刚接触全栈开发或希望提升项目启动效率的读者。通过这份脚手架,可省去从零配置的重复劳动,快速产出规范、可维护的后端骨架,同时理解Node+Express+MySQL的典型协作方式。
1. 基于 node+express+mysql 的快速开发脚手架:到底省了多少事
做后端接口,很多人第一反应是 node + express + mysql 三件套:express 管路由,mysql 存数据,照教程半天能起一个 demo。真等你要写登录鉴权、要接数据库连接池、要统一所有接口的返回格式、要在凌晨排查“为什么第一个请求卡了三十秒”的时候,才发现 demo 离上线还差十条街。这个快速开发脚手架干的就是把公共部分提前封装掉:目录分层、mysql2 连接池、统一响应结构、错误兜底、JWT 登录态,你拿到手解压、改配置、跑起来,直接在业务目录里写自己的接口。适合三类人:第一次用 node 写正式项目的、要给团队定后端规范的、以及不想每次新建项目都从零铺一遍基础设施的人。
2. 解压后的第一件事:看懂目录分层,再决定要不要改结构
拿到脚手架的 zip 包,别急着 npm install 然后埋头改代码,先把目录结构从头到尾看一遍。我见过太多新人在第一天就把路由全塞进 app.js,等到第三天接口多了,自己都找不到代码在哪。这套脚手架的目录是提前分好的,你顺着走就行,不需要你来发明结构。
2.1 三层业务划分:routes 只做路由,models 只碰 SQL
下面这份目录树是这套脚手架最常见的组织方式,你解压后看到的会略有出入,但思路是一致的:
project-root/ ├── app.js # express 入口,注册全局中间件 ├── package.json ├── .env.example # 环境变量模板,提交到仓库 ├── config/ │ └── index.js # 按 NODE_ENV 返回对应配置 ├── routes/ │ ├── index.js # 汇总所有路由模块 │ └── user.js # 用户模块路由表 ├── controllers/ │ └── userController.js # 接收参数、调 service、拼响应 ├── services/ │ └── userService.js # 业务逻辑:校验、组合、事务 ├── models/ │ └── userModel.js # SQL 与数据库交互 ├── middleware/ │ ├── auth.js # JWT 鉴权 │ └── errorHandler.js # 404 与统一错误处理 ├── utils/ │ └── response.js # 统一响应工具 └── public/ # 静态资源,一般不放业务文件这段目录看着普通,但它把“改一处会不会炸一片”这个问题提前解决了。routes 只做路由表,controller 收参数,service 写业务,model 只写 SQL。这样分层之后,换数据库驱动只动 models,加权限只动 middleware,新来的人看代码也知道去哪里找东西。我见过太多项目把 SQL 直接写在路由回调里,接口一多,改表结构的时候全局搜索字段名,搜出来几十处,改一次提心吊胆一次。
为什么要这样拆而不是把所有逻辑压在一个文件里?核心原因是 express 的路由回调太自由了,自由到团队里每个人写出来的接口风格都不一样:有人用回调,有人用 async/await;有人错误处理用自己的 try-catch,有人直接抛给 express。统一的分层约定,本质上是把这种“自由”关进笼子里,让代码可预测。脚手架里用 commonjs 而不是 esm,也同样是出于兼容性考虑——老一点的 node 环境、以及大量现存依赖,commonjs 的坑最少,真要切 esm,等团队所有人都在 node 18+ 再说。
2.2 入口文件 app.js:全局中间件注册顺序是命门
入口文件决定了所有请求的必经路径,这段代码很短,但中间件的顺序错一个,行为就完全不对:
const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const morgan = require('morgan'); const routes = require('./routes'); const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); const app = express(); app.use(helmet()); // 基础安全头 app.use(cors()); // 允许跨域,按实际收紧 app.use(morgan('combined')); // 请求日志 app.use(express.json()); // 解析 JSON body app.use(express.urlencoded({ extended: true })); app.use('/api', routes); // 所有业务接口挂在 /api 下 app.use(notFoundHandler); // 404 兜底 app.use(errorHandler); // 统一错误处理 module.exports = app;helmet 给响应加安全头,默认配置就够了;cors 在开发阶段可以全开,上线前要改成白名单,否则任何网站都能跨域调你的接口;morgan 的 combined 格式日志最全,生产环境可以换成 tiny 减少磁盘写入。body 解析必须放在路由之前,否则 req.body 永远是 undefined。而 404 和错误处理必须放在所有路由之后——express 中间件是按注册顺序执行的,你把错误处理放在前面,后面的业务错误根本到不了它手里。
2.3 配置方案:.env 管变量,config 管加载
数据库地址、端口、密码这些不能写死在代码里,这套脚手架用 .env 存放变量,由 config/index.js 统一读取:
const dotenv = require('dotenv'); dotenv.config(); const env = process.env.NODE_ENV || 'development'; const config = { development: { port: 3000, db: { host: process.env.DB_HOST || '127.0.0.1', port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER || 'root', password: process.env.DB_PASSWORD || '', database: process.env.DB_NAME || 'app_dev', }, jwtSecret: process.env.JWT_SECRET || 'dev-secret', }, production: { port: process.env.PORT, db: { host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, }, jwtSecret: process.env.JWT_SECRET, }, }; module.exports = config[env];注意看两套配置的区别:开发环境每个字段都有默认值,是为了让你少配一个变量就能跑起来;生产环境全部强制从环境变量读取,缺了就是 undefined,启动阶段直接报错,而不是带病运行。JWT_SECRET 生产环境绝对不能有默认值,这是安全红线,不是技术选型问题。另外 .env.example 要提交到仓库,.env 必须写进 .gitignore。配置项对应关系如下:
| 配置项 | 默认值 | 说明 |
|---|---|---|
| NODE_ENV | development | 决定加载哪套配置,生产必须设 production |
| PORT | 3000 | 服务监听端口 |
| DB_HOST | 127.0.0.1 | 数据库地址 |
| DB_PORT | 3306 | 数据库端口 |
| DB_USER / DB_PASSWORD | root / 空 | 生产必须改成专用账号 |
| DB_NAME | app_dev | 数据库名 |
| JWT_SECRET | dev-secret | 生产必须换成随机值 |
为什么把配置单独抽出来而不是在 db.js 里硬编码?因为本地、测试、生产三套环境必然要用不同的库,连接信息不集中管理,部署的时候你就得在代码里改一遍再发上去,每次都提心吊胆。配置集中是脚手架必须付的成本。
3. 把脚手架跑起来:从 node 环境到 mysql 初始化的完整通关
很多人在这一步卡住,不是代码问题,而是环境问题。这一章按实际动手顺序来:先装 node,再初始化 mysql,最后 npm install 启动。顺序别反,反了会出现“装了半天发现 mysql 连不上”的尴尬。
3.1 node 环境:用 nvm 管版本,别裸装
先看脚手架 package.json 里 engines 字段写的 node 版本要求,常见是 >= 14,推荐用 LTS。如果电脑上同时有多个项目,每个项目的依赖版本不一样,直接官网下载安装版 node 会让你痛不欲生——装高了老项目跑不起来,装低了新项目用不了新语法。用 nvm 管理版本是业界最稳的做法:
# Linux/macOS 用 nvm,Windows 用 nvm-windows,命令一致 nvm install 18 nvm use 18 node -v npm -vnvm install 18 表示安装 18 这个 LTS 大版本,nvm 会自动装该系列最新的补丁版。node 高版本兼容低版本吗?这个问题我后面专门讲,这里只提醒一句:别一上来就装最新版,脚手架里有些老依赖在 node 20+ 上要重新编译,编译不过会连带一堆报错。切换到 18 之后,node -v 和 npm -v 能正常打印版本号,第一步就算过了。
3.2 mysql 8.0 初始化:my.ini、初始化命令、建库授权
mysql 安装教程在网上能搜到一大堆,但很多教程只讲 msi 图形安装。脚手架项目我一般用解压版 zip,因为所有配置项都看得见、可控。Windows 下先把 mysql 的 zip 解压到指定目录,然后写 my.ini:
[mysqld] port=3306 basedir=D:/tool/mysql-8.0.46-winx64 datadir=D:/tool/mysql-8.0.46-winx64/data character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ci default-authentication-plugin=mysql_native_password [client] port=3306 default-character-set=utf8mb4basedir 和 datadir 按你自己的解压路径改。default-authentication-plugin 这里先保留 mysql_native_password,能省掉第 5 章的认证坑;如果你想去掉这行、用 mysql 8 默认的 caching_sha2_password,前提是脚手架依赖的 mysql2 版本足够新,后面细说。然后执行初始化:
mysqld --initialize-insecure # 生成 data 目录,root 密码为空 mysqld --install # 注册为 Windows 服务 net start mysql # 启动服务--initialize-insecure 会生成一个空密码的 root,只适合本地开发;--initialize(不带 insecure)会生成一个临时随机密码写在日志文件里,生产环境用那种。服务起来之后,登录建库建账号:
CREATE DATABASE app_dev DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON app_dev.* TO 'app_user'@'localhost'; FLUSH PRIVILEGES;为什么单独建账号而不直接用 root?脚手架配置里 DB_USER 填 root 的话,业务代码一旦被注入或误操作,影响的可是整个 mysql 实例。业务账号把权限收敛到单个库,出事也就一个库的事。
3.3 安装依赖与启动:npm install 与 dev 脚本
先看 package.json 里的脚本定义:
{ "scripts": { "dev": "nodemon app.js", "start": "node app.js" } }然后执行安装并启动:
npm install npm run devdev 用 nodemon 监听文件变化自动重启,开发时改代码不用手动重启;start 是生产启动方式,没有自动重启。npm install 慢的话可以换 registry,比如 npm config set registry https://registry.npmmirror.com,这只影响下载源,不影响代码逻辑。如果 npm install 阶段报 node-gyp 编译错误,先别急着百度报错文案,大概率是 node 版本和依赖不匹配,用 nvm 切回 LTS 再 npm install。还有一点:装好之后别手痒去把 package.json 里的依赖版本改成最新,脚手架锁定的版本是跑通验证过的,大版本一升,坑重新来一遍。
3.4 首次启动验证:curl 冒烟两个接口
服务起来后,用 curl 做一轮冒烟测试,比用浏览器更直观:
curl http://127.0.0.1:3000/api/health curl -X POST http://127.0.0.1:3000/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'health 接口返回{ code: 0, message: 'ok', data: { uptime: xx } },说明 express 起来了。login 接口如果脚手架里预置了种子用户数据,会返回一个 token;看到 code: 0 说明整条链路——路由、controller、service、model、mysql——都是通的。这一步过了,脚手架才真正在你机器上落地。
4. 脚手架内置的四个核心模块:连接池、响应规范、错误兜底与 JWT 鉴权
这四个模块是脚手架的公共底座,也是它跟“随手写的 demo”拉开差距的地方。新手可以不会写,但必须知道它们各自解决什么问题,改业务代码时才知道哪些东西不能动。
4.1 数据库连接池:mysql2 的 createPool、事务与关键参数
为什么不直接用 mysql 这个包?mysql 包是回调风格,写起来啰嗦;更关键的是它按次创建连接,每次请求都新建连接,高并发下数据库连接数直接被打爆。脚手架用 mysql2/promise,支持 async/await,内置连接池。核心文件长这样:
const mysql = require('mysql2/promise'); const config = require('../config'); const pool = mysql.createPool({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password, database: config.db.database, waitForConnections: true, connectionLimit: 10, queueLimit: 0, charset: 'utf8mb4', timezone: '+08:00', // 与服务器时区保持一致,避免时间字段错 8 小时 dateStrings: true, // 日期按字符串返回,前端不用再解析 }); module.exports = pool;connectionLimit 决定连接池上限,默认 10,按业务并发调。我踩过一次:活动接口并发高,把连接池调到 50,结果忘了 mysql 的 max_connections 默认只有 151,差点把整个库打死。调连接池之前先看数据库这边的上限,两边对齐再改。waitForConnections 表示连接池满了以后新请求是排队还是直接报错,queueLimit 为 0 表示不限制排队长度;如果业务接受不了排队等待,把 queueLimit 设成 1,超了直接抛错给客户端重试。charset、timezone、dateStrings 三个参数都是用来治“数据对不上”的,具体坑在第 5 章讲。
需要事务的业务,比如转账、下单,写法是这样:
const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.execute('UPDATE account SET balance = balance - ? WHERE id = ?', [100, 1]); await conn.execute('UPDATE account SET balance = balance + ? WHERE id = ?', [100, 2]); await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }跨账户操作必须事务,没有事务,第一步成功第二步失败,钱就凭空消失了。重点是 finally 里的 release——事务占着连接不放,连接池很快就会耗尽,这是新手最容易漏的一步。复杂的统计报表可以走存储过程,但接口层尽量别放逻辑,存储过程只做数据库擅长的聚合计算。
4.2 统一响应结构:前端只认 code 这一个字段
没有统一响应结构之前,前端对接每个后端同事的接口都要单独问一遍“成功了你返回什么”。这套脚手架的 utils/response.js 把这个规矩定死了:
function ok(res, data = null, message = 'ok') { res.json({ code: 0, message, data }); } function fail(res, message = 'error', code = 1) { res.json({ code, message, data: null }); } module.exports = { ok, fail };所有成功接口返回code: 0,所有失败返回非 0 的 code,前端拦截器只需要判断一次 code 不等于 0 就进错误分支。为什么不用 HTTP status 直接当业务码?因为 404/500 是传输层语义,表达不了“账号存在但密码错误”这种业务语义;HTTP 状态码给传输层,业务码给业务层,两者各干各的。顺带提一句,express fastify 这类框架也有自己的响应序列化机制,但如果团队已经习惯 express 生态,脚手架选 express 更稳——网上能搜到的排查资料量级完全不一样。
4.3 错误处理中间件:4 个参数少一个都不生效
express 的错误处理中间件有个坑:必须声明 4 个参数,哪怕不用 next 也得占位。少一个,express 就不把它当错误处理中间件,你的兜底直接失效:
function notFoundHandler(req, res, next) { res.status(404).json({ code: 404, message: '接口不存在', data: null }); } function errorHandler(err, req, res, next) { console.error(err); const status = err.status || 500; res.status(status).json({ code: err.code || status, message: err.message || '服务器内部错误', data: null, }); } module.exports = { notFoundHandler, errorHandler };业务代码里经常在 async 函数里 throw 错误,但 express 4 默认不会自动捕获 async 里的 rejected promise,要让错误进到上面的 errorHandler,得包一层:
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);有了 asyncHandler,controller 里想抛业务错误就直接 throw new Error,不用每个接口手写 try-catch。没有这层包装,async 里抛出的错误会直接变成未处理的 promise rejection,接口挂起,客户端等到超时,日志里什么都查不到——这是 express 项目最常见的黑匣子现场。
4.4 JWT 鉴权:登录签发、接口拦截与密码存储
脚手架默认用 JWT 做登录态,middleware/auth.js 里两个函数:一个签发 token,一个校验 token:
const jwt = require('jsonwebtoken'); function signToken(payload) { return jwt.sign(payload, config.jwtSecret, { expiresIn: '2h' }); } function authRequired(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) { return res.status(401).json({ code: 401, message: '未登录', data: null }); } try { req.user = jwt.verify(token, config.jwtSecret); next(); } catch (e) { res.status(401).json({ code: 401, message: '登录已过期', data: null }); } } module.exports = { signToken, authRequired };登录接口验证完用户名密码后调 signToken,把用户 id 和角色塞进 payload;需要登录的接口在路由层挂 authRequired,比如/api/user/profile先过中间件再进 controller。token 放 header 的 Authorization 字段、用 Bearer 前缀,这是行业惯例。过期时间 2h 按业务调,可以改成 30m 更安全,但用户体验会差;登出不用做服务端逻辑,前端把 token 删掉即可。密码存储用 bcryptjs 的 hashSync(password, 10),成本因子 10 够用,别用默认的 4,太弱;纯 JS 实现不涉及编译,比 bcrypt 省心。
5. 避坑手册:五个现场记录,从 mysql 认证到 node 升级
以下五条,每条都是我或者身边同事真金白银踩过的现场,按“现象 → 原因 → 解决”的顺序写,你遇到其中任何一条,直接照着处理。
5.1 mysql 8 认证插件:ER_NOT_SUPPORTED_AUTH_MODE 不是密码错了
现象:npm run dev 启动后,mysql2 抛ER_NOT_SUPPORTED_AUTH_MODE: Client does not support authentication protocol requested by server,很多人以为是密码错了,反复改密码没用。
原因:mysql 8 默认认证插件是 caching_sha2_password,老驱动不认识。mysql 5.7 用的还是 mysql_native_password,所以到现在还有大量 mysql 5.7.44 在生产环境跑,不是它多优秀,是 8.0 升级认证插件把一批老客户端卡住了。
解决:两个方案二选一。方案一是把 mysql2 升到支持 caching_sha2_password 的新版本,新建环境我推荐这个;方案二是建账号时指定老认证插件:ALTER USER 'app_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your_password';,存量库应急用这个。别两个都做,改完一个先重启验证,再看另一个。
5.2 node 高版本兼容低版本吗:npm install 阶段先翻车
现象:npm install 时 node-gyp rebuild 报错,一直卡在编译步骤;或者装完启动直接崩,报 OpenSSL provider 相关错误。
原因:带原生编译的依赖如 bcrypt、sharp,node 大版本一变,预编译好的二进制对不上,就得现场编译;编译环境缺 python 或 VS build tools 就挂。node 高版本兼容低版本吗?语言层面大体兼容,依赖层面真不一定,尤其是带原生模块的依赖。
解决:脚手架开发统一用 LTS 版本,别追 latest。项目里把 bcrypt 换成纯 JS 的 bcryptjs,把编译型依赖降到最少,这是治本。救急命令NODE_OPTIONS=--openssl-legacy-provider能临时跑起来,但别长期用,它只是绕过了 OpenSSL 3 的 provider 变更,不是真正兼容。
5.3 连接池睡死:凌晨第一个请求永远在转圈
现象:服务跑了一晚上,第二天早上第一个接口请求要等很久,然后超时或直接报ETIMEDOUT / Can't read from MySQL。表面看起来像锁等待,其实根本不是锁的事。
原因:mysql 的 wait_timeout 默认 8 小时,空闲超过 8 小时的连接被服务端静默断开,连接池不知道,继续把死连接发给业务。mysql 锁的分类里排除了半天锁问题,最后一看连接早断了。
解决:连接池加enableKeepAlive: true, keepAliveInitialDelay: 10000,让连接池定期给 mysql 发探测包。更土的办法是启动时 setInterval 每小时执行一次SELECT 1,代价极小。用 docker 跑 mysql 的还要注意 volume 和宿主机磁盘空间,磁盘写满会报 ephemeral-storage 相关错误,表现也是连不上,但那是存储问题,先df -h排除再折腾连接池。
5.4 时区错乱:存 10 点返回 2 点,别急着改业务代码
现象:数据库里存的时间是 10:00,接口返回 02:00,整整差 8 小时。这种问题排查起来特别玄学,因为它只影响 datetime 字段,varchar 一点事没有。
原因:node 进程默认按 UTC 处理日期,mysql 连接时区没对齐,两边换算时差出来的。业务代码里存的是当前时间,但取出来的时候被当成另一个时区解析了。
解决:连接池统一配置timezone: '+08:00'和dateStrings: true,让时间按字符串原样输出,不在 node 层做任何时区转换。注意别同时改多个地方——改一处生效后先验证,再动下一处,一次改三处,出了问题不知道是哪边改坏的。
5.5 字符集没到 utf8mb4:emoji 存进去变问号
现象:接口写入 emoji 报Incorrect string value,或者存进去读出是??。
原因:mysql 的 utf8 实际只能存 3 字节字符,emoji 是 4 字节,必须用 utf8mb4。这个坑在建库那一层就埋下了,表建错了后面全跟着错。
解决:建库建表统一 utf8mb4,连接池 charset 配utf8mb4。存量表补救用ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4,但转换前先检查 VARCHAR 大字段的索引长度——utf8mb4 下每个字符占 4 字节,VARCHAR(255) 做索引可能超长,先把字段长度改小或改用前缀索引,再执行转换。
6. 用脚手架新增一个业务模块:从建表到接口出数的标准动作
跑通之后,真正的高频操作是新增模块。以订单模块为例,套路是固定的:routes 注册 → controller 收参 → service 业务 → model 拿数。先在 routes 下建 order.js:
// routes/order.js const express = require('express'); const router = express.Router(); const orderController = require('../controllers/orderController'); const { authRequired } = require('../middleware/auth'); router.get('/', authRequired, orderController.list); module.exports = router;controller 里写 list 方法,顺手把排序参数白名单做掉:
// controllers/orderController.js const { ok } = require('../utils/response'); const orderService = require('../services/orderService'); async function list(req, res) { const { page = 1, pageSize = 10, sort = 'id', order = 'desc' } = req.query; const allowedSort = ['id', 'created_at', 'amount']; if (!allowedSort.includes(sort)) { throw new Error('不支持的排序字段'); } const data = await orderService.page(req.user.id, { page, pageSize, sort, order }); ok(res, data); } module.exports = { list };注意排序字段必须走白名单。mysql 排序确实用得多,但用户传的 sort 不能直接拼进 SQL——ORDER BY后面是字段名不是值,参数化?只能替换值、替换不了字段名,不白名单直接拼,等于把 order by 注入的门敞开了。service 层拿到白名单校验过的 sort,拼进ORDER BY ${sort} ${order}才安全,order 也要校验成 asc/desc 二选一。
新模块的完整动作就四步:建表 SQL 放进 models 或迁移目录,model 层写查询方法,controller 收参调 service,routes 注册路由。照着脚手架里 user 模块复制一份再改,比自己从零写快得多,也不容易漏掉鉴权和统一响应。从建表到接口出数,十分钟能走完。
我从带人的经验里确认了一件事:复制现有模块再改,永远比凭记忆重写可靠。从那以后我每次新开模块,都强制先看一遍脚手架里最接近的目录结构,先复制再改,不再凭记忆从零写。希望帮到你。
本文还有配套的精品资源,点击获取