上下文
在 Oak 中,context 可以理解为“当前这次逻辑执行所依赖的运行时环境”。
你在 aspect、trigger、checker、watcher、timer、routine 里拿到的那个 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 还会继续提供:
selectoperatecountaggregatebegincommitrollbackgetLocale
也就是说,Oak 中真正的业务逻辑,大多数都是“在 context 上操作”的。
当前 SerializedData、BackendRuntimeContext 和 FrontendRuntimeContext 都已经带上 locale。需要按当前语言做翻译、日志、通知或多语言内容生成时,不要再通过零散参数传递语言,优先从 context 取得。
这里和查询最相关的一点是:
select/aggregate的语法,请直接以查询和操作对象里的Selection、Projection、Filter、Sorter为准;count适合“只要数量,不要行数据”的场景;- 如果要查软删除数据或加锁查询,则要在第三个参数里带
includedDeleted、forUpdate这类SelectOption。
为什么 Oak 要强调 context
因为 Oak 并不希望你把“当前用户”“当前事务”“当前缓存状态”“当前异常同步”等信息,到处通过函数参数手工传递。
框架的做法是:把这些信息统一放进 context 中,让所有运行时逻辑都在同一个载体上工作。这样才能保证:
- 前后端上下文语义一致;
- 一次业务动作中的多次操作共享同一事务;
- 异常和 opRecords 可以顺着上下文回流。
可复用业务库怎样贡献 Context
普通应用只消费最终 Context;真正需要定义 backendContextLayer 和 backendContextModule 的,是会向下游项目贡献后端 Context 能力的 Oak 业务库。前端完全对称,名称换成 frontendContextLayer 和 frontendContextModule。
这两个对象都放在原来的 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;需要参加公共生命周期的方法,用 hooks 或 reducers 描述:
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 模块。新业务库应使用 apply、hooks 和 reducers,不要继续增加 base。
成员策略
字段必须在 members 中显式声明。apply 返回 class 的自有原型方法能够被发现,但只要方法需要组合语义,也应明确声明或改用 hook/reducer。
| policy | 语义 |
|---|---|
unique | 默认值。整个依赖图中只允许一个模块拥有这个字段或方法。 |
replace | 覆盖祖先提供的同名成员。新模块必须传递依赖旧 owner,且双方都声明 replace;两个兄弟模块不能互相覆盖。最终只使用后代实现。 |
final | 表示方法不允许再被覆盖或组合;再次出现同名成员就是冲突。字段不能声明 final。 |
series | 一个生命周期方法需要让多个模块都执行。基础方法只执行一次,其余贡献通过 hooks 串行运行。 |
reduce | 多个模块依次变换同一个返回值。先调用基础方法,再把结果依次传给 reducers。 |
hooks 会自动把同名方法登记为 series,reducers 会自动登记为 reduce,因此同一个方法不能又写进 members。hook 的 handler 需要访问最终实例时,要使用 function 和 this,不要使用没有动态 this 的箭头函数。
hook 支持两个顺序维度:
order默认为forward,按依赖优先顺序执行;清理类行为可用reverse,按依赖的反方向执行;phase默认为after,即先执行原方法再执行 handlers;设为before时 handlers 先执行;- 同一方法的所有 hooks 必须使用相同的
order和phase。
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.ts 和 generatedFrontend.ts 由 make: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”而已。