☰
MongoDB与Express从安装到CRUD接口全链路避坑指南
2026/10/28 6:37:38 网站建设 项目流程

MongoDB 和 Express 这两个东西,单独拎出来看都不算难,难的是把它们凑到一块跑通第一条完整链路:数据能写进去、能查出来、接口能返回。我带过不少人入门,卡住的位置出奇地一致——装完 MongoDB,敲了mongod,终端刷一堆日志然后窗口一关就没了;装完 Express,能响应一个 "hello world",但一接数据库就报一堆看不懂的错。这篇就把 MongoDB、Express 的安装和基本使用这条线从头到尾捋一遍,重点放在那些文档里不写、但第一次动手一定会卡住的细节上。适合完全没碰过后端的人照着做,也适合那种"能跑但不知道为什么这么写"的人回来补一遍底层逻辑。

1. 这套组合的定位:先搞清楚自己在装什么

在装任何东西之前,值得花两分钟想清楚这两块各自负责什么,不然装完只会照着教程复制命令,出了错完全不知道往哪个方向排查。

1.1 文档数据库和表格数据库的思维切换

MongoDB 是文档型数据库,数据以 BSON(二进制的 JSON)形式存放在集合(collection)里,一条记录就是一个文档(document)。这跟关系型数据库的"表 + 行 + 列"完全不是一回事。关系型数据库要求你先定义好表结构,每一行的字段固定,加个字段就得ALTER TABLE;MongoDB 里同一个集合下的两个文档,字段可以不一样,嵌套对象、数组直接往里塞。

这个差异带来的第一个实际影响是:你在设计数据时,优先考虑的是"这个页面要一次性拿到什么",而不是"怎么消除冗余"。比如做博客,评论可以直接嵌在文章文档里,一次查询就全拿到了;而关系型数据库你会想着拆成两张表再 JOIN。反过来说,如果评论数量会无限膨胀、需要独立分页,那就该拆成独立集合,用postId关联。这不是"谁更好"的问题,是数据形态决定的选择。

第二个影响是没有强制的 schema。听起来自由,实际上是个陷阱。新手很容易在一个集合里塞进三种不同结构的文档,两周后自己都记不清哪条数据有哪些字段。所以后面讲到 Mongoose 的时候,我会坚持让大家写 Schema——不是因为 MongoDB 需要,而是因为你的代码需要。

1.2 Express 的"薄"到底薄在哪

Express 的定位是"极简的 Web 框架",说人话就是:它只帮你做了三件事——把 HTTP 请求解析成对象、按路径和请求方法分发到对应的处理函数、把处理函数的返回值打包成响应。除此之外,路由分组、参数校验、鉴权、日志、错误处理,全要你自己加或者找中间件。

这种"薄"对学习是好事,你能清楚看到每一个请求进来之后发生了什么;对生产是坏事,因为稍不注意就会写出一个几百行、什么都往里塞的app.js。我见过不少项目,路由、业务逻辑、数据库操作全挤在一个文件里,改一个字段要翻三处代码。所以从第一天起就该有分层意识,哪怕只是把路由文件和入口文件拆开这么简单的一步。

1.3 动手前必须确认的三个前置条件

装之前先确认环境,能省掉一半的"玄学问题"。

  • Node.js 版本。Express 5 要求 Node 18 以上,Mongoose 8 也要求 Node 16.20 以上。跑node -v看一眼,低于 18 建议直接用版本管理工具切一个 LTS 版本,别硬扛。
  • 端口占用情况。MongoDB 默认吃 27017,Express 常用 3000。这两个端口在本机被别的软件占用的概率不低,尤其是前后端一起开发的时候,前端脚手架也爱抢 3000。提前用lsof -i :3000或netstat -ano | findstr :3000查一下。
  • 磁盘写入权限。MongoDB 需要往数据目录写文件,Windows 装在C:\Program Files下时,服务账户权限没配好会出现"启动了但立刻退出"的情况。这个后面会具体讲。

提示:如果你只是在本地做练习,不要一上来就折腾云端托管数据库。本地跑通 CRUD,理解连接、索引、查询这些东西之后,再迁移到托管服务,中间只差改一个连接串。

