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

✨ Adds dependency injection system #1461

Merged
merged 12 commits into from
Nov 21, 2022

Conversation

palle-k
Copy link
Contributor

@palle-k palle-k commented Nov 10, 2022

Proposed Changes

This PR adds a dependency injection system that works similar to the system in Nest.js.

Quick Demo:

Providers can be marked as Injectable. Doing so registers them in the metadata storage and allows them to be constructed automatically:

@Injectable()
class OrderService {
  async performOrder() {
    ...
  }
}

Providers are then registered in the app:

const app = new App({
  providers: [
    OrderService
  ]
});

And we can then use these providers in components and output classes.

@Component()
class OrderPizzaComponent extends BaseComponent {
  constructor(jovo: Jovo, options: UnknownObject, private readonly orderService: OrderService) {
    super(jovo, options);
  }

  @Intents('ConfirmOrderIntent')
  async confirmOrder() {
    try {
      await this.orderService.performOrder()
    } catch (e) {
      return this.$send("order failed");
    }
  }
}

Providers can also be defined in a more granular way, which is especially desirable during testing:

const testSuite = new TestSuite();
testSuite.app.configure({providers: [{
  provide: OrderService,
  useClass: MockOrderService,
}]})

Dependency injection can be traced using middlewares:

app.middlewareCollection.use(
  'event.DependencyInjector.instantiateDependency',
  (jovo: Jovo, dependencyTree: DependencyTree<any>) => { ... },
);

Note on @Injectable

Looking at the codebase it may not be immediately obvious why @Injectable is needed. However, it is essential to the construction of providers, as adding it to a provider causes metadata about the parameter types of the annotated class to be emitted. Without it, the Injection system would be unable to provide constructor parameters for the injectable class. Also, configuration options can be added in the future if desired.

Types of Changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Note on breaking changes: This PR changes the constructor signature for BaseComponent and BaseOutput.
By changing from options?: TYPE to options: undefined | TYPE, subsequent parameters are not required to be optional.

Checklist

  • My code follows the code style of this project
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

@palle-k palle-k changed the base branch from v4/latest to v4/dev November 10, 2022 14:30
@rmtuckerphx
Copy link
Contributor

@palle-k Can you show an example of how you would register providers in app.dev.ts as either part of app.configure or in an app.hook?

@palle-k
Copy link
Contributor Author

palle-k commented Nov 10, 2022

@rmtuckerphx sure!

app.configure({
  providers: [
    ...
 ]
});

This would replace all the providers declared in app.ts.

Should we merge the list of providers (giving providers provided to configure precedence over existing providers)?

@rmtuckerphx
Copy link
Contributor

@palle-k What I want to make sure is that I can handle the following stages/environments:

  • app.dev.ts - local, uses jovo run proxy to local machine, ServiceA uses hardcoded JSON responses changeable with special test intents
  • app.test.ts - unit tests where service can be mocked out
  • app.integration.ts - developer integration env similar to app.dev.ts but code is deployed to server
  • app.staging.ts - uses real ServiceA that makes API calls
  • app.prod.ts - uses real ServiceA that makes API calls

@rmtuckerphx
Copy link
Contributor

@palle-k Any thoughts on adding an Event Middleware for injection?
https://www.jovo.tech/docs/middlewares#event-middlewares

I would like to see how injection was resolved in the Jovo Debugger similar to routes

@rmtuckerphx
Copy link
Contributor

@rmtuckerphx sure!

app.configure({
  providers: [
    ...
 ]
});

This would replace all the providers declared in app.ts.

Should we merge the list of providers (giving providers provided to configure precedence over existing providers)?

I think we should merge instead of replace.

@rmtuckerphx
Copy link
Contributor

@palle-k What I want to make sure is that I can handle the following stages/environments:

  • app.dev.ts - local, uses jovo run proxy to local machine, ServiceA uses hardcoded JSON responses changeable with special test intents
  • app.test.ts - unit tests where service can be mocked out
  • app.integration.ts - developer integration env similar to app.dev.ts but code is deployed to server
  • app.staging.ts - uses real ServiceA that makes API calls
  • app.prod.ts - uses real ServiceA that makes API calls

For dev and integration, I would be using ServiceADev and staging and prod, the real ServiceA both of which implements ServiceAInterface.

@rmtuckerphx
Copy link
Contributor

@palle-k Just want to say a huge THANK YOU for working on this.

@palle-k
Copy link
Contributor Author

palle-k commented Nov 10, 2022

@rmtuckerphx dependency overrides via app.configure have been implemented.

@palle-k
Copy link
Contributor Author

palle-k commented Nov 10, 2022

