Teach you how to build your own dependency injection tool

Preparation before reading

Before reading this document, you can understand these knowledge first, so that you can keep up with the contents of the document:

  • Conceptual knowledge: Inversion of Control, Dependency Injection, Dependency Inversion;

  • Technical knowledge: decorator Decorator, reflection Reflect;

  • Definition of TSyringe: Token, Provider https://github.com/microsoft/tsyringe#injection-token .

All the implementation practices in this article are written in codesandbox. If you are interested, you can click to see the source code https://codesandbox.io/s/di-playground-oz2j9.

What is Dependency Injection

simple example

Here we implement a simple example to explain what dependency injection is: a student drives a vehicle from home to school.

class Transportation {
  drive() {
    console.log('driving by transportation')
  }
}

class Student {
  transportation = new Transportation()
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

Then in real life, students who are far away will choose to drive to school, and students who are near will choose to go to school by bicycle. Then we may continue to abstract the above code and write it as follows:

class Car extends Transportation {
  drive() {
    console.log('driving by car')
  }
}

class FarStudent extends Student {
  transportation = new Car()
}

This does meet the needs of students who are far away from driving to school, but there is a problem here. Students also have their own specific choices and preferences. Some people like BMW, and some like Tesla. In order to solve such problems, we can continue to use inheritance and continue to abstract, so as to get rich students who like BMW and Tesla.

Everyone probably thinks that writing code in this way is completely unfeasible, and the degree of coupling is too high. Each type of student is directly bound to a specific means of transportation when abstracted. The means of transportation that a student owns is not determined by the student, but what kind of means of transportation he uses to go to school is determined according to his family situation and preferences; he may even have a lot of cars at home, and he drives to school every day depending on his mood.

Then in order to reduce coupling and create dependencies according to specific states and conditions, we need to talk about the following patterns.

inversion of control

Inversion of Control (Inversion of Control, abbreviated as IoC ) is a design principle that reduces the coupling between codes by reversing program logic.

An inversion of control container (IoC container) is a specific tool or framework used to execute code logic inverted from an internal program, thereby improving code reusability and readability. The DI tool we often use plays the role of IoC container, connecting all objects and their dependencies.

e29967fd9ce06208ccbaabb3f939ff71.png

Refer to Martin Fowler's article on Inversion of Control and Dependency Injection https://martinfowler.com/articles/injection.html

dependency injection

Dependency injection is a specific implementation of inversion of control. By giving up the control of object life creation inside the program, the dependent objects are created and injected from the outside.

There are four main methods of dependency injection:

  • Interface based. Implement a specific interface for external containers to inject objects of the dependent type.

  • Based on the set method. Implement the public set method of a specific property to let the external container call the object of the dependent type.

  • Based on the constructor. Implement a constructor with specific parameters, and pass in an object of the dependent type when creating a new object.