2. MongoDB 本地安装:从下载到服务自启的完整链路

安装这一步最容易出问题的地方,不是安装本身,而是"装完之后它到底有没有在后台老老实实跑着"。很多人装完以为万事大吉,写代码一连就报错,回头才发现服务压根没启动。

2.1 三个平台各自的安装路径差异

Windows走 MSI 安装包最省事。安装向导里有一个关键勾选项:Install MongoDB as a Service。勾上之后,安装程序会自动把mongod注册成系统服务,开机自启,你不需要手动开一个终端窗口挂着。默认安装路径是C:\Program Files\MongoDB\Server\<版本号>\,bin目录下你会看到mongod.exe(服务端)和mongosh.exe(新版自带,老版本需要单独装)。安装向导里还有一个Run MongoDB as a Network Service User的选项,本地开发选默认就行。

macOS用 Homebrew 最干净:

brew tap mongodb/brew brew install mongodb-community@7.0 brew services start mongodb-community@7.0

注意brew services start和直接运行mongod的区别:前者是把它注册成 launchd 服务,开机自启且有统一日志;后者只是前台跑一个进程,终端一关就结束。新手最常见的误会就是把这两者混为一谈。

Linux有两种路径。喜欢用系统包管理器的,可以配置官方软件源后用apt或dnf安装;喜欢完全掌控的,下载 tgz 解压到/usr/local/mongodb,自己建配置文件和 systemd 单元。我更推荐后者给学习用途,因为你能亲眼看到目录结构、数据文件在哪、日志在哪,出问题好排查。

不管哪个平台,验证方式都一样:

mongod --version mongosh --version

两条都能输出版本号,说明二进制文件到位了。但二进制到位不等于服务在跑,这是两码事。

2.2 手写一份 mongod.conf 的意义

MongoDB 的启动参数可以全写在命令行里,但那意味着每次启动都要敲一长串,而且没法做成服务。正经做法是写配置文件。

Linux/macOS 下的配置长这样:

storage: dbPath: /usr/local/mongodb/data journal: enabled: true systemLog: destination: file path: /usr/local/mongodb/log/mongod.log logAppend: true net: port: 27017 bindIp: 127.0.0.1

Windows 下安装向导会生成mongod.cfg,结构基本一样,只是路径换成 Windows 风格。

几个字段值得单独说:

配置项作用常见误区
storage.dbPath数据文件存放位置目录必须提前存在且有写权限,MongoDB 不会自动创建
net.bindIp监听地址写0.0.0.0会让局域网内所有机器都能连,本地开发务必用127.0.0.1
net.port监听端口改了端口,所有连接串都要跟着改,容易漏
systemLog.path日志文件位置目录不存在时启动直接失败,日志都没得看
security.authorization是否开启鉴权初学阶段关着方便,但只要绑定地址不是回环地址就一定要开

storage.dbPath那个坑我踩过不止一次。MongoDB 启动时会检查这个目录,不存在就报NonExistentPath: Data directory ... not found然后退出。这不是"权限问题",是"目录根本不存在",但报错信息看起来很像权限错,很容易往错的方向查。养成习惯:装完先把数据目录和日志目录手动mkdir -p建好。

bindIp那条也值得强调。默认配置里常能看到bindIp: 127.0.0.1和bindIp: 0.0.0.0两种写法,后者方便你在虚拟机、容器里从宿主机连,但代价是这台机器暴露在网络上时,任何人扫到 27017 端口都能直接连库。本地开发老老实实写127.0.0.1,需要跨机器访问时再改用 SSH 端口转发之类的安全方式。

2.3 服务注册与开机自启的实操

Linux(systemd)下建一个单元文件/etc/systemd/system/mongod.service:

[Unit] Description=MongoDB Database Server After=network.target [Service] User=mongodb Group=mongodb ExecStart=/usr/local/mongodb/bin/mongod --config /usr/local/mongodb/etc/mongod.conf Restart=on-failure LimitNOFILE=64000 [Install] WantedBy=multi-user.target

然后:

sudo systemctl daemon-reload sudo systemctl enable --now mongod sudo systemctl status mongod