@palle-k Any thoughts on adding an Event Middleware for injection? https://www.jovo.tech/docs/middlewares#event-middlewares

I would like to see how injection was resolved in the Jovo Debugger similar to routes

I'm interested how you would expect a dependency injection middleware to look like.
Basically, there's two options: One being to call the middleware once after a full injection process completes (meaning all transitive dependencies have been resolved), the other would be to call the middleware on every single resolution step. Which one do you prefer?

@rmtuckerphx
Copy link
Contributor

@palle-k Maybe there are 2 event middlewares: each individual resolve and injection complete

@palle-k
Copy link
Contributor Author

palle-k commented Nov 11, 2022

@rmtuckerphx I've added a middleware that outputs the whole dependency tree that contains information about the token being resolved, the resolved value and any arguments that have been included in the construction of the resolved value. Let me know if this is sufficient for you.

@palle-k palle-k marked this pull request as ready for review November 11, 2022 10:39
@palle-k palle-k force-pushed the v4/feat/dependency-injection branch from 34f99f1 to c392f3b Compare November 11, 2022 10:52
@acerbisgianluca
Copy link
Contributor

acerbisgianluca commented Nov 11, 2022

Hi @palle-k, this looks very very interesting! I've never used Nest.js so a couple of questions came to my mind:

  • When are injectables instantiated? For each request or just once when the App is initialized?
  • If they are instantiated for each request, does the injectable constructure receive the Jovo object? This would be really usefull if the Service, for example, needs the user's access token from the request.

@palle-k
Copy link
Contributor Author

palle-k commented Nov 11, 2022

Hi @palle-k, this looks very very interesting! I've never used Nest.js so a couple of questions came to my mind:

  • When are injectables instantiated? For each request or just once when the App is initialized?

For each time that a component/output class is instantiated. Instances are not cached in any way. In Nest.js there is the option to cache dependencies and I guess this can be added as a future expansion to this system.

  • If they are instantiated for each request, does the injectable constructure receive the Jovo object? This would be really usefull if the Service, for example, needs the user's access token from the request.

