Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

编写对象

在 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 运行时值合并解析。

这意味着模块发布时要保证:

  • SchemaActionState、枚举类型等声明出现在 .d.ts 中;
  • entityDescActionDef 等运行时值出现在同名 .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>TextString<L> 适合短文本,Text 适合长文本
文件类ImageFile图片、文件资源标识
时间类DatetimeDayTime日期时间相关
布尔Boolean布尔值
地理位置GeoSingleGeo地图和坐标相关

例如 Address 中就有这样一组非常典型的字段:

export interface Schema extends EntityShape {
    detail: String<32>;
    phone: String<12>;
    name: String<32>;
    default: Boolean;
    remark?: Text;
}

这里还有两个很实用的经验:

  • 是否可选,直接用 ? 表达,例如 remark?: Text
  • TextImageFileObject 在框架内部被视为不适合建立普通索引的类型,设计索引时要慎用。

2.2 枚举、字面量联合与可选字段

Oak 不要求所有字段都必须来自 DataType。像枚举、字面量联合这类纯 TypeScript 写法,框架也是支持的。

例如 oak-general-businessUser

export interface Schema extends User {
    gender?: 'male' | 'female';
    idCardType?: 'ID-Card' | 'passport' | 'Mainland-passport';
}

这种写法非常适合:

  • 性别、状态等级、来源类型这类有限枚举值;
  • 需要在 entityDesc.locales.v 中统一做显示文案映射的字段;
  • 后续还要配合颜色、图标做统一展示的字段。

2.3 对象字段与 JSON 风格字段

如果某个字段本身就是一个复杂对象,Oak 也允许你直接使用 TypeScript 对象类型,而不必强行拆成大量基础字段。

例如 oak-general-businessApplication

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-businessUser

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 表示父对象的主键;
  • 它们不是普通业务字段,不要拿这两个名字表达别的含义;
  • 类型也不要自行改动。

AddressSessionAccountOperOperEntity 都使用了这种模式。

如果你希望某个对象可以成为这类动态关联的“父对象”,通常还要在父对象上补出对应的一对多数组关系。例如 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: WebConfigNativeConfig`
普通多对一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 / UserActionDef
  • IdAction / IdState / IdActionDef

最后再把所有动作汇总成当前对象的 Action

export type Action = UserAction | IdAction | 'play';

这也是比较推荐的写法:不同语义的状态机分开写,最后统一汇总动作。

自定义 Action 不一定非得改状态

不一定。Oak 支持你定义“不改变状态,但有明确业务语义”的动作。

这类动作依然很有价值,因为它们会:

  • 让操作日志更清楚;
  • 让权限控制更细;
  • 让 trigger、checker、aspect 中的业务判断更明确。

通用 Action 关键字

框架本身还保留了一组通用动作名:

  • create
  • update
  • remove
  • select
  • count
  • download
  • aggregate
  • stat

因此你定义自己的 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动作名
rRelation 名
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唯一索引
typefulltextbtreehashspatial
parserMySQL 全文索引分词器
tsConfigPostgreSQL 文本检索配置
chineseParserPostgreSQL 中文分词配置

索引设计时有两点要牢记:

  • 对象查询能力会受到索引声明影响,例如全文搜索必须先有全文索引;
  • TextImageFileObject 这类字段不适合作为普通索引候选。

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 用于声明对象的整体行为模式。当前最常用的是 actionTypestatic

actionType 支持的值如下:

含义
readOnly只读对象
appendOnly只能新增,不能改删
excludeUpdate不允许更新
excludeRemove不允许删除
crud标准增删改查

公共业务包里有两个很好的例子:

  • Area 使用 configuration: { actionType: 'readOnly', static: true },表示它是只读维表;
  • AccountOperOperEntity 使用 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;
  • 查询、聚合、级联更新等运行时能力。

所以要记住两件事:

  1. 只要改了 src/entities,就应重新执行 npm run make:domain
  2. 只要依赖模块的实体产物更新,也应重新执行 npm run make:domain
  3. 如果项目已经上线,修改对象定义时还要同时维护升级脚本,保证数据库结构和线上数据能正确迁移。

下一小节会继续介绍:对象编译完成后,如何查询和操作这些对象。

7. 编写对象时最常见的坑

  • 只写了 Schema,却没有把 entityDesc.locales 补全,导致后续显示名称到处不统一。
  • entityentityId 当成普通字段使用。它们在 Oak 中是动态多对一关系的保留写法。
  • 在一个对象里试图定义两组不同含义的动态多对一关系。Oak 并不支持这种设计。
  • 在父对象上忘记声明反向数组关系,结果后续查询和级联语义不完整。
  • 继续使用 FloatDouble 设计业务金额,新项目应优先使用 Decimal
  • TextImageFile、对象字段随意建普通索引,后续查询效果和存储成本都可能出问题。
  • 修改对象后忘记重新执行 npm run make:domain
  • 升级依赖模块后忘记重新执行 npm run make:domain,导致 oak-app-domain 仍然是旧类型。
  • 项目已上线后,只改了对象定义,却没有补数据库升级脚本。
  • 先把 entityDesc 组装到别的变量里再赋值。当前编译器是直接解析 entityDesc 字面量的,这种写法不稳妥。
  • 继续沿用旧式的 localesindexesconfiguration 分散定义方式。当前推荐的规范写法是统一收进 entityDesc
  • 覆盖默认实体时只考虑新增字段,没有保持旧动作、状态、关系和常量兼容。

如果你第一次写对象,建议按下面这个顺序来:

  1. 先把 Schema 写清楚;
  2. 再补普通关系、自关联、动态关系;
  3. 如果对象有业务状态,再补 Action / State / ActionDef
  4. 最后把 entityDesclocalesindexesstyleconfiguration 补完整。

按这个顺序写,对象定义通常就不会乱。