Skip to content
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

Closed
wants to merge 8 commits into from

Conversation

raymondfeng
Copy link
Contributor

@raymondfeng raymondfeng commented Oct 17, 2017

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

  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style
    guide

@raymondfeng raymondfeng requested a review from bajtos as a code owner October 17, 2017 17:35
@raymondfeng raymondfeng added review and removed review labels Oct 17, 2017
@raymondfeng raymondfeng force-pushed the extension-point branch 2 times, most recently from 22588d0 to da5315f Compare October 17, 2017 21:01
Copy link
Contributor

@virkt25 virkt25 left a 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');
Copy link
Contributor

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?

Copy link
Contributor Author

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.

  1. Use naming convention such as authentication.strategies.*
  2. Use tags (all bindings with tag 'authentication-strategy' will be treated as extensions for the authentication.manager extension point.

Copy link
Member

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.

Copy link
Contributor Author

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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Member

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.

Copy link
Contributor Author

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: {
Copy link
Contributor

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`?
Copy link
Contributor

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.

Copy link
Contributor Author

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> {
Copy link
Contributor

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 ...

Copy link
Contributor Author

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:

  1. 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.

  2. Invoke all extensions, for example, email/sms/push notifications.

  3. Try extensions one by one until one of them can handle the request.

  4. Only one extension is allowed for a given type. For example, Password hashing function.

@raymondfeng
Copy link
Contributor Author

Yes, I'm advocating to come up an out-of-box solution for extension point/extension pattern so that we can:

  1. Allow multiple servers for the REST component
  2. Allow multiple strategies for authentication
  3. Allow multiple logging implementations
  4. Allow multiple deserialization/serialization schemes for different media types
    ...

Copy link
Member

@bajtos bajtos left a 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[] {
Copy link
Member

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.

Copy link
Contributor Author

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);
Copy link
Member

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?

Copy link
Contributor Author

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) {}
Copy link
Member

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,
Copy link
Member

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> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the nameManager is considered as antipattern, see e.g here and here.

Let's use a more descriptive name please, for example AuthenticationHandler.

.bind('authentication.strategies.local')
.toClass(LocalStrategy)
.inScope(BindingScope.SINGLETON)
.tag('authentication-strategy');
Copy link
Member

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: {
Copy link
Member

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.

Copy link
Contributor Author

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: {
Copy link
Member

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
Copy link
Member

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.)

Copy link
Contributor Author

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];
Copy link
Member

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..

@raymondfeng raymondfeng force-pushed the extension-point branch 2 times, most recently from 96b8755 to db0a1d0 Compare October 18, 2017 22:23
@raymondfeng raymondfeng changed the title test(context): Add acceptance for extension point/extension [WIP] feat(core): Add acceptance for extension point/extension Oct 19, 2017
@raymondfeng raymondfeng changed the title [WIP] feat(core): Add acceptance for extension point/extension [RFC] feat(core): Add acceptance for extension point/extension Oct 19, 2017
@raymondfeng raymondfeng force-pushed the extension-point branch 3 times, most recently from 6ba46f5 to 7c67206 Compare October 19, 2017 15:38
Copy link
Contributor

@kjdelisle kjdelisle left a 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

@raymondfeng raymondfeng force-pushed the extension-point branch 8 times, most recently from 24a4b15 to 040dd90 Compare October 19, 2017 22:21
@raymondfeng raymondfeng force-pushed the extension-point branch 8 times, most recently from 7d2561f to 2cd34f7 Compare February 12, 2018 22:51
@raymondfeng raymondfeng force-pushed the extension-point branch 3 times, most recently from aabdd20 to 2b7268c Compare February 26, 2018 17:51
raymondfeng and others added 8 commits April 20, 2018 08:32
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.
@bajtos
Copy link
Member

bajtos commented Oct 26, 2018

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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants