MongoDB

Finch 使用 mongo_dart 包来支持 MongoDB,并通过一小套约定——FinchDBConfig、DBCollection 以及 DQ 查询构建器——对其进行了封装,因此你的控制器永远不需要手动拼接连接字符串,也不必重复编写样板化的 CRUD 代码。

本指南涵盖以下内容:

  • 连接并配置连接池
  • 使用 DBCollection 编写集合类
  • 每个集合都自带的内置辅助方法
  • 使用 DQ 构建查询和聚合管道
  • 将集合接入模型和控制器
  • 检查连接状态(例如在定时任务中)

1. 配置

MongoDB 通过 FinchDBConfig 进行配置,并传递给 FinchConfigs 中的 dbConfig:

FinchConfigs configs = FinchConfigs(
  dbConfig: FinchDBConfig(
    enable: true,
    host: env.get('MONGODB_CONNECTION', 'localhost'),
    port: env.get('MONGODB_PORT', '27017'),
    user: env.get('MONGODB_USER', 'root'),
    pass: env.get('MONGODB_PASSWORD', 'password'),
    dbName: env.get('MONGODB_NAME', 'my_app'),
    auth: env.get('MONGODB_AUTH', 'admin'), // 认证源数据库
    maxConnections: 10, // 连接池的大小(Db.pool)
  ),
);
字段 类型 描述
enable bool 当为 false 时,app.mongoDb 永远不会建立连接——对于只使用 MySQL/SQLite、完全不需要 Mongo 的应用很有用
host、port、user、pass、dbName String 标准连接凭据。注意 port 是 String 类型,而不是 int
auth String MongoDB 的 authSource 数据库(通常为 admin)
maxConnections int Db.pool 打开的连接池连接数量(默认值为 10)

Finch 会为你构建连接字符串:

mongodb://user:pass@host:port/dbName/?authSource=auth

如果你没有显式传入某个字段,FinchDBConfig 会回退到自行从环境变量中读取(MONGO_CONNECTION、MONGO_PORT、MONGO_INITDB_DATABASE、MONGO_INITDB_ROOT_USERNAME、MONGO_INITDB_ROOT_PASSWORD、MONGO_INITDB_ROOT_AUTH)——但更清晰的做法是像上面那样自行传入 env.get(...) 调用,使变量名与你 .env 文件中的其余部分保持一致。完整的应用级环境变量列表请参阅 Configuration。

连接池

Finch 从不会只打开单个 MongoDB 套接字。启动时,DBManager 会调用:

Db.pool(List.filled(config.maxConnections, config.link))

这会创建一个包含 maxConnections 个独立连接的连接池,因此并发请求会由不同的套接字处理,而不必排队等待同一个连接。对于高流量应用,可以调高 maxConnections;对大多数项目来说,默认值 10 已经足够。

2. 访问数据库

var db = app.mongoDb; // 返回 mongo_dart 的 Db 实例(即连接池)

在运行启动逻辑或定时任务之前,先检查连接状态:

if (app.mongoDb.isConnected) {
  // 可以安全地执行查询
}

这与 Finch 自身的定时任务所使用的模式相同——完整的定时任务 API 请参阅 Commands。

3. DBCollection——集合基类

DBCollection(来自 package:finch/finch_model.dart)是一个抽象类,你需要为应用中的每一个 MongoDB 集合继承它。它为你提供了原始的 mongo_dart 集合,外加一组现成的辅助方法;并且如果该集合尚不存在,会在首次实例化时自动创建它。

import 'package:finch/finch_model.dart';
import '../app.dart';
import '../models/example_model.dart';

class ExampleCollections extends DBCollection {
  ExampleCollections() : super(db: app.mongoDb, name: 'example');

  Future<ExampleModel> insertExample(ExampleModel model) async {
    var res = await collection.insert(model.toJson());
    return ExampleModel.fromJson(res);
  }

  Future<List<ExampleModel>> getAllExample({int? start, int? count}) async {
    start = (start != null && start > 0) ? start : null;
    var rows = await collection
        .modernFind(limit: count, skip: start, sort: DQ.order('_id'))
        .toList();
    return ExampleModel.fromListJson(rows);
  }
}
  • collection 是底层的 mongo_dart DbCollection(即 db.collection(name)),用于处理下方辅助方法未覆盖的任何操作。
  • name 和 db 是你传给 super(...) 的构造函数参数。

