veasin / ff-ddd
DDD modeling layer: concept-config driven, separation of abstraction and data operations, built on the veasin/ff functional framework
Requires
- php: >=8.5
- veasin/ff: >=0.3.8
README
基于 veasin/ff 函数式框架的 DDD 业务建模层。
概念配置驱动:抽象与数据操作分离 —— then 之前只构建概念配置,then 触发才执行数据操作。
安装
composer require veasin/ff-ddd
veasin/ff 为依赖项,框架函数(如 ff\db、ff\container)通过 composer 的 autoload.files 自动加载。
核心概念
| 术语 | 说明 |
|---|---|
| 概念 | 领域模型最小单元,由 ddd::define() 定义,概念名全小写 / 分隔,如 corp/user |
| 集合 | 概念的一组实例,可被范围方法分割,ddd::collection(name) 开链 |
| 单体 | 集合经 id()/create() 缩小到的唯一元素,ddd::entity(name) 开链 |
| 范围 | 对集合的筛选条件,链上每一步都在叠加 |
| 关系 | 概念间的关联(从属导航),触发根转化 + 范围转换 |
| 配置 | 概念的全部定义,then 前链式操作的最终产物 |
铁律
- 抽象与数据操作分离(
then前只构建配置) - 集合 → 范围方法 → 新集合;单体 → 关系方法 → 集合/单体
- 一切显式,无自动推导(概念必须
define)
配置结构
ddd::define(name, $colCfg, $entCfg) 将集合与单体配置分离为第二、三参数,各自独立归一化、独立存储,不合并。
子类型
// 范围项:在某字段 field 上施加一次筛选 // op 缺省 'eq';顺序固定 [field, value, op?] type ScopeItem = [field: string, value: unknown, op?: string]; // 范围定义 = 范围方法返回值: // · ScopeItem —— 字面范围项 // · 闭包 —— 运行时追加范围项 type Def = ScopeItem | ((c: Concept) => void); type Op = 'eq' | 'ne' | 'gt' | 'ge' | 'lt' | 'le' | 'like' | 'in' | 'not_in' | 'is_null' | 'not_null'; type RelationDef = { concept: string; // 目标概念(类名或注册概念名) via: Record<string, string>; // map:targetField => currentField }; // with 关联项:查询时 JOIN 目标概念,带出主表之外的字段 type WithJoin = { concept: string; // 目标概念(类名或注册概念名) via: Record<string, string>; // map:targetField => currentField(JOIN ON) fields?: string[] | Record<string, string>; // 带出字段;缺省 = 全部(输出 别名.*);列表直接输出 别名.字段,key=>value 重命名 type?: 'LEFT' | 'INNER' | 'RIGHT'; // join 类型;缺省 'LEFT' };
集合配置(colConfig,第二参数)
interface ColConfig { base?: string | null; // 继承父集合;合并时子优先、子 null 删父对应项 source: { table: string; primary?: string; db?: string }; // 数据源;table 可带别名('posts p'),primary 默认 'id',db 默认 'default' defs: Record<string, Def>; // map:method => Def(范围方法定义) mutators: Record<string, (v: unknown) => unknown>; // map:field => 闭包(persist 前作用) uniques: string[]; // 唯一键;默认 ['id'] order: null | string | Record<string, 'asc' | 'desc'>; // 默认排序 entity?: string; // id()/create()/findUnique() fork 目标 entity 类 trigger?: string; // 自定义集合 Trigger 类 with: Record<string, Record<string, WithJoin>>; // map:段名 => (别名 => 关联项);list/get 为默认段 // —— 运行时状态(链式构建自动填充,不写死在定义中) —— scopes: ScopeItem[]; // 已应用范围项 update: Record<string, unknown> | null; // 当前概念瞬态更新 pending: Pending[]; // 仅上游 create 态(内联 data 快照) refs: Record<string, Record<string, unknown>>; // concept => (field => value) 上游瞬态包 }
ddd::define('corp/user', [ 'source' => ['table' => 'corp_users', 'primary' => 'id', 'db' => 'default'], 'defs' => ['active' => ['status', 1], 'newest' => ['created_at', null, 'desc']], 'uniques' => ['id', 'email'], 'mutators' => ['password' => static fn($v) => password_hash($v, PASSWORD_DEFAULT)], 'order' => 'created_at', 'entity' => UserEnt::class, // 'base' => 'base/user', ], null);
单体配置(entConfig,第三参数)
interface EntConfig { base?: string | null; // 继承父单体;合并时子优先、子 null 删父对应项 relations: Record<string, RelationDef>; // map:method => RelationDef(关系定义) mutators: Record<string, (v: unknown) => unknown>; // 优先读自身,fallback 到 colConfig uniques: string[]; // 唯一键;优先读自身,fallback 到 colConfig with?: Record<string, Record<string, WithJoin>>; // 同 colConfig;定义时整数组遮蔽 col,未定义读 col trigger?: string; // 自定义实体 Trigger 类 // —— 运行时状态(链式构建自动填充) —— scopes: ScopeItem[]; // 已应用范围项 create: Record<string, unknown> | null; // 当前概念构造数据;存在即 create 态 update: Record<string, unknown> | null; // 当前概念瞬态更新 pending: Pending[]; // 仅上游 create 态(内联 data 快照) refs: Record<string, Record<string, unknown>>; // concept => (field => value) 上游瞬态包 }
ddd::define('corp/user', null, [ 'source' => ['table' => 'corp_users', 'primary' => 'id', 'db' => 'default'], 'relations' => ['tokens' => ['concept' => 'corp/user/token', 'via' => ['user_id' => 'id']]], 'uniques' => ['id', 'email'], 'mutators' => ['password' => static fn($v) => password_hash($v, PASSWORD_DEFAULT)], // 'base' => 'base/user', ]);
字段归属
| 字段 | colConfig | entConfig | 说明 |
|---|---|---|---|
source |
✅ | ✅ | 实体构造时 ent→col fallback |
defs |
✅ | ❌ | 集合专属 |
relations |
❌ | ✅ | 单体专属 |
uniques |
✅ | ✅ | 实体构造时 ent→col fallback |
mutators |
✅ | ✅ | 实体构造时 ent→col fallback |
with |
✅ | ✅ | 独立存储不合并;读取时整数组 ent→col fallback |
order |
✅ | ❌ | 仅集合配置持有 |
base |
✅ | ✅ | 同类型继承(col→col, ent→ent) |
配置分离强制规则:colConfig 不含
relations/create;entConfig 不含defs/entity。normalize()阶段强制执行(传入即剥离)。
视图构造 fallback
实体构造时,最终配置 = entConfig 为基底,以下共享字段若 entConfig 不存在则从 colConfig 回退读取:
final.source = entConfig.source ?? colConfig.source ?? 默认
final.uniques = entConfig.uniques ?? colConfig.uniques ?? ['id']
final.mutators = entConfig.mutators ?? colConfig.mutators ?? []
共享字段仅 source、uniques、mutators 三项。order 不属于共享字段——实体没有默认排序。
with 为特例:独立存储、不做构造期合并,仅在读取时由 concept 整数组 fallback(ent.with ?? col.with)。entity 定义了 with 则整数组遮蔽 col(不按段回退);未定义则读 col 的 with。
base 继承
base 仅同类型继承(colConfig→父 colConfig,entConfig→父 entConfig),不跨类型:
- 父项某 key 为数组(
defs/relations)则逐项合并,子优先;子值为null删除父对应项 - 非数组字段直接覆盖(
with属此类——整键替换,不做段级合并) base继承关系在normalize()阶段解析展开,展开后base字段被移除
正常化(normalize)拆分
集合和单体各自独立归一化:
normalizeCol:defs→[],mutators→[],with→[],scopes→[],pending→[],refs→[];uniques→['id'];剥离relations/createnormalizeEnt:relations→[],scopes→[],pending→[],refs→[];剥离defs/entity/order。with不设默认值——未定义则不存,从而保证 concept 读取时能 fallback 到 col
with 查询级联
with 用于"查询主概念时同时 JOIN 关联概念、带出额外字段"。配置在集合(默认),单体可额外定义(整数组遮蔽集合)。
ddd::define('blog/post', [ 'source' => ['table' => 'posts', 'primary' => 'id'], 'with' => [ 'list' => [ // list() 默认读取的段 'author' => [ // 别名 = join 表别名 'concept' => 'blog/user', 'via' => ['user_id' => 'id'], // targetField => currentField(ON 条件) 'fields' => ['name', 'avatar'], // 带出字段;缺省 = 全部(别名.*);列表直接输出,key=>value 重命名 ], ], 'get' => [...], // get() 默认读取的段 '自定义段' => [...], ], ], null);
返回形态:主表不设别名(字段用限定符限定:表带别名用别名、否则用表名),join 表别名 = 配置 key。fields 为字段列表时直接输出 别名.字段(不设别名);fields 为 输出键 => 字段 时重命名输出。join 生效时,ON / WHERE / ORDER BY 的字段一律用主表限定符限定(防同名列歧义):
SELECT `posts`.*, `author`.`name`, `author`.`avatar` FROM `posts` LEFT JOIN `blog_users` `author` ON (`author`.`user_id` = `posts`.`id`) WHERE `posts`.`status` = 1 ORDER BY `posts`.`created_at` DESC
trigger 签名(末尾参数 = with 段名):
list(array $options = [], ?string $with = 'list')——null禁用、其他字符串读自定义段get(array $options = [], ?string $with = 'get')—— 同上
规则:
with生效时options['select']被忽略(带出字段由 with 生成)- 分页
list()的 count 始终单表单表,不 join $with === null回退普通单表查询- join 目标概念无数据源或 via 为空 →
DomainException(铁律 #5 构建错误致命化)
ddd::collection('blog/post')->then->list(['page' => 1]); // list 段 ddd::collection('blog/post')->then->list(['page' => 1], 'custom'); // 自定义段 ddd::collection('blog/post')->then->list(['page' => 1], null); // 禁用 ddd::collection('blog/post')->id(5)->then->get(); // get 段
自定义 Trigger 扩展
业务方法(如 launch()、toggleSkip())属于"找到实体后的数据操作",应放在 Trigger 上。通过 lv0 配置注入 trigger 即可,不必绕三层继承:
use ff\ddd\ddd; use ff\ddd\trigger\entity as entityTrigger; // 1. 自定义 Trigger:继承单体触发基类,写业务方法 class DirsTrigger extends entityTrigger { public function launch(): array { $dir = $this->get(); // 复用原生 get() // ... 执行业务逻辑(文件系统、exec 等) return $dir; } } // 2. lv0 配置注入 trigger 类名 ddd::define('dirs', [ 'source' => ['table' => 'dirs', 'primary' => 'id'], 'trigger' => DirsTrigger::class, ]); // 3. 使用:链式走到 then,自动返回自定义 Trigger 实例 $dir = ddd::collection('dirs')->id(5)->then->launch();
同样支持集合 Trigger 扩展(继承 trigger\collection)。若需自定义 Entity(如有额外导航方法),配置 entity 指定 fork 目标:
ddd::define('dirs', [ 'source' => ['table' => 'dirs', 'primary' => 'id'], 'entity' => DirsEntity::class, // id()/create()/findUnique() fork 到 DirsEntity ]);
快速开始
use function ff\{container, db}; use ff\ddd\ddd; // 1. 注册概念(集合与单体配置分离) ddd::define('corps', [ 'source' => ['table' => 'corps', 'primary' => 'id'], ], [ 'relations'=> ['users' => ['concept' => 'corp/users', 'via' => ['id' => 'corp_id']]], ]); ddd::define('corp/users', [ 'source' => ['table' => 'corp_users', 'primary' => 'id'], 'order' => ['created_at' => 'desc'], 'uniques' => ['id', 'email'], 'mutators' => ['password' => static fn($v) => password_hash($v, PASSWORD_DEFAULT)], 'defs' => ['active' => ['status', 1], 'newest' => ['created_at', null, 'desc']], ], [ 'relations'=> ['tokens' => ['concept' => 'corp/user/token', 'via' => ['user_id' => 'id']]], ]); ddd::define('corp/user/token', [ 'source' => ['table' => 'corp_user_tokens', 'primary' => 'id'], ]); // 2. 列表(集合,范围方法 active() 来自配置的 defs) ddd::collection('corp/users')->active()->then->get(); // 3. 读取单体 ddd::collection('corp/users')->id(5)->then->get(); // 4. 新建(一级 rules:unique/mutators 自动生效) ddd::collection('corp/users')->create(['email' => 'a@b.com', 'password' => 'secret'])->then->persist(); // 5. 关联创建(依赖链自动处理:先 persist user 得 id,再回填 token.user_id) ddd::collection('corps') ->id(5) ->users() ->create(['email' => 'u@b.com', 'password' => 'x']) ->tokens() ->create(['token' => 'xxx', 'client' => 'web']) ->then->persist(); // 返回 token 的持久化结果 // 6. 更新 / 删除 / 计数 / 存在性 ddd::collection('corp/users')->id(5)->update(['nick_name' => '新名'])->then->persist(); ddd::collection('corp/users')->id(5)->then->delete(); ddd::collection('corp/users')->active()->then->count(); ddd::collection('corp/users')->findUnique(['email' => 'a@b.com'])->then->exists(); // 7. 配置继承(base) ddd::define('corp/vip/users', [ 'base' => 'corp/users', 'defs' => ['vip' => ['is_vip', 1]], ]); ddd::collection('corp/vip/users')->vip()->then->get(); // 8. 显式定义(类形态) class VipUsers extends \ff\ddd\collection{ public const array CONCEPT = [ 'base' => 'corp/users', 'defs' => ['vip' => ['is_vip', 1]], ]; } ddd::define('corp/vip2/users', VipUsers::class); ddd::collection('corp/vip2/users')->vip()->then->get(); // 9. with 查询级联(list 默认段;自定义段名见上文) ddd::define('blog/post', [ 'source' => ['table' => 'posts', 'primary' => 'id'], 'with' => ['list' => ['author' => ['concept' => 'blog/user', 'via' => ['user_id' => 'id'], 'fields' => ['name']]]], ]); ddd::collection('blog/post')->then->list(['page' => 1]); // JOIN 带出 author.name
IDE 辅助
# 自动生成 .phpstorm.meta.php + _ddd.php(桩类命名空间 _ddd) php vendor/bin/ddd.php # 监控模式:文件变动自动重生成 php vendor/bin/ddd.php --watch
生成 PhpStorm 元数据(override / expectedArguments / registerArgumentsSet),
以及 IDE 桩类(_ddd 命名空间,@method 标注 defs / relations / 自定义方法)。
支持 lv1 class 反射 + base 继承链合并。collection() 和 entity() 各自独立提示列表。