Restart=on-failure这一行很有用,进程异常退出后自动拉起来,省得你半夜发现服务挂了。LimitNOFILE是文件描述符上限,MongoDB 在高并发下会占用大量句柄,默认值偏小。

macOS用brew services:

brew services list brew services restart mongodb-community@7.0

brew services list能看到当前状态是started还是error。如果是error,日志通常在/usr/local/var/log/mongodb/mongo.log(Intel 机器)或/opt/homebrew/var/log/mongodb/mongo.log(Apple Silicon)。路径不同这一点很坑,很多人照着 Intel 的路径去找日志,结果文件不存在,以为日志功能坏了。

Windows下如果你在安装时没勾选服务选项,也可以手动装:

sc.exe create MongoDB binPath= "\"C:\Program Files\MongoDB\Server\7.0\bin\mongod.exe\" --config \"C:\Program Files\MongoDB\Server\7.0\bin\mongod.cfg\"" DisplayName= "MongoDB" start= auto

注意binPath=后面那个空格是必须的,这是sc.exe的语法怪癖,漏了就会报参数错误。

2.4 怎么确认它是真的活了

服务起来之后,用mongosh连一下:

mongosh "mongodb://127.0.0.1:27017"

看到提示符变成test>就说明连上了。这时候敲一句:

db.runCommand({ ping: 1 })

返回{ ok: 1 }才算真正连通。这比看服务状态更可靠,因为服务"正在运行"和"能接受连接"是两回事——监听端口还没起来、配置解析到一半失败,都可能出现状态显示 running 但连不上的情况。

3. mongosh 里的第一课:库、集合、文档的手感

mongosh是 MongoDB 的交互式 shell,语法是 JavaScript。别把它当成一个"数据库客户端"就完事了,它本质上是一个 JS 运行环境,你可以写循环、定义变量、调用函数。理解这一点,后面写批量操作会顺手很多。

3.1 惰性创建:为什么 use 一个库 show dbs 却看不到

这是新手最经典的一个困惑:

use blog show dbs

show dbs里没有blog。不是你操作错了,是 MongoDB 的库和集合都是惰性创建的——执行use blog只是把当前上下文切过去,真正的库要等你写入第一条数据才会创建。

use blog db.posts.insertOne({ title: "第一篇", status: "draft" }) show dbs

这次blog就出现了。集合posts同理,db.createCollection("posts")可以不写,第一次insertOne自动建。

惰性创建的好处是不会产生一堆空库,坏处是你不能靠"库存在"来判断初始化是否成功。实际项目里,初始化脚本末尾一定要插入一条哨兵数据或者调用一次db.stats(),才能真正确认结构建好了。

3.2 四条 CRUD 命令的正确姿势

基础的四条命令:

// 增 db.posts.insertOne({ title: "Express 路由分层实践", slug: "express-router-layers", tags: ["node", "express"], status: "draft", createdAt: new Date() }) // 查 db.posts.find({ status: "draft" }).sort({ createdAt: -1 }).limit(10) // 改 db.posts.updateOne( { slug: "express-router-layers" }, { $set: { status: "published" }, $currentDate: { updatedAt: true } } ) // 删 db.posts.deleteOne({ slug: "express-router-layers" })

updateOne的第二个参数必须带更新操作符($set、$inc、$push等),如果直接写{ status: "published" },MongoDB 会把整个文档替换成{ status: "published" },其他字段全丢。这个错误我见过太多次了,而且它不报错,静默地把你数据洗掉了,非常危险。养成习惯:更新永远用操作符。

另外注意find返回的是游标不是数组,mongosh里会自动toArray()显示前 20 条,但在 Node 代码里必须自己处理。这个差异下面讲到驱动时会再提。

3.3 查询操作符与投影的取舍

条件查询的几个高频操作符:

db.posts.find({ tags: "node" }) // 数组包含某元素 db.posts.find({ "meta.views": { $gt: 100 } }) // 嵌套字段比较 db.posts.find({ status: { $in: ["draft", "review"] } }) db.posts.find({ title: { $regex: "路由", $options: "i" } }) db.posts.find({ $or: [{ status: "draft" }, { tags: "urgent" }] })

