这是本系列的第6篇文章,由于Context是整个Cordis的核心,所以前几篇文章都有在介绍它。第一篇和第二篇中介绍的三种形式的插件和服务是注册在Context上;在第三篇中我们介绍每个Context用以一个用来管自身和注册插件的Fiber对象,虽然第四篇介绍的事件总线相对独立,但是作为核心的EventsService也是附加在Context中四个核心基础服务之一,第五章就不用说了,对于注册在Context的一个服务单例,它绑定的Conext固定,执行的方法也不会传入相关的Context,但是它的方法就是能够动态绑定作为当前Context对象,这个魔法是Context最引人注目的设计。在接下来两篇文章中,我们将从Context类型的定义对对Cordis体系中这个最终的对象展开介绍,几乎会涉及到Context的各个细节,争取把每一点都讲透。
1. 面向接口的编程
虽然了解Context的设计和实现对于有效地使用Cordis非常重要,但是在实际编程的时候,我们基本上采用面向接口的编程方式,只使用Context接口。如下面的代码片段所示,我们可以利用Context接口得到附加在它上面的四个单例的基础服务,分别是用于提供时间总线的EventsService,提供日志服务的LoggerService、利用反射手段对Context本身进行扩展的ReflectService,以及用来注册插件的RegistryService。
typescript
interface Context {
[symbols.isolate]: Dict<symbol>
[symbols.intercept]: Dict
root: this
baseUrl?: string
events: EventsService
logger: LoggerService
reflect: ReflectService
registry: RegistryService
}
Context类型定义了一个核心方法extend使我们可创建子Context,从而使Context形成了一个树形层次结构,这个层次结构由原型链+代理 共同构建而成,代理用来解决可跟踪(Traceablity)问题,说白了就是让某个插件所在的子Context应用到服务调用上,让服务操作在执行的时候可以跟踪 到作为调用方法的插件。对于任意一个Context,都可以利用root属性得到作为根的那个Context,symbols.isolate和symbols.intercept这两个预定义Symbol标识的属性都返回一个字典,前者来存储服务注册名称和注册标识(一个Symbol对象)之间的映射关系,后者用来存储服务的配置。前者允许我们为同一个名称注册不同的服务对象,后者则允许为同一个服务提供不同的配置。由于都是存储在创建的子Context字典上,并不会对继承链以上的Context造成任何影响。
虽然Context接口本身并没有定义丰富的成员,但是由于TypeScript的接口是可以任意扩展的,实际上我们能够使用的成员绝不仅有这些。如果这些原生和扩展的成员还不够,我们还可以按照如下的方式通过模块声明的方式为Context接口添加额外的成员。
typescript
import { Context, Service} from '@deepseek-ai/cordis'
class Greeter extends Service {
greet(){
console.log("Hello World");
}
constructor(ctx:Context){
super(ctx,"greeter");
}
}
declare module '@deepseek-ai/cordis'{
interface Context{
greeter:Greeter
}
}
const context = new Context();
new Greeter(context);
context.greeter.greet();
输出:
bash
Hello World
比如在上面的例子中,我们通过扩展Service基类定义了一个Greeter服务类型,构造函数针对基类构造函数的调用会完成服务的注册,此时相当于为Context添加了一个以注册名称(greeter)命名的属性成员,所以我们利用模块声明的形式为Context添加了一个类型为Greeter的greeter属性。在针对Cordis编程的时候,你会发现这是我们的常规操作。
2. 基于子Context的自由扩展
Context类型的定义是很简单的,它仅仅定义了extend、isolate和intercept三个方法,而且isolate和intercept方法最终还是调用的extend方法,所以extend是Context最核心的方法。extend 方法的主要作用是派生出一个新的子上下文。在 Cordis 中,当一个插件被加载、或者一个中间件被注册时,框架不会直接使用全局根上下文,而是通过 extend 创建一个子上下文。这样可以确保每个插件拥有独立的生命周期 、私有状态 和隔离的服务 ,同时又能通过原型链共享全局资源。
typescript
export class Context {
extend(meta = {}): this {
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
const self = Object.create(getTraceable(this, this))
for (const prop of Reflect.ownKeys(meta)) {
Object.defineProperty(self, prop, Reflect.getOwnPropertyDescriptor(meta, prop)!)
}
if (!shadow) return self
return Object.assign(Object.create(self), { [symbols.shadow]: shadow })
}
}
extend 方法基本的流程:
- 从当前
Conext提供由symbols.shadow(一个预定义的Symbol对象)标识的属性提取影子上下文。通过上一篇文章的介绍我们知道,当调用noShadow没有显式设置成true的服务对象时,对方会将创建服务对象时提供的原始Context以影子上下文 的形式作为symbols.shadow属性的值; - 调用
getTraceable方法创建一个针对当前Context的代理,该方法实现其最具特色的依赖注入和归属追踪机制的核心幕后功臣,这是我们下一篇文章介绍的重中之重; - 遍历参数对象
meta的属性成员,并将其定在代理对象上; - 如果影子上下文不存在,直接方法上面创建的代理对象;
- 反之,返回以代理为原型创建一个新对象,并将影子上下文作为它的
symbols.shadow属性。
extend 方法相当于给予了我们一种对Context进行无限扩充的安全手段。这种手段之所以是安全的,是因为它永远保持当前Context树不变,扩展的成员总是添加在新创建的子Context中,原型链 + 代理 的模式保证上层Context可以被下层Context继承。
typescript
import { Context, Service, symbols} from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis'{
interface Context{
foo:number
bar:number
baz:number
}
}
let context = new Context();
context = context.extend({foo:111, bar: 222});
context = context.extend({bar:333, baz: 444});
console.assert(context.foo == 111);
console.assert(context.bar == 333);
console.assert(context.baz == 444);
context = Object.getPrototypeOf(context);
console.assert(context.foo == 111);
console.assert(context.bar == 22);
console.assert(context.baz == undefined);
context = Object.getPrototypeOf(context);
console.assert(context.foo == undefined);
console.assert(context.bar == undefined);
console.assert(context.baz == undefined);
console.assert(context.root === context);
在如上的演示程序中,我们创建了一个根Context,并调用extend方法创建子Context并扩展了foo和bar属性,针对子Context再次调用extend方法扩展了bar和baz属性,从断言可以看出,最终的context可以使用继承下来的foo 、重写的bar 和添加的baz 。由于注册对象没有使用Tracker,针对当前Context的跟踪机制并不会生效,也就意味着代理其实没有真正生效,所以三个Context构建的三代单传 结构本质上就是JavaScript的原型链,演示程序针对Object.getPrototypeOf方法的调用也证实了这一点。
3. 上下文隔离解决服务注册冲突
Cordis语义下,一切皆插件,而插件所需的基本功能一般由注册的服务来提供。随着注册的插件越来越多,出现针对同一服务接口,不同插件需要不同实现的可能性越来越多。如果服务名称和注册实例的映射关系存储在一个扁平的结构中,必然导致命名冲突。此时就需要调用isolate方法生成一个隔离的命名空间。
typescript
export class Context {
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}
isolate方法的目的是为指定的服务名称映射到一个唯一标识服务实例的Symbol, 它定义了如下两个参数:
- name: 需要提供独立命名空间的服务注册名称,由于是字符串不保证唯一;
- label :为服务的注册提供唯一标识,由于是
Symbol,能够保证唯一性。
从上面的代理可以看出,isolate 方法的实现非常简单:它利用symbols.isolate这个预定义Symbol将隔离字典从当前Context中提取出来,并以此为原型创建一个新的字典。并将当前服务名称name和作为全局唯一标识的Symbol(如果指定则根据name创建一个Symbol)之间的映射关系添加到字典中,最后将调用extend方法针对隔离字典扩展Context生成返回的子Context。这意味着创建的子Context中的symbols.isolate拥有了属于自己的用来解析依赖服务的隔离字典,它可以在这个字典中将某一个注册的服务名称映射到另一个标识服务实例的Symbol上。通过由于父Context的隔离字典是自己的原型,所以原型链保证两者之间的继承关系。
附加在根Context的reflect属性上的RefactService单例对象利用内部维护的store字典存储了注册的服务实例。为了避免命名冲突,这个store字典使用唯一标识服务实例的Symbol作为Key,就是symbols.isolate中与服务名称映射的那个Symbol。下面这段程序通过三层上下文(根上下文 context、隔离上下文 subContext1、隔离上下文 subContext2),实现了同一个服务属性名greeter在不同上下文分支中指向完全独立的实例对象。
typescript
import { Context} from '@deepseek-ai/cordis'
interface Greeter{
greet():void;
}
declare module '@deepseek-ai/cordis'{
interface Context{
greeter:Greeter
}
}
const context = new Context();
context.provide("greeter",{greet(){console.log("Good morning")}});
const subContext1 = context.isolate("greeter");
subContext1.provide("greeter",{greet(){console.log("Good afternoon")}});
const subContext2 = context.isolate("greeter");
subContext2.provide("greeter",{greet(){console.log("Good evening")}}); \
context.greeter.greet();
subContext1.greeter.greet();
subContext2.greeter.greet();
process.stdin.resume();
输出:
bash
Good morning
Good afternoon
Good evening
4. 上下文隔离实现针对服务的动态配置
isolate 方法实现了为当前注册的服务提供一个不同的实现。实际上类似的需求还有另一种解法:动态提供相应的配置 。这个解决方案可以利用Context如下两个重载的intercept方法来完成。它是 Cordis 框架中用来实现 声明式服务配置改写, 运行时依赖动态调参 的核心 API。
typescript
export class Context {
intercept<K extends InjectKey>(name: K, config: Context[K]
extends { [symbols.config]: infer T } ? T : never): this
intercept(name: string, config: any): this
intercept(name: string, config: any) {
const intercept = Object.create(this[symbols.intercept])
intercept[name] = config
return this.extend({ [symbols.intercept]: intercept })
}
}
从上面的代码可以看出,intercept 方法最终调用的依然是extend方法,但是扩展的是通过symbols.intercept标识的字典,该字典存储的是服务名称(字符串,不是Symbol)与配置对象的映射关系。与isolate方法类似, 由于子Context的symbols.intercept字典以父Context的symbols.intercept字典为原型,所以同时起到了隔离与继承 的双重作用。在如下这段演示程序中,全局只有一个服务实例,但当它被不同层级的上下文调用时,它能够动态、隐式地去顺着当前调用者的原型链(this.ctx[symbols.intercept])提取专属于该分支的配置,从而展现出不同的业务行为。
typescript
import { Context, Service, symbols} from '@deepseek-ai/cordis'
interface Greeter{
greet():void;
}
declare module '@deepseek-ai/cordis'{
interface Context{
greeter:Greeter
}
}
class GreeterService extends Service{
greet(){
const config = this.ctx[symbols.intercept]["greeter"] as {timeOfDay:string};
console.log(`Good ${config.timeOfDay}`);
}
constructor(ctx: Context){
super(ctx, "greeter")
}
}
let context = new Context();
new GreeterService(context);
context = context.intercept("greeter", {timeOfDay:"morning"});
context.greeter.greet();
context = context.intercept("greeter", {timeOfDay:"afternoon"});
context.greeter.greet();
context = context.intercept("greeter", {timeOfDay:"evening"});
context.greeter.greet();
输出:
bash
Good morning
Good afternoon
Good evening
5. Context还剩下什么?
上面我们介绍了Context的 extend、isolate和 intercept 三个方法,其中又以 extend方法最为核心,它涉及到针对一个特殊的getTraceable函数的调用。如下所示的Context类的完整定义,出来上述这三个方法,它还定义了一个用于判断指定对象是否Context的静态方法is,余下的就是定义的构造函数函数了。构造函数除了初始化包括根、symbols.isolate和symbols.intercept字段,Fiber和四大核心基础服务之外, 最特别之处就是返回的self的创建方式。具体来说,返回的self是根据this创建的一个代理 ,但是构建代理的处理器定义ReflectService的静态属性handler上。getTraceable和这个代理赋予了服务动态跟踪当前Context 的能力,这一点在整个DeepSeek Harness体系中尤为重要。究竟背后的实现原始如何,请听下回分解。
typescript
export class Context {
static readonly effect: unique symbol = symbols.effect
static readonly filter: unique symbol = symbols.filter
static readonly isolate: unique symbol = symbols.isolate
static readonly intercept: unique symbol = symbols.intercept
static is(value: any): value is Context {
return !!value?.[Context.is as any]
}
static {
Context.is[Symbol.toPrimitive] = () => Symbol.for('cordis.is')
Context.prototype[Context.is as any] = true
}
/** Create the root context and install the built-in services. */
constructor() {
this[symbols.isolate] = Object.create(null)
this[symbols.intercept] = Object.create(null)
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
this.baseUrl = undefined
this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])
this.reflect = new ReflectService(self)
this.registry = new RegistryService(self)
this.events = new EventsService(self)
this.logger = new LoggerService(self)
this.fiber._disposables.clear()
return self
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Context <${this.fiber.name}>`
}
extend(meta = {}): this {
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
const self = Object.create(getTraceable(this, this))
for (const prop of Reflect.ownKeys(meta)) {
Object.defineProperty(self, prop, Reflect.getOwnPropertyDescriptor(meta, prop)!)
}
if (!shadow) return self
return Object.assign(Object.create(self), { [symbols.shadow]: shadow })
}
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}
intercept<K extends InjectKey>(name: K, config: Context[K]
extends { [symbols.config]: infer T } ? T : never): this
intercept(name: string, config: any): this
intercept(name: string, config: any) {
const intercept = Object.create(this[symbols.intercept])
intercept[name] = config
return this.extend({ [symbols.intercept]: intercept })
}
}