Cordis利用动态创建子Context的方式构建具有树形层次化结构的Context Tree,进而为插件提供的独立的执行环境。貌似很完美地为插件解决了上下文隔离问题。但是还有一个问题得解决:由于Cordis总是将服务注册到根Context上,意味着以此为根的整棵Context Tree注册的插件使用的同名服务都是同一个实例,如何让服务执行的时候通过提取与插件关联的上下文信息,而不是使用与自身绑定的上下文。
1. 有状态服务实例复用的困境
对于一个不维护自身状态,只提供单纯操作的服务对象来说,它可以毫无风险地很复用。但是一旦让服务实例拥有了状态,复用的时候就要小心了。以如下这个演示程序为例,我们创建了一个绑定了一个Context对象(对应ctx属性)的 SingletonService,我们定义了一个名为 foobar 的访问器属性从这个Context中提取foobar属性。
import { Context} from '@deepseek-ai/cordis'
class SingletonService{
get foobar() {return this.ctx?.foobar ?? "N/A"}
ctx: Context = new Context()
}
declare module '@deepseek-ai/cordis' {
interface Context {
service: SingletonService
foobar: string,
}
}
const context = new Context();
context.provide("service",new SingletonService())
const subContext1 = context.isolate("child1")
Object.defineProperty(subContext1, 'foobar', { get() { return "foo" } });
subContext1.plugin(ctx=>console.log(`[Plugin1] ctx.service.foobar = ${ctx.service.foobar }`));
const subContext2 = context.isolate("child2");
Object.defineProperty(subContext2, 'foobar', { get() { return "bar" } });
subContext2.plugin(ctx=>console.log(`[Plugin2] ctx.service.foobar = ${ctx.service.foobar }`));
我们调用provide方法将SingletonService对象注册到创建的根Context上。然后创建了两个子Context,并分别定义了foobar属性,对应返回值分别为 foo 和 bar。我们在这两个子Context上分别注册了插件,并让插件输出当前上下文的ctx.service.foobar的值。 由于SingletonService对象的ctx属性是一个完全独立的Context,所以它的foobar访问器属性应该永远返回 N/A,如下的输出也体现了这一点。
[Plugin1] ctx.service.foobar = N/A
[Plugin2] ctx.service.foobar = N/A
但是我们真实的需求就是希望SingletonService能够在调用它的插件提供的Context内执行,所以它的foobar访问器属性就应该在不同的插件调用下分别返回foo 和 bar。但是按照我们对上面程序的理解,这貌似是要完成一件无中生有的事情,感觉误解。实则不然,我们看看Cordis是如何解决这个问题的。
2. 基于代理的属性解析
让同一个对象的属性针对当前上下文返回不同的值,源于Context内部采用了基于代理的动态属性解析。从如下的代码可以看出, Context的构造函数返回的并不是this,而是一个将它作为目标对象的代理。这是 Cordis 最精妙的设计,这意味着所有对 Context 属性的访问都不会直接触发,而是经过这个 Proxy。至于这个Proxy究竟服务返回属性访问请求,全部实现在作为拦截处理器的ReflectService.handler静态属性上。不过这不是本篇文章关注的内容,我们会在本系列后续文章中对此进行详细介绍,在这里我们只需要知道:在整Cordis体系内针对Context属性成员的访问都由ReflectService.handler控制。
export class Context {
constructor() {
...
const self = new Proxy<this>(this, ReflectService.handler)
...
return self
}
}
3. symbols.tracker 和 Tracker 接口
Cordis针对这个问题的解决需要使用到如下这个名为Tracker的接口。如果注册到Context上的服务对象定义了一个由symbols.tracker标识的属性提供了这样一个对象,它会改变属性访问的解析策略。
export interface Tracker {
associate?: string
property?: string
noShadow?: boolean
}
三个属性说明如下:
- associate:用于开启跨命名空间的动态属性重定向;
- property:用来指定当前Context绑定到对象的哪个属性上;
- noShadow: 决定在依赖注入入口处,是否要 强行保留最外层的影子上下文
说到这里,你肯定还是很晕,在服务对象上定义一个由symbols.tracker标识的Tracker接口类型的属性就可以了吗?确实是的,我们可以通过实例演示的方式来证实这一点。
4. 利用Tracker.property 动态绑定上下文
对于我们演示实例来说,其实我们的目的明确:让SingletonService对象的ctx属性直接与当前的Context绑定在一起。所以我们需要按照如下的方式为SingletonService额外添加一个由symbols.tracker标识的属性,并将作为属性值的Tracker对象的property属性设置为当前上下文绑定的目标属性 ctx 就可以。再次运行,将看到我们希望的结果。
import { Context ,Tracker, symbols} from '@deepseek-ai/cordis'
class SingletonService{
[symbols.tracker]:Tracker = {property : "ctx"}
get foobar(){return this.ctx?.foobar??"N/A"}
ctx: Context = new Context()
}
输出:
[Plugin1] ctx.service.foobar = foo
[Plugin2] ctx.service.foobar = bar
5. 利用Tracker.associate 关联属性成员
Tracker的property解决的是当前Context绑定到服务对象的哪个属性成员上,而associate刚好相反,解决是服务对象如何与当前Context的某个属性成员进行关联。具体例子,当某个服务以名称 service 注册到Context上, 服务对象携带的Tracker对象将associate设置为 foo。假设当前Context通过变量ctx表示, 此时我们可以通过ctx.service得到这个服务对象的代理。如果我们访问ctx.service.bar,并且此时 ctx 具有一个名为 foo.bar 的属性成员,该成员的值将作为返回值。下面的程序很好的演示了这一点:
import { Context ,Tracker} from '@deepseek-ai/cordis'
class FooService {
[symbols.tracker]:Tracker = {
"associate": "foo"
}
}
class BarService {
[symbols.tracker]:Tracker = {
"associate": "bar"
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
foo: {baz: string},
bar: {baz: string},
}
}
const context = new Context();
context.provide("foo", new FooService());
context.provide("bar", new BarService());
context.provide("foo.baz","ctx.foo.baz");
context.provide("bar.baz","ctx.bar.baz");
console.log(context.foo.baz);
console.log(context.bar.baz);
输出:
ctx.foo.baz
ctx.bar.baz
6. 关于 noShadow
对于Tracker的noShadow属性的作用和用法,我到目前没有看到明确的说明文档,只有从源代码去寻找答案。根据目前提供的源代码,我发现如下的方式是唯一能够验证noShadow属性分别设置成false和true的差异。如代码所示,我们定义了一个名为FoobarService的服务类型,我们会在构造函数中为当前对象添加一个Tracker对象并作为symbols.tracker标识的属性值。Tracker的property设置成ctx,意味着将当前Context绑定到ctx属性上,noshadow则来源于指定的参数。
import { Context, symbols, Tracker } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
shadowService: FoobarService,
noShadowService: FoobarService,
foobar:string
}
}
class FoobarService{
invoke (){
const tranker: Tracker = this[symbols.tracker];
console.log(`\ntranker: property=${tranker.property}, noShadow=${tranker.noShadow}`);
console.log(`this.ctx.foobar = ${this.ctx.foobar}`);
const shadow:Context = this.ctx[symbols.shadow];
if(shadow){
console.log(`this.ctx[symbols.shadow].foobar = ${shadow.foobar}`);
} else{
console.log(`this.ctx[symbols.shadow] = ${shadow}`);
}
}
constructor (private ctx: Context, noShadow: boolean) {
this[symbols.tracker] = {property:"ctx", noShadow:noShadow}
}
}
const context = new Context()
Object.defineProperty(context, "foobar", {value: "foo", writable: true});
(context as any)["shadowService"] = new FoobarService(context, false);
(context as any)["noShadowService"] = new FoobarService(context, true);
const subConext = context.extend();;
Object.defineProperty(subConext, "foobar", {value: "bar", writable: true});
subConext.shadowService.invoke()
subConext.noShadowService.invoke()
process.stdin.resume();
FoobarService的invoke方法会依次输入如下的内容:
- 当前的
Tracker; - 当前
Context(this.ctx)的foobar属性值; - 如果
symbols.shadow存在,则输出其foobar属性值,否则输出undefined。
我们创建了一个根Context,并将其foobar属性设置为foo。然后基于这个Context对象和不同的noShadow值创建了FoobarService,并将它们作为当前Conext的shadowService和noShadowService属性值。我们最终通过extend方法创建了一个子Context,并将foobar属性修改为bar。我们最终从子Context利用shadowService和noShadowService属性提取FoobarService对象,并调用其invoke方法。
tranker: property=ctx, noShadow=false
this.ctx.foobar = bar
this.ctx[symbols.shadow].foobar = foo
tranker: property=ctx, noShadow=true
this.ctx.foobar = bar
this.ctx[symbols.shadow] = undefined
从上面的输出可以看出,两种情况下从当前Context提供相同的值,但是只有在默认noshadow=false的情况下,才能通过symbols.shadow得到服务对象真正绑定的Context。 这种情况下,目标方法(invoke)执行流程为:
- Cordis会将服务实例的原始上下文,即构造函数中提供的Context对象(
foobar=foo)提取出来,并将其作为影子上下文; - 如果将影子上下文表示成
shadow,接下Cordis会使用对象{[symbols.shadow]:shadow}作为参数调用extend方法扩展当前Context并生成一个子Context; - 为方法(
invoke)调用的上下文 (即invoke方法中的this,ctx.shadowService返回的FoobarSerivce的代理对象)创建一个代理(代理的代理)。通过提供的get处理器在利用此代理访问Tracker的property属性表示的属性时,直接返回上面创建的子Context,即将携带影子上下文的Context对象绑定到服务对象由Tracker.property指定的属性上; - 以新创建的代理作为上下文(
this)调用方法(invoke)。
所示当noshadow=false,invoke方法中this.ctx属性得到的是携带影子上下文的Context对象,如果noshadow=true,该属性返回的就是当前上下文。从这个意义上讲:
- 影子上下文指的是目标服务对象绑定的原始Context,我原来以为当前提供的Context才是影子;
- noShadow的意思是不要构建影子上下文(shadow翻译为名词影子),或者不用影子上下文来遮挡当前上下文(shadow翻译成动词遮挡)。我原以为服务希望使用原始上下文,不希望被当前上下文遮挡。
但是让我疑惑的,如果invoke方法在两种情况下直接使用this.ctx去提取上下文信息,还是会得到相同的上下文信息,针对要求开发者利用symbols.shadow去提取影子上下文。虽然Cordis存在一些类似于下面的代理,但是我发现除非我手工创建一个包含影子的Cotext,否则这些分支都走不到。鉴于noshadow这个开关与我原有认知存在错位,我目前也没有看到官方文档对这个开关的作用和用法又什么确定的描述,上面的内容仅仅我通过代码反推的结果,仅当参考。
if (ctx[symbols.shadow] && !tracker.noShadow) {
ctx = Object.getPrototypeOf(ctx)
}
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber

1万+

被折叠的 条评论
为什么被折叠?