投影(第二个参数)很多人忽略,但它直接影响传输量:

db.posts.find({ status: "published" }, { title: 1, slug: 1, _id: 0 })

1表示包含,0表示排除。注意包含和排除不能混用,唯一例外是_id可以单独排除。如果文章正文有几十 KB,列表页只查标题和 slug,传输量能差出一个数量级。这个小习惯在数据量上来之后收益非常明显。

正则查询那条要当心:$regex如果不是前缀匹配,用不上索引,等于全集合扫描。文章量上万之后,搜索功能老老实实用全文索引或者专门的搜索组件,别指望正则扛。

3.4 索引:用 explain 让性能差异看得见

索引这事儿,讲道理不如直接看数字。先造点数据:

for (let i = 0; i < 50000; i++) { db.posts.insertOne({ title: "文章" + i, slug: "post-" + i, status: i % 2 ? "draft" : "published" }) }

然后对比:

db.posts.find({ slug: "post-49999" }).explain("executionStats")

看executionStats里的totalDocsExamined和executionTimeMillis。没建索引时,totalDocsExamined会接近 50000,也就是全表扫描。

建索引:

db.posts.createIndex({ slug: 1 }, { unique: true })

再跑一次同样的explain,totalDocsExamined应该变成 1,执行时间降到 1ms 以内。这个对比比任何文字描述都有说服力。

unique: true这个选项也值得说。它在数据库层面保证唯一性,比在应用层"先查再插"靠谱得多——后者在并发下存在竞态,两个请求同时查到"不存在",然后都插入成功。数据库的唯一索引是原子的,冲突时直接抛 E11000 错误,你在代码里捕获就行。

4. Express 骨架:五行代码跑起来之后才是开始

Express 上手快是公认的,但快也意味着很多人跳过理解阶段,直接抄模板。等到要加鉴权、加日志、加统一错误处理的时候,就不知道往哪下手了。

4.1 初始化工程与依赖的最小集合

mkdir blog-api && cd blog-api npm init -y npm i express mongoose dotenv npm i -D nodemon

四个包各自的分工:

  • express:Web 框架本体
  • mongoose:MongoDB 的对象建模工具,提供 Schema、校验、中间件
  • dotenv:把.env文件里的配置读进process.env
  • nodemon:开发期监听文件变化自动重启,只在开发环境用,所以放devDependencies

package.json里补上:

{ "type": "commonjs", "scripts": { "dev": "nodemon src/server.js", "start": "node src/server.js" } }

"type": "commonjs"这句在 Node 20 之后建议显式写上。不写的话默认也是 CommonJS,但一旦项目里出现.mjs文件或者某个依赖的package.json影响了模块解析规则,就可能出现require is not defined这种莫名其妙的错。显式声明能避免这一类问题。

4.2 逐行拆解 app.js

分成两个文件:src/app.js负责组装应用,src/server.js负责启动。这个拆分看起来多此一举,实际上是为了后面写测试——测试的时候你只想拿到app实例丢给测试工具,不想真的监听端口。

// src/app.js const express = require('express'); const postRoutes = require('./routes/post.routes'); const app = express(); app.use(express.json({ limit: '1mb' })); app.get('/health', (req, res) => { res.json({ ok: true, ts: Date.now() }); }); app.use('/api/posts', postRoutes); module.exports = app;
// src/server.js require('dotenv').config(); const app = require('./app'); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`api listening on http://127.0.0.1:${PORT}`); });

express.json()这行是新手最容易漏的。它是个内置中间件,作用是把请求体里的 JSON 字符串解析成对象挂到req.body上。不写这行,req.body永远是undefined,然后你会看到一堆Cannot read properties of undefined (reading 'title')。

limit: '1mb'是请求体大小上限。默认值 100kb,对于文章正文来说不够用;但也不能设太大,否则一个恶意请求就能把你的内存撑爆。1mb 是内容类接口比较常见的取值,具体还得看你的业务。

中间件的注册顺序是有意义的,从上到下依次执行。express.json()必须放在所有需要读请求体的路由之前,这是顺序问题,不是风格问题。