  • Based on annotations, add annotations like "@Inject" before private variables, so that tools or frameworks can analyze dependencies and automatically inject dependencies.

The first two methods will not be used in the DI tools commonly used in the front end. Here we mainly introduce the latter two.

If passed from the constructor, it can be written like this:

class Student {
  constructor(transportation: Transportation) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

class Car extends Transportation {
  drive() {
    console.log('driving by car')
  }
}

const car = new Car()
const student = new Student(car)
student.gotoSchool()

In the absence of tools, the definition method based on the constructor can be written by hand, but although the writing method here belongs to dependency injection, too many cumbersome manual instantiations will be a nightmare for developers; especially Car The object itself may depend on different tires, different instances of engines.

Tools for Dependency Injection

The tool for dependency injection is a kind of IoC container. By automatically analyzing dependencies, the process of object instantiation that was originally performed manually is completed in the tool.

@Injectable()
class Student {
  constructor(transportation: Transportation) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const injector = new Injector()
const student = injector.create(Student)
student.gotoSchool()

If you use annotations, you can write it like this:

@Injectable()
class Student {
  @Inject()
  private transportation: Transportation

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const injector = new Injector()
const student = injector.create(Student)
student.gotoSchool()

The difference between the two lies in the dependence on the tool. The class defined from the constructor can still run normally even if it is manually created, but the class defined in the way of annotation can only be created by the tool, and cannot be created manually.

Dependency Inversion

One of the six principles of software design patterns, Dependency Inversion Principle, English abbreviation DIP , full name Dependence Inversion Principle.

High-level modules should not depend on low-level modules, both should depend on their abstractions; abstractions should not depend on details, details should depend on abstractions.

In the scenario with an loC container, the control of object creation is not in our hands, but created within the tool or framework. Whether a student drives a BMW or a Tesla when he is in school is determined by the operating environment. In the actual operating environment of JS, the duck model is followed, no matter whether it is a vehicle or not, as long as it can be driven, any car can be used.

So we can change the code to the following, relying on an abstraction rather than a specific implementation.

// src/transportations/car.ts
class Car {
   drive() {
     console.log('driving by car')
   }
}

// src/students/student.ts
interface Drivable {
   drive(): void
}

class Student {
  constructor(transportation: Drivable) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

Why is it so important to rely on abstractions rather than implementations? In a complex architecture, reasonable abstraction can help us maintain simplicity, improve cohesion within domain boundaries, reduce coupling across different boundaries, and guide projects to divide into reasonable directory structures. When implementing the composite capability of SSR and CSR, when the client is running, it needs to request data through HTTP, while on the server, we only need to directly call DB or RPC to get the data.

We can abstract the object that requests data, define an abstract Service, and implement the same function on the client and server respectively to request data:

interface IUserService {
  getUserInfo(): Promise<{ name: string }>
}

class Page {
  constructor(userService: IUserService) {
    this. userService = userService
  }

  async render() {
    const user = await this.userService. getUserInfo()
    return `<h1> My name is ${user.name}. </h1>`
  }
}

Use this on a web page:

class WebUserService implements IUserService {
  async getUserInfo() {
    return fetch('/api/users/me')
  }
}

const userService = new WebUserService()
const page = new Page(userService)

page.render().then((html) => {
  document.body.innerHTML = html
})

Use this on the server side:

class ServerUserService implements IUserService {
  async getUserInfo() {
    return db.query('...')
  }
}

class HomeController {
  async renderHome() {
    const userService = new ServerUserService()
    const page = new Page(userService)
    ctx.body = await page.render()
  }
}

Testability

In addition to realizing the most important high cohesion and low coupling in software engineering, dependency injection can also improve the testability of code.

A general test we might write like this:

class Car extends Transportation {
  drive() {
    console.log('driving by car')
  }
}

class Student {
  this.transportation = new Car()

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

it('goto school successfully', async () => {
  const driveStub = sinon.sub(Car.prototype, 'drive').resolve()
  const student = new Student()
  student. gotoSchool()
  expect(driveStub.callCount).toBe(1)
})

Although such a unit test can run normally, because the function of the Stub is invaded on the prototype, this is a global side effect, which will affect other unit tests if they run here. If the side effects of sinon are cleared at the end of the test, it will not affect the serial unit test, but parallel testing will not be possible. With the dependency injection method, there will be no such problems.

class Student {
  constructor(transportation: Transportation) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

it('goto school successfully', async () => {
  const driveFn = sinon.fake()
  const student = new Student({
    { drive: driveFn },
  })
  student.gotoSchool()
  expect(driveFn.callCount).toBe(1)
})

circular dependency

In the case of circular dependencies, we generally cannot create objects, such as the following two class definitions. Although it is necessary to avoid such a situation logically, it is difficult to say that such a situation will not be fully used in the process of writing code.

export class Foo {
  constructor(public bar: Bar) {}
}

export class Bar {
  constructor(public foo: Foo) {}
}

With DI, through the creation of IoC inversion of control, when the object is first created, the instance will not be actually created, but a proxy object will be given, and the instance will be created when the object is actually used, and then the circular dependency will be resolved. question. As for why the Lazy decorator must exist here, we will explain it later when we implement it later.

@Injectable()
export class Foo {
  constructor(public @Lazy(() => Bar) bar: Bar) {}
}

@Injectable()
export class Bar {
  constructor(public @Lazy(() => Foo) foo: Foo) {}
}

some disadvantages

Of course, using DI tools is not completely without disadvantages. The more obvious disadvantages include:

  • Uncontrollable life cycle, because the instantiation of the object is in IoC, so when the object is created is not completely determined by the current program. So this requires us to have a good understanding of the principles before using tools or frameworks, and it is best to read the source code inside.

  • When dependencies go wrong, it is more difficult to locate which is going wrong. Because dependencies are injected, when dependencies go wrong, you can only analyze them through experience, or live on-site debugging, and do in-depth debugging a little bit, to know where the content is wrong. This requires a lot of debugging ability, or the overall control ability of the project.

  • The code cannot be read coherently. If it is dependent on implementation, you can see the entire code execution tree from the entrance all the way down; if it is dependent on abstraction, the connection relationship between the specific implementation and the implementation is separated, and documents are usually required to see the overall picture of the project .

community tools

From the DI category of github, you can view some popular DI tools https://github.com/topics/dependency-injection?l=typescript.

InversifyJS (https://github.com/inversify/InversifyJS): A powerful dependency injection tool with a strict implementation of dependency abstraction; although the strict statement is good, it is very repetitive and verbose to write.

TSyringe (https://github.com/microsoft/tsyringe): Easy to use, inherited from the abstract definition of angular, it is worth learning.

achieve basic competencies

In order to realize the capabilities of basic DI tools, to take over object creation, realize dependency inversion and dependency injection, we mainly realize three capabilities:

  • Dependency analysis: In order to be able to create an object, the tool needs to know what dependencies exist.

  • Registration creator: In order to support different types of instance creation methods, it supports direct dependencies, abstract dependencies, and factory creation; different contexts register different implementations.

  • Create an instance: Use the creator to create an instance, supporting singleton mode and multi-instance mode.

Assuming that our final form is the execution code like this, if you want the final result, you can click on the online coding link https://codesandbox.io/s/di-playground-oz2j9 .

@Injectable()
class Transportation {
  drive() {
    console.log('driving by transportation')
  }
}

@Injectable()
class Student {
  constructor(
    private transportation: Transportation,
  ) {}

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()

Dependency Analysis

In order to enable DI tools to perform dependency analysis, it is necessary to enable the decorator function of TS and the metadata function of the decorator.

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Decorator

So first let's take a look at how the dependency of the constructor is analyzed. After enabling the decorator and metadata functions, try to compile the previous code in the playground of TS, and you can see that the running JS code looks like this.

bc8f0160bd6789904a4479ba8260040d.png

It can be noticed that the more critical code definitions are as follows:

Student = __decorate([
  Injectable(),
  __metadata("design:paramtypes", [Transportation])
], Student);

If you read the logic of the __decorate function carefully, it is actually a high-order function. In order to execute the ClassDecorator and Metadata Decorator in reverse order, translate the above code, which is equivalent to:

Student = __metadata("design:paramtypes", [Transportation])(Student)
Student = Injectable()(Student)

Then we carefully read the logic of the __metadata function, which executes the function of Reflect, which is equivalent to the code:

Student = Reflect.metadata("design:paramtypes", [Transportation])(Student)
Student = Injectable()(Student)

reflection

For the previous code results, we can temporarily ignore the first line and read the meaning of the second line. This is exactly the ability of dependency analysis we need. Reflect.metadata is a high-level function that returns a decorator function. After execution, the data is defined on the constructor. You can find the defined data from this constructor or its successors through getMetadata.

eeea816ea2b2e41be0eaef3daef553f0.png

For example, in the above reflection, we can get the defined data in the following way:

const args = Reflect.getMetadata("design:paramtypes", Student)
expect(args).toEqual([Transportation])

Proposal for reflective metadata: https://rbuckton.github.io/reflect-metadata/#syntax

After enabling emitDecoratorMetadata, TS will automatically fill in three kinds of metadata when compiling the decorated place:

  • design:type The type metadata of the current property, which appears in PropertyDecorator and MethodDecorator;

  • The metadata of design:paramtypes input parameters appears in ClassDecorator and MethodDecorator;

  • design:returntype returns type metadata, which appears in MethodDecorator.

f8f7687eb577ee4fcd53d0adb001e7db.png

tag dependencies

In order for DI tools to collect and store dependencies, we need to parse out the dependent constructors in Injectable, and then define the constructors through reflection, and record the data description in reflection through a Symbol value.

const DESIGN_TYPE_NAME = {
  DesignType: "design:type",
  ParamType: "design:paramtypes",
  ReturnType: "design:returntype"
};

const DECORATOR_KEY = {
  Injectable: Symbol.for("Injectable"),
};

export function Injectable<T>() {
  return (target: new (...args: any[]) => T) => {
    const deps = Reflect.getMetadata(DESIGN_TYPE_NAME.ParamType, target) || [];
    const injectableOpts = { deps };
    Reflect.defineMetadata(DECORATOR_KEY.Injectable, injectableOpts, target);
  };
}

This serves two purposes:

  • The configuration data is marked by the internal Symbol, and the superficial constructor is decorated with Injectable and can be created by IoC.

  • Collect and assemble configuration data, defined in the constructor, including dependent data, configuration data such as single instance and multiple instances that may be used later.

define container

With the data defined by the decorator in reflection, the most important part of Container in IoC can be created. We will implement a resolve function to automatically create the instance:

const DECORATOR_KEY = {
  Injectable: Symbol.for("Injectable"),
};

const ERROR_MSG = {
  NO_INJECTABLE: "Constructor should be wrapped with decorator Injectable.",
}

export class ContainerV1 {
  resolve<T>(target: ConstructorOf<T>): T {
    const injectableOpts = this.parseInjectableOpts(target);
    const args = injectableOpts.deps.map((dep) => this.resolve(dep));
    return new target(...args);
  }

  private parseInjectableOpts(target: ConstructorOf<any>): InjectableOpts {
    const ret = Reflect.getOwnMetadata(DECORATOR_KEY.Injectable, target);
    if (!ret) {
      throw new Error(ERROR_MSG.NO_INJECTABLE);
    }
    return ret;
  }
}

The main core logic is the following:

  • Parse the data defined by the Injectable decorator through reflection from the constructor. If there is no data, an error will be thrown; a little attention should be paid to the fact that because the reflection data is inherited, only getOwnMetadata can be used to get the reflection data of the current target. Make sure the current target must be decorated.

  • Then recursively create dependent instances through dependencies, and get the input parameter list of the constructor.

  • Finally, by instantiating the constructor, we get the result we want.

create instance

At this point, the most basic function of creating objects has been realized, and the following code can finally run normally.

@Injectable()
class Transportation {
  drive() {
    console.log('driving by transportation')
  }
}

@Injectable()
class Student {
  constructor(
    private transportation: Transportation,
  ) {}

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()

You can also visit the codesandbox and select the ContainerV1 mode on the left to see such a result.

1012a51979f1f826653d53f03fbeeb89.png

Dependency abstraction

Then we have completed the basic IoC, but then we need to change the requirements, hoping to replace the vehicle with any tool we want at runtime, and the dependency of Student should still be a vehicle that can be driven.

Next, we implement it in two steps:

  • Instance replacement: Replace Transportation with Bicycle at runtime.

  • Dependency abstraction: Change Transportation from class to Interface.

Before realizing the ability of instance replacement and dependency abstraction, we must first define the relationship between dependency and dependency implementation, so that IoC can know which instance to create to inject dependencies, so we must first talk about Token and Provider.

Token

As a unique token of dependency, it can be String, Symbol, Constructor, or TokenFactory. In the absence of dependent abstraction, it is actually a direct dependency between different Constructors; String and Symbol are the dependency IDs we will use after relying on the abstraction; and TokenFactory is used when we really want to carry out file circular references. Resolve dependent schemes.

We can ignore the TokenFactory, the other part of the definition Token does not need to be implemented separately, it is just a type definition:

export type Token<T = any> = string | symbol | ConstructorOf<T>;

Provider

The instance creation definition corresponding to the Token registered in the container, and then IoC can create the correct instance object through the Provider after getting the Token. Subdividing it further, Providers can be divided into three types:

  • ClassProvider

  • ValueProvider

  • FactoryProvider

ClassProvider

Use constructors to define instantiation. Generally, the simple version we implemented earlier is actually a simplified version of this model; with a little modification, it is easy to implement this version, and after implementing ClassProvider, we can pass The way to register Provider is to replace the means of transportation in the previous example.

interface ClassProvider<T = any> {
  token: Token<T>
  useClass: ConstructorOf<T>
}

ValueProvider

ValueProvider is very useful when there is already a unique implementation globally, but abstract dependencies are defined internally. To give a simple example, in the mode of concise architecture, we require the core code logic to be context-independent, so if the front-end wants to use global objects in the browser environment, it needs to define abstractly, and then put this Objects are passed in via ValueProvider.

interface ClassProvider<T = any> {
  token: Token<T>
  useValue: T
}

FactoryProvider

This Provider will have a factory function, and then create an instance, which is very useful when we need to use the factory pattern.

interface FactoryProvider<T = any> {
  token: Token<T>;
  useFactory(c: ContainerInterface): T;
}

Implement registration and creation

After defining Token and Provider, we can implement a registration function through them and connect Provider with creation. The logic is also relatively simple, with two key points:

  • Use Map to form the mapping relationship between Token and Provider, and at the same time deduplicate the implementation of Provider, and the registered ones will cover the previous ones. TSyringe can be registered multiple times. If the constructor depends on an example array, an instance will be created for each Provider in turn; this situation is actually rarely used, and it will increase the complexity of Provider implementation. Very high, interested students can study this part of its implementation and definition.

  • By parsing different types of Providers, then create different dependencies.

export class ContainerV2 implements ContainerInterface {
  private providerMap = new Map<Token, Provider>();

  resolve<T>(token: Token<T>): T {
    const provider = this.providerMap.get(token);
    if (provider) {
      if (ProviderAssertion.isClassProvider(provider)) {
        return this.resolveClassProvider(provider);
      } else if (ProviderAssertion.isValueProvider(provider)) {
        return this.resolveValueProvider(provider);
      } else {
        return this.resolveFactoryProvider(provider);
      }
    }

    return this.resolveClassProvider({
      token,
      useClass: token
    });
  }

  register(...providers: Provider[]) {
    providers.forEach((p) => {
      this.providerMap.set(p.token, p);
    });
  }
 }

instance replacement

After implementing the function that supports Provider registration, we can replace the means of transportation that students use when they go to school by defining the Provider of Transportation.

const container = new ContainerV2();
container.register({
  token: Transportation,
  useClass: Bicycle
});

const student = container.resolve(Student);
return student.gotoSchool();

So we can see the following effect in codesandbox, and finally we can go to school by bike.

976e7af51b5ebb713a6dd442c9d67910.png

factory pattern

We realized the replacement of dependencies. Before implementing the abstraction of dependencies, we first inserted a new requirement, because it is too hard to go to school by bike, so the road conditions are better on weekends, and we hope to be able to drive to school. Through the factory pattern, we can implement it in the following way:

const container = new ContainerV2();
container.register({
  token: Transportation,
  useFactory: (c) => {
    if (weekday > 5) {
      return c.resolve(Car);
    } else {
      return c.resolve(Bicycle);
    }
  }
});

const student = container.resolve(Student);
return student.gotoSchool();

Here is a simple introduction to the factory mode. Both TSyringe and InversifyJS have factory mode creation functions, which is a recommended way; at the same time, you can also design other DI tools. Some tools will put the judgment of the factory function in the Where the class is declared.

This is not impossible. It will be easier to write when a single function is implemented individually, but here we will talk about the purpose of introducing DI, for decoupling. The logical judgment of the factory function is actually a part of the business logic, and it does not belong to the field of the specific implementation; and when the implementation is used in multiple factory logics, the logic in this place will become very strange.

define abstraction

So after the instance replacement, let's see how to make Transportation an abstraction instead of a concrete implementation object. So the first step is to change the dependency of Student from concrete implementation logic to abstract logic.

What we need is an abstraction of transportation, a vehicle that can be driven, bicycles, motorcycles, and cars; as long as it can be driven, any car can be used. Then create a new student class to inherit the old object for distinction and comparison.

interface ITransportation {
  drive(): string
}

@Injectable({ muiltple: true })
export class StudentWithAbstraction extends Student {
  constructor(protected transportation: ITransportation) {
    super(transportation);
  }
}

If you write it like this, you will find that the dependency analysis will be wrong; because when TS is compiled, interface is a type, and it will become the corresponding construction object of the type at runtime, and the dependency cannot be correctly resolved.

5039719890e82237db246a09ae977ed4.png

So in addition to defining an abstract type here, we also need to define a unique tag for this abstract type, which is the string or symbol in the Token. We generally choose symbol, which is a globally unique value. Here you can use the multiple definitions of TS with the same name in value and type, and let TS analyze it by itself as a value and as a type.

const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
  drive(): string
}

@Injectable({ muiltple: true })
export class StudentWithAbstraction extends Student {
  constructor(
    protected @Inject(ITransportation) transportation: ITransportation,
  ) {
    super(transportation);
  }
}

replace abstract dependencies

Note that, in addition to defining the token value of the abstract dependency, we also need to add an additional decorator to make the parameter dependency of the marker constructor and give it a Token mark.

function Inject(token: Token) {
  return (target: ConstructorOf<any>, key: string | symbol, index: number) => {
    if (!Reflect.hasOwnMetadata(DECORATOR_KEY.Inject, target)) {
      const tokenMap = new Map([[key, token]]);
      Reflect.defineMetadata(DECORATOR_KEY.Inject, tokenMap, target);
    } else {
      const tokenMap: Map<number, Token> = Reflect.getOwnMetadata(
        DECORATOR_KEY.Inject,
        target
      );
      tokenMap.set(index, token);
    }
  };
}

At the same time, the logic in Injectable also needs to be changed to replace the dependencies in the corresponding positions.

export function Injectable<T>(opts: InjectableDecoratorOpts = {}) {
  return (target: new (...args: any[]) => T) => {
    const deps = Reflect.getMetadata(DESIGN_TYPE_NAME.ParamType, target) || [];
    const tokenMap: Map<number, Token> = Reflect.getOwnMetadata(
      DECORATOR_KEY.Inject,
      target
    );
    if (tokenMap) {
      for (const [index, token] of tokenMap.entries()) {
        deps[index] = token;
      }
    }

    const injectableOpts = {
      ...opts,
      deps
    };
    Reflect.defineMetadata(DECORATOR_KEY.Injectable, injectableOpts, target);
  };
}

Register abstract Provider

There is still the last step here, injecting the corresponding Provider of the Token can be used, we only need to change the Token definition of the previous FactoryProvider, and then we have reached our goal.

const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
  drive(): string
}

const container = new ContainerV2();
container.register({
  token: ITransportation,
  useFactory: (c) => {
    if (weekday > 5) {
      return c.resolve(Car);
    } else {
      return c.resolve(Bicycle);
    }
  }
});
const student = container.resolve(StudentWithAbstraction);
return student.gotoSchool();

Implement lazy creation

Previously, we have implemented the constructor-based dependency injection method, which is very good and does not affect the normal use of the constructor. But one problem with this is that all object instances on the dependency tree will be created when the root object is created. There will be some waste in this way, and those instances that are not used may not be created originally.

In order to ensure that the created instances are used, we choose to create instances when using them instead of initializing the root object.

Define usage

Here we need to change the Inject function so that it can support both the decoration of the constructor and the decoration of the Property.

const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
  drive(): string
}

@Injectable()
class Student {
  @Inject(ITransportation)
  private transportation: ITransportation

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()

property decorator

Let's take a look at the characteristics of ParameterDecorator and PropertyDecorator by combining the results of TS compilation and type definitions.

Below is the description in .d.ts

acc6faa471f279101c66b456b5469b15.png

The following is the result of compilation

1c8bb607855bc7c2732659ef69631080.png

You can see the following differences:

  • The number of input parameters is different, because ParameterDecorator will have the data of the number of parameters.

  • The description of the object is different. The ParameterDecorator of the constructor describes the constructor; and the PropertyDecorator describes the Prototype of the constructor.

So by identifying the mark, and then returning the description file of the property, the getter function of the corresponding property is added on the Prototype, and the logic of object creation during use is realized.

function decorateProperty(_1: object, _2: string | symbol, token: Token) {
  const valueKey = Symbol.for("PropertyValue");
  const ret: PropertyDescriptor = {
    get(this: any) {
      if (!this.hasOwnProperty(valueKey)) {
        const container: IContainer = this[REFLECT_KEY.Container];
        const instance = container.resolve(token);
        this[valueKey] = instance;
      }

      return this[valueKey];
    }
  };
  return ret;
}

export function Inject(token: Token): any {
  return (
    target: ConstructorOf<any> | object,
    key: string | symbol,
    index?: number
  ) => {
    if (typeof index !== "number" || typeof target === "object") {
      return decorateProperty(target, key, token);
    } else {
      return decorateConstructorParameter(target, index, token);
    }
  };
}

One thing to note here is that in the design of TS's own description, it is not recommended to return to PropertyDescriptor to change the definition of properties, but in fact, in the implementation of standards and TS, he actually did this, so here may be in the future will change.

circular dependency

After finishing the lazy creation, let's talk about a problem with a little relationship, circular dependency. In general, we should avoid circular dependencies from our logic, but if we have to use them, we still need to provide solutions to resolve circular dependencies.

For example such an example:

@Injectable()
class Son {
  @Inject()
  father: Father

  name = 'Thrall'

  getDescription() {
    return `I am ${this.name}, son of ${this.father.name}.`
  }
}

@Injectable()
class Father {
 @Inject()
  son: Son

  name = 'Durotan'

  getDescription() {
    return `I am ${this.name}, my son is ${this.son.name}.`
  }
}

const container = new Container()
const father = container.resolve(Father)
console.log(father. getDescription())

why is there a problem

The problem is because of the runtime of the decorator. The purpose of the constructor decorator is to describe the constructor, that is, when the constructor is declared, the logic of the decorator will be run immediately, but at this time its dependencies have not been declared, and the obtained value is still undefined.

a19e858fbad32dddffdf8dd630193292.png

file loop

In addition to looping within files, there are also loops between files, such as the following example.

ae37a45d872fd31b857290498a5afb9f.png

The following will happen:

  • Father file is read by Node;

  • The Father file will initialize a module in Node and register it in the total modules; but exports is still an empty object, waiting for assignment;

  • The Father file starts executing the first line, referencing Son's result;

  • Start reading the Son file;

  • The Son file will initialize a module in Node and register it in the total modules; but exports is still an empty object, waiting to be assigned;

  • Son file executes the first line, quotes the result of Father, and then reads the empty module registered by Father;

  • Son starts to declare the constructor; then reads the constructor of Father, but it is undefined at this time, and executes the decorator logic;

  • Son's Module assigns exports and ends execution;

  • After Father reads Son's constructor, he begins to declare the constructor; read Son's constructor correctly and execute the decorator logic.

break the cycle

When a circular dependency occurs, the first idea should be to break the cycle; let the dependency become an abstract logic without a cycle, and break the sequence of execution.

export const IFather = Symbol.for("IFather");
export const ISon = Symbol.for("ISon");

export interface IPerson {
  name: string;
}

@Injectable()
export class FatherWithAbstraction {
  @Inject(ISon)
  son!: IPerson;

  name = "Durotan";
  getDescription() {
    return `I am ${this.name}, my son is ${this.son.name}.`;
  }
}

@Injectable()
export class SonWithAbstraction {
  @Inject(IFather)
  father!: IPerson;

  name = "Thrall";
  getDescription() {
    return `I am ${this.name}, son of ${this.father.name}.`;
  }
}

const container = new ContainerV2(
 { token: IFather, useClass: FatherWithAbstraction },
 { token: ISon, useClass: SonWithAbstraction }
);
const father = container.resolve(FatherWithAbstraction);
const son = container.resolve(SonWithAbstraction);
console.log(father.getDescription())
console.log(son.getDescription())

By defining the public Person abstraction, the getDescription function can be executed normally; by providing the Providers of ISon and IFather, the specific implementations of their respective dependencies are provided, and the logic code can run normally.

lazy dependency

In addition to relying on abstraction, if circular dependencies are really needed, we can still solve this problem through technical means, that is, to enable dependency resolution to be executed after the constructor is defined, rather than when it is declared with the constructor. At this time, only a simple method is needed, using function execution, which is the lazy logic we mentioned earlier.

Because variables within the scope of JS are promoted, variable references can be held in functions. As long as the variables have been assigned values ​​when the function is executed, dependencies can be resolved correctly.

@Injectable()
class Son {
  @LazyInject(() => Father)
  father: Father

  name = 'Thrall'

  getDescription() {
    return `I am ${this.name}, son of ${this.father.name}.`
  }
}

@Injectable()
class Father {
  @LazyInject(() => Son)
  son: Son

  name = 'Durotan'

  getDescription() {
    return `I am ${this.name}, my son is ${this.son.name}.`
  }
}

const container = new Container()
const father = container.resolve(Father)
console.log(father. getDescription())

TokenFactory

What we need to do is to add a new Token parsing method, which can use functions to dynamically obtain dependencies.

interface TokenFactory<T = any> {
  getToken(): Token<T>;
}

Then add a LazyInject decorator and be compatible with this logic.

export function LazyInject(tokenFn: () => Token): any {
  return (
    target: ConstructorOf<any> | object,
    key: string | symbol,
    index?: number
  ) => {
    if (typeof index !== "number" || typeof target === "object") {
      return decorateProperty(target, key, { getToken: tokenFn });
    } else {
      return decorateConstructorParameter(target, index, { getToken: tokenFn });
    }
  };
}

Finally, make this logic compatible in Container and write a V3 version of Container.

export class ContainerV3 extends ContainerV2 implements IContainer {
  resolve<T>(tokenOrFactory: Token<T> | TokenFactory<T>): T {
    const token =
      typeof tokenOrFactory === "object"
        ? tokenOrFactory.getToken()
        : tokenOrFactory;

    return super.resolve(token);
  }
}

Finally, look at the effect of use:

const container = new ContainerV3();
const father = container.resolve(FatherWithLazy);
const son = container.resolve(SonWithLazy);
father.getDescription();
son.getDescription();
84b973cbc08fabf26b69f10648f35119.png

at last

So far, we have basically implemented a basic and usable DI tool. Let’s review our content a little bit:

  • Using reflection and TS decorator logic, we implement dependency resolution and object creation;

  • Through Provider definition, instance replacement, dependency abstraction, and factory pattern are realized;

  • By using PropertyDecorator to define the getter function, we achieve lazy creation;

  • By dynamically obtaining dependencies through TokenFactory, we have resolved circular dependencies.

- END -

About Qi Wu Troupe

Qi Wu Troupe is the largest front-end team of 360 Group, and participates in the work of W3C and ECMA members (TC39) on behalf of the group. Qi Wu Troupe attaches great importance to talent training. There are engineers, lecturers, translators, business interface people, team leaders and other development directions for employees to choose from, and supplemented by providing corresponding training on technical skills, professional skills, general skills, leadership skills, etc. course. Qi Dance Troupe welcomes all kinds of outstanding talents to pay attention to and join Qi Dance Troupe with an open and talent-seeking attitude.

bd69e53b4b655e3ab8d79d03ed94843c.png

Guess you like

Origin blog.csdn.net/qiwoo_weekly/article/details/132288468