2. 领域驱动设计基础:通用语言、限界上下文、实体与值对象的概念,及其在报表模块中的应用
在开始优化报表导出模块的边界之前,必须先建立一套统一的领域设计语言。领域驱动设计(DDD)提供了四组核心构造块——通用语言、限界上下文、实体与值对象——它们能帮助团队将复杂的业务逻辑从技术实现中剥离出来,让报表模块的每一次变更都有据可依。
2.1 通用语言:消除“报表”一词的歧义
在大多数项目中,“报表”这个词至少承载了三种不同的含义:
- 业务人员眼中的“报表”:一张带有汇总数据和图表的 PDF 或 Excel 文件。
- 后端开发眼中的“报表”:一个 SQL 查询 + 模板引擎渲染的异步任务。
- 前端开发眼中的“报表”:一个包含筛选条件、预览按钮和下载链接的页面组件。
通用语言要求团队为每个概念赋予唯一且无歧义的术语。在报表模块中,我们建议建立如下词汇表:
| 通用术语 | 业务含义 | 技术映射 |
|---|---|---|
| 报表模板 | 定义了报表的布局、数据源和参数结构的元数据 | 数据库中的模板配置表 + 模板文件存储 |
| 报表实例 | 基于模板和具体参数生成的一次执行结果 | 异步任务记录 + 输出文件 |
| 数据源 | 报表数据的原始出处,可以是数据库、API 或文件 | 数据源连接配置 + 查询策略 |
| 导出任务 | 用户发起的、将报表实例输出为特定格式的请求 | 任务队列中的 Job 对象 |
实践建议:在代码仓库中维护一份
GLOSSARY.md 文件,每次代码评审时对照通用语言检查命名一致性。如果发现一个类名或方法名偏离了词汇表,立即重构。
2.2 限界上下文:划定报表模块的自治边界
限界上下文是 DDD 中最关键的战术模式。它定义了一个模型所适用的显式边界——在边界内部,通用语言是自洽的;在边界外部,模型可以有不同的含义甚至不同的实现。
在报表导出场景中,至少存在三个限界上下文:
- 报表设计上下文:负责模板的创建、编辑和版本管理。内部模型包括
ReportTemplate、ParameterDefinition、LayoutSection。 - 报表执行上下文:负责接收导出请求、调度任务、生成文件。内部模型包括
ExportRequest、ExportJob、FileOutput。 - 报表展示上下文:负责在前端呈现报表预览、下载链接和历史记录。内部模型包括
ReportPreview、DownloadLink、ExportHistory。
边界原则:每个限界上下文拥有独立的数据库表或 schema,禁止跨上下文直接访问表。上下文之间只通过事件或轻量级 RPC 通信。例如,报表执行上下文不应直接读取报表设计上下文的模板表,而应通过“模板已发布”事件获取模板快照。
2.3 实体与值对象:区分“你是谁”和“你是什么”
实体(Entity)拥有唯一标识符,其属性可以变化但身份不变。值对象(Value Object)没有标识符,由属性值共同定义,两个值对象如果所有属性相等则视为同一个对象。
在报表模块中,这种区分至关重要:
| 概念 | 报表模块中的实体 | 报表模块中的值对象 |
|---|---|---|
| 定义 | 有唯一 ID,可被追踪和修改 | 无 ID,由属性值定义,不可变 |
| 示例 | ReportTemplate(模板 ID)、ExportJob(任务 ID) |
ParameterValue、FileFormat、DateRange |
| 生命周期 | 创建 → 修改 → 归档/删除 | 创建后即固定,可被共享和复制 |
| 相等性 | 通过 ID 判断 | 通过所有属性值判断 |
| 持久化 | 独立数据库表,有主键 | 通常作为实体的一部分序列化存储 |
2.4 在报表模块中应用实体与值对象
以下是一个具体的代码示例,展示如何在 Python 中区分实体和值对象:
from dataclasses import dataclass
from uuid import uuid4, UUID
from datetime import date
from enum import Enum
# ---------- 值对象 ----------
class FileFormat(Enum):
PDF = "pdf"
EXCEL = "xlsx"
CSV = "csv"
@dataclass(frozen=True)
class DateRange:
"""值对象:日期范围,不可变且无 ID"""
start: date
end: date
def __post_init__(self):
if self.start > self.end:
raise ValueError("start must be before end")
@dataclass(frozen=True)
class ParameterValue:
"""值对象:参数键值对"""
key: str
value: str
# ---------- 实体 ----------
@dataclass
class ReportTemplate:
"""实体:报表模板,有唯一 ID"""
template_id: UUID
name: str
data_source: str
layout_config: dict # 值对象作为属性
version: int = 1
def __init__(self, name: str, data_source: str, layout_config: dict):
self.template_id = uuid4()
self.name = name
self.data_source = data_source
self.layout_config = layout_config
self.version = 1
def bump_version(self):
self.version += 1
@dataclass
class ExportJob:
"""实体:导出任务,有唯一 ID 和状态变化"""
job_id: UUID
template_id: UUID
parameters: list[ParameterValue] # 值对象列表
file_format: FileFormat # 值对象
status: str = "pending"
output_path: str | None = None
def mark_completed(self, path: str):
self.status = "completed"
self.output_path = path
def mark_failed(self):
self.status = "failed"
常见陷阱:不要将值对象设计为可变对象。如果
DateRange 允许修改 start 或 end,就会破坏值对象的共享安全性和相等性判断。始终使用 @dataclass(frozen=True) 或不可变集合来定义值对象。
2.5 实体与值对象的协作模式
在报表导出流程中,实体和值对象按照以下模式协作:
- 用户发起导出请求:前端提交
ParameterValue列表和FileFormat值对象。 - 系统创建 ExportJob 实体:将请求参数封装为值对象列表,关联到
ReportTemplate实体。 - 执行引擎读取模板:通过
template_id找到ReportTemplate实体,获取其layout_config值对象。 - 生成输出文件:将结果写入文件系统,更新
ExportJob实体的状态和output_path。
2.6 本章小结
- 通用语言是团队沟通的基石,必须为“报表”“模板”“导出”等术语建立精确的词汇表。
- 限界上下文将报表模块拆分为设计、执行、展示三个自治区域,每个区域拥有独立的模型和数据库。
- 实体(如
ReportTemplate、ExportJob)通过唯一 ID 标识,负责生命周期管理。 - 值对象(如
ParameterValue、FileFormat)不可变且无 ID,负责承载业务数据。 - 在代码实现中,使用
@dataclass(frozen=True)定义值对象,使用UUID主键定义实体,从第一天起就保持边界清晰。