MongoDB 已经在运行,但 bookstore 目前还是空的。这一节会先理解 BSON、文档和集合的边界,然后一次性导入全课共用的书籍、顾客与订单。后面章节都从这份固定状态继续,因此这里会使用稳定、可读的 _id,并让每次导入都有明确计数。
本节的初始化脚本会删除 books、customers、orders 三个同名集合,再重新创建并导入种子数据。它只适合课程初始化与重置,不能对包含业务数据的数据库直接执行。
JSON 很适合网络传输,但它的类型范围较窄。MongoDB 在存储和通信时使用 BSON。BSON 保留文档的键值结构,同时支持更多数据库需要的类型,例如日期、二进制、ObjectId、Decimal128、32 位整数和 64 位整数。
下面是一张订单在 JavaScript 语法中的样子:
{
_id: "order-1001",
customerId: "customer-lin",
status: "paid",
items: [
{
bookId: "book-mongodb",
title: "MongoDB 从入门到实践",
unitPrice: 89,
quantity: 1
}
],
total: 158,
createdAt: ISODate("2026-07-01T10:00:00Z")
}这里有四类值得分清的值:
_id、customerId、status 是字符串。items 是数组,数组元素又是嵌套文档。unitPrice、quantity、total 是数值。createdAt 是 BSON 日期,不是长得像日期的普通字符串。课程中的价格使用整数元,是为了让结果容易核对。真实支付系统通常选择“最小货币单位的整数”或 Decimal128,并明确舍入规则;直接使用二进制浮点数保存需要精确对账的金额,容易埋下精度问题。
mongosh 启动后会持有一个名为 db 的数据库对象。use bookstore 会把这个对象切换到 bookstore。MongoDB 不要求先执行单独的“创建数据库”命令;第一次在该数据库中创建集合或写入数据时,它才真正出现在数据库列表中。
先进入容器中的 mongosh:
docker exec -it paperboat-mongo mongosh --quiet进入 shell 后执行:
use bookstore
db.getName()后续标为 javascript 的数据库操作都在这个 shell 会话中粘贴执行。
switched to db bookstore
bookstore第一行确认切换成功,第二行来自 db.getName()。如果第二行不是 bookstore,先重新执行 use bookstore,不要把种子数据写进默认数据库。
MongoDB 在第一次插入时可以隐式创建集合,但显式 createCollection() 允许我们在写入前配置校验规则。纸舟书店只为 orders 设置第一版校验器:订单必须有顾客、状态、至少一个明细、总额和创建时间;状态只能是四个允许值之一。
这个校验器没有列出书店未来可能增加的每一个字段。它保护不可缺少的业务事实,同时保留增加配送信息、备注等字段的空间。这就是“受控的灵活模式”。
确认当前数据库是 bookstore 后,粘贴整段脚本:
for (const name of ["books", "customers", "orders"]) {
db.getCollection(name).drop();
}
db.createCollection("books");
db.createCollection("customers");
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["customerId"
["books","customers","orders"]drop() 在集合不存在时会返回 false,存在时返回 true。脚本没有把这些布尔值当成验收结果,因为首次运行和重置运行必然不同;最后的集合名称才是稳定检查点。
insertMany() 在一次调用中插入多个文档,并返回 acknowledged 与 insertedIds。我们用字符串 _id 保证每本书都能被稳定引用。实际项目可以使用自动生成的 ObjectId,但不要把“自动生成”误解为“业务上不需要标识策略”。
五本文档有意覆盖后续查询需要的差异:两个数据库类别、不同价格和库存、数组标签、四本已发布书和一份待删除草稿。
const booksResult = db.books.insertMany([
{
_id: "book-mongodb",
title: "MongoDB 从入门到实践",
category: "数据库",
price: 89,
stock: 20,
tags: ["MongoDB", "后端"],
published: true
},
{
_id: "book-node",
title: "Node.js 项目开发",
category: "编程"
{"acknowledged":true,"insertedCount":5}acknowledged: true 表示服务端确认了写入,insertedCount: 5 表示五本文档都获得了插入结果。因为 _id 固定,跳过前面的重置脚本直接重复插入会触发重复键错误;这是保护唯一标识的正常行为。
顾客的地址随顾客资料一起读取,数量也通常有限,因此这里把地址数组嵌入顾客文档。数组中的每个元素仍是完整文档,可以拥有 label、city 和 detail 字段。
如果地址以后变成独立管理、被多个账户共享、拥有复杂生命周期的实体,我们可以重新评估是否拆成引用。当前模型服务的是当前访问模式,不是假设永远不变。
const customersResult = db.customers.insertMany([
{
_id: "customer-lin",
name: "林晓舟",
email: "lin@example.com",
addresses: [
{
label: "默认",
city: "上海",
detail: "静安区纸舟路 8 号"
}
]
},
{
_id: "customer-zhou",
name: "周雨",
email:
{"acknowledged":true,"insertedCount":3}三位顾客写入成功。地址没有单独的 _id,因为课程暂时只在所属顾客内部定位它;顶层顾客则有稳定 _id,供订单引用。
订单同时使用引用与快照:customerId 指向顾客,items.bookId 指向书籍;items.title 和 items.unitPrice 保存下单时的书名与单价。以后书籍调价,历史订单的 total 不会跟着改变。
订单时间使用 ISODate() 创建 BSON 日期。末尾的 Z 表示 UTC。把时间明确存成日期后,MongoDB 才能正确进行范围查询、排序和日期聚合。
const ordersResult = db.orders.insertMany([
{
_id: "order-1001",
customerId: "customer-lin",
status: "paid",
items: [
{
bookId: "book-mongodb",
title: "MongoDB 从入门到实践",
unitPrice: 89,
quantity: 1
},
{
bookId: "book-node",
title: "Node.js 项目开发",
{"acknowledged":true,"insertedCount":5}五张订单通过校验并写入。订单总额是下单事实,不会在查询时根据书籍当前价格临时重算。
导入成功不能只看“没有报错”。我们需要核对三个集合的文档数,并验证订单时间确实是 Date。countDocuments({}) 是明确的文档计数方法;不要使用已经废弃的 count()。
const sampleOrder = db.orders.findOne({ _id: "order-1001" });
print(EJSON.stringify({
counts: {
books: db.books.countDocuments({}),
customers: db.customers.countDocuments({}),
orders: db.orders.countDocuments({})
},
sample: {
id: sampleOrder._id,
createdAtIsDate: sampleOrder.createdAt instanceof Date,
itemCount: sampleOrder.items.length
}
}));{"counts":{"books":5,"customers":3,"orders":5},"sample":{"id":"order-1001","createdAtIsDate":true,"itemCount":2}}数量与种子设计一致;createdAtIsDate: true 证明它不是普通字符串;itemCount: 2 证明订单明细数组保留了两个嵌套文档。
集合校验器在服务端执行。无论写入来自 mongosh、应用驱动还是导入工具,只要没有被明确授权绕过,错误文档都会受到同一规则约束。
下面的文档缺少 customerId,并且 items 是空数组,同时违反两条规则。我们用 try...catch 只打印稳定的错误码,避免把很长且可能随补丁版本变化的错误详情当作固定输出。
try {
db.orders.insertOne({
_id: "order-invalid",
status: "paid",
items: [],
total: 0,
createdAt: new Date()
});
} catch (error) {
print("INVALID_REJECTED code=" + error.code);
}
print("invalidCount=" + db.orders.countDocuments({ _id: "order-invalid" }));INVALID_REJECTED code=121
invalidCount=0错误码 121 表示文档校验失败。invalidCount=0 再次确认错误订单没有偷偷进入集合。错误详情会指出缺少 customerId,并且 items 没有满足 minItems: 1。
现在纸舟书店拥有一个可重复的初始状态:5 本书、3 位顾客、5 张订单,订单集合还有第一道服务端校验。后面的结果都以这份状态为起点。