内置辅助方法

DBCollection 的每个子类都会自动获得以下方法,无需编写任何代码:

方法 签名 描述
existId Future<bool> existId(String idField) 如果存在具有该 _id 的文档,则返回 true。对于无效的 ObjectId 字符串会返回 false,而不是抛出异常
exist Future<bool> exist(String field, Object value) 如果任意文档满足 field == value,则返回 true
getCount Future<int> getCount({String? field, Object? value, Map<String,Object?>? filter}) 统计文档数量,可选地通过单个字段/值组合或原始过滤器 map 进行过滤
isEmpty / isNotEmpty Future<bool> get getCount() == 0 的快捷方式
delete Future<bool> delete(String id) 按 _id 删除一个文档
deleteAll Future<bool> deleteAll() 删除集合中的所有文档——请谨慎使用
copy Future<void> copy(String id) 复制一个文档(会去掉 _id,以便 Mongo 分配一个新的)
updateField Future<void> updateField(String id, String field, Object? value) 仅当该 id 存在时,为一个文档设置单个字段
updateFields Future<void> updateFields(String id, Map<String, dynamic> fields) 将多个字段合并写入一个文档
updateAllForField Future<void> updateAllForField({required String field, required Object? value, required Map<String,Object?>? filter}) 对所有匹配 filter 的文档批量更新某个字段

示例——直接在控制器中使用这些内置方法,无需编写任何额外的查询代码:

var col = ExampleCollections();

if (await col.exist('slug', 'my-slug')) {
  return rq.renderError(409, message: 'Slug already exists');
}

var total = await col.getCount();
var isFirstRun = await col.isEmpty;

await col.updateField(id, 'title', 'New title');
await col.delete(id);

4. 使用 modernFind 查询

modernFind 是查询集合的推荐方式——它可以在一次调用中同时接受 filter、sort、limit 和 skip:

// 所有文档,按最新排序
var rows = await collection
    .modernFind(sort: DQ.order('_id', true))
    .toList();

// 过滤并分页
var rows = await collection
    .modernFind(
      filter: where.eq('slug', 'my-slug'),
      limit: 10,
      skip: 0,
    )
    .toList();

where 是 mongo_dart 自带的选择器构建器(where.eq、where.id 等),可以与 DQ 配合使用。

5. DQ 查询构建器

DQ 是一个静态辅助类(从 finch_model.dart 中重新导出),用于生成普通的 Map<String, Object?> 形式的 MongoDB 查询/聚合片段——它存在的唯一目的,就是让查询比手写带 $ 前缀键的 map 更具可读性。

比较与逻辑运算符

DQ.eq('value')                       // 'value' — 直接相等
DQ.gt(18)                            // { '$gt': 18 }
DQ.gte(18)                           // { '$gte': 18 }
DQ.lt(65)                            // { '$lt': 65 }
DQ.lte(65)                           // { '$lte': 65 }
DQ.hasIn(['a', 'b'])                 // { '$in': ['a', 'b'] }
DQ.hasNin(['a', 'b'])                // { '$nin': ['a', 'b'] }
DQ.and([cond1, cond2])               // { '$and': [cond1, cond2] }
DQ.or([cond1, cond2])                // { '$or': [cond1, cond2] }
DQ.field('age', DQ.gte(18))          // { 'age': { '$gte': 18 } }

文本匹配

DQ.like('john')                      // { '$regex': 'john', '$options': 'i' } — 不区分大小写的包含匹配
DQ.uncase('john')                    // { '$regex': '^john$', '$options': 'i' } — 不区分大小写的精确匹配

两者都会自动转义输入中的正则表达式特殊字符,因此可以安全地直接传入用户提供的搜索词。

ID 辅助方法

DQ.id('507f191e810c19729de860ea')    // { '_id': ObjectId(...) } — 从 String 构建
DQ.oid(objectId)                     // { '_id': ObjectId(...) } — 从已有的 ObjectId 构建

综合起来,一个典型的过滤搜索如下:

var rows = await collection
    .modernFind(
      filter: DQ.and([
        DQ.field('status', 'active'),
        DQ.field('name', DQ.like(searchTerm)),
      ]),
      sort: DQ.order('createdAt'),
      limit: 20,
    )
    .toList();

排序、分页与计数

