编写对象

对象和属性

Entity在Oak框架中代表一个实体对象,应用开发首先应将需求分解,设计出最基础的对象及相对应的关系,并在src/entities目录中对之加以定义。

一个对象拥有若干属性,在Entity定义文件中,可以像以下这样来定义对象及其属性。我们引用的代码是oak-general-business包当中的Address对象,它代表着一个地址。

import { String, Int, Boolean, Text } from 'oak-domain/lib/types/DataType';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { Schema as Area } from './Area';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';

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

设计并编写Entity的一些规范如下:

  1. 需要用 export interface Schema extends EntityShape 来定义对象。我们设计Entity时,要么继承EntityShape(在其中定义了id等几个通用属性),要么继承并扩展已经设计好的对象(在下一小节的User对象中你们就可以看到例子),
  2. 需要使用 'oak-domain/lib/types/DataType' 中的类型来声明对象的属性
  3. 可以引用其它Entity,并将之声明为当前Entity的某一属性(此时称之为定义了对象之间的多对一关系)。例如在Address对象中就声明了area属性是代表(地址所属的)地区。

对象的属性也可以指向其自身。例如,在Area对象的定义中,就有一个 parent 属性指向它自己(Schema),代表该地区的上级地区。

动态多对一关系

在一些应用中,某对象A可能和不同的对象B/C/D……具有多对一关系(但这种关系是互斥的,不可能同时和多于一个对象具有这种关系)。此时,如果像这样设计A对象:

export interface A extends EntityShape {
    b?: B;
    c?: C;
    d?: D;
};

显然是一种比较臃肿的方式,同时也不具备良好的扩展性(比方说随着业务的发展,A又要指向更多的对象时,必须来修改其Entity定义)。

此时可以通过声明两个特殊的属性,告诉Oak框架,此对象可能会指向多个不同的其它对象。

export interface A extends EntityShape {
    entity: String<32>;
    entityId: String<64>;
};

注意,这里entityentityId是类型关键字,您不能拿它们去声明自身对象的其它属性,同时它们的类型也是固定的。

回到上面的Address对象的例子中,由于oak-general-business是较底层的抽象模块,我们想实现Address的功能,但并不想限制应用地址对象必须归属于哪个对象(例如,在某个应用系统中,不仅“用户”对象拥有地址,“公司”对象也拥有地址)。

问题来了,如何声明当前(拥有动态多对一关系的)对象和其它哪些对象可能有关联呢?只需要在要关联的对象中像下面这样声明就可以了,这里我们引用的代码来自oak-general-business中的User对象

import { Schema as ExtraFile } from './ExtraFile';
import { Schema as WechatQrCode } from './WechatQrCode';
import { Schema as Address } from './Address';
import { Schema as User } from 'oak-domain/lib/entities/User';

export interface Schema extends User {
    passwordSha1?: Text;
    birth?: Datetime;
    gender?: 'male' | 'female';
    idCardType?: 'ID-Card' | 'passport' | 'Mainland-passport';
    idNumber?: String<32>;
    files: Array<ExtraFile>;
    codes: Array<WechatQrCode>;
    isRoot?: Boolean;
    addresses?: Address[];
};

用户这个对象,(以一对多的方式)关联了三个拥有动态多对一设计的对象:ExtraFile, WechatQrCode, Address。这里我们不去深究其具体含义,只要知道,这三个对象中都设计有 entity/entityId 属性,同时我们在User对象的定义中声明了,User和它们具有一对多关系。这里用Array和[]的写法都是可以的。

如果一个对象想拥有超过一个的动态多对一关系怎么办?很遗憾,Oak并不支持这种设计。而且根据我们的经验,在实际应用开发中,出现如此复杂的对象往往意味着你应该重新审视你的对象设计是否合理了。

当一个对象A定义了与另一个对象B具有多对一关系时(无论是否是动态的),我们称B对象为A对象的父对象,同时称A对象为B对象的子对象

更多属性相关

Oak当前支持的基本属性类型可以参见:oak-domain/lib/types/DataType

其中,FloatDouble类型已经不再推荐,请尽量使用Decimal来定义非整型数值

除去这些类型之外,Oak还支持:

  • 以枚举的方式定义。例如在上一小节中Usergender属性
  • 以对象的方式定义。如果某个属性非常复杂,不能用基本类型表示。你既可以定义其为Object,也可以用TS来定义其具体格式

