ARTICLE DETAIL

资讯详情

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

Node+Express+MySQL脚手架:连接池、路由与避坑实践

Node+Express+MySQL脚手架:连接池、路由与避坑实践 简介这是一份基于 Node.js、Express 和 MySQL 的快速开发脚手架面向需要快速搭建后端服务的 Node 开发者可解决项目初始化慢、目录结构混乱、重复配置等痛点适合从零启动 RESTful API、管理系统或 Web 应用后端时直接复用。压缩包共含 31 个文件其中 17 个 JS 文件承载核心业务逻辑JSON 与 YAML 负责依赖与运行配置Markdown 文档提供使用说明HTML 文件可作为接口测试或示例页面整体压缩包仅 37KB轻量精炼目前已有 31 人学习下载。脚手架预设了标准化目录结构将数据库连接、SQL 操作、缓存读取、接口调用、实体映射等通用能力封装为独立模块并内置统一基础配置与项目文档开发者只需按业务需求修改扩展即可快速得到稳定可维护的后端基础框架显著缩短项目启动周期也有助于团队保持一致的开发环境与代码风格。1. 先聊清楚这个脚手架到底解决什么问题接手任何一个新后端服务最磨人的往往不是业务逻辑而是把环境、目录、连接池、路由这些“地基”重新铺一遍。node 装哪个版本、mysql 用什么认证方式、连接池怎么设、路由文件怎么挂每换一台电脑就重来一次而且每次都会在完全想不到的地方翻车。这套基于 node express mysql 的脚手架就是把从零搭一个可维护后端的最小骨架打包成一份能直接解压使用的工程模板。它解决的是“项目第一行代码之前”的那段空白解压后你能得到一个带连接池、统一路由、建表脚本和错误处理的项目结构改几个环境变量就能跑通第一个接口。适合刚转 node 后端的前端工程师也适合想把项目从单文件 app.js 里拆出来的初中级开发者。真正值钱的不是那几十个文件而是这些环境决策已经被提前做完了。2. 脚手架目录为什么这样分把“能跑”变成“能撑住业务”2.1 从一个能跑的服务到能撑住业务的服务差在哪很多人第一次用 express 写接口就是一个 app.js 从头写到尾路由、查询、JSON 返回全堆在一起。三个接口以内很爽十个接口之后开始痛苦五十个接口之后就是灾难。这不是代码风格问题是职责边界问题。express 本身不限制你怎么组织文件它只提供路由和中间件机制所以脚手架要解决的第一个问题就是把“能跑”和“能撑住业务”之间的差距补上。差距主要体现在三个方面第一数据库连接如果每个文件都自己建连接数会失控所以需要全局唯一的连接池第二路由如果散落在各个文件里手动挂载排查接口时找不到入口所以需要统一的路由注册中心第三错误处理如果没有兜底中间件任何一个异步报错都会让进程直接退出所以必须有一层全局错误捕获。这些不是业务功能但缺了任何一个项目都会在某个阶段被迫推倒重来。我一般会把脚手架拆成“入口、配置、路由、数据访问、基础设施”五个层面。入口负责启动服务配置负责读环境变量路由负责定义 URL 映射数据访问负责与 mysql 打交道基础设施是中间件、错误处理、工具函数这类横切关注点。这样当你需要在已有项目里加新模块时只需要往 routes 和 services 里各加一个文件其他什么都不用动。2.2 目录结构按职责切分而不是按文件类型堆这份脚手架的目录结构大致长这样project/ ├─ app.js ├─ package.json ├─ .env.example ├─ src/ │ ├─ config/ │ │ ├─ db.js │ │ └─ env.js │ ├─ routes/ │ │ ├─ index.js │ │ └─ user.js │ ├─ middlewares/ │ │ ├─ errorHandler.js │ │ └─ notFound.js │ ├─ services/ │ ├─ utils/ │ └─ app.js ├─ sql/ │ └─ schema.sql └─ scripts/ └─ check-db.js注意这里出现了两个 app.js一个是根目录的启动入口一个是 src 下组装中间件和路由的应用实例。很多脚手架不区分这两者导致测试时无法单独导入应用只能真的把端口监听起来。拆开之后你可以在不监听端口的情况下用 supertest 直接打请求这是后续自动化测试的基础也是这个目录结构最值得保留的设计。routes 里 index.js 负责汇总所有业务路由模块user.js 是一个具体业务的示例。services 目录在初始模板里是空的但保留它的目的是明确约定复杂业务逻辑不要写在路由里下沉到 service 层。中大型项目里你还会加 controllers 和 models但脚手架不应该一开始就把这些空目录全建出来空目录没有约定意义反而让人困惑。让目录跟着真实需求长出来比一开始铺一堆空壳更合理。2.3 最小可运行目录拿到就能 start 的结构package.json 是脚手架的启动开关。这里有几个关键点需要注意入口字段要指向根目录的 app.jsscripts 里要提供 dev 和 start 两个命令依赖只需要 express、mysql2、dotenv开发依赖加一个 nodemon 就够了。不要在一开始引入 sequelize 或 typeormORM 会掩盖 sql 本身的行为等你需要排查慢查询时黑匣子会更多。mysql2 是 mysql 官方驱动的高性能版本下面会专门讲。{ name: node-express-mysql-starter, version: 1.0.0, main: app.js, scripts: { dev: nodemon app.js, start: node app.js, check-db: node scripts/check-db.js }, dependencies: { dotenv: ^16.3.1, express: ^4.18.2, mysql2: ^3.6.0 }, devDependencies: { nodemon: ^3.0.1 } }这里把版本号写成了带 ^ 的区间实际解压后安装时会拉取当前满足区间的最新兼容版本。之所以选这三个依赖是因为它们覆盖了脚手架的最小闭环express 提供 http 层能力mysql2 提供连接池和预处理语句dotenv 把配置从代码里剥离出来。不要小看 dotenv很多人把数据库密码直接写在 db.js 里然后提交到仓库这是最常见的生产事故源头。安装依赖和启动服务的命令如下npm install cp .env.example .env npm run devcp 命令在 Windows 的 cmd 下可能不生效可以改成 copy 或者在编辑器里手动复制。第一次跑起来后你会在终端看到“server running at http://127.0.0.1:3000”这样的输出这就是脚手架活了的标志。如果你在本机已经装过 mysql 并建好了库现在就可以试着在浏览器里访问一个示例接口了。3. 用连接池把 mysql 接进来避免 2002 和 SSL 两个经典报错3.1 为什么不用 mysql 默认连接而用连接池node 的 mysql 生态里有两套驱动mysql 和 mysql2。mysql 是老牌驱动但它的 API 更古老默认不支持 Promise需要手动包装才能配合 async/await 使用mysql2 在兼容 mysql 的同时原生支持 Promise并且提供了 prepared statement 缓存同样的查询在高频场景下性能更好。所以脚手架里选 mysql2 是当前的主流做法。直接用 createConnection 每次查询都新建一个连接然后用完再关闭在高并发场景下会频繁握手mysql 服务端的线程数量会被快速打满。连接池的本质是维护一组长期存活的连接请求来了从池子里借一条用完了还回去。这个机制对新手来说是黑匣子但你必须理解它的几个参数否则连接池不仅不解决问题还会成为新的故障源。比较常见的错误是认为连接池能无限扛并发。实际上连接池是资源复用而不是资源扩张当连接数达到 connectionLimit 时新的请求会进入等待队列等待时间由 connectTimeout 和 acquireTimeout 决定。如果你把连接池调得过大mysql 端会先撑不住调得过小高峰期请求会排队超时。下面这套配置是经验值适用于绝大多数中小业务。3.2 连接池与 mysql2 配置连接数、超时、字符集src/config/db.js 是整个数据库访问层的地基脚手架的启动自检也是围绕它做的。完整代码如下const mysql require(mysql2/promise); const dotenv require(dotenv); dotenv.config(); const pool mysql.createPool({ 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, waitForConnections: true, connectionLimit: 10, queueLimit: 0, charset: utf8mb4, timezone: 08:00, enableKeepAlive: true, keepAliveInitialDelay: 0 }); async function query(sql, params) { const [rows] await pool.execute(sql, params); return rows; } module.exports { pool, query };逻辑说明这里导出两个东西pool 和 query。pool 是连接池实例给那些需要事务或手动管理连接的场景使用query 是封装好的便捷函数业务代码里直接写 sql 和参数数组函数内部用 pool.execute 执行并把结果集的 rows 取出返回。注意 execute 使用的是预处理语句参数通过占位符传入这能有效防止 sql 注入。参数说明connectionLimit 控制池中最大连接数10 对于单机开发环境足够queueLimit 设为 0 表示等待队列不设上限这样即使高峰期连接被占满请求也只会等待而不会直接报错charset 必须用 utf8mb4 而不是 utf8否则存 emoji 和特殊符号会变成问号timezone 设置成 08:00 是为了让读取 DATETIME 时按北京时间解析否则 node 默认按服务器本地时区读取容易出现差 8 小时的问题。下面这张表列出了每个参数的推荐范围和影响面方便你按项目规模调整参数推荐值说明connectionLimit5-20小项目 5中项目 10-15再大就要考虑读写分离了queueLimit00 表示不限制排队长度生产环境建议设一个上限connectTimeout10000 默认建立连接的超时时间网络差时可调大charsetutf8mb4必须用这个utf8 存不了 emojitimezone08:00解决 mysql 时间读取差 8 小时的问题enableKeepAlivetrue避免连接被 mysql 服务端空闲回收3.3 初始化数据库表把建表语句放进 sql/schema.sql脚手架不能只给代码不给数据底座。sql/schema.sql 里应该有一份最小可运行的建库建表语句让使用者在本地初始化出与代码配套的表结构。以下是我固定放在脚手架里的初始结构CREATE DATABASE IF NOT EXISTS app DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; USE app; CREATE TABLE IF NOT EXISTS user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 昵称, age TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 年龄默认0, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常 0禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明先建库再切库再建表。建库时指定了默认字符集为 utf8mb4排序规则为 utf8mb4_unicode_ci这样建出来的表如果不单独指定字符集也会继承这个规则。user 表里设置了一个默认值为 0 的 age 字段和一个默认值为 1 的 status 字段用来演示默认值在 mysql 里如何工作插入数据时不传这两个字段会自动落为初始值。说明一下age 用 TINYINT UNSIGNED 就够因为 255 已经是年龄的物理上限created_at 和 updated_at 直接交给数据库维护避免应用层时间不一致。你在自己的项目里加表时尽量保持这个风格主键无符号自增、字符串有明确长度、时间字段由数据库生成。这份 sql 文件不需要在应用启动时自动执行因为自动执行建表在生产环境是个隐患正确做法是在开发环境手动执行一次生产环境由 DBA 或迁移工具接管。4. 把 express 的入口做成路由注册中心API 落地的最小写法4.1 app.js 的职责边界与中间件顺序很多新手会把 app.use 写在 listen 之后这是完全错误的。listen 只是启动端口监听它不参与请求处理链路的组装所以必须在 listen 之前把所有中间件和路由都挂到 app 上。脚手架的 src/app.js 专门负责这件事根目录的 app.js 只做一件事引入它然后监听端口。这样分层后写自动化测试时可以直接 require src/app.js而不用真的启动服务。中间件的挂载顺序极其重要。express 的请求处理是按顺序层层穿透的如果 express.json() 放在路由之后请求体还没解析路由里读到 req.body 就是 undefined如果错误处理中间件放在路由之前它根本捕获不到任何错误。所以标准顺序是内置解析中间件在最前然后挂载路由最后放 404 兜底和错误处理。这个顺序是无数人踩坑换来的经验不要随意调整。const express require(express); const routes require(./src/routes); const { notFound } require(./src/middlewares/notFound); const { errorHandler } require(./src/middlewares/errorHandler); const dotenv require(dotenv); dotenv.config(); const app express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(/api, routes); app.use(notFound); app.use(errorHandler); module.exports app;逻辑说明express.json() 解析 Content-Type 为 application/json 的请求体express.urlencoded 解析表单格式的请求体两者是绝大多数接口的基础前置app.use(/api, routes) 把所有路由统一挂到 /api 前缀下这样业务路由里写 /user最终访问路径就是 /api/usernotFound 兜底未被匹配的路径errorHandler 捕获所有同步异常和 next(err) 传过来的异步异常。参数说明urlencoded 的 extended: true 表示允许解析嵌套对象这是默认推荐值。如果设置为 false表单里的嵌套结构会被解析成字符串容易导致参数丢失。注意这里没有再引入 morgan 或 cors 这类中间件脚手架保持最小依赖你需要跨域时再安装 cors 也不迟不要一上来就把所有中间件全装上。4.2 路由文件的注册写法从单文件到业务模块routes/index.js 是路由注册中心。所有业务路由模块在这里被汇总然后一次性挂到根路由上。这样你在新增一个业务模块时只需要两步新建一个路由文件然后在 index.js 里加一行 app.use。不要用 fs 自动扫描目录来加载路由虽然省事但会让路由加载顺序变得隐式化出问题时很难定位。const express require(express); const router express.Router(); const userRoutes require(./user); router.use(/user, userRoutes); module.exports router;逻辑说明index.js 创建了一个新的 Router 实例把 userRoutes 挂载到 /user 路径下。因为 src/app.js 里已经把这份 index 挂到了 /api所以最终 URL 是 /api/user/xxx。这种嵌套挂载的方式让你可以在不同层级控制前缀比在每个业务路由里写完整路径更干净。参数说明这里暂时只注册了 userRoutes实际项目里会有 order、product、auth 等多个路由文件每个文件都是一个独立的 Router 实例。路由文件内部只关注自身业务不关心整体路径前缀这是解耦的核心。4.3 一个查询接口的完整链路从路由到查询到 JSON 返回routes/user.js 演示了一个最简单的查询接口。它包含三层内容定义路由路径、调用查询函数、封装响应格式。这三层都写在同一文件里是为了让你在脚手架阶段能看到完整链路。const express require(express); const router express.Router(); const { query } require(../config/db); router.get(/list, async (req, res, next) { try { const list await query( SELECT id, name, age, status, created_at FROM user ORDER BY id DESC, [] ); res.json({ code: 0, data: list, message: ok }); } catch (err) { next(err); } }); router.post(/add, async (req, res, next) { try { const { name, age } req.body || {}; if (!name) { res.status(400).json({ code: 1, message: name is required }); return; } const result await query( INSERT INTO user (name, age) VALUES (?, ?), [name, age || 0] ); res.json({ code: 0, data: { id: result.insertId }, message: ok }); } catch (err) { next(err); } }); module.exports router;逻辑说明/list 接口执行一条 SELECT 语句用 ORDER BY id DESC 让新用户排前面返回数组便于前端直接渲染/add 接口接收 body 里的 name 和 age用预处理语句的 ? 占位符做插入注意这里对 name 做了必填校验但是别在这里做复杂校验简单判断可以写在路由里复杂规则应该下沉到 service 层。参数说明查询函数 query 的第二个参数是参数数组即使没有参数也要传一个空数组这是为了保持调用格式统一避免漏传参数时出现难以排查的 undefined 错误result.insertId 是 mysql2 在执行 INSERT 后返回的自增主键这是数据库行为无需应用层额外查询。返回格式统一为 code 加 0 表示成功非 0 表示失败这样前端可以统一做处理不用每换一个接口就适配一种响应体。5. 避坑清单Node 版本、MySQL 8 认证与 Windows 下的六个老大难5.1 npm.ps1 无法加载因为在此系统上禁止运行脚本现象在 Windows PowerShell 里执行 npm install 直接报错提示“无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本”。原因PowerShell 的默认执行策略是 Restricted禁止运行任何 .ps1 脚本文件。npm 的 PowerShell 包装脚本被拦截了cmd 里跑正常但 PowerShell 里跑就报错。这个热词的搜索量极高因为它卡住了几乎每个 Windows 新手的第一次 node 体验。解决以管理员身份打开 PowerShell执行以下命令把当前用户的执行策略调整为可运行本地脚本Set-ExecutionPolicy -Scope CurrentUser RemoteSignedRemoteSigned 的含义是本地创建的脚本可以运行从网络下载的脚本必须有可信数字签名。这是安全性和便利性之间的平衡点不要图省事直接用 Unrestricted那是把系统防护直接关掉。改完之后重新打开终端再跑 npm install 就不会再撞墙了。5.2 ERROR 2002 (HY000)Cant connect to local MySQL server through socket /tmp/mysql.sock现象通过命令行连接 mysql 时报 2002 错误提示通过 socket 连接失败。这个报错在热词里出现过是 mysql 连接问题里搜索量最高的一条之一。原因mysql 客户端在 Linux 和 macOS 下默认通过 Unix socket 连接本机 mysql而不是 TCP 端口。当 mysql 服务没有启动、或者 socket 文件路径不是默认的 /tmp/mysql.sock 时就会出现这个错误。注意我们的脚手架配置里 host 用的 127.0.0.1而不是 localhost这正是为了绕过 socket 连接直接走 TCP所以脚手架本身不会触发这个坑。解决第一步确认 mysql 服务是否在运行Ubuntu 下用 systemctl status mysqlmacOS 下用 brew services list第二步如果服务在运行仍然报错检查 my.cnf 里的 socket 路径配置第三步紧急绕过时用“mysql -h 127.0.0.1 -P 3306”强制走 TCP 连接。这个坑的核心教训是代码里的 localhost 和 127.0.0.1 有本质区别前者可能触发 socket 连接后者一定走 TCP。5.3 MySQL 8 认证插件与旧客户端不兼容现象node 应用启动后一直报握手失败或认证失败但在命令行里用 root 密码却能正常登录。密码明明是对的代码里就是连不上。原因MySQL 8.0 默认创建的用户使用 caching_sha2_password 认证插件而一些老版本的客户端驱动或依赖不支持这个插件导致认证流程无法完成。尤其是当你用了过时的 mysql 驱动而不是 mysql2 时这个问题出现概率极高。这也是为什么脚手架强制要求用 mysql2因为它从 v3 开始完整支持 caching_sha2_password。解决两种方案。第一种是升级到 mysql2 驱动这也是推荐方案第二种在建用户时显式指定老认证插件“CREATE USER app% IDENTIFIED WITH mysql_native_password BY password”加上 GRANT 语句授权。新项目一定要选第一种因为 mysql_native_password 在 MySQL 8.0.34 之后已被标记为废弃你不想在新项目里用一个注定被移除的兼容模式。5.4 Node 版本与操作系统不兼容升级 node 时把系统搞崩现象用官方独立安装包安装了某个新版本 node双击安装到一半提示“操作系统版本过低”或者“该 node 版本不兼容此操作系统”然后安装回滚。原因Node 的高版本对操作系统有最低版本要求比如超高版本要求在 Windows 10 特定版本以上。直接覆盖式安装新版本 node不仅可能安装不上还会把原本可用的环境弄乱。热词里“node历史版本”“node升级 windows”“nvm安装”这些高频搜索都指向同一个核心需求用版本管理器管理 node而不是用安装包覆盖。解决先卸载当前 node再安装 nvm-windows然后通过 nvm 安装指定版本nvm install 18.18.2 nvm use 18.18.2 node -v参数说明18 系列是当前兼容性最好的 long-term support 版本如果你的 mysql、express 都是新版本选它比追最新版可靠。用 nvm 的好处是切回旧项目时可以“nvm use 16”不用反复卸载重装。血泪经验永远不要让生产环境的 node 版本跟本地漂移装 nvm 是性价比最高的第一步。5.5 Windows 下安装 mysql 模块编译失败缺 Visual C 运行库现象npm install 时出现 node-gyp 编译错误报错信息里能看到“Visual Studio”“windows-build-tools”或“vcxproj”等字样最终安装失败。原因某些 npm 包需要从源码编译原生模块而 Windows 下编译依赖 Visual C Build Tools 和 Python。mysql 官方驱动不需要编译但如果你或某个间接依赖用了需要编译的 addon就会触发这个坑。错误信息会让你误以为是 mysql 装不上其实是本机缺 C 编译链。解决两个路径。第一确认依赖清单里没有这类包脚手架里 mysql2 和 express 都是纯 JS 实现不会触发编译第二确实需要编译时以管理员身份安装 windows-build-tools 或者直接安装 Visual Studio Build Tools 并勾选“C 桌面开发”工作负载。新手在 Windows 遇到编译错误优先排查是不是缺了这套运行库而不是反复删 node_modules 重装重装一百次也解决不了编译链缺失的问题。5.6 mysql 连接池耗尽与 SSL 连接错误现象应用运行一段时间后接口开始间歇性超时日志里出现“ETIMEDOUT”或“ER_SECURE_TRANSPORT_REQUIRED”mysql 连接数被占满或者 SSL 连接握手失败。原因连接池里的空闲连接被 mysql 服务端强制回收但池没有及时感知到导致下一次请求拿到的是已经断开的连接。另一种常见情况是 mysql 服务端要求 SSL 连接而驱动未启用 SSL。这些问题在开发环境不常出现因为开发环境连接量少空闲回收周期长一旦部署到服务器网络环境和连接波动会让问题快速暴露。解决在 createPool 配置里加上 enableKeepAlive 和 ssl 相关的设置。keepAlive 让连接保持活性避免被服务端静默断开如果服务端要求 SSL加上“ssl: { rejectUnauthorized: false }”可以完成加密握手但注意这个只在内部网络使用生产环境应该配置正式的 CA 证书。另外不要在请求代码里手动调用 pool.end()那会关闭整个连接池把它当作全局单例对待交给进程生命周期管理就够了。6. 验证与收尾启动自检、环境变量与上线前的两个习惯脚手架给你的是骨架但骨架是否接对了需要一次启动自检来验证。我习惯在 scripts 目录放一个 check-db.js它做的事很简单从连接池拿一条连接执行 SELECT 1成功则退出 0失败则退出 1。npm run check-db 脚本与它关联部署时把它放在应用启动之前数据库没就绪时绝不启动业务进程。const { pool } require(../src/config/db); async function check() { const [rows] await pool.query(SELECT 1 AS ok); if (rows[0].ok ! 1) { throw new Error(db check failed); } console.log(db connection ok); process.exit(0); } check().catch((err) { console.error(db check failed:, err.message); process.exit(1); });逻辑说明整个脚本只做连通性验证不做建表不做迁移。因为建表是 DBA 的职责、迁移是发布流程的职责这里的自检只是保证进程不在“数据库不可用”的状态下空跑。SELECT 1 是数据库连通性检测的通用写法它不依赖任何业务表即使业务库被清空也能正常检测。环境变量方面.env.example 是脚手架提供给你复制的模板实际使用时复制成 .env 并填上真实值。db.js 和 app.js 里的取值逻辑都用了“环境变量优先、默认值兜底”的写法这意味着你在本机不配置也能跑但上线前必须把 .env 配置完整。生产环境建议用 systemd 或 pm2 拉取环境变量不要把 .env 文件传到服务器上裸奔。pm2 start app.js --name app -i 1升级到 node 更高版本之前先在 nvm 里切过去跑一遍自检脚本再用真实请求打一遍接口。我经历过太多次“本地好好的测试环境起不来”的尴尬后来养成的习惯是任何环境变更都先跑 npm run check-db再 pm2 restart。这套脚手架把数据库连接收敛到了单独文件里所以验证路径非常短这也是它能在十分钟内让人确认“这个环境是可用的”的原因。希望这套从连接池到路由到自检的流程能帮你少从零趟一遍泥把时间花在业务本身。本文还有配套的精品资源点击获取
返回列表