要在 Node.js 中使用 MongoDB,先安装官方驱动(npm install mongodb),用 new MongoClient(uri) 连接,用 client.db('name') 选择数据库,用 db.collection('name') 选择集合。每个操作(insertOne、find、updateOne、deleteOne)都返回一个 Promise,所以你要 await 它。文档存进去和取出来都是普通的 JavaScript 对象。
import { MongoClient } from 'mongodb';
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
const books = client.db('shop').collection('books');
await books.insertMany([
{ title: 'Dune', author: 'Herbert', year: 1965, price: 12 },
{ title: 'Neuromancer', author: 'Gibson', year: 1984, price: 9 },
{ title: 'Hyperion', author: 'Simmons', year: 1989, price: 11 },
]);
const all = await books.find().toArray();
console.log(all.map((b) => b.title));
await client.close();
它打印 [ 'Dune', 'Neuromancer', 'Hyperion' ]。shop 数据库和 books 集合之前并不存在:MongoDB 在第一次插入时创建了它们,而 toArray() 加载了 find() 游标指向的所有文档。
用筛选条件和投影查询文档
筛选条件是一个描述你想要哪些文档的对象。普通值表示精确匹配;$gt、$lt、$in、$regex 等运算符写在一个嵌套对象里。第二个参数中的 projection 用来选择返回哪些字段。
import { MongoClient } from 'mongodb';
const client = new MongoClient('mongodb://localhost:27017');
const books = client.db('shop').collection('books');
await books.insertMany([
{ title: 'Dune', author: 'Herbert', year: 1965, price: 12 },
{ title: 'Neuromancer', author: 'Gibson', year: 1984, price: 9 },
{ title: 'Hyperion', author: 'Simmons', year: 1989, price: 11 },
{ title: 'Foundation', author: 'Asimov', year: 1951, price: 8 },
]);
const recent = await books
.find({ year: { $gt: 1960 } }, { projection: { _id: 0, title: 1, year: 1 } })
.sort({ year: -1 })
.limit(2)
.toArray();
recent.forEach((b) => console.log(b));
const cheap = await books.findOne({ price: { $lt: 10 }, author: 'Asimov' });
console.log(cheap.title);
console.log(await books.countDocuments({ price: { $gte: 10 } }));
await client.close();
recent 中是 1960 年之后最新的两本书,先是 Hyperion,然后是 Neuromancer,只带 title 和 year 字段;findOne 返回第一个匹配的文档(或 null),countDocuments 统计出价格在 10 及以上的书有 2 本。把 $gt: 1960 改成 $gt: 1985,就只剩下 Hyperion 了。
更新和删除
updateOne(filter, update) 修改第一个匹配的文档。更新要用运算符:$set 替换字段,$inc 给数字加上一个值,$unset 删除字段,$push 向数组追加元素。updateMany 和 deleteMany 作用于所有匹配的文档。
import { MongoClient } from 'mongodb';
const client = new MongoClient('mongodb://localhost:27017');
const books = client.db('shop').collection('books');
await books.insertMany([
{ title: 'Dune', price: 12, stock: 3 },
{ title: 'Neuromancer', price: 9, stock: 0 },
]);
const res = await books.updateOne(
{ title: 'Dune' },
{ $set: { price: 10 }, $inc: { stock: -1 } }
);
console.log(res.matchedCount, res.modifiedCount);
const dune = await books.findOne({ title: 'Dune' });
console.log(dune.price, dune.stock);
const del = await books.deleteOne({ stock: 0 });
console.log('deleted', del.deletedCount, 'left', await books.countDocuments());
await client.close();
这次更新匹配并修改了一个文档,所以 Dune 现在价格是 10,库存是 2;删除没有库存的那本书后,剩下 1 本。更新必须使用运算符:不带 $set 直接传入 { price: 10 },驱动会拒绝它(替换整个文档要用 replaceOne)。
ObjectId 和索引
每个文档都有一个 _id;如果你没有设置,驱动会创建一个 ObjectId。要按一个以字符串形式传来的 id(比如来自 URL)查找文档,就把它包进 new ObjectId(id)。索引能让某个字段的查询变快,唯一索引则会让 MongoDB 拒绝重复值。
import { MongoClient, ObjectId } from 'mongodb';
const client = new MongoClient('mongodb://localhost:27017');
const users = client.db('app').collection('users');
await users.createIndex({ email: 1 }, { unique: true });
const { insertedId } = await users.insertOne({ email: 'ada@example.com' });
const idString = insertedId.toString();
console.log(idString.length);
const found = await users.findOne({ _id: new ObjectId(idString) });
console.log(found.email);
console.log(await users.findOne({ _id: idString }));
try {
await users.insertOne({ email: 'ada@example.com' });
} catch (err) {
console.log('duplicate, error code', err.code);
}
await client.close();
ObjectId 转成字符串是 24 个十六进制字符,直接用这个字符串查询什么也找不到,所以那一行打印 null。第二次插入违反了唯一索引,以错误码 11000 失败,你就是这样检测“邮箱已被占用”的。
在 Express API 中使用 MongoDB
在服务器启动时连接一次,然后共享这个集合;不要为每个请求都打开一个新连接。驱动会维护一个连接池并重复使用其中的连接。
// GET /books
import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';
const client = new MongoClient('mongodb://localhost:27017');
const books = client.db('shop').collection('books');
// Setup: seed two books
await books.insertMany([
{ title: 'Dune', year: 1965 },
{ title: 'Hyperion', year: 1989 },
]);
const app = express();
app.use(express.json());
app.get('/books', async (req, res) => {
const list = await books.find().sort({ year: 1 }).toArray();
res.json(list);
});
app.get('/books/:id', async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(400).json({ error: 'Bad id' });
}
const book = await books.findOne({ _id: new ObjectId(req.params.id) });
if (!book) return res.status(404).json({ error: 'Not found' });
res.json(book);
});
app.post('/books', async (req, res) => {
const { insertedId } = await books.insertOne({ title: req.body.title, year: req.body.year });
res.status(201).json({ _id: insertedId });
});
app.listen(3000);
GET /books 返回两本书,每本都带一个 _id。把其中一个 id 复制到 GET /books/<id> 中,就能获取单本书;或者发送 POST /books,请求体是 {"title": "Foundation", "year": 1951} 这样的 JSON,然后再列出所有书看看。
真实的应用会从 process.env.MONGODB_URI 读取连接字符串,而不是把它写在代码里。
如果你想在驱动之上使用 schema 和验证,可以用封装了驱动的 Mongoose(npm install mongoose):
const Book = mongoose.model('Book', new mongoose.Schema({ title: String, year: Number }));
await Book.create({ title: 'Dune', year: 1965 });
常见错误
忘记调用 toArray()。 find() 返回的是一个游标,而不是文档。打印它只会看到一个游标对象;要调用 toArray(),或者用 for await 遍历它。
每个请求都建立连接。 每个 new MongoClient 都会打开自己的连接池。在启动时创建一个客户端,然后到处复用它。
脚本里从不关闭客户端。 代码执行完后,一个打开着的客户端会让进程一直运行。在脚本末尾调用 await client.close();服务器则在整个生命周期内保持它打开。