-
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
[WIP] feat(core): Add support for extension point/extension pattern on top of Context #657
Conversation
bce8157
to
10fccd6
Compare
22588d0
to
da5315f
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.
+1 for the overall implementation but have some questions I would like to know for clarification.
-
From the example what advantage does this provide? (A user can just bind multiple AuthStrategy classes and inject/call the one they want).
-
In this design it seems like a developer is responsible for choosing the final extension they wants to use ... can this work whereby all extensions mounted to an extension point are notified and triggered? (An example might be a NotificationManager -> send out email notif + slack notif + push notif)
-
Would you suggest servers be written as an extensionPoint?
.bind('authentication.strategies.local') | ||
.toClass(LocalStrategy) | ||
.inScope(BindingScope.SINGLETON) | ||
.tag('authentication-strategy'); |
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'm not familiar with .tag()
... And reading this code I'm not sure where it's used. Can you explain it's usage?
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.
There are a few ways to group a set of bindings as extensions for a given extension point.
- Use naming convention such as
authentication.strategies.*
- Use tags (all bindings with tag
'authentication-strategy'
will be treated as extensions for theauthentication.manager
extension point.
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.
Use tags (all bindings with tag 'authentication-strategy' will be treated as extensions for the authentication.manager extension point.
I think it's a bad idea to have two possibly conflicting conventions for looking up extensions. For example, it allows you to bind authentication strategy as a controller too via ctx.bind('controllers.authentication').toClass(MyStrategy).tag('authentication-strategy')
. I know this example is silly, but my concern is that users often do silly things and then we have to spend our precious time explaining them what they did wrong.
I personally prefer to get rid of Binding's .tag()
method entirely, considering that we haven't used it anywhere so far, and we are already almost in Beta.
Alternatively, if you insist on supporting both naming convention and binding tags to look up extensions, then please capture this in acceptance tests, including how edge cases like my silly example above should be handled.
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'm not proposing to support both. Personally, I don't want to rely too much on the naming magic and prefer to use tag
to explicitly group bound services. Tags can also be used as filters for @inject
. If we visualize the Context
, tags will be really useful too.
|
||
# TBD | ||
|
||
- Should we go beyond naming conventions to make ExtensionPoint/Extension first class entity of the context? |
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.
+1
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 agree that we should provide interface/base classes OOTB. However, I think they should be part of a different package than @loopback/context
, probably @loopback/core
.
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'll move it into @loopback/core
. This way, @loopback/context
stays as the IoC container while core implements common patterns on top of Context
, for example, application
, extension point/extension
, and component
.
ctx.bind('authentication.manager').toClass(AuthenticationManager); | ||
ctx.bind('authentication.config').to({ | ||
extensions: { | ||
local: { |
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.
Once Kevin's PR lands for .options()
or .withDependencies
(whatever it ends up being) ... I think it would be better to leverage that then as it would make configuration easier by allowing this to be moved to the Class bindings below. (I think that will make for a better developer experience).
# TBD | ||
|
||
- Should we go beyond naming conventions to make ExtensionPoint/Extension first class entity of the context? | ||
- Should we introduce decorators such as `@extensionPoint` and `@extension`? |
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 would @extension
be used for? I assume @extensionPoint
will be used for declaring the extensionPoint classes instead of extending ExtensionPoint
.
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.
@extension
decorator can be used to denote the name
of an extension point it contributes to.
* Get an instance of an extension by name | ||
* @param name Name of the extension | ||
*/ | ||
async getExtension(name: string): Promise<EXT> { |
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 make sense for us to automatically return the extension if only one has been registered to an ExtensionPoint? --> or mark a default one to use? ... I think it makes for an better developer experience.
As in the example below -> A developer with only one AuthStrategy would find it tedious to have to specify local
again and again ...
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.
When an extension point manages one or more extensions, a few common delegating cases are involved:
-
Pick up one by the matching metadata. For example, an authentication action can use the
authentication.manager
extension point to delegate a corresponding strategy provider based on the decoration of the target controller method. -
Invoke all extensions, for example, email/sms/push notifications.
-
Try extensions one by one until one of them can handle the request.
-
Only one extension is allowed for a given type. For example, Password hashing function.
Yes, I'm advocating to come up an out-of-box solution for extension point/extension pattern so that we can:
|
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 pushed a new commit fixing markdown syntax in the .md file, please update your local branch before making further changes, @raymondfeng.
The overall goal and design looks pretty good to me.
Besides to comments below, I'd like us to consider what's the right abstraction layer where to implement support for extensions. I personally think @loopback/context
should focus on providing a good IoC container only and the concept of extensions/extension points does not belong there, extensions should be a part of @loopback/core
, probably implemented in the Application
class.
/** | ||
* Find an array of bindings for extensions | ||
*/ | ||
getExtensionBindings(): Binding[] { |
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 difference between names getExtensionBindings
and getExtensionBinding
(below) is too small and easy to overlook. I am proposing to rename this one to getAllExtensionBindings
.
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.
+1.
async getExtension(name: string): Promise<EXT> { | ||
const binding = this.getExtensionBinding(name); | ||
// Create a child context to bind `config` | ||
const extensionContext = new Context(this.context); |
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.
We need to carefully consider implications for CONTEXT-scoped bindings, as they will be cached at the level of this temporary extension context. Is this the right thing to do? Shouldn't we cache CONTEXT-scoped bindings at this.context
level instead?
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.
Let me think about that.
Meanwhile, I'll evaluate the possibility to make ExtensionPoint
extend Context
. This way, each extension point will have its own isolated context.
|
||
```ts | ||
class LocalStrategy implements AuthenticationStrategy { | ||
constructor(@inject('config') private config: LocalConfig) {} |
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.
+1, this is great.
|
||
class AuthenticationManager extends ExtensionPoint<AuthenticationStrategy> { | ||
constructor( | ||
@inject('$context') context: Context, |
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 am not very happy that we need to inject the full context object here, but I am not sure if there is anything we can do about that.
} | ||
} | ||
|
||
class AuthenticationManager extends ExtensionPoint<AuthenticationStrategy> { |
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.
.bind('authentication.strategies.local') | ||
.toClass(LocalStrategy) | ||
.inScope(BindingScope.SINGLETON) | ||
.tag('authentication-strategy'); |
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.
Use tags (all bindings with tag 'authentication-strategy' will be treated as extensions for the authentication.manager extension point.
I think it's a bad idea to have two possibly conflicting conventions for looking up extensions. For example, it allows you to bind authentication strategy as a controller too via ctx.bind('controllers.authentication').toClass(MyStrategy).tag('authentication-strategy')
. I know this example is silly, but my concern is that users often do silly things and then we have to spend our precious time explaining them what they did wrong.
I personally prefer to get rid of Binding's .tag()
method entirely, considering that we haven't used it anywhere so far, and we are already almost in Beta.
Alternatively, if you insist on supporting both naming convention and binding tags to look up extensions, then please capture this in acceptance tests, including how edge cases like my silly example above should be handled.
extensions: { | ||
[extensionName: string]: any; | ||
}; | ||
extensionPoint: { |
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 point of this member? AFAICT, it's not used anywhere else in this pull request.
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 was trying to separate config properties for the extension point itself from the ones for extensions.
// Register the extension point | ||
ctx.bind('authentication.manager').toClass(AuthenticationManager); | ||
ctx.bind('authentication.config').to({ | ||
extensions: { |
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 we avoid this extra level of nesting (config -> extensions -> local) and go for shorter config -> local?
}); | ||
|
||
// Register multiple extensions | ||
ctx |
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'd like to see some kind of helpers to simplify the registration of extensions. Ideally, I'd want the registration code to refer to extension point by constructor (AuthenticationHandler
) and then have everything else filled for me by the framework.
For example:
app.bindExtensionOf(AuthenticationHandler).toClass(LocalStrategy);
(I am using AuthenticationHandler
instead of AuthenticationManager
per my earlier comment.)
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.
+1.
getExtensionBinding(name: string): Binding { | ||
const key = `${this.name}.${name}`; | ||
const extensions = this.getExtensionBindingMap(); | ||
return extensions[key]; |
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 am proposing to simplify this method to the following:
return this.context.getBinding(key);
The current implementation builds a full map of all extension bindings, which is a preliminary pessimization IMO.
There is also a subtle difference handling of unknown extension names: the current proposal returns undefined, my proposal throws error The key ${key} was not bound to any value.
.
96b8755
to
db0a1d0
Compare
6ba46f5
to
7c67206
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.
Echoing feedback I gave earlier, it seems a bit verbose, but I can't think of a more terse* way that isn't broken, or doesn't require a rewrite of something fundamental (which would be a mistake, IMO).
LGTM
24a4b15
to
040dd90
Compare
7d2561f
to
2cd34f7
Compare
aabdd20
to
2b7268c
Compare
2b7268c
to
bedf553
Compare
bedf553
to
73c6e7e
Compare
1. Add context.bindOptions to configure bindings 2. Add context.getOptions to look up options for a binding 3. Add @injection.options to receive injection of options
Extension point/extension is a pattern for extensibility. This PR illustrates how we can support it using the Context apis and naming conventions. It also serves as an invitation to discuss if we should support such entities out of box.
73c6e7e
to
30fec10
Compare
There have been no updates in this pull request for more than a year by now, I am closing it for now. @raymondfeng feel free to reopen if you get time to continue this work. (Considering how outdated these changes are, it may be better to start from scratch.) |
Extension point/extension is a pattern for extensibility.
This PR illustrates how we can support it using the Context apis
and naming conventions. It also serves as an invitation to discuss
if we should support such entities out of box.
Description
Related issues
Checklist
guide