第2章 文档 CRUD 与 Java Driver 实战
🎯 本章学习目标
- 掌握
MongoClient → MongoDatabase → MongoCollection<Document>三层入口,理解客户端线程模型。 - 会
insertOne / insertMany,理解_id/ObjectId 的生成规则与批量插入 ordered 语义。 - 把 MySQL
WHERE的常用条件(=、范围、IN、LIKE、IS NULL、AND/OR)逐条翻译成Filters。 - 会用
Projections控制返回字段(对照 SELECT 列)、Sorts + skip + limit实现排序分页(对照 ORDER BY / LIMIT OFFSET)。 - 掌握更新操作符
$set / $inc / $push / $unset / $pull(对照 UPDATE 列赋值,外加数组操作这个 MySQL 没有的能力)。 - 掌握删除语义与「清空集合」的正确姿势;会用
bulkWrite把增删改混合成一次请求提交;初步接触 POJO 映射(完整版在第 7 章)。
本章统一使用官方同步驱动
mongodb-driver-sync 5.x。所有 mongosh 写法与 Java 写法成对给出——它们是同一套查询对象语法的两种外壳。
2.1 三层入口与准备数据
MongoClient client = MongoClients.create("mongodb://localhost:27017"); // 全局一个,别每次 new
MongoDatabase db = client.getDatabase("yjoffer");
MongoCollection<Document> users = db.getCollection("t_user");本章示例数据——往 t_user 塞几个字段并不完全一致的文档(感受一下无固定 schema):
// mongosh:准备同一批种子数据
use yjoffer
db.t_user.insertMany([
{ username: "tom", age: 25, city: "武汉", tags: ["java", "mysql"], status: 1 },
{ username: "amy", age: 30, city: "北京", tags: ["python"], status: 1 },
{ username: "bo", age: 22, city: "武汉", status: 0 }, // 没有 tags 字段
{ username: "cathy",age: 35, city: "上海", tags: ["java", "spring", "mongodb"], status: 1 }
])// Java Driver:同一批数据,Document.append 链式拼装
List<Document> seeds = List.of(
new Document("username", "tom").append("age", 25).append("city", "武汉")
.append("tags", List.of("java", "mysql")).append("status", 1),
new Document("username", "amy").append("age", 30).append("city", "北京")
.append("tags", List.of("python")).append("status", 1),
new Document("username", "bo").append("age", 22).append("city", "武汉")
.append("status", 0), // 没有 tags 字段
new Document("username", "cathy").append("age", 35).append("city", "上海")
.append("tags", List.of("java", "spring", "mongodb")).append("status", 1)
);
users.insertMany(seeds);2.2 插入:insertOne / insertMany
| 关注点 | MySQL | MongoDB |
|---|---|---|
| 单行/多行 | INSERT INTO ... VALUES 一条/多条 | insertOne / insertMany |
| 主键 | AUTO_INCREMENT 回填 | 不传 _id 则自动生成 ObjectId;结果里 insertedId 可拿 |
| 批量失败行为 | 事务回滚或逐条报错 | insertMany 默认 ordered:遇错即停,之前的已插入;unordered 尽力全插 |
| 重复主键 | Duplicate entry 报错 | 同名 Unique index violation:_id 重复同样报 DuplicateKeyException |
// 单条:拿回生成的 _id
InsertOneResult r = users.insertOne(new Document("username", "dave").append("age", 28));
ObjectId newId = (ObjectId) r.getInsertedId().asObjectId().getValue();
// 自定义 _id(合法:任何唯一、可哈希的值都行,数字/字符串均可,但分布式场景推荐 ObjectId 或雪花ID字符串)
users.insertOne(new Document("_id", 1001).append("username", "admin"));
// 批量 + 无序(对照「批量导入尽量多成功」场景)
users.insertMany(moreDocs, new InsertManyOptions().ordered(false));// mongosh 对照
db.t_user.insertOne({ username: "dave", age: 28 }); // 返回 { acknowledged: true, insertedId: ObjectId(...) }
db.t_user.insertOne({ _id: 1001, username: "admin" });
db.t_user.insertMany(moreDocs, { ordered: false });ObjectId 是什么
12 字节:4 字节时间戳 + 5 字节随机值 + 3 字节自增计数器。自带生成时间、无需协调、分布式友好——这解释了 MongoDB 为什么不需要 MySQL 式的 AUTO_INCREMENT。前 4 字节可反解创建时间(new ObjectId(id).getTimestamp()),排查「这条数据什么时候进来的」很好用。
2.3 查询条件:WHERE → Filters 逐条对照
这是日常开发最高频的部分,先上对照表再讲细节:
| MySQL WHERE | Java Driver (Filters) | mongosh |
|---|---|---|
age = 25 | Filters.eq("age", 25) | {age: 25} |
age <> 25 | Filters.ne("age", 25) | {age: {$ne: 25}} |
age >= 18 AND age < 30 | Filters.gte("age",18) + Filters.lt("age",30)(组合见下) | {$gte/$lt} |
city IN ('武汉','北京') | Filters.in("city", List.of("武汉","北京")) | {$in: [...]} |
city NOT IN (...) | Filters.nin(...) | {$nin: [...]} |
username LIKE 'to%' | Filters.regex("username", "^to") | {username: /^to/} |
status IS NULL(或字段缺失) | Filters.exists("status", false). 或 Filters.eq("status", null) | {status: {$exists: false}} |
A AND B | Filters.and(f1, f2) 或直接两个条件链式组合 | {$and: [...]}(多数可省略) |
A OR B | Filters.or(f1, f2) | {$or: [...]} |
| 布尔列 | Filters.eq("deleted", false) | {deleted: false} |
| 数组包含 'java' | Filters.eq("tags", "java")(对数组字段等值即「包含」) | {tags: "java"} |
| 数组全含 a,b | Filters.all("tags", List.of("a","b")) | {$all: [...]} |
| 数组长度=3 | Filters.size("tags", 3) | {$size: 3} |
| 按下标匹配 | Filters.eq("tags.0", "java") | {"tags.0": "java"} |
组合查询完整示例:
// mongosh 对照:同一个组合条件,只是把 Filters 换成字面量对象
db.t_user.find({
status: 1,
$or: [{ city: "武汉" }, { city: "北京" }],
age: { $gte: 18 }
}).sort({ age: -1 })// 对照 SQL: WHERE status=1 AND (city='武汉' OR city='北京') AND age>=18 ORDER BY age DESC
Bson filter = Filters.and(
Filters.eq("status", 1),
Filters.or(Filters.eq("city", "武汉"), Filters.eq("city", "北京")),
Filters.gte("age", 18)
);
for (Document u : users.find(filter).sort(Sorts.descending("age"))) {
System.out.println(u.getString("username") + " / " + u.get("age"));
}类型是严格匹配的!MySQL 会做的隐式转换这里不做
Filters.eq("age", 25) 查不到存成字符串 "25" 的文档;eq("id", 1) 也查不到 _id 为 NumberLong(1) 或字符串 "1" 的文档(Int32/Int64/Double 在 MongoDB 比较时按数值处理,但数字与字符串之间绝不互转)。MySQL 里 WHERE varchar_col = 123 能匹配 '123' 的隐式转换习惯,在这里只会让你查不到数据还以为是缓存问题。
2.4 投影与排序分页:SELECT 列 / ORDER BY / LIMIT
2.4.1 Projections 对照 SELECT 列
// mongosh 对照(find 的第二个参数就是投影文档)
db.t_user.find({}, { username: 1, age: 1 }) // include
db.t_user.find({}, { content: 0 }) // exclude
db.t_user.find({}, { _id: 0, username: 1, tags: { $slice: 2 } }) // 排除 _id + 数组截取
// 数组内条件取一个元素:投影里的 $elemMatch
db.t_user.find({}, { orders: { $elemMatch: { status: 1 } } })// 对照 SQL: SELECT username, age FROM t_user (默认 _id 总会返回,除非显式排除)
Bson proj = Projections.include("username", "age");
users.find().projection(proj); // mongosh: db.t_user.find({}, {username:1, age:1})
// 只排除某大字段:对照 SELECT 除了 content 都要
Projections.exclude("content");
// 计算列、改名、数组截取等进阶:
Projections.fields(
Projections.excludeId(),
Projections.rename("username", "name"),
Projections.slice("tags", 2), // 数组只回前2个
Projections.elemMatch("orders", Filters.eq("orders.status", 1)) // 数组内条件过滤(对照 JSON 数组里"挑一个元素")
);2.4.2 排序 + 分页:对照 ORDER BY ... LIMIT n OFFSET m
// mongosh 对照
db.t_user.find().sort({ age: -1, _id: 1 }).skip(20).limit(10)// SQL: SELECT * FROM t_user ORDER BY age DESC, id ASC LIMIT 10 OFFSET 20
users.find()
.sort(Sorts.orderBy(Sorts.descending("age"), Sorts.ascending("_id")))
.skip(20)
.limit(10)
.into(new ArrayList<>());分页三件套心智与 MySQL 完全一致;但性能教训也完全一致:skip(1000000) 依然要扫过前一百万条再丢弃,深分页同样慢。对策也同款——书签法:Filters.gt("_id", lastId).limit(10),用游标代替 OFFSET(第 4 章索引章节还会强调)。
2.4.3 count 与 exists
// mongosh 对照
db.t_user.countDocuments({ status: 1 })
db.t_user.estimatedDocumentCount()long total = users.countDocuments(Filters.eq("status", 1)); // 对照 SELECT COUNT(*) WHERE status=1
// 估算级:集合大、只要大概数时用(走元数据,快但不保证精确)
long estimate = db.getCollection("t_user").estimatedDocumentCount();2.5 更新:UPDATE → updateOne/updateMany + 操作符
MySQL 的 UPDATE 只能「给列赋新值」;MongoDB 更新必须通过操作符表达(没有操作符的裸文档 = 整档替换,是经典事故源):
// mongosh 对照:更新文档里的 $ 操作符与 Java 手写的 Document 完全同形
db.t_user.updateOne({ username: "tom" }, { $set: { age: 26, city: "深圳" } });
db.t_user.updateOne({ username: "tom" }, { $inc: { age: 1 } });
db.t_user.updateOne({ username: "tom" }, { $push: { tags: "juc" } });
db.t_user.updateOne({ username: "tom" }, { $addToSet: { tags: "jvm" } });
db.t_user.updateOne({ username: "tom" }, { $pull: { tags: "mysql" } });
db.t_user.updateOne({ username: "tom" }, { $unset: { city: "" } });
db.t_user.replaceOne({ username: "bo" }, { _id: boId, username: "bo" });
db.t_user.updateMany({ status: 0 }, { $set: { status: 2 } });
db.t_user.updateOne(
{ username: "eve" },
{ $setOnInsert: { age: 18 }, $set: { status: 1 } },
{ upsert: true }
);// 对照 SQL: UPDATE t_user SET age=26, city='深圳' WHERE username='tom'
users.updateOne(
Filters.eq("username", "tom"),
new Document("$set", new Document("age", 26).append("city", "深圳"))
);
// 对照 UPDATE ... SET stock = stock - 1 WHERE ...(原子扣减,MySQL 同款防超卖语义)
users.updateOne(Filters.eq("username", "tom"), new Document("$inc", new Document("age", 1)));
// 数组操作——MySQL JSON 列做不到这么顺手的部分:
users.updateOne(Filters.eq("username", "tom"), new Document("$push", new Document("tags", "juc"))); // 追加元素
users.updateOne(Filters.eq("username", "tom"), new Document("$addToSet", new Document("tags", "jvm"))); // 去重追加
users.updateOne(Filters.eq("username", "tom"), new Document("$pull", new Document("tags", "mysql"))); // 按值删除元素
// 删除字段(对照 SET col = NULL 但更彻底:字段真的没了)
users.updateOne(Filters.eq("username", "tom"), new Document("$unset", new Document("city", "")));
// 全档替换(危险:漏字段=丢数据,生产慎用;等价 DELETE+INSERT)
users.replaceOne(Filters.eq("username", "bo"), new Document("_id", boId).append("username", "bo"));
// 批量更新(对照 UPDATE 无 WHERE 主键时命中多行)
users.updateMany(Filters.eq("status", 0), new Document("$set", new Document("status", 2)));
// upsert:有则更、无则插 —— 对照 INSERT ... ON DUPLICATE KEY UPDATE
users.updateOne(
Filters.eq("username", "eve"),
new Document("$setOnInsert", new Document("age", 18)).append("$set", new Document("status", 1)),
new UpdateOptions().upsert(true)
);返回对象 UpdateResult 提供 getMatchedCount / getModifiedCount / getUpsertedId——对应 MySQL 里「匹配行数 vs 实际变更行数」的经典区分(值相同则 matched=1、modified=0,两边一致)。
事故预警:裸文档更新
users.replaceOne(eq("username","tom"), new Document("age", 26)) 或 updateOne(filter, new Document("age",26))(不带操作符)会把整个文档替换得只剩 age。写更新操作时,第一个 $ 键没进去,就是数据删除的开始。Code Review 时值得单独盯这条。
2.6 删除:DELETE / TRUNCATE / DROP 对照
// mongosh 对照
db.t_user.deleteOne({ username: "eve" });
db.t_user.deleteMany({ age: { $lt: 18 } });
db.t_user.deleteMany({}); // 清空(TRUNCATE 语义)
db.t_user.drop(); // 删集合(DROP TABLE 语义)users.deleteOne(Filters.eq("username", "eve")); // 对照 DELETE ... WHERE username='eve' LIMIT 1
users.deleteMany(Filters.lt("age", 18)); // 对照 DELETE ... WHERE age<18
// 清空集合数据但保留索引与集合定义(对照 TRUNCATE TABLE)
users.deleteMany(new Document());
// 删整个集合(对照 DROP TABLE;连索引一起没了)
users.drop();注意两点:
- MongoDB 的 DELETE 默认逐条写 oplog、不锁全表,删大量数据期间集合仍可读可写(对照 MySQL 大事务 DELETE 的锁与 binlog 压力,体验更好,但空间不会立即归还磁盘,需要 compact 或等回收)。
deleteOne命中多条时删哪条不保证顺序(除非配合排序再 limit——精确做法:先 find 排序取_id再按_id删)。
2.7 批量操作 bulkWrite:一次请求混合增删改
对照 JDBC 的 addBatch()/executeBatch():把多个操作攒成一次网络往返提交。注意 MongoDB 的批量是「混合操作」而非「同操作多参数」。
// mongosh
db.t_user.bulkWrite([
{ insertOne: { document: { username: "frank", age: 27, status: 1 } } },
{ updateOne: { filter: { username: "amy" }, update: { $set: { city: "杭州" } } } },
{ deleteOne: { filter: { username: "dave" } } }
], { ordered: true })// Java Driver:每个动作包成 XxxModel,一次性提交
users.bulkWrite(List.of(
new InsertOneModel<>(new Document("username", "frank").append("age", 27).append("status", 1)),
new UpdateOneModel<>(Filters.eq("username", "amy"), new Document("$set", new Document("city", "杭州"))),
new DeleteOneModel<>(Filters.eq("username", "dave"))
), new BulkWriteOptions().ordered(true));
ordered(true)(默认)按顺序执行、遇错即停;ordered(false)尽可能多执行——与insertMany的语义一致。生产上做「初始化一批数据」这类场景,bulkWrite 比逐条调用快一个量级(省往返)。
2.8 POJO 映射初探:别再手搬 Document
裸 Document 取值 doc.get("age") 没类型安全,真实项目要映射成实体。两条路:
- 第 7 章主角 Spring Data MongoDB:注解
@Document/@Id全自动,推荐; - 纯 Driver 项目的轻量方案:POJO Codec(社区编解码器,注解极少、零 Spring 依赖):
public class User {
@Id // org.bson.codecs.pojo.annotations.BsonId:映射到 _id
private ObjectId id;
private String username;
private Integer age;
private List<String> tags;
// 无参构造 + getter/setter 必需(POJO Codec 基于反射实例化)
}
// 给集合挂上 POJO 编解码 Registry(PojoCodecProvider 内置于驱动 bson 包,无额外依赖)
CodecRegistry pojoRegistry = CodecRegistries.fromRegistries(
db.getCodecRegistry(),
CodecRegistries.fromProviders(PojoCodecProvider.builder().automatic(true).build()));
MongoCollection<User> userColl = db.getCollection("t_user", User.class)
.withCodecRegistry(pojoRegistry);
User u = userColl.find(Filters.eq("username", "tom")).first(); // 直接拿到强类型对象
userColl.insertOne(newUser); // 实体直接入库POJO Codec 能解决 80% 的映射需求;但它对复杂类型、懒加载、与 Spring 生态的整合都不如专业框架完善——企业项目直接用第 7 章的 Spring Data MongoDB,注解更丰富、还自动处理类型转换。
2.9 Java 程序员十大注意点(本章精华)
| # | 注意点 | 对应 MySQL 习惯 |
|---|---|---|
| 1 | MongoClient 全局单例、线程安全 | 别按请求 new 连接 |
| 2 | 数值/字符串类型严格匹配,不隐式转换 | WHERE varchar=123 那套没了 |
| 3 | 更新必须带 $ 操作符,裸文档=替换 | 没有「UPDATE t SET」的语法糖 |
| 4 | 对数组字段 eq 即「包含」 | JSON 数组查询的直觉要重建 |
| 5 | 字段缺失 ≠ null?$exists 才精确 | NULL 一值两用 vs 「没这个键」 |
| 6 | skip 深分页照样慢,用书签法 | 与 MySQL LIMIT 大偏移同病 |
| 7 | _id 默认 ObjectId,自带时间可反解 | 自增 id 的「可比时间」特性丢了 |
| 8 | 库/集合首次写入自动创建 | CREATE DATABASE/TABLE 仪式感没了(生产要靠规范约束!) |
| 9 | 批量插入默认 ordered,遇错即停 | 与 JDBC batch 行为不同,需显式设 unordered |
| 10 | 删除大集合不立即释放磁盘 | InnoDB DELETE 空间回收心智要更新 |
2.10 本章小结 + 练习
- 三层入口 + 一个全局 client;
find(filter, projection).sort().skip().limit()就是「SELECT…WHERE…ORDER BY…LIMIT」。 - Filters 对照表覆盖 95% 的 WHERE 翻译需求;数组与「字段缺失」查询是 MongoDB 独有的两块新 territory。
- 更新操作符
$set/$inc/$push/$pull/$unset+ upsert,等价并超出 MySQL 的 UPDATE/ON DUPLICATE 能力。 - 删除的
deleteOne/deleteMany/drop三件套与「空间不即还」的运维意识;混合批量用bulkWrite省往返。 - 本章所有 mongosh/Java 成对示例其实是同一套查询对象语法的两个外壳——以后遇到任何 shell 写法,都可以直接「翻外壳」成 Java,反之亦然。
✍️ 课后练习
- 把下面这条 SQL 翻译成 Java Driver 调用(含投影、排序、分页):
SELECT username, age FROM t_user WHERE status = 1 AND age BETWEEN 20 AND 30 AND tags LIKE '%java%' ORDER BY age DESC LIMIT 10 OFFSET 0; - 用
$push+$addToSet给文档 tags 数组分别追加元素并观察重复行为;再用$pull删掉其中一个。 - 制造一次「裸文档替换事故」:
replaceOne只传一个 age 字段,find 验证数据丢了什么,再想办法补回来(练习 upsert 与 $set 的正确姿势)。 - (思考题)为什么
Filters.eq("_id", "665f1a2b...")(字符串)查不到数据?正确写法是什么?这体现了 MongoDB 的什么类型规则?
📖 参考答案
第 1 题:SQL → Java Driver
第 1 步,准备输入数据(t_user 5 条,注意 tags 是数组字段——MySQL 的 LIKE '%java%' 在这里对应「数组包含」语义,不是正则):
// mongosh 播种
db.t_user.insertMany([
{ username: "amy", age: 25, status: 1, tags: ["java", "mysql"] },
{ username: "bob", age: 35, status: 1, tags: ["java"] },
{ username: "coco", age: 22, status: 0, tags: ["java", "redis"] }, // status=0 被过滤
{ username: "dave", age: 19, status: 1, tags: ["go"] }, // age 不在区间
{ username: "eve", age: 28, status: 1, tags: ["python", "java"] }
])第 2 步,逐句翻译 WHERE:status=1 → eq、BETWEEN 20 AND 30 → gte+lte、LIKE→数组直接 eq(第 2 章注意点 #4);投影/排序/分页:include("username","age")、descending("age")、skip(0).limit(10)。
第 3 步,完整 Java 调用:
FindIterable<Document> rs = users.find(new Document("$and", List.of(
Filters.eq("status", 1),
Filters.gte("age", 20),
Filters.lte("age", 30),
Filters.eq("tags", "java"))) // 数组包含,等价 LIKE '%java%' 的意图
.projection(Projections.include("username", "age"))
.sort(Sorts.descending("age"))
.skip(0).limit(10);
rs.forEach((Block<? super Document>) d -> System.out.println(d.toJson()));第 4 步,mongosh 等价验证 + 预期输出(amy 25 在前、eve 28 在后?——age 降序,eve28 > amy25,所以 eve 第一行):
db.t_user.find({ $and: [{status:1},{age:{$gte:20}},{age:{$lte:30}},{tags:"java"}] },
{ username: 1, age: 1, _id: 0 })
.sort({ age: -1 }).skip(0).limit(10)
// { "username": "eve", "age": 28 } ← 28 在 25 前面,降序
// { "username": "amy", "age": 25 }若 tags 真是字符串列(不是数组),才需要
Filters.regex("tags", "java"),且无锚定的正则不走索引(第 4 章)。
第 2 题:$push / $addToSet / $pull 重复行为
第 1 步,播种一个干净的 tags:
db.t_user.updateOne({ username: "amy" }, { $set: { tags: ["java"] } })第 2 步,观察两种追加的差异(重复值!):
db.t_user.updateOne({ username: "amy" }, { $push: { tags: "java" } })
db.t_user.findOne({ username: "amy" }, { tags: 1 })
// { tags: [ "java", "java" ] } ← $push 不去重,重复照收
db.t_user.updateOne({ username: "amy" }, { $addToSet: { tags: "java" } })
db.t_user.findOne({ username: "amy" }, { tags: 1 })
// { tags: [ "java", "java" ] } ← 已存在,$addToSet 静默不添(matchedCount=1, modifiedCount=0)
db.t_user.updateOne({ username: "amy" }, { $addToSet: { tags: "mysql" } })
// { tags: [ "java", "java", "mysql" ] } ← 不存在才添第 3 步,$pull 删掉一个(注意:pull 按值删,同值全部移除,不是删一个):
db.t_user.updateOne({ username: "amy" }, { $pull: { tags: "java" } })
// { tags: [ "mysql" ] } ← 两个 "java" 一次全删!想「删一个」要改用 $pull FILTER 或重写数组Java 侧同构:updateOne(Filters.eq("username","amy"), new Document("$addToSet", new Document("tags","mysql")))。
第 3 题:裸文档替换事故与补救
第 1 步,备份认知制造事故(拿 coco 开刀,她有 4 个字段):
db.t_user.findOne({ username: "coco" })
// { _id: ..., username: "coco", age: 22, status: 0, tags: ["java","redis"] }
db.t_user.replaceOne({ username: "coco" }, new Document("age", 99)) // ⚠️ 裸文档 = 整档替换第 2 步,find 验证丢了什么:
db.t_user.findOne({ username: "coco" })
// { _id: ..., age: 99 } ← username/status/tags 全没了!只剩 _id(不可变)+ 新文档的 age第 3 步,补救 + 正确姿势:
// 补救:用 $set 把缺的键填回去(事故现场能恢复的前提是你记得原值/有备份)
db.t_user.updateOne({ username: null, age: 99 },
{ $set: { username: "coco", status: 0, tags: ["java", "redis"] }, $unset: { age: "" } })
// 注意定位条件也变了(username 被替换没了),这就是事故的代价
// 正确姿势:局部更新永远用 $set;确实要整档替换才用 replaceOne,且带上完整文档附加练习:replaceOne(filter, doc, new ReplaceOptions().upsert(true)) 体验「命中替换、未命中插入」——对照 INSERT ... ON DUPLICATE KEY UPDATE。
第 4 题(思考题)参考思路
t_user 里 _id 存的是 ObjectId 类型,而 "665f1a2b..." 是 string——MongoDB 的等值比较类型不同即不匹配,没有 MySQL 那种隐式转换(第 2 章注意点 #2)。正确写法:Filters.eq("_id", new ObjectId("665f1a2b..."))(mongosh 里是 ObjectId("665f1a2b..."))。这条规则推广到一切类型:age: 25(int32)查不到存成 25.0(double)或 "25"(string)的文档——这也是为什么 3.5 的 $jsonSchema 和 7.2 的 DECIMAL128 都要强调类型声明。
下一章进入文档数据库的灵魂:数据建模——内嵌还是引用?这是 MySQL 程序员最需要「重装思维」的一章。
