-
Notifications
You must be signed in to change notification settings - Fork 0
Core
The core library provides basic mechanisms that are used by the other packages:
- dependency injection
- aop
- configuration values
- tracing
Dependency is a pattern especially used in large scale applications, in order to increase flexibility, testability and last but mot least the quality and maintainability, by letting a container assemble the different parts of an application - instead or having to write hardwired dependencies - and also taking care of the lifecycle. In my mind a must for every non-trivial application, especially since the coding and runtime overhead is minimal.
In contrast to frameworks that rely on manual registration, this approach relies completely on annotated classes. Let's look at the different possibilities:
Class
Every class annotated with @injectable is eligible for injection.
Example:
@injectable()
class Bar {
const Bar();
}Possible arguments of the annotation are:
-
scope? stringdefines the lifecycle of the instance. Supported scopes are "singleton", "request", "environment". The default issingleton - 'eager?: boolean' if
false- or missing - , the instance will be created on demand, iftruedirectly during the initialization of the containers. - 'module?: string` determines the mapping to modules.
We will come back to the scopes later.
The constructor can define parameters, as long as they are injectable as well.
@injectable()
class Foo {
const Foo(private bar: Bar);
}In this case, the container will construct the objects in the correct order, and call the constructors with the appropriate arguments. The container will of course detect cycles, in which case an exception is thrown.
Every injectable class can define additional methods, that act as factories for additional instances, by annotating methods with @create().
@injectable()
class Factory {
@create()
createBar() : Bar {
return new Bar();
}
}The methods can contain any number of injectable arguments.
The @create() accept the same arguments as @injectable.
It is possible to annotate methods with specific annotations to mark them as callbacks which will be called during construction of the instance. The annotations relate to the lifecycle phases which are:
-
@inject: methods called after constructor instantiation, that provide additional attributes for construction -
@onInit: called after the inject phase, assuming that the object is now completely initialized -
@onRunning: called after all objects have been constructed -
@onDestroy: called after the container has been destroyed
In case of parent classes, all methods except @onDestroy are called from parent to child classes!
All methods can declare any number of injectable parameters.
Example:
@injectable()
class Foo{
const Foo();
@onInit()
onInit(environment: Environment) {
...
}
@onRunning()
void start(manager: ConfigurationManger) {
...
}
@onDestroy()
void stop() {
...
}
}As already mentioned, a couple of methods - including the constructor - can declare any number parameters that are injectable. These are:
- injectable classes
- the type
Environment - annotated parameters that can be resolved.
Currently the annotation @config(key: string, defaultValue?: any) is possible to resolve configuration values based on an initialized ConfigurationManager.
Injectable classes my extends the base class PostProcessor giving them the chance to post process any newly cerated instance by implementing the method
abstract process(instance: any, environment: Environment): voidAs an example the AOP processing is done with this mechanism!
Classes annotated with @module determine which classes will be handled by a container.
Example:
@module({}) // implicitly {name: ""}
class ApplicationModule extends Module {
...
}A module is a regular injectable class, so it can add additional factory methods as well.
The logic how classes relate to modules are quite different to a Spring-like logic, since Typescript doesn't remember file or directory locations, so a different approach is taken by matching module names and @injectable module references:
Example:
@module({name: "test"})
class TestModule extends Module {}
@injectable({scope: "test"})
class Test {}TestModule in this case will only register Test.
As a default the module name and the @injectable module property is "".
Another property imports: [...] may specify other modules ( by type ) recursively adding the respective names the the list of supported injectables.
The di container - called environment - is constructed, given a module:
let environment = new Environment({module: TestModule})
environment.start()
...
environment.stop()starting and stopping an environment will trigger the corresponding lifecycle methods @onRunning and @onDestroy!
Different parameters are possible:
-
parent?: Environmentan optional parent environment.
Setting a parent environment means that all of its providers are inherited.
The only method to retrieve objects is
get<T>(type: new(...args: any[]) => T): TDepending on the scope, it will either create a new instance or return a cached instance! Valid scopes are;
-
"singleton"object is created exactly once and cached -
"request"object is recreated on evergetcall. -
"environment"object is recreated per environment,
If a base class is requested, it will return a concrete class, as long as there is only one registered implementing class. Otherwise an exception is thrown.
If an inherited object is requested and created, it will still stay in the defining environment ( the parent in that case ).
The exceptions are objects with scope "environment", which will be recreated even if the parent already owns an instance!
Two use-cases require inherited environments:
- "environment" scoped classes
- injections that rely on initialized objects
"environment" scoped objects live inside its own environment although the may be specified in a parent. Think of objects in a react applciation that only live inside a component! The solution is to simply create a new environment inside the component, linking it to the "active" environment as part of a context.
When creating and injecting objects, a number of factors may be relevant:
- certain post processors
- certain objects that aer required to be fully initialized.
Example: Injecting a @config("server.url") requires a fully initialized configuration manager including loaded sources.
Since the loading of source is part of a @onRunning we cannot guarantee the exact order. The solutions is:
@module({name: "config"})
class ApplicationConfigModule extends AbstractModule {
@create()
createConfigurationManager() : ConfigurationManager {
return new ConfigurationManager(
new ValueConfigurationSource({...}),
);
}
}
@module()
class ApplicationModule extends AbstractModule {
...
}
// config environment
const configEnvironment = new Environment({module: ApplicationConfigModule})
await configEnvironment.start();
// the real environment
const environment = new Environment({
module: ApplicationModule,
parent: configEnvironment
})
await environment.start()
```
# AOP
The biggest advantage in contrast to all other solutions is that it has an integrated aop mechanism which let's you modify functions with a fluent interface that decorate injectable classes.
```ts
@injectable()
class Aspects {}
@around(methods().of(Service).thatAreAsync())
async aroundMethod(invocation: Invocation): Promise<any> {
...
try {
return await invocation.proceed()
}
finally {
...
}
}
@after(methods().named("foo"))
afterMethod(invocation: Invocation) {
...
}
@error(methods().of(Service))
error(invocation: Invocation) {
...
}
}
```
This of course is a perfect fit, since aspects typically require its own infrastructure which here is simply injected in the respective class.
Different aspects are possible:
- `before` aspects run before the targeted original methods
- `after` aspects run after the targeted original methods
- `error` aspects run in case the original targeted methods throw an error
- `around` aspects run "around" the targeted original methods which means that the can add any logic prior to delegating to the original code.
Corresponding decorators with the same names are available. A fluent interface is used to filter the respective methods that will inherit the aspects by starting with a `@methods()` call and the following filters:
- `named(...expressions: string[])` matches methods as per regular expression
- `matching(...name: string[])` matches methods as per name
- `returning(type: Type<any>)` matches the return type
- `of(type: Type<any>)` matched the method class ( or subclass )
- `thatAreSync()` matches sync methods
- `thatAreAsync()` matches async methods
- `classDecoratedWith(decorator: ClassDecorator)` matches class decorators
- `decoratedWith(decorator: MethodDecorator)` matches method decorators
Every aspect is expected to have on argument of type `Invocation`. In case or `around` methods a `proceed` call must be executed.
**Example**:
```ts
@around(...)
aroundMethod(invocation: Invocation): any {
try {
...
return invocation.proceed() // or await in cxase of async arounds
}
finally {
...
}
}
```