ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Node+Express+MySQL快速开发脚手架:目录分层、连接池、JWT鉴权与避坑指南

Node+Express+MySQL快速开发脚手架:目录分层、连接池、JWT鉴权与避坑指南 简介基于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人学习下载适合刚接触全栈开发或希望提升项目启动效率的读者。通过这份脚手架可省去从零配置的重复劳动快速产出规范、可维护的后端骨架同时理解NodeExpressMySQL的典型协作方式。1. 基于 nodeexpressmysql 的快速开发脚手架到底省了多少事做后端接口很多人第一反应是 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_ENVdevelopment决定加载哪套配置生产必须设 productionPORT3000服务监听端口DB_HOST127.0.0.1数据库地址DB_PORT3306数据库端口DB_USER / DB_PASSWORDroot / 空生产必须改成专用账号DB_NAMEapp_dev数据库名JWT_SECRETdev-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 用 nvmWindows 用 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] port3306 basedirD:/tool/mysql-8.0.46-winx64 datadirD:/tool/mysql-8.0.46-winx64/data character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci default-authentication-pluginmysql_native_password [client] port3306 default-character-setutf8mb4basedir 和 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_userlocalhost IDENTIFIED BY your_password; GRANT ALL PRIVILEGES ON app_dev.* TO app_userlocalhost; 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);有了 asyncHandlercontroller 里想抛业务错误就直接 throw new Error不用每个接口手写 try-catch。没有这层包装async 里抛出的错误会直接变成未处理的 promise rejection接口挂起客户端等到超时日志里什么都查不到——这是 express 项目最常见的黑匣子现场。4.4 JWT 鉴权登录签发、接口拦截与密码存储脚手架默认用 JWT 做登录态middleware/auth.js 里两个函数一个签发 token一个校验 tokenconst 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_userlocalhost IDENTIFIED WITH mysql_native_password BY your_password;存量库应急用这个。别两个都做改完一个先重启验证再看另一个。5.2 node 高版本兼容低版本吗npm install 阶段先翻车现象npm install 时 node-gyp rebuild 报错一直卡在编译步骤或者装完启动直接崩报 OpenSSL provider 相关错误。原因带原生编译的依赖如 bcrypt、sharpnode 大版本一变预编译好的二进制对不上就得现场编译编译环境缺 python 或 VS build tools 就挂。node 高版本兼容低版本吗语言层面大体兼容依赖层面真不一定尤其是带原生模块的依赖。解决脚手架开发统一用 LTS 版本别追 latest。项目里把 bcrypt 换成纯 JS 的 bcryptjs把编译型依赖降到最少这是治本。救急命令NODE_OPTIONS--openssl-legacy-provider能临时跑起来但别长期用它只是绕过了 OpenSSL 3 的 provider 变更不是真正兼容。5.3 连接池睡死凌晨第一个请求永远在转圈现象服务跑了一晚上第二天早上第一个接口请求要等很久然后超时或直接报ETIMEDOUT / Cant 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 字符集没到 utf8mb4emoji 存进去变问号现象接口写入 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 收参调 serviceroutes 注册路由。照着脚手架里 user 模块复制一份再改比自己从零写快得多也不容易漏掉鉴权和统一响应。从建表到接口出数十分钟能走完。我从带人的经验里确认了一件事复制现有模块再改永远比凭记忆重写可靠。从那以后我每次新开模块都强制先看一遍脚手架里最接近的目录结构先复制再改不再凭记忆从零写。希望帮到你。本文还有配套的精品资源点击获取
返回列表