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 中,context 可以理解为“当前这次逻辑执行所依赖的运行时环境”。

你在 aspecttriggercheckerwatchertimerroutine 里拿到的那个 context,并不只是一个数据库操作对象,它同时还携带了:

  • 当前事务信息;
  • 当前用户信息;
  • 当前是否处于 root 模式;
  • 当前操作产生的 opRecords;
  • 当前 locale;
  • 异常推导与消息传递能力。

最基础的 Context 接口

oak-domain/src/types/Context.ts 中,最基础的公共接口只有几个方法:

export interface Context {
    getCurrentTxnId(): string | undefined;
    getCurrentUserId(allowUnloggedIn?: boolean): string | undefined;
    isRoot(): boolean;
    allowUserUpdate(): boolean;
    toString(): Promise<string>;
}

这几个方法虽然不多,但已经把 Oak 上下文最核心的身份说清楚了:

  • 它属于某个事务;
  • 它知道当前用户是谁;
  • 它知道当前是否有 root 权限;
  • 它可以被序列化。

实际项目中的 context 会更强

在真实项目中,你拿到的通常不会只是这个最小接口,而是更具体的运行时上下文实现,例如:

  • 前端的 FrontendRuntimeContext
  • 后端的 BackendRuntimeContext
  • 项目自己扩展出来的 src/context/BackendRuntimeContext

这些具体 context 还会继续提供:

  • select
  • operate
  • count
  • aggregate
  • begin
  • commit
  • rollback
  • getLocale

也就是说,Oak 中真正的业务逻辑,大多数都是“在 context 上操作”的。

当前 SerializedDataBackendRuntimeContextFrontendRuntimeContext 都已经带上 locale。需要按当前语言做翻译、日志、通知或多语言内容生成时,不要再通过零散参数传递语言,优先从 context 取得。

这里和查询最相关的一点是:

  • select / aggregate 的语法,请直接以查询和操作对象里的 SelectionProjectionFilterSorter 为准;
  • count 适合“只要数量,不要行数据”的场景;
  • 如果要查软删除数据或加锁查询,则要在第三个参数里带 includedDeletedforUpdate 这类 SelectOption

为什么 Oak 要强调 context

因为 Oak 并不希望你把“当前用户”“当前事务”“当前缓存状态”“当前异常同步”等信息,到处通过函数参数手工传递。

框架的做法是:把这些信息统一放进 context 中,让所有运行时逻辑都在同一个载体上工作。这样才能保证:

  • 前后端上下文语义一致;
  • 一次业务动作中的多次操作共享同一事务;
  • 异常和 opRecords 可以顺着上下文回流。

可复用业务库怎样贡献 Context

普通应用只消费最终 Context;真正需要定义 backendContextLayerbackendContextModule 的,是会向下游项目贡献后端 Context 能力的 Oak 业务库。前端完全对称,名称换成 frontendContextLayerfrontendContextModule

这两个对象都放在原来的 src/context/BackendRuntimeContext.ts 中:

  • backendContextLayer 是文件内私有常量,描述本库新增了哪些字段、方法和生命周期行为;
  • backendContextModule 是具名导出,声明模块身份、Context 依赖和 layer;
  • BackendRuntimeContext 类及其默认导出继续保留,业务代码不需要改去新的 Contract 或 Module 文件;
  • 普通应用不定义 module 描述,应用的 generatedBackend.ts 负责组装最终运行时 class;
  • 没有 BackendRuntimeContext.ts 的纯实体库会被自动忽略,不需要创建空 Context 文件。

普通业务库的定义方式

defineContextLayer<Requires, Provides> 的第一个类型参数表示传入 Base 必须已经具备的能力,第二个参数表示本 layer 新增的能力;第二个参数省略时默认为空对象。现有大型 RuntimeContext 为了兼容可以像 general/pay 一样直接用最终 Context 类型作为第一个参数。defineContextModule<Contract>Contract 则表示下游从整个 module 获得的最终 Context 类型。

