读写分离CQRS(命令查询职责分离)

CQRS 命令查询职责分离模式及其在读写模型上的分工说明

#status / growing #type / concept

[!info] related notes

读写分离CQRS(命令查询职责分离)

这是一个非常好的架构问题。你现在遇到的正是 CQRS (命令查询职责分离) 模式的入门场景。

所谓的“读写分离”,在你的这个阶段(单体/Monorepo 应用),主要指 逻辑上的读写分离,而不是物理数据库的主从读写分离。

核心概念:为什么 GoalStatistics 不能是聚合根?

  1. 写模型 (Write Model / Command Side):

    • 代表: Goal 聚合根。
    • 职责: 保证数据一致性。比如“完成目标时,进度不能超过 100%”。
    • 特点: 必须加载完整的业务规则,通常是通过 ID 查单条。
  2. 读模型 (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 运行:

  1. 调用 GoalStatisticsQuery.getDashboardStats() 拿到当下的数据。
  2. 将数据写入 GoalDailySnapshot 表。

查询趋势:

在上面的 GoalStatisticsQuery 中增加一步:

const snapshots = await this.prisma.goalDailySnapshot.findMany({
  where: { 
    identityId,
    date: { gte: sevenDaysAgo }
  },
  orderBy: { date: 'asc' }
});
// 转换 snapshots 为 number[]

总结:读写分离架构图

  1. Write Side (写路径):

    • Goal Aggregate: 用户创建/更新目标。
    • Repository: 保存到 goals 表。
    • Events: 可能会触发 GoalCompleted 事件。
  2. Read Side (读路径 - 你的 GoalStatistics):

    • Query Service: GoalStatisticsQuery 类。
    • Data Source:
      • 实时数据 -> 直接聚合查询 goals 表。
      • 趋势数据 -> 查询 goal_daily_snapshots 表。
    • Return: 直接返回 GoalStatisticsDTO 给前端,不经过 Domain Model

你的 Action List

  1. Contracts 里定义 GoalStatisticsDTO
  2. Infrastructure 里创建 queries 文件夹,写一个 GoalStatisticsQuery 类,专门写大 SQL 或 Prisma 聚合。
  3. Prisma Schema 里加一个 GoalDailySnapshot 表用于存历史趋势。
  4. 不要试图创建 GoalStatistics 聚合根,它只是一个 Read-Only DTO
创建于 2026/1/31 更新于 2026/4/9