读写分离CQRS(命令查询职责分离)
CQRS 命令查询职责分离模式及其在读写模型上的分工说明
[!info] related notes
读写分离CQRS(命令查询职责分离)
这是一个非常好的架构问题。你现在遇到的正是 CQRS (命令查询职责分离) 模式的入门场景。
所谓的“读写分离”,在你的这个阶段(单体/Monorepo 应用),主要指 逻辑上的读写分离,而不是物理数据库的主从读写分离。
核心概念:为什么 GoalStatistics 不能是聚合根?
-
写模型 (Write Model / Command Side):
- 代表:
Goal聚合根。 - 职责: 保证数据一致性。比如“完成目标时,进度不能超过 100%”。
- 特点: 必须加载完整的业务规则,通常是通过 ID 查单条。
- 代表:
-
读模型 (Read Model / Query Side):
- 代表:
GoalStatistics。 - 职责: 为了 UI 展示。比如“仪表盘”、“周报”、“趋势图”。
- 特点: 需要聚合查询 (Count, Sum, GroupBy),跨多个实体,不需要业务规则校验,只求查得快。
- 代表:
🚀 具体的落地实施方案
对于 goal-statistics,我建议采用 “Service层即时查询 + 每日快照” 的混合策略。
1. 代码放哪里? (目录结构)
统计逻辑通常涉及复杂的 SQL 或 Prisma 聚合查询,这属于 基础设施层 的能力,但服务于 应用层 的需求。
建议的目录结构如下:
Plaintext
libs/
├── contracts/
│ └── modules/goal/dtos/
│ └── goal-statistics.api-dto.ts <-- 定义返回给前端的数据结构
├── infrastructure/
│ └── persistence/
│ └── queries/ <-- 【新增】专门存放读模型的查询服务
│ └── goal-statistics.query.ts <-- 实现复杂的统计查询逻辑
└── application-server/ <-- 应用层
└── modules/goal/services/
└── goal-dashboard.service.ts <-- 协调者,调用 Query 服务
2. Contracts 定义 (DTO)
这是前后端的契约。
libs/contracts/src/modules/goal/dtos/goal-statistics.api-dto.ts
TypeScript
export interface GoalStatisticsDTO {
// 实时聚合数据
totalGoals: number;
activeGoals: number;
completedGoals: number;
completionRate: number; // 0-100
// 分组统计
byImportance: Record<string, number>; // { "HIGH": 5, "LOW": 2 }
byCategory: Record<string, number>;
// 趋势数据 (通常需要读快照表)
weeklyTrend: number[];
}
3. Infrastructure 层 (Prisma 实现查询)
这是读写分离的关键。注意,这里不经过 GoalRepository,也不返回 Goal 实体。它是直接对数据库进行“投影”。
libs/infrastructure/src/persistence/queries/goal-statistics.query.ts
import { PrismaClient } from '@prisma/client';
import { GoalStatus } from '@dailyuse/contracts/goal/enums';
import { GoalStatisticsDTO } from '@dailyuse/contracts/goal/dtos';
export class GoalStatisticsQuery {
constructor(private prisma: PrismaClient) {}
/**
* 获取实时仪表盘数据
* 直接使用 Prisma 的聚合功能 (groupBy, count)
*/
async getDashboardStats(identityId: string): Promise<GoalStatisticsDTO> {
// 1. 并行执行多个聚合查询
const [statusCounts, importanceCounts] = await Promise.all([
// 按状态分组计数
this.prisma.authIdentity.findUnique({ // 假设关联查或者直接查 goal 表
where: { id: identityId }
}).goals({
groupBy: ['status'],
_count: { status: true }
}),
// 按重要性分组计数
this.prisma.goal.groupBy({
by: ['importance'],
where: { identityId },
_count: { _all: true }
})
]);
// 2. 在内存中组装数据 (Data Mapping)
// Prisma 返回的是数组,转成 Map 对象
const statusMap = new Map(statusCounts.map(i => [i.status, i._count.status]));
const total = Array.from(statusMap.values()).reduce((a, b) => a + b, 0);
const completed = statusMap.get(GoalStatus.COMPLETED) || 0;
const active = (statusMap.get(GoalStatus.IN_PROGRESS) || 0) + (statusMap.get(GoalStatus.PENDING) || 0);
return {
totalGoals: total,
activeGoals: active,
completedGoals: completed,
completionRate: total === 0 ? 0 : Math.round((completed / total) * 100),
byImportance: Object.fromEntries(importanceCounts.map(i => [i.importance, i._count._all])),
// ... 其他字段
byCategory: {},
weeklyTrend: [] // 暂时留空,下面讲怎么做
};
}
}
4. 处理 “趋势” 数据 (Trend) - 引入快照
对于 weeklyTrend (过去7天每天完成多少),你不能实时算(太慢了,要回溯日志)。你需要每日快照。
Schema 设计 (schema.prisma):
// 这是一个纯粹的分析表,不需要对应的 Domain Entity
model GoalDailySnapshot {
id String @id @default(uuid())
identityId String
date DateTime // 2023-10-27
// 存当天的切片数据
activeCount Int
completedCount Int
avgProgress Int
@@unique([identityId, date])
@@map("goal_daily_snapshots")
}
如何生成数据 (Schedule 模块联动):
你需要一个 Cron Job(定时任务),每天凌晨 00:01 运行:
- 调用
GoalStatisticsQuery.getDashboardStats()拿到当下的数据。 - 将数据写入
GoalDailySnapshot表。
查询趋势:
在上面的 GoalStatisticsQuery 中增加一步:
const snapshots = await this.prisma.goalDailySnapshot.findMany({
where: {
identityId,
date: { gte: sevenDaysAgo }
},
orderBy: { date: 'asc' }
});
// 转换 snapshots 为 number[]
总结:读写分离架构图
-
Write Side (写路径):
- Goal Aggregate: 用户创建/更新目标。
- Repository: 保存到
goals表。 - Events: 可能会触发
GoalCompleted事件。
-
Read Side (读路径 - 你的 GoalStatistics):
- Query Service:
GoalStatisticsQuery类。 - Data Source:
- 实时数据 -> 直接聚合查询
goals表。 - 趋势数据 -> 查询
goal_daily_snapshots表。
- 实时数据 -> 直接聚合查询
- Return: 直接返回
GoalStatisticsDTO给前端,不经过 Domain Model。
- Query Service:
你的 Action List
- 在 Contracts 里定义
GoalStatisticsDTO。 - 在 Infrastructure 里创建
queries文件夹,写一个GoalStatisticsQuery类,专门写大 SQL 或 Prisma 聚合。 - 在 Prisma Schema 里加一个
GoalDailySnapshot表用于存历史趋势。 - 不要试图创建
GoalStatistics聚合根,它只是一个 Read-Only DTO。