编写对象
在 Oak 中,src/entities 不是单纯的“建表目录”,而是整个应用的业务字典。一个对象定义同时决定了:
如果你还没有创建过 Oak 应用,建议先完成新手入门任务清单,再把本页当作实体设计的深入参考。
- 数据有哪些字段;
- 对象之间有哪些关系;
- 允许执行哪些业务动作;
- 状态如何流转;
- 组件、checker、trigger、aspect、endpoint 在后续开发时可以拿到哪些类型和元数据。
因此,编写对象这件事,建议从一开始就写规范。对象一旦定义清楚,后续页面、权限、查询、业务逻辑的编写都会顺很多。
一个对象文件通常包含什么
在 Oak 项目中,一个对象通常写在 src/entities/<Entity>.ts 中。一个完整的对象文件,通常包含下面几部分:
| 部分 | 是否常见 | 作用 |
|---|---|---|
Schema | 必有 | 定义对象字段和对象关系 |
Action / State / ActionDef | 按需 | 定义动作和状态机 |
Relation | 按需 | 定义对象与用户之间的关系 |
entityDesc | 强烈建议始终提供 | 定义命名、索引、风格、配置等元数据 |
如果这是一个要发布给其它项目依赖的 Oak 模块,最终消费方通常不会读取你的 src/entities。当前编译器会优先读取发布包的 es/entities,并把 .d.ts 声明和同名 .js 运行时值合并解析。
这意味着模块发布时要保证:
Schema、Action、State、枚举类型等声明出现在.d.ts中;entityDesc、ActionDef等运行时值出现在同名.js中;- 不需要、也不应该为了实体编译而发布
src。
一个最小可用对象通常长这样:
import { String, Boolean, Text } from '@oak-domain/types/DataType';
import { EntityShape } from '@oak-domain/types/Entity';
import { EntityDesc } from '@oak-domain/types/EntityDesc';
export interface Schema extends EntityShape {
name: String<32>;
default?: Boolean;
remark?: Text;
}
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: {
name: '标签',
attr: {
name: '名称',
default: '是否默认',
remark: '备注',
},
},
},
};
如果对象还有状态、动作、关系等语义,就在这个骨架上继续往下加。
1. Schema:定义对象和字段
Oak 约定使用 Schema 来描述对象本身。最常见的写法有两种:
export interface Schema extends EntityShape {
...
}
或者继承一个已经存在的对象:
import { Schema as User } from '@oak-domain/entities/User';
export interface Schema extends User {
...
}
第二种写法在公共业务包里很常见,例如 oak-general-business 中的 User 就是在 Oak 内置 User 的基础上继续扩展业务字段。
EntityShape 自带哪些字段
所有正常对象都会继承 EntityShape,它已经定义了下面这些通用字段:
| 字段 | 含义 |
|---|---|
id | 主键 |
$$seq$$ | 自增序列 |
$$createAt$$ | 创建时间 |
$$updateAt$$ | 更新时间 |
$$deleteAt$$? | 删除时间,软删除时会用到 |
这些字段不要自己重复定义。除此之外,运行时还会使用 $$triggerData$$、$$triggerUuid$$ 之类的内部字段,它们也不是业务对象作者需要手写的内容。
2. 框架支持哪些字段写法
Oak 支持的对象字段写法,实际上比“几个基础类型”要丰富得多。日常开发里最常见的是下面这几类。
2.1 基础数据类型
基础字段类型来自 @oak-domain/types/DataType。当前源码中已经定义了这些类型:
| 类别 | 可用类型 | 说明 |
|---|---|---|
| 整数 | Int<L>、Uint<L> | 有符号/无符号整数 |
| 小数 | Decimal<P, S>、Price | 金额和精确小数优先用这组 |
| 浮点 | Float<P, S>、Double<P, S> | 仍然存在,但新项目不推荐,优先 Decimal |
| 字符串 | String<L>、Text | String<L> 适合短文本,Text 适合长文本 |
| 文件类 | Image、File | 图片、文件资源标识 |
| 时间类 | Datetime、Day、Time | 日期时间相关 |
| 布尔 | Boolean | 布尔值 |
| 地理位置 | Geo、SingleGeo | 地图和坐标相关 |
例如 Address 中就有这样一组非常典型的字段:
export interface Schema extends EntityShape {
detail: String<32>;
phone: String<12>;
name: String<32>;
default: Boolean;
remark?: Text;
}
这里还有两个很实用的经验:
- 是否可选,直接用
?表达,例如remark?: Text; Text、Image、File、Object在框架内部被视为不适合建立普通索引的类型,设计索引时要慎用。
2.2 枚举、字面量联合与可选字段
Oak 不要求所有字段都必须来自 DataType。像枚举、字面量联合这类纯 TypeScript 写法,框架也是支持的。
例如 oak-general-business 的 User:
export interface Schema extends User {
gender?: 'male' | 'female';
idCardType?: 'ID-Card' | 'passport' | 'Mainland-passport';
}
这种写法非常适合:
- 性别、状态等级、来源类型这类有限枚举值;
- 需要在
entityDesc.locales.v中统一做显示文案映射的字段; - 后续还要配合颜色、图标做统一展示的字段。
2.3 对象字段与 JSON 风格字段
如果某个字段本身就是一个复杂对象,Oak 也允许你直接使用 TypeScript 对象类型,而不必强行拆成大量基础字段。
例如 oak-general-business 的 Application:
export type AppType = 'web' | 'wechatMp' | 'wechatPublic' | 'native';
export type NativeConfig = {
type: 'native';
wechatNative?: {
appId: string;
appSecret: string;
domain?: string;
};
location: Location;
};
export interface Schema extends EntityShape {
type: AppType;
config: WebConfig | WechatMpConfig | WechatPublicConfig | NativeConfig;
style?: Style;
}
这说明 Oak 支持:
- 字段直接写成对象类型;
- 字段写成多个对象类型的联合;
- 对象内部继续嵌套更深的结构。
这种写法很适合:
- 应用配置;
- 第三方平台参数;
- UI 样式配置;
- 结构稳定、但不值得单独拆成一个实体的配置对象。
2.4 普通多对一关系
如果当前对象需要“指向”另一个对象,直接把该字段写成另一个对象的 Schema 即可。这表示一条普通的多对一关系。
例如 Address 指向 Area:
import { Schema as Area } from './Area';
export interface Schema extends EntityShape {
area: Area;
}
这类写法的含义是:
- 在对象定义层,关系按对象来写,可读性最好;
- 编译后的
OpSchema会落成外键字段,例如areaId; - 但在查询、过滤、级联操作时,依然可以沿着
area这个关系名继续写。
因此,定义层和查询层的心智模型会比较统一。
2.5 自关联
对象字段也可以指向它自己,这在树结构、行政区划、分类体系里非常常见。
例如 Area:
export interface Schema extends EntityShape {
name: String<32>;
parent?: Schema;
}
这里的 parent?: Schema 就表示“地区的上级地区”。这种写法常用于:
- 地区树;
- 分类树;
- 组织架构;
- 文章目录树。
2.6 反向一对多关系
如果一个对象需要声明“它有哪些子对象”,可以直接在父对象上写数组字段。T[] 和 Array<T> 两种写法都可以。
例如 oak-general-business 的 User:
export interface Schema extends User {
files: Array<ExtraFile>;
codes: Array<WechatQrCode>;
addresses?: Address[];
}
这类数组字段的含义是:
- 当前对象和这些子对象存在一对多关系;
- 真正的外键仍然通常落在子对象侧;
- 这组定义会进入编译后的数据字典,后续查询、过滤、级联更新都能沿着这条关系使用。
什么时候应该写这种字段?通常是当你希望下面这些能力成立时:
- 可以从父对象直接查询子对象;
- 可以从父对象过滤“满足某条件的子对象”;
- 可以在级联操作里一次操作父子对象。
2.7 动态多对一关系
有些对象并不固定指向某一个父对象,而是“可能指向若干种对象中的一种”。Oak 为这种场景保留了一组专门写法:
export interface Schema extends EntityShape {
entity?: String<32>;
entityId?: String<64>;
}
这两个字段是有特殊含义的保留写法:
entity表示父对象的实体名;entityId表示父对象的主键;- 它们不是普通业务字段,不要拿这两个名字表达别的含义;
- 类型也不要自行改动。
Address、Session、AccountOper、OperEntity 都使用了这种模式。
如果你希望某个对象可以成为这类动态关联的“父对象”,通常还要在父对象上补出对应的一对多数组关系。例如 User 中的:
addresses?: Address[];
这样编译后的关系路径、级联查询和后续业务表达才会完整。
需要特别注意的一点是:一个对象内,Oak 实际上只支持一组这样的动态多对一保留字段。 如果你发现自己想在同一个对象里再造第二组“多态父对象”关系,通常就说明对象设计已经过于复杂,应该重新拆分。
2.8 一张速查表
如果你只想快速确认“某种对象字段写法支不支持”,可以直接看下表:
| 需求 | 写法示例 | 是否支持 |
|---|---|---|
| 继承基础对象 | interface Schema extends EntityShape | 支持 |
| 继承已有对象 | interface Schema extends User | 支持 |
| 基础字段 | name: String<32> | 支持 |
| 可选字段 | remark?: Text | 支持 |
| 枚举字段 | `gender?: 'male' | 'female'` |
| 对象字段 | style?: Style | 支持 |
| 联合对象字段 | `config: WebConfig | NativeConfig` |
| 普通多对一 | area: Area | 支持 |
| 自关联 | parent?: Schema | 支持 |
| 反向一对多 | addresses?: Address[] | 支持 |
| 动态多对一 | entity?: String<32>; entityId?: String<64> | 支持 |
3. Action 和 State 怎么写
当一个对象有明确的业务动作和状态流转时,建议把它们直接定义在对象文件里。这样后续权限、日志、页面动作、业务逻辑都会更清晰。
Oak 中描述状态机的核心类型是 ActionDef<A, S>,它的实际结构很简单:
type ActionDef<A extends string, S extends string> = {
stm: {
[action in A]: [prevState: S | S[], nextState: S];
};
is?: S;
};
例如 User 中的一组状态机:
export type UserAction = 'activate' | 'disable' | 'enable' | 'mergeTo' | 'mergeFrom';
export type UserState = 'shadow' | 'normal' | 'disabled' | 'merged';
export const UserActionDef: ActionDef<UserAction, UserState> = {
stm: {
activate: ['shadow', 'normal'],
disable: [['normal', 'shadow'], 'disabled'],
enable: ['disabled', 'normal'],
mergeTo: [['normal', 'shadow'], 'merged'],
mergeFrom: ['normal', 'normal'],
},
};
这里每一项都表示:
- 某个动作允许从哪些旧状态出发;
- 执行后会落到哪个新状态。
其中:
stm是状态转换矩阵;is是初始状态,可选;- 旧状态可以写成单值,也可以写成数组。
发布包场景下,ActionDef 可以来自运行时 JS,也可以通过 import alias 引入。编译器会结合声明文件里的 Action / State 类型和 JS 里的 initializer 生成 ActionDefDict。
一个对象可以有多组状态机
可以。User 就同时定义了用户状态和认证状态两组状态机:
UserAction/UserState/UserActionDefIdAction/IdState/IdActionDef
最后再把所有动作汇总成当前对象的 Action:
export type Action = UserAction | IdAction | 'play';
这也是比较推荐的写法:不同语义的状态机分开写,最后统一汇总动作。
自定义 Action 不一定非得改状态
不一定。Oak 支持你定义“不改变状态,但有明确业务语义”的动作。
这类动作依然很有价值,因为它们会:
- 让操作日志更清楚;
- 让权限控制更细;
- 让 trigger、checker、aspect 中的业务判断更明确。
通用 Action 关键字
框架本身还保留了一组通用动作名:
createupdateremoveselectcountdownloadaggregatestat
因此你定义自己的 Action 时,应避免与这些关键字重名。
4. Relation 怎么写
Relation 表达的不是对象之间的普通外键关系,而是“对象与用户之间的业务关系”。它常用于权限和业务身份表达。
例如 Session:
export type Relation = 'partner';
这里的 partner 表示“参与会话的人”。如果某个用户和某条 Session 存在 partner 关系,就表示这个用户参与了这场会话。
对应的 entityDesc.locales 中,还可以给关系补展示名称:
export const entityDesc: EntityDesc<Schema, '', Relation> = {
locales: {
zh_CN: {
name: '会话',
attr: {
entity: '关联对象',
entityId: '关联对象id',
},
r: {
partner: '所有者',
},
},
},
};
如果你理解了这里的 Relation,再去看 Oak 内置的 UserRelation 对象就很自然了。它本质上就是在表达:
- 哪个用户;
- 对哪个对象;
- 拥有什么关系。
并不是每个对象都必须定义 Relation。只有当对象真的要和“用户身份/权限关系”挂钩时,才需要这一层。
5. EntityDesc 怎么写
entityDesc 是对象的描述信息。它不是装饰性的补充,而是对象定义的重要组成部分。当前编译器会从这里读取命名、索引、配置、风格等元数据。
最常用的部分有下面几项:
| 字段 | 用途 |
|---|---|
locales | 定义对象、字段、动作、关系、枚举值的显示文案 |
indexes | 定义数据库索引 |
style | 定义动作图标、枚举/状态颜色 |
configuration | 定义对象的行为模式,如只读、只追加等 |
recursiveDepth | 递归对象的编译配置 |
5.1 locales
这是最重要的一项。它至少应该把对象名和字段名补齐。
locales: {
zh_CN: {
name: '用户',
attr: {
name: '姓名',
nickname: '昵称',
addresses: '收货地址',
},
action: {
activate: '激活',
disable: '禁用',
},
r: {
partner: '参与者',
},
v: {
gender: {
male: '男',
female: '女',
},
},
},
}
这里几组键的含义很固定:
| 键 | 含义 |
|---|---|
name | 对象名 |
attr | 字段名 |
action | 动作名 |
r | Relation 名 |
v | 枚举值/状态值名 |
如果对象定义了动作、关系、枚举或状态,建议这里一并补全,不要只写一半。后续组件和管理端展示会明显受益。
entityDesc 的泛型参数也会参与枚举字段推导。像 LocalizedContent.language: Language 这种 Language = keyof typeof LANGUAGE_LABELS 的写法,只要类型声明和必要常量声明在发布包中存在,就可以被编译器识别为字符串枚举。
5.2 indexes
索引直接定义在 entityDesc.indexes 中。User 里的写法就是一个很好的例子:
indexes: [
{
name: 'index_birth',
attributes: [
{
name: 'birth',
direction: 'ASC',
},
],
},
{
name: 'index_fulltext',
attributes: [
{ name: 'name' },
{ name: 'nickname' },
],
config: {
type: 'fulltext',
parser: 'ngram',
},
},
]
其中 config 当前支持这些能力:
| 配置项 | 说明 |
|---|---|
unique | 唯一索引 |
type | fulltext、btree、hash、spatial |
parser | MySQL 全文索引分词器 |
tsConfig | PostgreSQL 文本检索配置 |
chineseParser | PostgreSQL 中文分词配置 |
索引设计时有两点要牢记:
- 对象查询能力会受到索引声明影响,例如全文搜索必须先有全文索引;
Text、Image、File、Object这类字段不适合作为普通索引候选。
5.3 style
style 用来统一描述动作图标和枚举/状态颜色。
以 User 为例:
style: {
icon: {
activate: '',
disable: '',
enable: '',
},
color: {
userState: {
normal: '#0000FF',
disabled: '#FF0000',
shadow: '#D3D3D3',
merged: '#9A9A9A',
},
},
}
源码里的 StyleDesc 说明了两件事:
- 如果对象定义了
Action,通常就会有icon; - 如果对象定义了枚举/状态字典,通常就会有
color。
虽然不同项目对这部分元数据的消费深度不完全一样,但它的设计目的很明确:让组件和前端展示层可以统一读取风格信息,而不是在各个页面里到处写死颜色和图标。
5.4 configuration
configuration 用于声明对象的整体行为模式。当前最常用的是 actionType 和 static。
actionType 支持的值如下:
| 值 | 含义 |
|---|---|
readOnly | 只读对象 |
appendOnly | 只能新增,不能改删 |
excludeUpdate | 不允许更新 |
excludeRemove | 不允许删除 |
crud | 标准增删改查 |
公共业务包里有两个很好的例子:
Area使用configuration: { actionType: 'readOnly', static: true },表示它是只读维表;AccountOper、OperEntity使用configuration: { actionType: 'appendOnly' },表示它们更像流水,只能追加。
其中 static: true 很适合地区、字典、配置类维表。
5.5 recursiveDepth
框架类型层面对 recursiveDepth 是支持的,它主要用于递归对象的编译配置。但在当前公共业务包里,并没有特别典型的现成实体示例。
因此这里给出的建议很简单:
- 普通对象先不用它;
- 只有当你确实在做递归结构,并且已经结合生成结果验证过时,再启用它。
6. 编译结果和后续影响
对象定义写完或修改完之后,都要重新编译数据字典:
npm run make:domain
编译后,Oak 会在 src/oak-app-domain 下生成完整的数据字典与存储描述。这份产物会被下面这些部分共同使用:
- 组件和页面;
- checker、trigger、watcher、timer、routine;
- aspect、endpoint;
- 查询、聚合、级联更新等运行时能力。
所以要记住两件事:
- 只要改了
src/entities,就应重新执行npm run make:domain。 - 只要依赖模块的实体产物更新,也应重新执行
npm run make:domain。 - 如果项目已经上线,修改对象定义时还要同时维护升级脚本,保证数据库结构和线上数据能正确迁移。
下一小节会继续介绍:对象编译完成后,如何查询和操作这些对象。
7. 编写对象时最常见的坑
- 只写了
Schema,却没有把entityDesc.locales补全,导致后续显示名称到处不统一。 - 把
entity、entityId当成普通字段使用。它们在 Oak 中是动态多对一关系的保留写法。 - 在一个对象里试图定义两组不同含义的动态多对一关系。Oak 并不支持这种设计。
- 在父对象上忘记声明反向数组关系,结果后续查询和级联语义不完整。
- 继续使用
Float、Double设计业务金额,新项目应优先使用Decimal。 - 给
Text、Image、File、对象字段随意建普通索引,后续查询效果和存储成本都可能出问题。 - 修改对象后忘记重新执行
npm run make:domain。 - 升级依赖模块后忘记重新执行
npm run make:domain,导致oak-app-domain仍然是旧类型。 - 项目已上线后,只改了对象定义,却没有补数据库升级脚本。
- 先把
entityDesc组装到别的变量里再赋值。当前编译器是直接解析entityDesc字面量的,这种写法不稳妥。 - 继续沿用旧式的
locales、indexes、configuration分散定义方式。当前推荐的规范写法是统一收进entityDesc。 - 覆盖默认实体时只考虑新增字段,没有保持旧动作、状态、关系和常量兼容。
如果你第一次写对象,建议按下面这个顺序来:
- 先把
Schema写清楚; - 再补普通关系、自关联、动态关系;
- 如果对象有业务状态,再补
Action/State/ActionDef; - 最后把
entityDesc的locales、indexes、style、configuration补完整。
按这个顺序写,对象定义通常就不会乱。