业务库自己的 BackendRuntimeContext 继承 make:dep 生成的 generatedBackend。如果本库确实增加运行时结构,用 apply 返回一个继承传入 Base 的 class;需要参加公共生命周期的方法,用 hooksreducers 描述:

import {
    defineContextLayer,
    defineContextModule,
} from '@oak-domain/context/ContextComposer';
import {
    backendContextModule as parentBackendContextModule,
} from '@parent-business/context/BackendRuntimeContext';
import GeneratedBackendRuntimeContext from './generatedBackend';

export class BackendRuntimeContext<ED extends EntityDict & BaseEntityDict>
    extends GeneratedBackendRuntimeContext<ED>
    implements RuntimeContext {
    protected applicationProjection = projection;
}

type BusinessBackendContext = BackendRuntimeContext<
    EntityDict & BaseEntityDict
>;

const backendContextLayer = defineContextLayer<BusinessBackendContext>({
    apply: (Base) => {
        abstract class BusinessBackendContextLayer extends Base {
            protected applicationProjection = projection;
        }
        return BusinessBackendContextLayer;
    },
    hooks: {
        refineOpRecords: {
            handler: async function () {
                await refineBusinessOpRecords(this);
            },
        },
    },
    members: {
        applicationProjection: {
            kind: 'field',
            policy: 'replace',
        },
    },
});

export const backendContextModule =
    defineContextModule<BusinessBackendContext>({
        id: 'oak-business-name',
        version: '1',
        dependencies: [parentBackendContextModule],
        layer: backendContextLayer,
    });

export default BackendRuntimeContext;

apply 收到的是已经组合好依赖模块的 Context class。必须基于这个 Base 派生并返回 class,不能在这里重新继承某个父业务库的 RuntimeContext,也不能自己构造依赖 Context。像示例中的公共处理逻辑,最好抽成 helper,供原 RuntimeContext class 和 layer hook 共同调用,避免两条入口的行为漂移。

如果一个库在某一端没有新增成员或行为,只需要传递 Context 依赖,module 可以没有 layer。例如只依赖父前端 Context 的库可以只导出:

export const frontendContextModule = defineContextModule<BusinessFrontendContext>({
    id: 'oak-business-name',
    version: '1',
    dependencies: [parentFrontendContextModule],
});

base 只用于兼容根层

base 不是普通业务库的写法。它用于把已有的大型 RuntimeContext class 整体接入组合系统,例如 oak-general-business

const backendContextLayer = defineContextLayer<GeneralBackendContext>({
    base: BackendRuntimeContext,
    members: {
        application: { kind: 'field' },
        applicationProjection: {
            kind: 'field',
            policy: 'replace',
        },
        initialize: {
            kind: 'method',
            policy: 'series',
        },
        getSerializedData: {
            kind: 'method',
            policy: 'reduce',
        },
    },
});

一次组合最多只能有一个 base layer,而且这个兼容 layer 不能再依赖另一个带 layer 的 Context 模块。新业务库应使用 applyhooksreducers,不要继续增加 base

成员策略

字段必须在 members 中显式声明。apply 返回 class 的自有原型方法能够被发现,但只要方法需要组合语义,也应明确声明或改用 hook/reducer。

policy语义
unique默认值。整个依赖图中只允许一个模块拥有这个字段或方法。
replace覆盖祖先提供的同名成员。新模块必须传递依赖旧 owner,且双方都声明 replace;两个兄弟模块不能互相覆盖。最终只使用后代实现。
final表示方法不允许再被覆盖或组合;再次出现同名成员就是冲突。字段不能声明 final
series一个生命周期方法需要让多个模块都执行。基础方法只执行一次,其余贡献通过 hooks 串行运行。
reduce多个模块依次变换同一个返回值。先调用基础方法,再把结果依次传给 reducers

hooks 会自动把同名方法登记为 seriesreducers 会自动登记为 reduce,因此同一个方法不能又写进 members。hook 的 handler 需要访问最终实例时,要使用 functionthis,不要使用没有动态 this 的箭头函数。