DQ.order('createdAt')                // { 'createdAt': -1 } — 默认降序
DQ.order('createdAt', false)         // { 'createdAt': 1 }  — 升序
DQ.sortField('createdAt', true)      // { 'createdAt': -1 } — 结果相同,适用于聚合阶段
DQ.limit(20)                         // { '$limit': 20 }
DQ.skip(40)                          // { '$skip': 40 }
DQ.count('total')                    // { '$count': 'total' }

聚合管道

对于简单过滤之外的需求——例如连接、分组、计算字段——可以使用 DQ.pipeline(...) 构建聚合管道,并通过 mongo_dart 提供的 collection.aggregateToStream 或 collection.modernAggregate 来运行它:

var pipeline = DQ.pipeline([
  DQ.match([
    DQ.field('status', 'active'),
  ]),
  DQ.lookup(
    from: 'users',
    localField: 'userId',
    foreignField: '_id',
    as: 'user',
  ),
  DQ.unwind(path: 'user', preserveNullAndEmptyArrays: true),
  DQ.group({
    '_id': DQ.$field('user.country'),
    'total': DQ.sum('amount'),
  }),
  DQ.sort({'total': -1}),
  DQ.limit(10),
]);

var results = await collection.aggregateToStream(pipeline).toList();

可用的聚合阶段构建方法:

方法 阶段 用途
DQ.match(List<Map>) $match 过滤进入管道的文档
DQ.group(Map) $group 对文档分组并计算聚合值
DQ.lookup(from:, localField:, foreignField:, as:) $lookup 与另一个集合进行左外连接
DQ.unwind(path:, as:, preserveNullAndEmptyArrays:) $unwind 将数组字段展开为多个独立文档
DQ.project(Map) $project 包含/排除/计算输出字段
DQ.sort(Map) / DQ.sortList(List<Map>) / DQ.sortOne(field, desc) $sort 对管道结果排序
DQ.limit(int) / DQ.skip(int) $limit / $skip 管道内的分页
DQ.count(field) $count 统计该管道阶段的文档数量
DQ.sum(field) / DQ.sumQuery(query) $sum 在 $group 内对字段(或计算表达式)求和
DQ.cond(ifCond:, thenCond:, elseCond:) $cond 条件表达式
DQ.dateToString(field:, format:, timezone:) $dateToString 将日期字段格式化为字符串
DQ.toDate(field) $toDate 将字段转换为日期类型
DQ.$field(name) — 为字段名添加 $ 前缀(例如 '$name'),以便在表达式中使用

6. 模型

Finch 的模型只是带有 toJson/fromJson 的普通 Dart 类——不涉及任何代码生成,因此你可以完全控制文档的结构:

class ExampleModel {
  String? id;
  String title;
  String slug;

  ExampleModel({this.id, required this.title, required this.slug});

  Map<String, dynamic> toJson() => {
    if (id != null) '_id': id,
    'title': title,
    'slug': slug,
  };

  factory ExampleModel.fromJson(Map<String, dynamic> json) => ExampleModel(
    id: json['_id']?.toString(),
    title: json['title'] ?? '',
    slug: json['slug'] ?? '',
  );

  static List<ExampleModel> fromListJson(List<Map<String, dynamic>> list) =>
      list.map(ExampleModel.fromJson).toList();
}

7. 在控制器中使用集合

class HomeController extends Controller {
  Future<String> exampleDatabase() async {
    var col = ExampleCollections();

    if (rq.isPost) {
      var model = ExampleModel(
        title: rq.get<String>('title', def: ''),
        slug: rq.get<String>('slug', def: ''),
      );
      await col.insertExample(model);
      return rq.redirect('/example/database');
    }

    var items = await col.getAllExample(count: 20);
    rq.addParam('items', items.map((e) => e.toJson()).toList());
    return rq.renderView(path: 'example/database');
  }
}

8. 在定时任务中使用 MongoDB

由于 app.mongoDb 是一个共享的连接池,你可以在应用的任何地方安全地访问它,包括定时任务。请始终先用 isConnected 进行保护性检查:

app.registerCron(
  FinchCron(
    schedule: FinchCron.evryDay(2),
    onCron: (index, cron) async {
      if (app.mongoDb.isConnected) {
        await ExampleCollections().deleteAll();
      }
    },
  ).start(),
);

完整的定时任务调度 API 请参阅 Commands;同样适用于 MongoDB 的模式/数据迁移,请参阅 Database Migration。