4.3 路由分层与中间件顺序

// src/routes/post.routes.js const router = require('express').Router(); const ctrl = require('../controllers/post.controller'); router.get('/', ctrl.list); router.get('/:slug', ctrl.detail); router.post('/', ctrl.create); router.patch('/:id', ctrl.update); router.delete('/:id', ctrl.remove); module.exports = router;

这里有两个细节。

第一,路由匹配是自上而下的。/:slug如果放在/前面不会有问题,但如果你有/latest这种固定路径,它必须写在/:slug之前,否则latest会被当成 slug 值。这个坑在加"最新文章"接口的时候一定会踩到。

第二,router和app的区别很多初学者搞不清。app是整个应用,router是一个迷你应用,只关心一段路径下的子路由,最后通过app.use('/api/posts', router)挂载到主应用。好处是路由文件完全不知道自己的前缀是什么,换前缀只改一行的挂载代码,不用动路由内部。

中间件按用途大致分四类:解析类(express.json、urlencoded)、日志类、鉴权类、错误处理类。前三类都是用app.use或router.use注册,错误处理类必须写在最后,且函数签名是四个参数(err, req, res, next)——少一个参数 Express 就不会把它当错误处理器。这个规则很死,但没写在显眼的地方,很多人第一次写错误中间件会漏掉next。

4.4 nodemon 热重载与调试启动方式

npm run dev

nodemon默认监听整个目录,node_modules除外。如果你的日志文件写在项目里,nodemon 会因为日志变化反复重启,这时候需要加nodemon.json:

{ "watch": ["src"], "ext": "js,json", "ignore": ["logs", "*.log"] }

需要断点调试的时候:

node --inspect src/server.js

然后在浏览器打开chrome://inspect就能看到断点面板。--inspect-brk会在第一行暂停,适合排查启动阶段的初始化问题,比如连接数据库时的逻辑错误。

5. 打通两者:连接、模型、接口一条龙

前面都是分头准备,这一节把线连起来。这一段跑通,一个最小可用的后端就有了。

5.1 官方驱动和 Mongoose 的取舍

官方驱动(mongodb包)给的是最原始的能力:client.db().collection().insertOne(),跟在mongosh里敲命令几乎一样。Mongoose 是在驱动之上包了一层,提供 Schema 定义、字段校验、类型转换、中间件(hooks)、虚拟字段这些能力。

选择逻辑很简单:要不要 Schema。如果你的数据结构固定、需要校验、需要关联查询,用 Mongoose;如果你只是把 MongoDB 当个 JSON 存储,或者要做一些底层的批量操作、聚合管道,直接用驱动会更直接。我个人的做法是业务代码用 Mongoose,数据迁移脚本、批量修复脚本用官方驱动,因为后者更接近原生命令,脚本写起来更直观。

5.2 连接串里那几个参数的真实作用

// src/config/db.js const mongoose = require('mongoose'); async function connectDB() { await mongoose.connect(process.env.MONGO_URI, { maxPoolSize: 10, serverSelectionTimeoutMS: 5000, socketTimeoutMS: 45000, }); console.log('mongodb connected'); } module.exports = connectDB;

.env里写:

MONGO_URI=mongodb://127.0.0.1:27017/blog PORT=3000

这里有个特别容易踩的细节:用127.0.0.1而不是localhost。在很多系统里,localhost会优先解析成 IPv6 的::1,而 MongoDB 默认只监听 IPv4 的127.0.0.1,结果就是ECONNREFUSED。这个错误看起来像"数据库没启动",实际上是地址解析问题,排查半天都想不到。

三个参数的作用:

参数默认值说明
maxPoolSize100连接池上限。小项目 10 就够了,开太多反而拖慢数据库
serverSelectionTimeoutMS30000选节点超时。默认 30 秒意味着连不上要等半分钟才报错,本地调试设成 5 秒体验好很多
socketTimeoutMS0(不超时)单次操作空闲超时。长时间不用的连接该释放掉

serverSelectionTimeoutMS这一项特别影响开发体验。默认 30 秒,你改代码重启,如果数据库没起来,要干等半分钟才看到错误,很容易误以为进程卡死。