以下代码节选自oak-general-businessApplication对象。每个Application对象代表着系统的一个应用(Web/小程序/App……),因此它需要丰富的配置信息。

export type AppType = 'web' | 'wechatMp' | 'wechatPublic' | 'native';

export type NativeConfig = {
    type: 'native',
    passport?: Passport[];
};

export interface Schema extends EntityShape {
    name: String<32>;
    description: Text;
    type: AppType;
    system: System;
    config: WebConfig | WechatMpConfig | WechatPublicConfig | NativeConfig;
    style?: Style;
    sessions?: Session[];
    domain?: Domain;
};

在这里,type属性指向了一个枚举类型,config属性和style属性都指向更复杂的对象,config对象甚至指向一个复杂对象的枚举。

Action和State

在应用中的一些对象(核心业务对象),往往具有状态的概念。通过在Entity中定义其状态和动作,以及两者之间的关系,可以使应用更加严谨和可维护。

以上面所提到的 oak-general-business 中的User对象为例,在我们的设计中,用户这个对象有两种状态的转变:

  1. 对象状态:分为四种:正常用户/被禁用用户/被合并用户/影子用户。在这里我们不去深究后面两种的语义,只需要知道这代表着User的状态可能发生变化。比如说如果发现某个用户经常违规,管理员可以将之禁用,此时用户就变成了被禁用状态,将无法再登录系统。
  2. 对象身份状态:有的应用需要验证用户的真实身份,此时用户的身份状态就有三种:未认证/认证中/已认证

很显然这两种状态的转变是彼此独立的,我们可以通过以下的声明代码,来规范这两种状态。除了状态之外,我们也可以定义对User这个对象可能的动作,来规范状态的转变(以及后面的权限管理)。

export type IdAction = 'verify' | 'accept' | 'reject';
export type IdState = 'unverified' | 'verified' | 'verifying';
export const IdActionDef: ActionDef<IdAction, IdState> = {
    stm: {
        verify: ['unverified', 'verifying'],
        accept: [['unverified', 'verifying'], 'verified'],
        reject: [['verifying', 'verified'], 'unverified'],
    },
    is: 'unverified',
};

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'],
    },
};

export type Action = UserAction | IdAction;

在这里,我们声明了两组状态和动作,以及状态转换矩阵,同组中的状态/动作/转换矩阵的前缀必须要一致。状态转换矩阵规定了某个Action将把User的当前(对应的状态值)修改成什么,这种转换在规定之后应当是一目了然的。其中,is定义的是初始状态(若对象创建时没有赋初始状态则默认使用这种状态值)。

最后,我们通过export type Action来声明了当前对象上的所有Action。对当前对象的操作将被限制在这些自定义的Action和通用的Action当中。通用的Action包括:

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

在定义您自有的Action时请避免使用这些关键字。

Action是否必须与State相关联?并不是,完全可以定义某个Action并不改变对象的状态,甚至不改变对象本身的任何属性!实际上,我们鼓励根据应用的需求,尽量细的划分不同动作,以便实现更好的程序严谨性和权限管理。某种意义上,可以将自定义的Action看成一种特殊的update

Relation

几乎所有的应用系统都有一个共同的核心对象——用户。用户就是使用应用系统的人,而应用系统要对用户使用应用系统的行为加以甄别和管理(权限分配和限制),前提就是要将系统中的业务对象“分配”给合适的用户。Relation所表达的,就是当前对象和用户的关系

如果一个对象和用户具有Relation,则可以像下面一样定义,下面的代码节选自oak-general-business中的Session对象定义。

export type Relation = 'partner';

Session对象代表的是一个会话,而partner关系表达的就是参与这场会话的人。如果一个User和一个Session存在partner关系,则代表这个用户参与了这场会话。

Relation的底层实现

oak-domain/src/entities中,定义了一些公共对象,这些对象存在于所有的Oak项目中(尽管或许个别项目不会用到它们全部)。其中有一个对象UserRelation,其代表的就是用户和某个对象之间的Relation

import { Schema as User } from './User';
import { Schema as Relation } from './Relation';

export interface Schema extends EntityShape {
    user: User;
    relation: Relation;
    entity: String<32>;
    entityId: String<64>;
};

如果你理解了本章的内容,应当能理解UserRelation对象中各个属性的含义了吧。😁

EntityDesc

最后,我们需要为我们所有定义的内容进行描述。描述的目的有两个:

  1. 使此对象上的一些命名和风格全局一致(例如对象命名、对象属性命名等)
  2. 使此对象在存储时得到优化