hook 支持两个顺序维度:

  • order 默认为 forward,按依赖优先顺序执行;清理类行为可用 reverse,按依赖的反方向执行;
  • phase 默认为 after,即先执行原方法再执行 handlers;设为 before 时 handlers 先执行;
  • 同一方法的所有 hooks 必须使用相同的 orderphase

reducer 的 order 同样默认为 forward,但没有 phase:它总是先取得原方法返回值,再串行交给各 reducer。一个模块如果需要稳定地排在另一个模块前后,必须通过 dependencies 表达;不要依赖两个无关兄弟模块的偶然顺序。

backendContextModule 的约束

id 通常使用 npm 包名,必须稳定且非空;version 表示这份 Context 组合合同的版本。dependencies 必须引用依赖库同一端 RuntimeContext 文件导出的 module,例如 backend 只引用 backendContextModule。依赖图会先执行依赖、后执行当前模块,并在菱形依赖中按 module id 去重。

运行时组装会拒绝依赖成环、同 id 的不一致版本或定义、多个 base、未返回 class 的 apply、非法覆盖、字段/方法冲突,以及 hook/reducer 顺序不一致。异常类型为 OakContextCompositionException,错误信息会明确列出冲突的两个模块,并带有“请联系管理员”。这些检查发生在最终 Context class 被动态创建时,不会静默选择某个实现。

生成与迁移

src/context/generatedBackend.tsgeneratedFrontend.tsmake:dep 重建,不要手工修改。业务库中的 generated 文件提供依赖能力的合并类型,使本库源码可以调用父 Context 方法;真正的依赖运行时仍由最终应用一次性组合。应用只需要声明直接依赖,例如只依赖 pay 时,pay module 会继续带出 general module。

旧项目先执行 make:dep,再运行:

oak-cli migrate context

迁移器会把可确认的直接父类继承改成 generated Context 继承,并保留原 RuntimeContext 文件。局部 mixin、factory 或手工 composeContextModules(...) 等无法安全判断的写法需要人工审阅。

在哪里会接触到 context

你几乎会在 Oak 的所有运行时逻辑里接触到它:

aspect

getLicenseSecretKey: async (params, context) => {
    const rows = await context.select('license', {
        data: { secretKey: 1 },
        filter: { id: params.licenseId },
    }, {});
    const secretKey = rows[0]?.secretKey;
    if (!secretKey) {
        throw new OakUserException(
            'error::license.secretKeyNotFound',
            'project-name',
            { licenseId: params.licenseId }
        );
    }
    return secretKey;
}

checker

checker: (operation, context) => {
    const userId = context.getCurrentUserId();
}

watcher

fn: async (context, data) => {
    await context.operate(...);
    return context.opResult;
}

root 模式

context.isRoot() 是 Oak 中非常关键的判断。很多系统级初始化、静态数据装载、超级管理员能力,都会依赖 root 模式。

例如 AppLoader.initialize() 在初始化静态数据时,就会调用 context.openRootMode()

这意味着:

  • 正常业务代码默认不应假设自己处在 root 模式;
  • 只有系统初始化或极少数管理逻辑才应显式使用它。

不要自己随意创建 context

在 Oak 中,context 的生命周期通常由框架管理:

  • AppLoader 在后端请求入口、aspect、endpoint、timer、routine 中创建 context;
  • cache 和 connector 在前端调用链里维护前端上下文。

因此,除非你非常清楚自己在做什么,否则不要把 context 当作普通类随便 new 出来。更推荐的做法是:

  • 在框架给你的 context 上继续工作;
  • 或者在 WBFreeWatcher / FreeTimer 中使用框架提供的 contextBuilder()FreeRoutine 则从第二个参数 env.contextBuilder 获取。

一个理解上下文的方式

你可以把 Oak 的 context 粗略理解成:

“带有事务、身份、权限和操作记录能力的业务执行现场”。

一旦这样理解,后面再去看 Oak 里的各种逻辑类型,就会发现它们其实都只是“在不同阶段、以不同方式使用 context”而已。