5.3 Schema 设计与接口实现

// src/models/post.model.js const mongoose = require('mongoose'); const postSchema = new mongoose.Schema({ title: { type: String, required: [true, '标题必填'], trim: true, maxlength: 120 }, slug: { type: String, required: true, unique: true, lowercase: true }, content: { type: String, default: '' }, tags: { type: [String], default: [] }, status: { type: String, enum: ['draft', 'published'], default: 'draft' } }, { timestamps: true }); module.exports = mongoose.model('Post', postSchema);

{ timestamps: true }会自动维护createdAt和updatedAt,省得你每次更新都手动写$currentDate。这个选项几乎每个集合都该开。

enum那个字段是应用层的约束,MongoDB 本身不认。好处是数据在进入数据库之前就被拦下来了,报错信息也更友好。坏处是如果你通过其他方式(比如 mongosh)直接插数据,绕过了这层校验。所以关键的业务约束最好同时写进 Schema 和数据库校验规则,双保险。

控制器:

// src/controllers/post.controller.js const Post = require('../models/post.model'); exports.list = async (req, res, next) => { try { const { status = 'published', page = 1, size = 10 } = req.query; const docs = await Post.find({ status }) .select('title slug tags createdAt') .sort({ createdAt: -1 }) .skip((page - 1) * size) .limit(Number(size)) .lean(); res.json({ data: docs }); } catch (err) { next(err); } }; exports.create = async (req, res, next) => { try { const doc = await Post.create(req.body); res.status(201).json({ data: doc }); } catch (err) { next(err); } };

.lean()这个调用值得单独说。默认情况下 Mongoose 返回的是带了一堆内部方法的文档实例,转 JSON 的时候还要跑一遍toJSON。加.lean()直接返回纯 POJO,内存占用和转换开销都小不少。只读场景一律加.lean(),这个习惯在列表接口上收益最明显。

skip分页也要注意:数据量大之后skip(100000)会非常慢,因为它要遍历前面十万条。真正的深分页要用游标方式,也就是基于上一页最后一条的_id或createdAt做$gt查询。练习阶段用skip没问题,但要知道它的上限在哪。

5.4 用请求验证整条链路

curl -X POST http://127.0.0.1:3000/api/posts \ -H "Content-Type: application/json" \ -d '{"title":"打通链路","slug":"connect-the-dots","content":"第一篇测试"}' curl "http://127.0.0.1:3000/api/posts?status=draft"

两条都通,说明解析、路由、模型、连接四层都没问题。如果第一条返回 500,看终端报错;如果返回{"data":[]},去 mongosh 里db.posts.find()确认数据到底有没有写进去。

6. 四个必踩的坑与排查链路

这一节是我认为整篇最有价值的部分。下面每个错误,都是第一次搭这套组合时大概率会撞上的。

6.1 ECONNREFUSED:连不上到底怪谁

MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017

这个错误的字面意思是"目标端口拒绝连接",可能的原因有四种,按概率排序:

  1. MongoDB 服务没启动。先systemctl status mongod或brew services list看一眼。
  2. 启动了但监听的不是这个地址。bindIp配成别的地址,或者端口改过。用lsof -i :27017看有没有进程在监听。
  3. 地址解析问题。前面提过的localhost解析成 IPv6。
  4. 容器/虚拟机网络。如果数据库跑在容器里,127.0.0.1指的是容器自己,要用容器名或者映射出来的宿主机地址。

排查顺序建议从下往上:先ping确认端口通不通,再确认服务状态,最后看配置。不要一上来就重装,重装解决不了配置问题。

6.2 认证失败的几种伪装

MongoServerError: Authentication failed.

如果你压根没开鉴权却报这个,八成是连接串里带了个空的用户名密码段,比如mongodb://@127.0.0.1:27017/blog,Mongoose 会当成你在尝试认证。检查.env里有没有多余的@或冒号。

如果确实开了鉴权,注意认证库。MongoDB 的用户是建在某个库下的,连接时要通过authSource指定:

mongodb://user:pass@127.0.0.1:27017/blog?authSource=admin