Yes, this was also important for me! If you look at the code, you can see that the dependency injector declares a provider for the Jovo instance. So if you declare an injectable with the type Jovo (or with @Injectable(Jovo)) anywhere in its constructor (doesn't matter in which position), it will receive the Jovo instance:

@Injectable()
class SomeService {
  constructor(readonly someOtherService: OtherService, jovo: Jovo) {}
}

@acerbisgianluca
Copy link
Contributor

Thanks for the explanation, that's perfect!

@jrglg
Copy link
Contributor

jrglg commented Nov 12, 2022 via email

@jankoenig
Copy link
Member

This is such a cool feature, thanks a lot @palle-k 🎉 Can't wait to merge this!

What do you folks think about grouping all services under a $services object, does this make sense?

this.$services.orderService.performOrder();

This would not necessarily add a lot of value, but be consistent with the naming of other Jovo properties.

@palle-k
Copy link
Contributor Author

palle-k commented Nov 13, 2022

What do you folks think about grouping all services under a $services object, does this make sense?

this.$services.orderService.performOrder();

This would not necessarily add a lot of value, but be consistent with the naming of other Jovo properties.

Can you go more into detail about this?

  1. How would this handle service instantiation? All of the services in my project depend on the Jovo object, so they'd have to be instantiated at the time of every request.
  2. If services are automatically instantiated at request time, how would this handle dependencies between services?
  3. Do you see this as an alternative to Dependency Injection or would the dependency injection tree be mapped to $services?

@jankoenig
Copy link
Member

would the dependency injection tree be mapped to $services?

I meant this one! Same functionality as the one you implemented, the only difference would be how the services are accessed by the user.

@palle-k
Copy link
Contributor Author

palle-k commented Nov 13, 2022

This seems a little cumbersome for me:

  • If dependency injection dynamically resolves only necessary tokens, it would be somewhat unpredictable on what would be available in $services. Also, there are no shared instances yet, so there is no unique resolution. Or would you expect this $services object to be specific for each component?

  • While dependencies are often services, a dependency can also be just some value (like configuration options):

const APP_CONFIG_TOKEN = Symbol("app_config");

app.configure({
  providers: [
    {
      provide: APP_CONFIG_TOKEN,
      useValue: loadConfig()
    }
  ]
});
  • If $services is global, accessing dependencies through a $services property would erase types of dependencies. If it was local to a component, we could use introduce a third generic type argument.

So I would actually vote against this.

@rmtuckerphx
Copy link
Contributor

@jankoenig Will this feature be merged soon?

@jankoenig
Copy link
Member

We're planning on merging this on Monday

@rmtuckerphx
Copy link
Contributor

So excited for this.

@rmtuckerphx
Copy link
Contributor

@palle-k How would you use interfaces with dependency injection?

interface IOrderService {
 async performOrder() : Promise<void>
}
@Injectable()
class OrderService implements IOrderService {
  async performOrder() {
    ...
  }
}
@Component()
class OrderPizzaComponent extends BaseComponent {
  constructor(jovo: Jovo, options: UnknownObject, private readonly orderService: IOrderService) {
    super(jovo, options);
  }

  @Intents('ConfirmOrderIntent')
  async confirmOrder() {
    try {
      await this.orderService.performOrder()
    } catch (e) {
      return this.$send("order failed");
    }
  }
}

@palle-k
Copy link
Contributor Author

palle-k commented Nov 19, 2022

@palle-k How would you use interfaces with dependency injection?

Interfaces do not exist in JavaScript, just in TypeScript. If you use an interface, it will appear as Object in the Reflect metadata (same also holds true for any, unknown, Record, usw.). So the dependency token will be Object.

If you want to inject a class that follows a given interface, you have 2 options:

  1. Instead of using an interface, you can use an abstract class. Abstract classes are valid dependency tokens.
  2. You can manually declare a dependency token and use the @Inject() decorator:
const ORDER_SERVICE_TOKEN = Symbol("OrderService");

@Injectable()
class SomeClass {
  constructor(
    @Inject(ORDER_SERVICE_TOKEN) readonly orderService: IOrderService
  ) {}
}

app.configure({ providers: [{
  provide: ORDER_SERVICE_TOKEN,
  useClass: OrderServiceImpl
}]});

The following types can be dependency tokens:

  • Class types
  • Abstract class types
  • Strings
  • Symbols

@rmtuckerphx
Copy link
Contributor

Copy link
Member

@jankoenig jankoenig left a comment

Choose a reason for hiding this comment

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

Thank you @palle-k 🎉 Great feature!

@jankoenig jankoenig merged commit 60c441b into jovotech:v4/dev Nov 21, 2022
@jrglg
Copy link
Contributor

jrglg commented Nov 28, 2022

Is it safe to store data (like a mongodb bulk) in an injected service as a member? I mean, the service instance is not shared for multiple users, right? So each instance of the same service is different.
That data lasts the same of the request I think, as I see in my tests, but I'm afraid i'm missing something

@palle-k
Copy link
Contributor Author

palle-k commented Nov 28, 2022

@jrglg Yes. If you use a useClass or useFactory provider, then there are no reused instances at all. If you inject the Jovo object, you can assume that it is always for the current request.

If you use a useValue provider, then this of course does not hold true.

@jrglg
Copy link
Contributor

jrglg commented Nov 28, 2022

All clear! thanks

@jrglg
Copy link
Contributor

jrglg commented Nov 29, 2022

Hello again, I'm not sure if this mine or not so I'm not opening an issue yet.
I'm having problems with DI when deploying it to Lambda.

It works nicely on local, with express, but fails at runtime in Lambda with these errors, at every injected service (actually not injected):

2022-11-29T16:23:46.605Z e4a1035f-4e75-41a8-a882-39c86f1635b2 ERROR TypeError: Cannot read property 'dumbMethod' of undefined at gn.handler [as LAUNCH] (/var/src/components/GlobalComponent.ts:41:36) at xJ.executeHandler (/var/node_modules/@jovotech/framework/src/ComponentTreeNode.ts:90:40) at processTicksAndRejections (internal/process/task_queues.js:95:5) at TY.handle (/var/node_modules/@jovotech/framework/src/plugins/HandlerPlugin.ts:58:5) at tY.run (/var/node_modules/@jovotech/framework/src/Middleware.ts:24:7) at iY.run (/var/node_modules/@jovotech/framework/src/MiddlewareCollection.ts:108:7) at XY.handle (/var/node_modules/@jovotech/framework/src/App.ts:246:7) at Runtime.handler (/var/src/server.lambda.ts:39:5)

My Global LAUNCH handler is so simple and just call a dumb method.

I tried putting providers at every level.

Please, anyone ever tried it in AWS Lambda? Thank you

@palle-k
Copy link
Contributor Author

palle-k commented Nov 30, 2022

@jrglg looks like this is a limitation of esbuild - see this issue. TL;DR: esbuild does not implement a type system and is therefore unable to emit decorator metadata.

There are some options how to continue:

  1. Use an esbuild plugin like @anatine/esbuild-decorators.
  2. Switch to a different bundler, like SWC and spack / swcpack.

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.

6 participants