我们已经在 mongosh 中完成了数据建模、查询、更新、索引和聚合。这一节开始写应用:用 Node.js 22 内置的 http 模块提供一个小型 API,数据库部分只使用 MongoDB 官方 Node.js 驱动 mongodb@7.5.0。
项目先做两件事:健康检查真正向 MongoDB 发送 ping,图书列表提供可重复的分页。下一节再在这个 API 上增加事务下单,这样连接、路由和事务的责任边界会更清楚。
Node.js 驱动会根据副本集配置发现成员。我们在启动 MongoDB 时,把 rs0 的成员地址设为 paperboat-mongo:27017。这个名称能在 Docker 网络 paperboat-net 内解析,但开发设备不会自动认识它。
因此 API 也运行在容器中,并加入 paperboat-net。它使用的连接串是:
mongodb://paperboat-mongo:27017/bookstore?replicaSet=rs0这样不用修改 hosts 文件,驱动初始连接和后续发现到的成员地址也保持一致。API 只把 3000 端口绑定到 127.0.0.1,MongoDB 仍使用前面已经创建的网络与数据卷。
连接副本集时,“第一个地址能连上”还不够。服务器返回的成员地址也必须能被应用解析和访问。把应用放入 paperboat-net 正是为了满足这个条件。
一个可落地的最小版本只需要五个文件:package.json 声明直接依赖,package-lock.json 锁定完整依赖树,server.js 实现 API,Dockerfile 定义 Node 22 运行镜像,.dockerignore 防止无关文件进入构建上下文。
本节不引入 Web 框架。这不是建议所有项目都手写 HTTP 路由,只是让我们把注意力放在驱动生命周期、查询边界和结果上。
新建项目目录:
mkdir paperboat-api
cd paperboat-api创建 package.json:
{
"name": "paperboat-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"mongodb": "7.5.0"
}
}使用同一个 Node 22 镜像生成锁文件。--package-lock-only 只解析并锁定依赖,不会在项目目录留下 node_modules:
docker run --rm \
--mount type=bind,src="$PWD",dst=/app \
-w /app \
node:22-bookworm-slim \
npm install --package-lock-only --ignore-scripts
test -s package-lock.json
grep -n '"mongodb": "7.5.0"' package-lock.jsonup to date, audited ... packages
found 0 vulnerabilities
12: "mongodb": "7.5.0"audited 的数量和行号可能随 npm 版本变化;真正的检查点是命令成功、锁文件非空,并且根项目仍要求精确版本 7.5.0。
创建 .dockerignore:
node_modules
npm-debug.log
.git创建 Dockerfile:
FROM node:22-bookworm-slim
LABEL dev.welearn.course="mongodb-paperboat"
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
USER node
EXPOSE 3000
CMD ["npm", "start"]先创建入口文件,下一节再把完整代码写进去:
: > server.js项目目录应当是这个形状:
paperboat-api/
├── .dockerignore
├── Dockerfile
├── package.json
├── package-lock.json
└── server.jspackage-lock.json 让直接依赖和传递依赖都可重复解析,npm ci 会严格按锁文件安装,并在声明与锁文件不一致时失败。镜像标签用于运行,dev.welearn.course 元数据则让后面的清理步骤能找到同一课程多次构建留下的旧镜像。
MongoClient 不只代表一条 TCP 连接。它负责副本集发现、连接池和服务器选择。如果每个 HTTP 请求都 new MongoClient() 再关闭,应用会反复建连和发现拓扑,延迟与连接数都会不必要地增长。
正确的边界是:进程启动时创建一个客户端,所有请求复用它,进程收到终止信号时关闭连接池。健康检查不应只回复一个写死的 ok,而要真正执行 ping。
图书列表按 price 升序排列,再用 _id 打破同价平局。该路由同时为 page 和 limit 设置下界与上界,避免用户传入负数或过大页面。
创建 server.js:
import http from "node:http";
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is required");
}
const client = new MongoClient(uri);
await client.connect();
const database
代码中只有一次 new MongoClient(uri) 和一次 client.connect(),它们都在请求处理函数之外。两个路由共用 database 与 books,终止信号则走到 client.close()。
启动时的 createIndex() 是幂等的:同名同定义索引已存在时,MongoDB 不会重复创建。大型项目通常把索引变更放入部署迁移流程;最小项目放在启动步骤,可以让查询与索引保持在同一个可运行示例里。
published_1_category_1_price_1_id_1 对应“已发布 + 固定分类 + 价格排序”,published_1_price_1_id_1 对应“不限分类 + 价格排序”。第二条索引不是为了凑数量:若请求不带 category,第一条索引在 published 之后出现字段空缺,不能直接提供跨分类价格顺序。两种访问模式都有明确路由,才值得分别支付索引维护成本。
readPositiveInteger() 先用完整正则验证,再转换为安全整数,所以 2abc 不会被悄悄解释成第 2 页;非法值回退到默认值,合法值仍会受到最大值限制。
docker build 会在 Node 22 镜像中安装官方驱动。运行容器时,--network paperboat-net 让 API 可以解析 paperboat-mongo,-e MONGODB_URI=... 通过环境变量传入连接串,-p 127.0.0.1:3000:3000 只暴露 API 端口。
这条连接串尚未包含用户名与密码,因为认证和授权会在安全部分完整配置。当前容器不应暴露到公网。
在 paperboat-api 目录执行:
docker build -t paperboat-api:course .
docker run -d \
--name paperboat-api \
--init \
--network paperboat-net \
-p 127.0.0.1:3000:3000 \
-e 'MONGODB_URI=mongodb://paperboat-mongo:27017/bookstore?replicaSet=rs0' \
paperboat-api:course查看启动日志、Node 版本和驱动版本:
docker logs paperboat-api
docker exec paperboat-api node --version
docker exec paperboat-api npm list mongodb --depth=0已验证的启动结果是:
paperboat-api listening on 3000
v22.23.1
paperboat-api@1.0.0 /app
└── mongodb@7.5.0node:22-bookworm-slim 的补丁版本会随镜像更新,因此你的 v22.x.x 可能更新;主版本应为 22。mongodb@7.5.0 由 package.json 精确锁定,应与上面一致。
应用进程活着,不等于数据库已经可用。/health 调用 database.command({ ping: 1 }),只有驱动选到服务器并完成往返后,才返回 ok: true。
这是一个最小的就绪检查。生产系统通常还会把“进程是否活着”与“是否准备接流量”分成两个端点,避免短暂数据库故障让编排器不断重启本来健康的进程。
curl -sS http://127.0.0.1:3000/health{"ok":true}这个结果同时证明 API 正在监听、容器网络能解析 paperboat-mongo、驱动已发现 rs0 且 MongoDB 接受命令。
纸舟书店有两本“编程”类图书:《现代 Web 基础》价格 59,《Node.js 项目开发》价格 69。把 limit 设为 1 后,它们应稳定地出现在两页中。
sort({ price: 1, _id: 1 }) 中的 _id 是确定性排序键。如果以后有多本书价格相同,它们也不会因为排序平局在页与页之间随意移动。
请求第一页:
curl -sS -G 'http://127.0.0.1:3000/books' \
--data-urlencode 'category=编程' \
--data-urlencode 'page=1' \
--data-urlencode 'limit=1'请求第二页:
curl -sS -G 'http://127.0.0.1:3000/books' \
--data-urlencode 'category=编程' \
--data-urlencode 'page=2' \
--data-urlencode 'limit=1'第一页:
{"page":1,"limit":1,"items":[{"_id":"book-web","title":"现代 Web 基础","category":"编程","price":59}]}第二页:
{"page":2,"limit":1,"items":[{"_id":"book-node","title":"Node.js 项目开发","category":"编程","price":69}]}两页没有重复,价格顺序正确。当页码很深时,skip 需要跳过大量结果;前面已经实践过的复合游标分页更适合大数据集。这个 API 先保留页码分页,便于把驱动调用链看完整。
固定分类查询可以利用包含 category 的索引;最终交付还会请求全部分类,因此要单独验证 { published: 1, price: 1, _id: 1 }。当前集合只有 4 本书,优化器可能认为集合扫描也很便宜。这里用 hint() 验证候选索引能否同时提供过滤与排序,重点是计划包含 IXSCAN 且不出现阻塞式 SORT;上线前还应在实际数据量上比较不带 hint() 的自然计划。
docker exec paperboat-mongo mongosh --quiet --eval '
const d = db.getSiblingDB("bookstore");
const explained = d.books
.find({ published: true }, { title: 1, price: 1 })
.sort({ price: 1, _id: 1 })
.hint("published_1_price_1_id_1")
.explain("executionStats");
const stages = [];
const indexNames = [];
function visit(node) {
if (!node || typeof node !== "object") return;
if (node.stage) stages.push(node.stage);
if (node.stage === "IXSCAN" && node.indexName) {
indexNames.push(node.indexName);
}
for (const value of Object.values(node)) visit(value);
}
visit(explained.queryPlanner.winningPlan);
print(EJSON.stringify({
hasIXSCAN: stages.includes("IXSCAN"),
hasBlockingSort: stages.includes("SORT"),
{"hasIXSCAN":true,"hasBlockingSort":false,"indexNames":["published_1_price_1_id_1"],"nReturned":4,"totalKeysExamined":4,"totalDocsExamined":4}这条结果证明不限分类的价格分页有一条顺序匹配的访问路径。读取标题仍需回表,因此 totalDocsExamined 是 4;优化目标不是把所有数字变成零,而是在保持结果不变时避免无界扫描和额外排序。
这个项目实际用到的驱动 API 有 MongoClient、connect()、db()、collection()、createIndex()、find()、游标链和 close()。这组代码已在 Node 22.23.1 与 mongodb@7.5.0 上执行。
升级驱动时,不要只看安装是否成功。应至少重新跑一遍连接、查询、BSON 类型转换、事务和关闭流程,并阅读目标主版本的迁移说明。下一节会继续验证 startSession() 与 withTransaction()。
连接池的大小不应在没有证据时随意调大。先复用单一 MongoClient,再根据并发量、请求耗时、连接等待和 MongoDB 连接上限设定池参数。
纸舟书店 API 现在有了一个可复用的数据库客户端、真实的健康检查和稳定分页。下一节会增加 POST /orders,让扣库存与写订单在同一个事务中提交或回滚。