不写authSource默认用连接串里的库名去认证,如果用户建在admin下就会失败。这个细节在配置文件里不明显,但在文档的角落里有写。

6.3 buffering timed out 的真相

MongooseError: Operation `posts.find()` buffering timed out after 10000ms

这个报错极具迷惑性,因为它说的是"操作超时",但它真正想说的是"数据库根本没连上"。Mongoose 有个缓冲机制:在连接建立之前发起的操作会先排队,等连接成功后再执行。如果 10 秒内连接还没建立(或者干脆失败了),队列里的操作就集体超时。

所以看到这个错误,不要去查find语句写错了没有,直接去看连接状态:

mongoose.connection.readyState // 0断开 1已连 2连接中 3断开中

0或者2卡住,就是连接问题。真正的排查方向是网络、地址、服务状态,跟查询语句一点关系都没有。

顺带说一句,可以通过bufferCommands: false关掉这个缓冲,让错误更早暴露,报错也更直接。开发阶段我一般关掉它,省得被误导。

6.4 EADDRINUSE 与残留进程

Error: listen EADDRINUSE: address already in use :::3000

这通常是上一次的nodemon没退干净。查进程:

lsof -i :3000 netstat -ano | findstr :3000

拿到 PID 之后kill -9 <PID>或taskkill /PID <PID> /F。

有个更隐蔽的版本:进程杀了,端口还占着,因为处于TIME_WAIT状态。这时候等几十秒自然释放,或者把本地开发端口换一个。用nodemon的时候还有个情况是它 fork 了子进程,主进程被杀了子进程还在,需要pkill -f nodemon清一遍。

7. 从能跑到敢用的几个习惯

最后一节聊几个"能跑之后马上就该补上"的东西。这些不做,项目不出三天就会变成一团乱麻。

7.1 配置外置与环境切换

所有跟环境相关的值——数据库地址、端口、密钥——都不该硬编码在代码里。.env文件负责本地配置,.env.example提交到仓库作为模板,.env本身写进.gitignore。这个流程看着啰嗦,但它是防止"密钥进版本库"这类事故最有效的办法。

# .env.example MONGO_URI=mongodb://127.0.0.1:27017/your_db PORT=3000 NODE_ENV=development

读取的时候在入口最上方require('dotenv').config(),且只调用一次。有些人在每个模块里都调一遍,虽然不会报错,但会让人搞不清配置到底在哪加载的。

7.2 统一错误处理中间件

// src/middlewares/error.js module.exports = (err, req, res, next) => { if (err.name === 'ValidationError') { return res.status(400).json({ message: Object.values(err.errors).map(e => e.message) }); } if (err.code === 11000) { return res.status(409).json({ message: '记录已存在', field: Object.keys(err.keyPattern)[0] }); } console.error(err); res.status(500).json({ message: 'server error' }); };

挂载时必须放在所有路由之后:

app.use('/api/posts', postRoutes); app.use(require('./middlewares/error'));

11000是 MongoDB 的唯一索引冲突错误码,捕获它就能把"重复 slug"这种问题变成 409 而不是 500。这个映射做了之后,前端拿到的错误信息会精准得多。

7.3 优雅关闭与连接复用

process.on('SIGINT', async () => { await mongoose.connection.close(); process.exit(0); });

进程被 Ctrl+C 或者被容器编排系统终止时,先关数据库连接再退出。不关的话,连接池里的连接会挂着,MongoDB 那边的连接数要过一段时间才会释放。本地开发影响不大,但一旦部署到连接数受限的环境,频繁重启会把连接数耗光。

连接复用这条也顺带提一句:数据库连接要在应用启动时建立一次,全局复用,不要在每次请求里mongoose.connect()。后者会导致连接池不断被创建和销毁,性能极差,而且大概率会撞上连接数上限。

这套组合从安装到跑通,快的话一个下午,慢的话卡在某一个报错上两三天都很正常。我自己最初在buffering timed out那个错上耗了小半天,把所有查询语句翻了三遍,最后才发现是mongod压根没起来。后来养成了一个习惯:任何"操作层面"的报错,先确认底层服务是不是活的,再去看代码。这个顺序能省掉一大半无效排查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询