简介
本文件面向 DouPHP 框架的 Eloquent 风格关系管理系统,系统性阐述一对一、一对多、多对多关系的实现原理与使用方式;解释 Relation 基类的设计模式与各具体关系类型的继承体系;说明关系查询构建过程、预加载机制与延迟加载策略;并提供嵌套关系、条件关系、聚合查询等丰富用法思路与性能优化建议,帮助避免 N+1 查询问题并掌握复杂关系场景的最佳实践。
项目结构
DouPHP ORM 的关系能力由以下核心部分构成:
- 模型基类 Model:提供属性访问、关系懒加载入口、默认 with 配置、事件与全局作用域等。
- 查询构造器 Builder:封装链式查询、with 预加载解析、水合后回调、全局作用域应用与终结方法(get/first/find/paginate)。
- 关系抽象基类 Relation:定义惰性取值 getResults 与批量预加载 addEagerConstraints/getEager/match 契约,以及嵌套预加载与约束注入。
- 具体关系类型 HasOne/HasMany/BelongsTo/BelongsToMany:实现不同外键语义下的批量与单例查询、结果回填。
- 声明式预热 PrefetchRunner:在 get()/paginate() 之后按模型 $prefetchers 批量预热 URL、语言、附件等,消除读取阶段 N+1。
graph TB
subgraph "ORM 层"
M["Model"]
B["Builder"]
R["Relation(抽象)"]
H1["HasOne"]
HM["HasMany"]
BT["BelongsTo"]
BTM["BelongsToMany"]
PR["PrefetchRunner"]
end
M --> B
B --> R
R --> H1
R --> HM
R --> BT
R --> BTM
B --> PR
核心组件
- Model:提供 newQuery() 获取 Builder;getAttribute() 支持懒加载关系;with 默认预加载;事件与全局作用域;集合与实例工厂。
- Builder:with() 解析点号路径与闭包约束;eagerLoadRelations() 调用各关系的 addEagerConstraints/getEager/match;hydrateRows() 统一水合、执行预加载与声明式预热。
- Relation:定义 setEagerOptions() 注入 nested 与 constraint;collectKeys() 收集父模型去重键;各子类实现批量查询与匹配回填。
- HasOne/HasMany/BelongsTo:基于 whereIn/where 的批量查询与字典映射回填。
- BelongsToMany:两步法(pivot → related)避免 JOIN 别名/前缀限制,保持 eager/lazy 一致。
- PrefetchRunner:按 $prefetchers 批量预热 URL、语言、附件等,消除 cast/accessor 读取阶段的 N+1。
架构总览
下图展示一次带预加载的查询从 Model/Builder 到关系与底层连接的完整流程,包括 with 解析、批量预加载、结果回填与声明式预热。
sequenceDiagram
participant App as "业务代码"
participant Model as "Model"
participant Builder as "Builder"
participant Rel as "Relation(具体)"
participant DB as "Connection"
participant PR as "PrefetchRunner"
App->>Model : Model : : query()->with('rel')->get()
Model-->>Builder : newQuery()
Builder->>Builder : with() 解析路径/闭包
Builder->>DB : select() 主表数据
DB-->>Builder : 原始行集
Builder->>Builder : hydrateRows()
Builder->>Rel : addEagerConstraints(models)
Rel->>DB : whereIn(...) 批量查询关联
DB-->>Rel : 关联结果集合
Rel->>Builder : match(models, results, name)
Builder->>PR : run(rows, model)
PR-->>Builder : 完成预热
Builder-->>App : Collection(已填充关系与预热)
详细组件分析
Relation 基类设计模式
- 职责:定义惰性取值与批量预加载的统一契约;维护父模型、关联模型原型、外键与本端键;收集 eager 键;注入嵌套关系与约束闭包;提供 collectKeys 工具。
- 关键方法:
- setEagerOptions(nested, constraint):为后续 applyEagerOptions 准备。
- addEagerConstraints(models):由各关系实现收集父模型键集合。
- getEager():批量查询关联结果。
- match(models, results, name):将结果按父键映射回各父模型。
- getResults():单父模型的惰性取值。
- collectKeys(models, key):提取去重非空键集合。
classDiagram
class Relation {
- parent : Model
- related : Model
- foreignKey : string
- localKey : string
- eagerKeys : array
- nested : array
- constraint : callable?
+ __construct(parent, related, foreignKey, localKey)
+ setEagerOptions(nested, constraint) Relation
# applyEagerOptions(query) Builder
+ addEagerConstraints(models) void
+ getEager() Collection
+ match(models, results, name) void
+ getResults() mixed
# collectKeys(models, key) array
}
一对一 HasOne
- 语义:关联模型持有外键 foreignKey 指向父模型的 localKey。
- 批量预加载:
- addEagerConstraints:收集父模型 localKey 集合。
- getEager:whereIn(foreignKey, keys) 批量取关联。
- match:以 foreignKey 为键建字典,按 localKey 回填单个对象或 null。
- 惰性取值:根据父模型 localKey 查 first()。
flowchart TD
Start(["开始"]) --> Keys["收集父模型 localKey 集合"]
Keys --> Query{"keys 是否为空?"}
Query -- 是 --> Empty["返回空集合"]
Query -- 否 --> WhereIn["WHERE foreignKey IN (keys)"]
WhereIn --> Apply["应用嵌套/约束"]
Apply --> Results["得到关联结果集合"]
Results --> Match["按 foreignKey 建字典并回填"]
Match --> End(["结束"])
一对多 HasMany
- 语义:关联模型持有外键 foreignKey 指向父模型的 localKey。
- 批量预加载:
- addEagerConstraints:收集父模型 localKey 集合。
- getEager:whereIn(foreignKey, keys) 批量取关联。
- match:以 foreignKey 为键建字典(数组),按 localKey 回填集合。
- 惰性取值:根据父模型 localKey 查 get()。
flowchart TD
Start(["开始"]) --> Keys["收集父模型 localKey 集合"]
Keys --> Query{"keys 是否为空?"}
Query -- 是 --> Empty["返回空集合"]
Query -- 否 --> WhereIn["WHERE foreignKey IN (keys)"]
WhereIn --> Apply["应用嵌套/约束"]
Apply --> Results["得到关联结果集合"]
Results --> Match["按 foreignKey 分组并回填集合"]
Match --> End(["结束"])
从属关系 BelongsTo
- 语义:父模型持有外键 foreignKey 指向关联模型的 localKey(ownerKey)。
- 批量预加载:
- addEagerConstraints:收集父模型 foreignKey 集合。
- getEager:whereIn(localKey, keys) 批量取关联。
- match:以 localKey 为键建字典,按 foreignKey 回填单个对象或 null。
- 惰性取值:根据父模型 foreignKey 查 first()。
flowchart TD
Start(["开始"]) --> Keys["收集父模型 foreignKey 集合"]
Keys --> Query{"keys 是否为空?"}
Query -- 是 --> Empty["返回空集合"]
Query -- 否 --> WhereIn["WHERE localKey IN (keys)"]
WhereIn --> Apply["应用嵌套/约束"]
Apply --> Results["得到关联结果集合"]
Results --> Match["按 localKey 建字典并回填"]
Match --> End(["结束"])
多对多 BelongsToMany
- 语义:通过中间表 pivot 建立多对多关系。采用两步法(pivot → related)而非 JOIN,避免表别名与前缀限制,且 eager/lazy 路径一致。
- 关键步骤:
- addEagerConstraints:收集父模型 parentKey 集合。
- loadPivotRelations:先查 pivot 表得到 parentKey→relatedKey 映射,再 whereIn(relatedKey) 批量取关联。
- match:用 pivotMap 按父键分组回填集合。
- getResults:单父模型时同样走两步法。
flowchart TD
Start(["开始"]) --> PKeys["收集父模型 parentKey 集合"]
PKeys --> Pivot{"是否有父键?"}
Pivot -- 否 --> Empty["返回空集合"]
Pivot -- 是 --> Q1["SELECT * FROM pivot WHERE foreignPivotKey IN (parentKeys)"]
Q1 --> Map["构建 pivotMap: parentKey => [relatedKey...]"]
Map --> RKeys["收集所有 relatedKey"]
RKeys --> Q2["SELECT * FROM related WHERE relatedKey IN (relatedKeys)"]
Q2 --> Apply["应用嵌套/约束"]
Apply --> Results["得到关联结果集合"]
Results --> Match["按 pivotMap 分组回填集合"]
Match --> End(["结束"])
查询构建与预加载机制
- with() 支持四种形态:
- 单关系:with('category')
- 多关系:with('a', 'b') / with(['a','b'])
- 嵌套:with('category.parent')
- 闭包约束:with(['brand' => function($q){...}])
- 归一化为 eagerLoad[topName] = ['nested' => [...], 'constraint' => callable|null]。
- 终结读路径(get/first/find/paginate)会:
- 应用全局 scope(仅读聚合也受保护)。
- 水合模型集合。
- 执行 eagerLoadRelations:遍历每个关系,调用 addEagerConstraints/getEager/match。
- 运行 PrefetchRunner 进行声明式预热。
- 触发 afterHydrate 回调栈。
sequenceDiagram
participant B as "Builder"
participant R as "Relation"
participant C as "Collection"
participant PR as "PrefetchRunner"
B->>B : with([...]) 解析
B->>B : get()/find()/first()
B->>B : hydrateRows()
B->>C : 水合模型集合
B->>R : addEagerConstraints(models)
R->>R : getEager()
R->>B : match(models, results, name)
B->>PR : run(rows, model)
B-->>B : 触发 afterHydrate 回调
延迟加载策略
- 当直接访问模型上的关系方法(如 $model->relation())时,Model::getAttribute() 会检测是否存在同名关系方法,若存在则调用该方法的 Relation::getResults() 并缓存至 relations 数组,避免重复查询。
- 适合单条记录的场景;批量场景应优先使用 with() 预加载。
依赖分析
- Model 依赖 Builder、Relation 及其子类、PrefetchRunner。
- Builder 依赖 Connection(通过 Model::getConnection())、Relation 抽象与具体实现、PrefetchRunner。
- Relation 依赖 Model、Collection;具体关系依赖各自的外键/主键字段语义。
- 无循环依赖:Model → Builder → Relation → Model(仅在运行时通过 newRelatedInstance 创建新实例,不构成编译期环)。
graph LR
Model["Model"] --> Builder["Builder"]
Builder --> Relation["Relation"]
Relation --> HasOne["HasOne"]
Relation --> HasMany["HasMany"]
Relation --> BelongsTo["BelongsTo"]
Relation --> BelongsToMany["BelongsToMany"]
Builder --> PrefetchRunner["PrefetchRunner"]
性能考虑
- 避免 N+1 查询:
- 批量列表页务必使用 with() 预加载必要关系,尤其是嵌套关系 with('category.parent')。
- 对多对多关系使用内置 BelongsToMany,其两步法天然避免 JOIN 带来的额外开销与别名限制。
- 利用声明式预热:
- 在模型中声明 $prefetchers,例如 'url'、'language'、'attachment'、'gallery_first',可在一次 get()/paginate() 后批量预热,消除 cast/accessor 读取时的 N+1。
- 合理使用聚合:
- 对于 count/sum/avg/max/min/value/exists/column 等只读聚合,Builder 会在透传前应用全局 scope,保证与 get()->count() 行为一致。
- 控制预加载范围:
- 仅预加载需要的关系与字段,减少内存占用与网络传输。
- 注意空值与边界:
- 关系匹配时对空键做防护,避免无效查询;BelongsToMany 在 pivot 为空时快速返回空集合。
故障排查指南
- 现象:访问关系方法报找不到方法或返回 null。
- 检查模型是否定义了正确的关系方法(返回 Relation 实例)。
- 确认外键与本端键配置正确(foreignKey/localKey/parentKey/relatedKey)。
- 现象:预加载未生效。
- 确认 with() 传入的关系名与方法名一致;嵌套关系使用点号语法。
- 检查关系方法是否返回 Relation 实例;否则 Builder 会跳过。
- 现象:N+1 仍然存在。
- 确认是否在批量场景使用了 with();单条场景可使用懒加载但需避免循环访问。
- 检查是否遗漏了声明式预热($prefetchers)。
- 现象:多对多关系结果不完整。
- 检查中间表字段是否正确(foreignPivotKey/relatedPivotKey);确保 pivot 映射正常构建。
结论
DouPHP 的关系系统以 Relation 抽象为核心,结合 Model/Builder 的懒加载与预加载机制,提供了清晰一致的 Eloquent 风格 API。通过 with() 的嵌套与闭包约束、PrefetchRunner 的声明式预热,以及 BelongsToMany 的两步法实现,能够在复杂业务场景中高效、安全地处理一对一、一对多、多对多关系,有效规避 N+1 查询问题并提升整体性能。
附录:使用示例与最佳实践
- 基础关系定义(在模型中声明方法):
- 一对一:hasOne(RelatedModel::class, 'foreignKey', 'localKey')
- 一对多:hasMany(RelatedModel::class, 'foreignKey', 'localKey')
- 从属:belongsTo(RelatedModel::class, 'foreignKey', 'localKey')
- 多对多:belongsToMany(RelatedModel::class, 'pivotTable', 'foreignPivotKey', 'relatedPivotKey', 'parentKey', 'relatedKey')
- 预加载与嵌套:
- 单关系:Model::query()->with('category')->get()
- 多关系:Model::query()->with('category', 'tags')->get()
- 嵌套:Model::query()->with('category.parent')->get()
- 闭包约束:Model::query()->with(['category' => function($q){ $q->where(...); }])->get()
- 聚合查询:
- 使用 count/sum/avg/max/min/value/exists/column 等只读聚合方法,自动应用全局 scope。
- 声明式预热(消除 N+1):
- 在模型中声明 $prefetchers:
- 'url'
- 'language' => 'f1,f2'
- 'attachment' => 'image' 或 ['image','thumb']
- 'gallery_first' => 'image'
- 在 get()/paginate() 后自动批量预热,避免逐条读取时的 N+1。
- 在模型中声明 $prefetchers:
- 最佳实践:
- 列表页一律使用 with() 预加载必要关系;详情页可懒加载。
- 多对多优先使用内置 BelongsToMany,避免手写 JOIN 的别名/前缀问题。
- 合理拆分预加载路径,避免过度加载无关数据。
- 使用声明式预热集中处理 URL、语言、附件等高频读取资源。