2. 领域驱动设计基础:通用语言、限界上下文、实体与值对象的概念,及其在报表模块中的应用

在开始优化报表导出模块的边界之前,必须先建立一套统一的领域设计语言。领域驱动设计(DDD)提供了四组核心构造块——通用语言、限界上下文、实体与值对象——它们能帮助团队将复杂的业务逻辑从技术实现中剥离出来,让报表模块的每一次变更都有据可依。

2.1 通用语言:消除“报表”一词的歧义

在大多数项目中,“报表”这个词至少承载了三种不同的含义:

  • 业务人员眼中的“报表”:一张带有汇总数据和图表的 PDF 或 Excel 文件。
  • 后端开发眼中的“报表”:一个 SQL 查询 + 模板引擎渲染的异步任务。
  • 前端开发眼中的“报表”:一个包含筛选条件、预览按钮和下载链接的页面组件。

通用语言要求团队为每个概念赋予唯一且无歧义的术语。在报表模块中,我们建议建立如下词汇表:

通用术语 业务含义 技术映射
报表模板 定义了报表的布局、数据源和参数结构的元数据 数据库中的模板配置表 + 模板文件存储
报表实例 基于模板和具体参数生成的一次执行结果 异步任务记录 + 输出文件
数据源 报表数据的原始出处,可以是数据库、API 或文件 数据源连接配置 + 查询策略
导出任务 用户发起的、将报表实例输出为特定格式的请求 任务队列中的 Job 对象
实践建议:在代码仓库中维护一份 GLOSSARY.md 文件,每次代码评审时对照通用语言检查命名一致性。如果发现一个类名或方法名偏离了词汇表,立即重构。

2.2 限界上下文:划定报表模块的自治边界

限界上下文是 DDD 中最关键的战术模式。它定义了一个模型所适用的显式边界——在边界内部,通用语言是自洽的;在边界外部,模型可以有不同的含义甚至不同的实现。

在报表导出场景中,至少存在三个限界上下文:

  • 报表设计上下文:负责模板的创建、编辑和版本管理。内部模型包括 ReportTemplate、ParameterDefinition、LayoutSection。
  • 报表执行上下文:负责接收导出请求、调度任务、生成文件。内部模型包括 ExportRequest、ExportJob、FileOutput。
  • 报表展示上下文:负责在前端呈现报表预览、下载链接和历史记录。内部模型包括 ReportPreview、DownloadLink、ExportHistory。
报表模块的限界上下文与协作关系 报表设计上下文 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 实体与值对象的协作模式

在报表导出流程中,实体和值对象按照以下模式协作:

  1. 用户发起导出请求:前端提交 ParameterValue 列表和 FileFormat 值对象。
  2. 系统创建 ExportJob 实体:将请求参数封装为值对象列表,关联到 ReportTemplate 实体。
  3. 执行引擎读取模板:通过 template_id 找到 ReportTemplate 实体,获取其 layout_config 值对象。
  4. 生成输出文件:将结果写入文件系统,更新 ExportJob 实体的状态和 output_path。
实体与值对象在报表导出中的协作流程 用户 ParameterValue[] FileFormat ExportJob job_id: UUID status: string ReportTemplate template_id: UUID layout_config: dict 协作要点: 值对象(参数、格式)作为实体的属性被传递和存储;实体(ExportJob)通过 ID 引用另一个实体(ReportTemplate),而非嵌套整个实体。 值对象负责“数据是什么”,实体负责“数据属于谁”和“数据状态如何变化”

2.6 本章小结

  • 通用语言是团队沟通的基石,必须为“报表”“模板”“导出”等术语建立精确的词汇表。
  • 限界上下文将报表模块拆分为设计、执行、展示三个自治区域,每个区域拥有独立的模型和数据库。
  • 实体(如 ReportTemplate、ExportJob)通过唯一 ID 标识,负责生命周期管理。
  • 值对象(如 ParameterValue、FileFormat)不可变且无 ID,负责承载业务数据。
  • 在代码实现中,使用 @dataclass(frozen=True) 定义值对象,使用 UUID 主键定义实体,从第一天起就保持边界清晰。