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.4.7
Requires (Dev)
- veasin/ff-cli: >=0.1.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
基于 veasin/ff 函数式框架的 DDD 业务建模层。
概念配置驱动:抽象与数据操作分离 —— db 静态类调用前只构建概念配置,db 静态类负责数据操作。
安装
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) 开链 |
| 范围 | 对集合的筛选条件,链上每一步都在叠加 |
| 关系 | 概念间的关联(从属导航),触发根转化 + 范围转换 |
| 配置 | 概念的全部定义,db 调用前链式操作的最终产物 |
铁律
- 抽象与数据操作分离(
db调用前只构建配置) - 数据操作统一走
ff\ddd\db静态类:save/get/list/count/delete/exists - 一切显式,无自动推导(概念必须
define)
配置结构
ddd::define(name, $colCfg, $entCfg) 将集合与单体配置分离为第二、三参数,各自独立归一化、独立存储,不合并。
子类型
// 范围项:在某字段 field 上施加一次筛选 // op 缺省 '=';顺序固定 [field, value, op?] type ScopeItem = [field: string, value: unknown, op?: string]; // 范围定义 = 范围方法返回值: // · ScopeItem —— 字面范围项 // · 闭包 —— 运行时追加范围项 type Def = ScopeItem | ((c: Concept) => void); // 操作符直接使用 SQL 符号 / 关键字,不做别名映射 type Op = '=' | '!=' | '<>' | '<' | '<=' | '>' | '>=' | '+' | '-' | '*' | '/' | '%' | 'LIKE' | 'IN' | 'NOT IN' | 'IS NULL' | 'IS NOT NULL'; type RelationDef = { concept: string; // 目标概念(类名或注册概念名) via: Record<string, string>; // map:targetField => currentField }; // 视图项:source.views 的段内项;有 table 即数据形态,否则按语义形态引用 relations type ViewItem = SemanticView | DataView; type SemanticView = { relation?: string; // relations 的键;缺省 = 外层 key(同时作为 SQL 别名) fields?: string[] | Record<string, string>; // 目标概念的语义字段 type?: 'LEFT' | 'INNER' | 'RIGHT'; // 缺省 'LEFT' }; type DataView = { table: string; // 目标表 on: Record<string, string>; // 目标列 => 当前列(原始列名) fields?: string[] | Record<string, string>; // 原始列 type?: 'LEFT' | 'INNER' | 'RIGHT'; // 缺省 'LEFT' };
source 配置
source 是概念 → 数据的适配层,可写在 colConfig 与 entConfig 中;读取时按项递归合并(ent 优先)。产物直接符合 ff\sql() 数组参数格式。
interface SourceConfig { table: string; // 表名;可带别名 'posts p' primary?: string; // 主键列;fields['#'] 优先 db?: string; // 连接名;默认 'default' time?: 's' | 'ms'; // 时间戳单位;默认 's' fields?: Record<string, string>; // 语义字段 => 列;'#' 为主键保留键 timestamps?: TimestampsConfig; // 完整写法 '@'?: TimestampsShort; // 简写;按项覆盖 timestamps 的对应项 views?: Record<string, Record<string, ViewItem>>; // 段名 => (别名/关系名 => 连接项) } type TimestampsConfig = { created?: boolean | string | null; // true => created_at;写入 BIGINT 时间戳 updated?: boolean | string | null; // true => updated_at deleted?: boolean | string | null; // true => deleted_at;启用即软删除,0 = 存活 }; type TimestampsShort = { '+'?: boolean | string | null; // created '~'?: boolean | string | null; // updated '-'?: boolean | string | null; // deleted };
- 配置层使用
table/fields;解析产物使用from/MAP。 fields方向固定为 语义字段 ⇒ 列名;主键键固定为#,不写id。- 未列出的字段按恒等映射处理;映射作用于 WHERE / ORDER / JOIN / INSERT / UPDATE。
@['-']启用时,读取、更新、删除、join 追加deleted = 0;未启用时delete抛DomainException。- 开放域(
time/db/type/on/fields)原样透传,不做校验或转换;table原样写入产物from。 views详见下文「views 查询级联」。
集合配置(colConfig,第二参数)
interface ColConfig { base?: string | null; // 继承父集合;合并时子优先、子 null 删父对应项 source: SourceConfig; // 数据源;详见「source 配置」 defs: Record<string, Def>; // map:method => Def(范围方法定义) mutators: Record<string, (v: unknown) => unknown>; // map:field => 闭包(persist 前作用) uniques: string[]; // 唯一键;默认 ['id'],仅 findUnique() 校验 order: null | string | Record<string, 'asc' | 'desc'>; // 默认排序 entity?: string; // id()/create()/findUnique() fork 目标 entity 类 // —— 运行时状态(链式构建自动填充,不写死在定义中) —— 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' => 'user_id', 'db' => 'default', 'fields' => ['#' => 'user_id', 'nick' => 'nick_name'], '@' => ['+' => true, '~' => true, '-' => true], 'time' => 'ms', 'views' => ['list' => ['tokens' => ['fields' => ['token']]]], ], 'defs' => ['active' => ['status', 1], 'vip' => ['is_vip', 1]], '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 删父对应项 source?: SourceConfig; // 与 col.source 按项递归合并,ent 优先 relations: Record<string, RelationDef>; // map:method => RelationDef(关系定义) mutators: Record<string, (v: unknown) => unknown>; // 优先读自身,fallback 到 colConfig uniques: string[]; // 优先读自身,fallback 到 colConfig order?: null | string | Record<string, 'asc' | 'desc'>; // 可选覆盖集合排序 // —— 运行时状态(链式构建自动填充) —— 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 优先 |
defs |
✅ | ❌ | 集合专属 |
relations |
❌ | ✅ | 单体专属 |
uniques |
✅ | ✅ | 实体构造时 ent→col fallback;仅 findUnique() 校验 |
mutators |
✅ | ✅ | 实体构造时 ent→col fallback |
order |
✅ | ✅ | 集合默认排序;ent 可覆盖,读取时 ent→col fallback |
base |
✅ | ✅ | 同类型继承(col→col, ent→ent) |
配置分离强制规则:colConfig 不含
relations/create;entConfig 不含defs/entity。normalize()阶段强制执行(传入即剥离)。
视图构造 fallback
实体构造时,最终配置 = entConfig 为基底,以下共享字段若 entConfig 不存在则从 colConfig 回退读取:
final.source = mergeSource(colConfig.source, entConfig.source) // 按项递归合并,ent 优先
final.uniques = entConfig.uniques ?? colConfig.uniques ?? ['id']
final.mutators = entConfig.mutators ?? colConfig.mutators ?? []
共享字段仅 source、uniques、mutators 三项。order 不属于共享字段——实体没有默认排序。source 内的 views 随 source 一并按段名、段内别名递归合并。
base 继承
base 仅同类型继承(colConfig→父 colConfig,entConfig→父 entConfig),不跨类型:
defs/relations按项合并,子优先;子值为null删除父对应项source递归按项合并(fields/timestamps/@/views逐层合并),子优先- 其他非数组字段直接覆盖
base继承关系在normalize()阶段解析展开,展开后base字段被移除
正常化(normalize)拆分
集合和单体各自独立归一化:
normalizeCol:defs→[],mutators→[],scopes→[],pending→[],refs→[];uniques→['id'];剥离relations/createnormalizeEnt:relations→[],scopes→[],pending→[],refs→[];剥离defs/entity
views 查询级联
views 用于"查询主概念时同时 JOIN 关联目标、带出主表之外的字段",配置在 source.views 中。段名由 db::get() / db::list() 的末参数选择,默认分别为 get / list。
ddd::define('blog/post', [ 'source' => [ 'table' => 'posts', 'primary' => 'id', 'views' => [ 'list' => [ // list() 默认读取的段 'author' => [ // 别名 = join 表别名;key 同时是 relations 键 'fields' => ['name', 'avatar'], 'type' => 'LEFT', ], 'stats' => [ // 有 table => 数据形态 'table' => 'post_stats', 'on' => ['post_id' => 'id'], // 目标列 => 当前列(原始列名) 'fields' => ['view_count' => 'views'], ], ], 'get' => [...], // get() 默认读取的段 '自定义段' => [...], ], ], ], [ 'relations' => ['author' => ['concept' => 'blog/user', 'via' => ['user_id' => 'id']]], ]);
形态判别:视图项含 table 即数据形态;否则为语义形态,按 relations 生成目标表与 ON。语义形态的 on 忽略,数据形态的 relation 忽略——结构域宽松归一,不抛异常。
返回形态:主表 fields = [](多表输出 表.*,单表退化为 *);join 字段放进 join[]['fields']。语义形态的 ON 使用符号操作符,目标列带别名、主表列经 MAP 映射为裸列名:
SELECT `posts`.*, `author`.`name` `name`, `author`.`avatar` `avatar`, `stats`.`views` `view_count` FROM `posts` LEFT JOIN `blog_users` `author` ON (`author`.`user_id` = `posts`.`id` AND `author`.`deleted_at` = 0) LEFT JOIN `post_stats` `stats` ON (`stats`.`post_id` = `posts`.`id`) WHERE `posts`.`status` = 1
db 静态类签名(末尾参数 = 视图段名):
db::get(entity $node, array $opt = [], ?string $view = null)—— 缺省 /null读get段,其他字符串读自定义段db::list(collection $node, array $opt = [], ?string $view = null)—— 缺省 /null读list段,同上db::save(collection|entity $node, array $opt = []): int|bool|nulldb::count($node): intdb::delete(collection|entity $node): booldb::exists(entity $node): bool
规则:
- 视图生效时
options['fields']被忽略(带出字段由视图生成) - 分页
list()的 count 始终单表,不 join db::get/db::list传null仍读取默认段($view ?? 'get'/'list');要禁用 join 请传入不存在的段名- 语义形态引用的
relations键不存在、目标概念未注册、via为空 →DomainException - 目标概念启用
@['-']时,ON 追加目标别名.<deleted> = 0;数据形态不感知软删除
db::list(ddd::collection('blog/post'), ['page' => 1]); // list 段 db::list(ddd::collection('blog/post'), ['page' => 1], 'custom'); // 自定义段 db::list(ddd::collection('blog/post'), ['page' => 1], 'none'); // 段名不存在:不启用 join db::get(ddd::collection('blog/post')->id(5)); // get 段
数据操作 API
数据操作统一走 ff\ddd\db 静态类,直接调用 ff\db() 执行 SQL,不经 ext 派发:
use ff\ddd\db; // 创建 / 更新 db::save(ddd::collection('corp/users')->create(['email' => 'a@b.com', 'password' => 'x'])); db::save(ddd::collection('corp/users')->id(5)->update(['nick_name' => '新名'])); // 查询 db::get(ddd::collection('corp/users')->id(5)); db::list(ddd::collection('corp/users')->active(), ['page' => 1, 'max' => 20], 'list'); // 计数 / 存在性 / 删除 db::count(ddd::collection('corp/users')->active()); db::exists(ddd::collection('corp/users')->findUnique(['email' => 'a@b.com'])); db::delete(ddd::collection('corp/users')->id(5));
管道符兼容:$node |> db::save(...)。
自定义领域方法
业务方法(如 launch()、toggleSkip())属于"找到实体后的数据操作",可直接写在领域服务类中,内部调用 db::*:
use ff\ddd\db\db; class DirsService { public function launch(int $id): array { $dir = db::get(ddd::collection('dirs')->id($id)); // ... 执行业务逻辑(文件系统、exec 等) return $dir; } }
若需自定义 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], 'vip' => ['is_vip', 1]], ], [ '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) db::get(ddd::collection('corp/users')->active()); // 3. 读取单体 db::get(ddd::collection('corp/users')->id(5)); // 4. 新建(一级 rules:unique/mutators 自动生效) db::save(ddd::collection('corp/users')->create(['email' => 'a@b.com', 'password' => 'secret'])); // 5. 关联创建(依赖链自动处理:先 save user 得 id,再回填 token.user_id) db::save( ddd::collection('corps') ->id(5) ->users ->create(['email' => 'u@b.com', 'password' => 'x']) ->tokens ->create(['token' => 'xxx', 'client' => 'web']) ); // 6. 更新 / 删除 / 计数 / 存在性 db::save(ddd::collection('corp/users')->id(5)->update(['nick_name' => '新名'])); db::delete(ddd::collection('corp/users')->id(5)); db::count(ddd::collection('corp/users')->active()); db::exists(ddd::collection('corp/users')->findUnique(['email' => 'a@b.com'])); // 7. 配置继承(base) ddd::define('corp/vip/users', [ 'base' => 'corp/users', 'defs' => ['vip' => ['is_vip', 1]], ]); db::get(ddd::collection('corp/vip/users')->vip()); // 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); db::get(ddd::collection('corp/vip2/users')->vip()); // 9. views 查询级联(list 默认段;自定义段名见上文) ddd::define('blog/post', [ 'source' => [ 'table' => 'posts', 'primary' => 'id', 'views' => ['list' => ['author' => ['fields' => ['name']]]], ], ], [ 'relations' => ['author' => ['concept' => 'blog/user', 'via' => ['user_id' => 'id']]], ]); db::list(ddd::collection('blog/post'), ['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() 各自独立提示列表。