-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(context): add support to watch context bindings #2122
Conversation
bfeb532
to
36c89b6
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@raymondfeng I like the idea of allowing other parts of the application to subscribe to context events (a binding was registered, unregistered, etc.).
However, the current implementation seems overly complicated and complex to me. Can you try to simplify it please and using the primary use cases/scenarios to drive the requirements?
What are actually the high-level end-to-end scenarios you are trying to support? They are not clear to me. The current acceptance tests are at context level, but I'd like to see acceptance tests at application and extension (Componet) level.
I also don't understand the connection between watching for bindings and receiving a list of values matching a filter ( |
|
@bajtos I extracted the |
f938235
to
b03de5d
Compare
90af513
to
145bc85
Compare
I am still confused. Why would we have to pay the penalty to find/resolve per request? Here is my understanding:
The new tests added by commit 145bc85 are kind of showing how to implement this, but the solution still seems unnecessary complicated to me. What other use cases (besides updating routing table) do you have in mind for ContextWatcher implementation as you proposed here? If the only motivation is to notify the routing table about controllers being added/removed, then I believe we can find a much simpler solution.
Scenario-driven development FTW! |
The usage is much wider than just
Please note we use Before this PR, the extension point will receive an injection of extensions (snapshot) when it's first resolved within a context chain. The list of extensions cannot be updated afterward unless the extension point finds/resolves matching extensions per request by code. To allow extensions for a given extension point to be added/removed after the extension point is initialized, The acceptance tests use the |
Makes sense 👍
In my mind, code (e.g. Routing table) consuming registered extensions (e.g. Controllers) needs not only an API for getting the up-to-date list of extensions (Controllers), but also a way how to be notified when that list changed. I see the second part (subscribe to change notifications) as more important, because I am not aware of any other way how to detect changes in context bindings. On the other hand, receiving an up-to-date list of binding matching a filter can be already implemented today by receiving the full Context object via dependency injection and using APIs like
This seems like a preliminary optimization to me. In the scenarios you have described above, I expect the list of bindings (extensions) to change very infrequently and therefore I expect that a simpler solution based on calling I am not entirely opposed to implementing a caching variant, but I would like the cache to be implemented as an optional add-on built on top of a simpler abstraction. What is the minimal API needed by places consuming a changing list of bound extension values? I am thinking about the following: export type ChangeObserver = (
event: ContextEventType,
binding: Readonly<Binding<unknown>>,
) => ValueOrPromise<void>;
export type BindingMatcher = (binding: Readonly<Binding<unknown>>) => boolean;
class LiveBindings {
// subscribe for changes
watch(onChange: ChangeObserver, matcher?: BindingMatcher);
// unsubscribe
unwatch(onChange: ChangeObserver);
// find bindings matching a pattern
find(matcher?: BindingMatcher): Readonly<Binding<T>>[];
// resolve given binding keys
resolve<T>(keys: BindingAddress<T>[], session?: ResolutionSession): Promise<T[]>;
} Maybe we don't need any new class like A mock-up showing a possible usage in RoutingTable: class RoutingTable {
constructor(
@inject(...)
private bindings: LiveBindings
) {
this.bindings.watch((event, binding) => this.onBindingsChanged(event, binding));
for (const b of this.bindings.find()) {
this.onBindingsChanged(ContextEventType.bind, b);
}
}
onBindingsChanged(event: ContextEventType, binding: Readonly<Binding<unknown>>) {
if (isControllerBinding(binding)) {
switch(event) {
case ContextEventType.bind:
this.addControllerFromBinding(binding);
break;
case ContextEventType.unbind:
this.removeControllerFromBinding(binding);
break;
default:
console.warn('Ignoring unknown ContextEventType %j', event);
}
} else if(isRouteBinding(binding)) {
// etc.
}
}
}
} Let's discuss. |
As I am re-reading my example above, it occurs to me that the RoutingTable mock-up is too involved, which I am taking as a sign that my proposal was too low-level. Next iteration: type BindingChangeListener<T> = (b: Readonly<Binding<T>>) => ValueOrPromise<T>;
interface WatcherHandle {
unsubscribe(): void;
}
interface LiveBindingWatcher {
onAdded(b: Readonly<Binding<T>>): ValueOrPromise<T>;
onRemoved(b: Readonly<Binding<T>>): ValueOrPromise<T>;
}
interface LiveBindings<T> {
// subscribe for changes
watch(listener: LiveBindingWatcher): WatcherHandle;
// list all bindings
find(): Readonly<Binding<T>>[];
// resolve given binding keys
resolve<T>(keys: BindingAddress<T>[], session?: ResolutionSession): Promise<T[]>;
}
class RoutingTable {
constructor(
@inject.liveList(b => b.tag === 'controller')
private controllerBindings: LiveBindings,
@inject.liveList(b => b.tag === 'route')
private routeBindings: LiveBindings,
) {
this.controllerBindings.watch({
onAdded: b => this.addControllerBinding(b),
onRemoved: b => this.removeControllerBinding(b),
});
for (const b of this.controllerBindings.find()) {
this.addControllerFromBinding(b);
}
// process routeBindings similarly
}
} |
@strongloop/loopback-maintainers @strongloop/loopback-next We are discussing different approaches for enhancing Context API to allow consumers to monitor changes (a new binding added, an existing binding removed). It would be great to hear about more different perspectives from you, please join the discussion if a reactive context is something you are interested in. |
This is exactly what I have in the PR:
The
Since the list of extensions is only changed infrequently, it makes more sense to cache the resolved values so that we don't have to find matching bindings every time and resolve them again. Please note that |
@bajtos Your proposal is very similar to what I have:
|
145bc85
to
58f625f
Compare
58f625f
to
59a3448
Compare
Thanks, I am starting to better understand your proposed design. As usually, the pull request would have been easier to review if it was split into smaller increments, e.g. 1) ContextListener 2) ContextWatcher 3) decorator. Anyhow. WatcherHandle vs ContextWatcher.unwatch(): I am fine with either approach 👍 LiveBindingWatcher vs ContextListener: I find the name "ContextListener" suboptimal - we are not listening to context per se, but to events emitted by the context. How about using ContextWatcher: I find this name rather misleading. As I understand this class, its primary purpose is to provide an always up-to-date list of bindings and/or resolved values. The fact that we are observing/watching context is just an implementation detail. In my experience, naming code entities after implementation details leads to code and APIs that are difficult to understand. Usually it's much better to use a name describing the intent and purpose. That's how I came to LiveBindings, but that name is not perfect either. Let's try to come up with a better one.
|
} | ||
|
||
ctx.bind('store').toClass(Store); | ||
expect(() => ctx.getSync<Store>('store')).to.throw( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: we can test in an async wayawait expect(ctx.get<Store>('store')).to.be.rejectedWith('The type of Store.constructor[0] (String) is not a Getter function')
.
const view = inst.view; | ||
expect(await view.values()).to.eql([3, 5]); | ||
// Add a new binding that matches the filter | ||
// Add a new binding that matches the filter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dup
} | ||
}); | ||
|
||
describe('ContextEventListener', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel this test suit repeated in PR #2291
}); | ||
|
||
it('resolves bindings', async () => { | ||
expect(await contextView.resolve()).to.eql(['BAR', 'FOO']); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is the difference between function resolve()
and values()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolve()
is used for @inject.*
to resolve sync/async values. values()
honors the cache.
expect(contextView.bindings).to.containEql(xyzBinding); | ||
// `abc` does not have the matching tag | ||
expect(contextView.bindings).to.not.containEql(abcBinding); | ||
expect(await contextView.values()).to.eql(['BAR', 'XYZ', 'FOO']); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, doesn't reset
mean BAR
and FOO
should be removed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reset() cleans up the cache to force contextView.values()
to pick up latest bindings.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I renamed reset
to be refresh
to avoid confusions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@raymondfeng Great refresh
sounds more clear to me. And no confusions any more when I read the implementation code.
9e2e739
to
e5340fd
Compare
*/ | ||
export class ContextView<T = unknown> implements ContextObserver { | ||
protected _cachedBindings: Readonly<Binding<T>>[] | undefined; | ||
protected _cachedValues: T[] | undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be too strict to assume that the values are all in the same type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No. We can relax as follows:
ContextView<Type1 | Type2>
e5340fd
to
bf5cf26
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re-posting two older comments that haven't been addressed yet. I did not look at the new patch yet.
packages/context/src/__tests__/acceptance/context-view.acceptance.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The pull request is much easier to review and it looks pretty good now.
My major complain is about the organization of tests. They seem to be organized around the implementation details (new injection flavors are using ContextView under the hood) instead of being organized around the user experience & use cases (inject by filter, inject a getter by filter, inject a view).
packages/context/src/__tests__/acceptance/context-view.acceptance.ts
Outdated
Show resolved
Hide resolved
packages/context/src/__tests__/acceptance/context-view.acceptance.ts
Outdated
Show resolved
Hide resolved
packages/context/src/__tests__/acceptance/context-view.acceptance.ts
Outdated
Show resolved
Hide resolved
packages/context/src/__tests__/acceptance/context-view.acceptance.ts
Outdated
Show resolved
Hide resolved
@@ -100,7 +101,8 @@ function resolve<T>( | |||
return injection.resolve(ctx, injection, s); | |||
} else { | |||
// Default to resolve the value from the context by binding key | |||
return ctx.getValueOrPromise(injection.bindingKey, { | |||
const key = injection.bindingSelector as BindingAddress; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add an assert
to verify that assumption at runtime please?
const selector = injection.bindingSelector;
if (!isBindingAddress(selector)) {
throw new AssertionError(...);
}
const key = selector; // no cast is needed
@@ -100,7 +101,8 @@ function resolve<T>( | |||
return injection.resolve(ctx, injection, s); | |||
} else { | |||
// Default to resolve the value from the context by binding key | |||
return ctx.getValueOrPromise(injection.bindingKey, { | |||
const key = injection.bindingSelector as BindingAddress; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it perhaps make more sense to simply move the code from resolveValuesByFilter
directly into resolve
, so that @inject(filter)
does not need any custom resolver?
bf5cf26
to
b5ae9a8
Compare
Added.
Not really, |
b5ae9a8
to
2dac020
Compare
@bajtos PTAL. |
cc5ace0
to
9874848
Compare
@bajtos Any more comments before I can land this PR? |
@raymondfeng I'll take another look. Just by reviewing the discussion above, I see the following older comments were not addressed nor responded too:
|
@raymondfeng see also #2122 (comment) |
9874848
to
9200f41
Compare
@bajtos PTAL. |
Recreated from #2111 due to branch renaming.
a live collection of values
@inject.view
- injects an array of values resolved from bindingsthat match the filter function
@inject
and@inject.getter
to accept a binding filter functionSee docs.
Checklist
npm test
passes on your machinepackages/cli
were updatedexamples/*
were updated