Vue 3 CQRS模式应用深度指南
概述
CQRS(Command Query Responsibility Segregation,命令查询责任分离)是一种架构模式,它将数据的写操作(命令)和读操作(查询)分离,使用不同的模型和处理管道。本集将深入探讨CQRS模式的核心概念、设计原则,以及如何在 Vue 3 项目中实现这一模式,包括命令处理、查询处理、事件驱动的更新机制以及与状态管理的结合。
一、CQRS核心概念
1. 什么是CQRS
CQRS 是由 Greg Young 提出的一种架构模式,它的核心思想是将系统的操作分为两类:
- 命令(Command):用于修改数据的操作,不返回结果(或只返回操作状态)
- 查询(Query):用于读取数据的操作,不修改数据,返回查询结果
2. 核心组成部分
- 命令(Command):表示要执行的操作,包含执行操作所需的数据
- 命令处理器(Command Handler):处理命令,执行相应的业务逻辑,修改数据
- 查询(Query):表示要执行的查询,包含查询条件
- 查询处理器(Query Handler):处理查询,从数据存储中获取数据并返回结果
- 事件(Event):表示系统中发生的重要事件,用于通知其他组件
- 事件处理器(Event Handler):处理事件,执行相应的业务逻辑
- 命令模型(Command Model):用于处理命令的数据模型
- 查询模型(Query Model):用于处理查询的数据模型
3. CQRS与传统架构的对比
| 传统架构 | CQRS架构 |
|---|---|
| 单一数据模型 | 分离的命令模型和查询模型 |
| 读写操作使用相同的接口 | 读写操作使用不同的接口 |
| 难以优化读写性能 | 可以独立优化读写性能 |
| 难以扩展 | 可以独立扩展读写组件 |
| 事务管理复杂 | 命令处理可以使用事件溯源,简化事务管理 |
4. CQRS架构模式
CQRS 可以与其他架构模式结合使用,如:
- 事件驱动架构:使用事件来同步命令模型和查询模型
- 事件溯源:将所有状态变更记录为事件,通过重放事件来重建系统状态
- DDD:与领域驱动设计结合,使用领域模型处理命令
- 微服务:在微服务架构中使用 CQRS,提高系统的可扩展性
二、Vue 3 项目中的 CQRS 设计
1. 项目目录结构
src/
├── core/ # 核心层
│ ├── application/ # 应用服务
│ │ ├── commands/ # 命令定义
│ │ │ ├── handlers/ # 命令处理器
│ │ │ └── models/ # 命令模型
│ │ ├── queries/ # 查询定义
│ │ │ ├── handlers/ # 查询处理器
│ │ │ └── models/ # 查询模型
│ │ └── events/ # 事件定义
│ ├── domain/ # 领域模型
│ │ ├── entities/ # 实体
│ │ ├── value-objects/ # 值对象
│ │ └── services/ # 领域服务
│ └── ports/ # 端口定义
│ ├── inbound/ # 输入端口
│ └── outbound/ # 输出端口
├── adapters/ # 适配器层
│ ├── primary/ # 主适配器
│ │ ├── ui/ # UI 适配器
│ │ │ ├── components/ # Vue 组件
│ │ │ ├── pages/ # 页面组件
│ │ │ └── stores/ # 状态管理
│ │ └── api/ # API 适配器
│ └── secondary/ # 从适配器
│ ├── persistence/ # 持久化适配器
│ └── http/ # HTTP 客户端适配器
├── shared/ # 共享代码
└── main.ts # 应用入口2. 命令和查询的定义
命令定义
// core/application/commands/models/create-product-command.ts
export class CreateProductCommand {
constructor(
public readonly name: string,
public readonly description: string,
public readonly price: number,
public readonly category: string,
public readonly stock: number
) {}
}
// core/application/commands/models/update-product-command.ts
export class UpdateProductCommand {
constructor(
public readonly id: string,
public readonly name?: string,
public readonly description?: string,
public readonly price?: number,
public readonly category?: string,
public readonly stock?: number
) {}
}
// core/application/commands/models/delete-product-command.ts
export class DeleteProductCommand {
constructor(
public readonly id: string
) {}
}查询定义
// core/application/queries/models/get-product-query.ts
export class GetProductQuery {
constructor(
public readonly id: string
) {}
}
// core/application/queries/models/get-products-query.ts
export class GetProductsQuery {
constructor(
public readonly page?: number,
public readonly pageSize?: number,
public readonly category?: string,
public readonly minPrice?: number,
public readonly maxPrice?: number
) {}
}三、命令处理实现
1. 命令处理器接口
// core/ports/inbound/command-handler.ts
export interface CommandHandler<TCommand, TResult = void> {
/**
* 处理命令
* @param command 命令对象
* @returns 处理结果
*/
handle(command: TCommand): Promise<TResult>;
}2. 命令处理器实现
// core/application/commands/handlers/create-product-command-handler.ts
import { CommandHandler } from '../../../ports/inbound/command-handler';
import { CreateProductCommand } from '../models/create-product-command';
import { ProductRepositoryPort } from '../../../ports/outbound/product-repository-port';
import { Product } from '../../../domain/entities/product';
import { Money } from '../../../domain/value-objects/money';
import { EventBusPort } from '../../../ports/outbound/event-bus-port';
import { ProductCreatedEvent } from '../../events/models/product-created-event';
export class CreateProductCommandHandler implements CommandHandler<CreateProductCommand, string> {
constructor(
private readonly productRepository: ProductRepositoryPort,
private readonly eventBus: EventBusPort
) {}
public async handle(command: CreateProductCommand): Promise<string> {
// 创建领域实体
const product = new Product(
`product-${Date.now()}`, // 实际项目中应使用 UUID
command.name,
command.description,
new Money(command.price, 'CNY'),
command.category,
command.stock
);
// 保存产品
await this.productRepository.save(product);
// 发布产品创建事件
const event = new ProductCreatedEvent(
product.getId(),
product.getName(),
product.getPrice().getAmount(),
product.getCategory(),
product.getStock()
);
await this.eventBus.publish(event);
// 返回产品 ID
return product.getId();
}
}四、查询处理实现
1. 查询处理器接口
// core/ports/inbound/query-handler.ts
export interface QueryHandler<TQuery, TResult> {
/**
* 处理查询
* @param query 查询对象
* @returns 查询结果
*/
handle(query: TQuery): Promise<TResult>;
}2. 查询处理器实现
// core/application/queries/handlers/get-products-query-handler.ts
import { QueryHandler } from '../../../ports/inbound/query-handler';
import { GetProductsQuery } from '../models/get-products-query';
import { ProductReadModel } from '../models/product-read-model';
import { ProductQueryRepositoryPort } from '../../../ports/outbound/product-query-repository-port';
export class GetProductsQueryHandler implements QueryHandler<GetProductsQuery, ProductReadModel[]> {
constructor(
private readonly productQueryRepository: ProductQueryRepositoryPort
) {}
public async handle(query: GetProductsQuery): Promise<ProductReadModel[]> {
// 构建查询条件
const filter: any = {};
if (query.category) {
filter.category = query.category;
}
if (query.minPrice !== undefined) {
filter.price = { $gte: query.minPrice };
}
if (query.maxPrice !== undefined) {
filter.price = { ...filter.price, $lte: query.maxPrice };
}
// 设置分页参数
const page = query.page || 1;
const pageSize = query.pageSize || 10;
const skip = (page - 1) * pageSize;
// 执行查询
return this.productQueryRepository.find(filter, skip, pageSize);
}
}3. 查询模型
// core/application/queries/models/product-read-model.ts
export class ProductReadModel {
constructor(
public readonly id: string,
public readonly name: string,
public readonly description: string,
public readonly price: number,
public readonly currency: string,
public readonly category: string,
public readonly stock: number,
public readonly isActive: boolean,
public readonly createdAt: Date,
public readonly updatedAt: Date
) {}
}五、事件驱动的更新机制
1. 事件定义
// core/application/events/models/product-created-event.ts
export class ProductCreatedEvent {
constructor(
public readonly productId: string,
public readonly name: string,
public readonly price: number,
public readonly category: string,
public readonly stock: number,
public readonly timestamp: Date = new Date()
) {}
}
// core/application/events/models/product-updated-event.ts
export class ProductUpdatedEvent {
constructor(
public readonly productId: string,
public readonly updates: Partial<{
name: string;
description: string;
price: number;
category: string;
stock: number;
}>,
public readonly timestamp: Date = new Date()
) {}
}
// core/application/events/models/product-deleted-event.ts
export class ProductDeletedEvent {
constructor(
public readonly productId: string,
public readonly timestamp: Date = new Date()
) {}
}2. 事件处理器实现
// core/application/events/handlers/product-created-event-handler.ts
import { EventHandler } from '../../../ports/inbound/event-handler';
import { ProductCreatedEvent } from '../models/product-created-event';
import { ProductQueryRepositoryPort } from '../../../ports/outbound/product-query-repository-port';
import { ProductReadModel } from '../../queries/models/product-read-model';
export class ProductCreatedEventHandler implements EventHandler<ProductCreatedEvent> {
constructor(
private readonly productQueryRepository: ProductQueryRepositoryPort
) {}
public async handle(event: ProductCreatedEvent): Promise<void> {
// 创建查询模型
const productReadModel = new ProductReadModel(
event.productId,
event.name,
'', // 描述在事件中未提供,实际项目中应包含
event.price,
'CNY',
event.category,
event.stock,
true,
event.timestamp,
event.timestamp
);
// 更新查询模型存储
await this.productQueryRepository.save(productReadModel);
}
}六、与 Pinia 状态管理结合
1. 创建 CQRS Store
// adapters/primary/ui/stores/cqrs-store.js
import { defineStore } from 'pinia';
import { CreateProductCommand } from '@/core/application/commands/models/create-product-command';
import { CreateProductCommandHandler } from '@/core/application/commands/handlers/create-product-command-handler';
import { GetProductsQuery } from '@/core/application/queries/models/get-products-query';
import { GetProductsQueryHandler } from '@/core/application/queries/handlers/get-products-query-handler';
import { ProductRepository } from '@/adapters/secondary/persistence/repositories/product-repository';
import { ProductQueryRepository } from '@/adapters/secondary/persistence/repositories/product-query-repository';
import { EventBus } from '@/utils/event-bus';
// 创建适配器实例
const productRepository = new ProductRepository();
const productQueryRepository = new ProductQueryRepository();
const eventBus = new EventBus();
// 创建命令和查询处理器
const createProductCommandHandler = new CreateProductCommandHandler(
productRepository,
eventBus
);
const getProductsQueryHandler = new GetProductsQueryHandler(
productQueryRepository
);
export const useCqrsStore = defineStore('cqrs', {
state: () => ({
products: [],
isLoading: false,
error: null
}),
actions: {
// 处理创建产品命令
async createProduct(productData) {
try {
this.isLoading = true;
this.error = null;
// 创建命令
const command = new CreateProductCommand(
productData.name,
productData.description,
productData.price,
productData.category,
productData.stock
);
// 执行命令
const productId = await createProductCommandHandler.handle(command);
// 重新加载产品列表
await this.loadProducts();
return productId;
} catch (error) {
this.error = error.message;
throw error;
} finally {
this.isLoading = false;
}
},
// 处理获取产品查询
async loadProducts(filters = {}) {
try {
this.isLoading = true;
this.error = null;
// 创建查询
const query = new GetProductsQuery(
filters.page,
filters.pageSize,
filters.category,
filters.minPrice,
filters.maxPrice
);
// 执行查询
this.products = await getProductsQueryHandler.handle(query);
} catch (error) {
this.error = error.message;
throw error;
} finally {
this.isLoading = false;
}
}
}
});2. 在 Vue 组件中使用 CQRS Store
<template>
<div class="product-management">
<h1>产品管理</h1>
<!-- 创建产品表单 -->
<div class="create-form">
<h2>创建产品</h2>
<form @submit.prevent="handleCreateProduct">
<div class="form-group">
<label for="name">产品名称</label>
<input v-model="productForm.name" id="name" type="text" required>
</div>
<div class="form-group">
<label for="description">产品描述</label>
<textarea v-model="productForm.description" id="description"></textarea>
</div>
<div class="form-group">
<label for="price">价格</label>
<input v-model.number="productForm.price" id="price" type="number" min="0" step="0.01" required>
</div>
<div class="form-group">
<label for="category">分类</label>
<input v-model="productForm.category" id="category" type="text" required>
</div>
<div class="form-group">
<label for="stock">库存</label>
<input v-model.number="productForm.stock" id="stock" type="number" min="0" required>
</div>
<button type="submit" :disabled="cqrsStore.isLoading">
{{ cqrsStore.isLoading ? '创建中...' : '创建产品' }}
</button>
</form>
</div>
<!-- 产品列表 -->
<div class="product-list">
<h2>产品列表</h2>
<div v-if="cqrsStore.isLoading" class="loading">
加载中...
</div>
<div v-else-if="cqrsStore.error" class="error">
{{ cqrsStore.error }}
</div>
<div v-else>
<ul>
<li v-for="product in cqrsStore.products" :key="product.id" class="product-item">
<div class="product-info">
<h3>{{ product.name }}</h3>
<p>{{ product.description }}</p>
<div class="product-meta">
<span class="price">¥{{ product.price }}</span>
<span class="category">{{ product.category }}</span>
<span class="stock" :class="product.stock > 0 ? 'in-stock' : 'out-of-stock'">
{{ product.stock > 0 ? `库存: ${product.stock}` : '缺货' }}
</span>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useCqrsStore } from '@/adapters/primary/ui/stores/cqrs-store';
const cqrsStore = useCqrsStore();
// 产品表单数据
const productForm = reactive({
name: '',
description: '',
price: 0,
category: '',
stock: 0
});
// 处理创建产品
const handleCreateProduct = async () => {
try {
await cqrsStore.createProduct(productForm);
// 重置表单
Object.assign(productForm, {
name: '',
description: '',
price: 0,
category: '',
stock: 0
});
} catch (error) {
console.error('创建产品失败:', error);
}
};
// 组件挂载时加载产品列表
onMounted(() => {
cqrsStore.loadProducts();
});
</script>
<style scoped>
.product-management {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #333;
margin-bottom: 30px;
}
.create-form {
background-color: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.create-form h2 {
margin-bottom: 20px;
color: #409eff;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
textarea {
resize: vertical;
min-height: 100px;
}
button {
padding: 10px 20px;
background-color: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:disabled {
background-color: #c6e2ff;
cursor: not-allowed;
}
.product-list h2 {
margin-bottom: 20px;
color: #67c23a;
}
.loading, .error {
padding: 20px;
text-align: center;
border-radius: 4px;
margin-bottom: 20px;
}
.loading {
background-color: #e3f2fd;
color: #1976d2;
}
.error {
background-color: #ffebee;
color: #d32f2f;
}
.product-item {
background-color: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
}
.product-info h3 {
margin-bottom: 10px;
color: #333;
}
.product-info p {
margin-bottom: 15px;
color: #666;
}
.product-meta {
display: flex;
gap: 15px;
font-size: 14px;
}
.price {
font-weight: 500;
color: #f56c6c;
}
.category {
color: #409eff;
}
.stock {
font-weight: 500;
}
.stock.in-stock {
color: #67c23a;
}
.stock.out-of-stock {
color: #f56c6c;
}
</style>七、API 适配器实现
1. 命令 API 实现
// adapters/primary/api/controllers/command-controller.ts
import { Router } from 'express';
import { CreateProductCommand } from '../../../core/application/commands/models/create-product-command';
import { CreateProductCommandHandler } from '../../../core/application/commands/handlers/create-product-command-handler';
import { ProductRepository } from '../../secondary/persistence/repositories/product-repository';
import { EventBus } from '@/utils/event-bus';
// 创建适配器实例
const productRepository = new ProductRepository();
const eventBus = new EventBus();
// 创建命令处理器
const createProductCommandHandler = new CreateProductCommandHandler(
productRepository,
eventBus
);
const router = Router();
// 创建产品
router.post('/products', async (req, res) => {
try {
const { name, description, price, category, stock } = req.body;
// 创建命令
const command = new CreateProductCommand(
name,
description,
price,
category,
stock
);
// 执行命令
const productId = await createProductCommandHandler.handle(command);
res.status(201).json({ id: productId });
} catch (error) {
res.status(400).json({ error: error instanceof Error ? error.message : '创建产品失败' });
}
});
export { router as commandRoutes };2. 查询 API 实现
// adapters/primary/api/controllers/query-controller.ts
import { Router } from 'express';
import { GetProductsQuery } from '../../../core/application/queries/models/get-products-query';
import { GetProductsQueryHandler } from '../../../core/application/queries/handlers/get-products-query-handler';
import { ProductQueryRepository } from '../../secondary/persistence/repositories/product-query-repository';
// 创建适配器实例
const productQueryRepository = new ProductQueryRepository();
// 创建查询处理器
const getProductsQueryHandler = new GetProductsQueryHandler(
productQueryRepository
);
const router = Router();
// 获取产品列表
router.get('/products', async (req, res) => {
try {
const { page, pageSize, category, minPrice, maxPrice } = req.query;
// 创建查询
const query = new GetProductsQuery(
page ? parseInt(page as string) : undefined,
pageSize ? parseInt(pageSize as string) : undefined,
category as string,
minPrice ? parseFloat(minPrice as string) : undefined,
maxPrice ? parseFloat(maxPrice as string) : undefined
);
// 执行查询
const products = await getProductsQueryHandler.handle(query);
res.json(products);
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : '获取产品列表失败' });
}
});
export { router as queryRoutes };八、CQRS 最佳实践
1. 命令设计原则
- 命令命名:使用动词开头,清晰表达意图,如
CreateProductCommand、UpdateUserCommand - 命令数据:只包含执行命令所需的必要数据
- 命令验证:在命令处理器中验证命令的有效性
- 命令幂等性:设计命令处理逻辑时,确保幂等性,避免重复执行导致的问题
- 命令响应:命令通常不返回数据,或只返回操作状态和 ID
2. 查询设计原则
- 查询命名:使用
Get或Find开头,清晰表达意图,如GetProductsQuery、FindUserByIdQuery - 查询数据:只返回客户端需要的数据,避免过度获取
- 查询优化:优化查询性能,如使用索引、缓存等
- 查询验证:验证查询参数的有效性
- 查询响应:返回结构化的数据,便于客户端处理
3. 事件设计原则
- 事件命名:使用过去式,清晰表达发生的事件,如
ProductCreatedEvent、UserUpdatedEvent - 事件数据:包含事件发生时的必要数据,便于重建状态
- 事件版本:考虑事件版本管理,以便向后兼容
- 事件幂等性:设计事件处理逻辑时,确保幂等性
4. 架构最佳实践
- 独立扩展:独立扩展命令和查询组件,根据负载调整资源
- 异步处理:命令处理可以异步执行,提高系统的响应性
- 事件溯源:考虑使用事件溯源,简化命令处理和状态管理
- 缓存策略:为查询结果实现缓存,提高查询性能
- 监控和日志:监控命令和查询的执行情况,记录详细日志
九、CQRS 的优势与适用场景
1. 优势
- 性能优化:可以独立优化读写性能,如为查询添加缓存,为命令使用异步处理
- 可扩展性:可以独立扩展读写组件,根据负载调整资源
- 安全性:可以为命令和查询设置不同的安全策略
- 简化设计:命令和查询使用不同的模型,简化了设计和实现
- 支持复杂业务逻辑:命令处理可以使用领域模型,支持复杂的业务逻辑
- 事件驱动:可以与事件驱动架构结合,提高系统的响应性和可扩展性
2. 适用场景
- 高并发应用:如电商网站、社交媒体平台等,读写负载不平衡的应用
- 复杂业务逻辑:需要处理复杂业务规则的应用
- 需要审计和追溯的应用:如金融系统、医疗系统等
- 微服务架构:在微服务架构中使用 CQRS,提高系统的可扩展性
- 事件驱动应用:与事件驱动架构结合,使用事件同步命令模型和查询模型
3. 不适用场景
- 简单应用:对于简单的 CRUD 应用,CQRS 可能会增加不必要的复杂性
- 读写比例均衡的应用:如果读写比例相近,CQRS 可能不会带来明显的性能提升
- 资源受限的应用:CQRS 需要更多的资源来维护分离的命令模型和查询模型
十、总结
CQRS 模式为 Vue 3 应用提供了一种强大的架构设计方式,它通过分离命令和查询,提高了系统的性能、可扩展性和安全性。通过本文的学习,您应该掌握了:
- CQRS 的核心概念和设计原则
- Vue 3 项目中实现 CQRS 的目录结构和代码组织
- 命令处理的实现方法,包括命令定义、命令处理器和事件发布
- 查询处理的实现方法,包括查询定义、查询处理器和查询模型
- 事件驱动的更新机制,用于同步命令模型和查询模型
- 与 Pinia 状态管理的结合,实现前端的 CQRS 模式
- API 适配器的实现,支持外部系统的命令和查询
- CQRS 的最佳实践、优势和适用场景
CQRS 代表了现代软件设计的发展方向,它强调分离关注点,提高系统的可扩展性和性能。在实际项目中,建议根据具体需求灵活应用 CQRS 模式,结合其他架构模式(如 DDD、事件驱动架构),构建可扩展、易维护的现代化 Vue 3 应用。
下集预告
下一集将深入探讨领域事件与消息队列的集成,包括领域事件的定义、发布和处理,以及与消息队列(如 RabbitMQ、Kafka)的结合使用。敬请期待!