对对象进行描述的代码如下面这样,我们仍然使用oak-general-business对象中的User对象举例:

export const entityDesc: EntityDesc<
    Schema,
    Action,
    '',
    {
        userState: UserState;
        idState: IdState;
        gender: Required<Schema>['gender'];
        idCardType: Required<Schema>['idCardType'];
    }
> = {
    locales: {
        zh_CN: {
            name: '用户',
            attr: {
                name: '姓名',
                nickname: '昵称',
                birth: '生日',
                password: '密码',
                passwordSha1: 'sha1加密密码',
                gender: '性别',
                idCardType: '证件类型',
                idNumber: '证件号码',
                ref: '指向用户',
                files: '相关文件',
                userState: '用户状态',
                idState: '认证状态',
                codes: '微信分享二维码',
                isRoot: '是否超级用户',
                addresses: '收货地址',
            },
            action: {
                activate: '激活',
                accept: '同意',
                verify: '认证',
                reject: '拒绝',
                enable: '启用',
                disable: '禁用',
                mergeTo: '合并',
                mergeFrom: '使合并',
            },
            v: {
                userState: {
                    shadow: '未激活',
                    normal: '正常',
                    disabled: '禁用',
                    merged: '已被合并',
                },
                idState: {
                    unverified: '未认证',
                    verifying: '认证中',
                    verified: '已认证',
                },
                gender: {
                    male: '男',
                    female: '女',
                },
                idCardType: {
                    'ID-Card': '身份证',
                    passport: '护照',
                    'Mainland-passport': '港澳台通行证',
                },
            },
        },
    },
    indexes: [
        {
            name: 'index_birth',
            attributes: [
                {
                    name: 'birth',
                    direction: 'ASC',
                },
            ],
        },
        {
            name: 'index_fulltext',
            attributes: [
                {
                    name: 'name',
                },
                {
                    name: 'nickname',
                },
            ],
            config: {
                type: 'fulltext',
                parser: 'ngram',
            },
        },
        {
            name: 'index_userState_refId',
            attributes: [
                {
                    name: 'userState',
                },
                {
                    name: 'ref',
                },
            ],
        },
    ],
    style: {
        icon: {
            verify: '',
            accept: '',
            reject: '',
            activate: '',
            enable: '',
            disable: '',
            mergeTo: '',
            mergeFrom: '',
        },
        color: {
            userState: {
                normal: '#0000FF',
                disabled: '#FF0000',
                merged: '#9A9A9A',
                shadow: '#D3D3D3',
            },
            idState: {
                unverified: '#FF0000',
                verified: '#0000FF',
                verifying: '#EEE8AA',
            },
            gender: {
                male: '#0000FF',
                female: '#EE82EE',
            },
            idCardType: {
                'ID-Card': '#E0FFFF',
                'Mainland-passport': '#2E8B57',
                passport: '#2F4F4F',
            },
        },
    },
};

在对象描述的类型定义EntityDesc中,可以传入四个参数,分别是对象定义Schema,对象动作定义Action,对象关系定义Relation,以及对象的相关状态和枚举属性定义字典。而对对象描述中,主要有:

  • locale定义,对对象本身/属性/属性枚举值/状态/动作/关系进行命名,这里的命名将被Oak框架编译成i18n的数据,实现全局命名的一致性(对编写优秀的应用极其重要!)
  • indexes定义,定义相关索引项。在这里定义的索引会在数据库建立的时候自动创建,也会规范对对象的查询方式(例如如果没有声明全文索引,则无法进行全文搜索)
  • style定义,定义对象的动作/关系/状态的颜色和图标(尚未实现),使前端渲染时可以获取相应的颜色,以实现颜色的全局一致性(同样对编写优秀的应用极其重要!)

由于TS的优秀的规范性,这里的定义如何详细书写不再赘述。

Entities的作用

Entity的定义可以说是整个应用业务的核心,通过执行

npm run make:domain

Oak框架会在src/oak-app-domain目录下编译出完整的应用对象定义,这份定义既包括开发人员编写代码时的Typescript类型定义,也包括程序运行过程中访问对象时的正确性检查。因此请牢记:

  1. 对Entity的任何修改都应当执行 npm run make:domain,使修改后的定义生效
  2. 如果应用已经上线迭代,对Entities目录下的任何修改,都应当在upgrade目录下维护好对数据库(结构或数据)的升级脚本,新版本上线时进行升级