In this article, We will explore IoC (Inversion of Control) and DI (dependency injection) in object-oriented programming from six aspects. After reading this article, you will know the following:
- What IoC is and what problems IoC can solve;
- The relationship between IoC and DI, the difference between not using DI framework and using DI framework;
- DI in AngularJS/Angular and NestJS;
- Learn how to implement an IoC container in TypeScript, and learn about decorators and reflections.
I. Background overview
Before going into what an IoC container is, Arbogo takes a look at a scenario that is common in everyday work: creating an instance of a specified class. The simplest case is that the class does not depend on other classes, but the reality is that when we create instances of a class, we need to rely on corresponding instances of different classes. In order to let the small friends can better understand the above content, Po Ge to give an example.
A car 🚗 usually consists of four parts: engine, chassis, body and electrical equipment. The internal structure of automotive electrical equipment is very complex. For simplicity, we will consider only three parts: engine, chassis and body.
(photo: www.newkidscar.com/vehicle-con…
In the real world, it’s hard to build a car. In the world of software, we can do that. 👇 is the car that Po elder brother wants to build, have very cool.
(Photo credit: pixabay.com/zh/illustra…
Before we start building the car, we need to look at the drawings:
After reading the above “drawings”, we will immediately start the journey of building a car. The first step is to define the body class:
1. Define the body class
export default class Body {}Copy the code
2. Define the chassis class
export default class Chassis {}Copy the code
3. Define engine classes
export default class Engine {
start() {
console.log("The engine started."); }}Copy the code
4. Define the car class
import Engine from './engine';
import Chassis from './chassis';
import Body from './body';
export default class Car {
engine: Engine;
chassis: Chassis;
body: Body;
constructor() {
this.engine = new Engine();
this.body = new Body();
this.chassis = new Chassis();
}
run() {
this.engine.start(); }}Copy the code
With everything in place, we are going to build a car:
const car = new Car(); // Bob builds a new car
car.run(); // Console output: Engine started
Copy the code
Now, although the car can start, there are the following problems:
- Problem # 1: When building a car, you can’t choose the configuration. For example, if you want to change the car engine, according to the current scheme, it is not possible.
- Problem 2: Inside the car class, you need to manually create the parts of the car in the constructor.
To solve the first problem and provide a more flexible solution, we can refactor the defined car class as follows:
export default class Car {
body: Body;
engine: Engine;
chassis: Chassis;
constructor(engine, body, chassis) {
this.engine = engine;
this.body = body;
this.chassis = chassis;
}
run() {
this.engine.start(); }}Copy the code
After reinventing the car category, let’s rebuild a new car:
const engine = new NewEngine();
const body = new Body();
const chassis = new Chassis();
const newCar = new Car(engine, body, chassis);
newCar.run();
Copy the code
Now that we have solved the first problem mentioned above, to solve the second problem we need to understand the concept of IoC (Inversion of control).
What is IoC
An IoC is an Inversion of Control. In development, IoC means that you have designed objects to be controlled by the container, rather than the traditional way of controlling them directly from within.
How to understand IoC? The key to a good understanding of IoC is to be clear about “who controls whom, what controls, why it is reversed and what is reversed”. Let’s take a closer look.
-
Who controls who, controls what: in traditional programming, we directly create objects in the object through the new way, is the program actively create dependent objects; IoC has a special container to create these objects, that is, the IoC container controls object creation;
Who controls whom? The IoC container, of course, controls the objects; Control what? It mainly controls the acquisition of external resources (dependent objects).
-
Why is the inversion, which aspects of the inversion: there is inversion, there is positive, traditional application is by our own active control in the program to obtain dependent objects, that is, positive; In inversion, the container helps create and inject dependent objects.
Why reverse? Because the container helps us find and inject the dependent objects, the objects only passively accept the dependent objects, so it is reversed; What has been reversed? The retrieval of dependent objects has been reversed.
What can the IoC do
IoC is not a technique, just an idea, a design principle in object-oriented programming that can be used to decouple computer code from one another.
In traditional applications, we actively create dependent objects within classes, which leads to high coupling between classes and is difficult to test. With the IoC container, control of creating and finding dependent objects is handed over to the container, which injects composite objects, so the objects are loosely coupled. It is also easy to test, easy to reuse, and more importantly, flexible to the overall architecture of the program.
In fact, the biggest change IoC has brought to programming is not in the code, but in the mind. In IoC thinking, an application becomes passive, waiting for the IoC container to create and inject the resources it needs.
Iv. The relationship between IoC and DI
For inversion of control, the most common approach is called Dependency Injection (DI).
Dependencies between components are determined at run time by the container, which, figuratively speaking, dynamically injects a dependency into the component. The purpose of dependency injection is not to bring more functions to the software system, but to increase the frequency of component reuse and build a flexible and extensible platform for the system.
Through the dependency injection mechanism, we can specify the resources needed by the target and complete our business logic through simple configuration without any code, regardless of where the specific resources come from and who implements them.
The key to understanding DI is “who depends on whom, why it depends on whom, who infuses whom, what infuses” :
- Who depends on whom: Applications depend on IoC containers, of course;
- Why dependencies: Applications need IoC containers to provide external resources (including objects, resources, constant data) that objects need;
- Who injects whom: The obvious dependencies of the IoC container injection application;
- What is injected: External resources (including objects, resources, constant data) needed to inject an object.
So what’s the relationship between IoC and DI? In fact, they are different descriptions of the same concept, and since the concept of inversion of control is rather vague (perhaps only understood as the level of container control object, it is difficult to think of who maintains the dependency), so in 2004, Martin Fowler gave a new name: Dependency injection. In contrast to IoC, dependency injection explicitly describes how the injected object depends on the IoC container to configure dependencies.
Generally, Inversion of Control means a transfer of Control over object creation. Previously, the application controlled the initiative and timing of object creation, but now this Control is transferred to the IoC container, which is a factory for object creation. It just gives you something. With the IoC container, the dependencies are changed, the original dependencies are gone, they all depend on the IoC container, through which they are established.
With all the concepts covered, let’s look at the obvious differences between not using a DEPENDENCY injection framework and using one.
4.1 No dependency injection framework is used
Suppose that our service A depends on service B, that is, we need to create service B before using service A. The specific process is shown in the figure below:
As you can see from the figure above, without a dependency injection framework, consumers of services need to care about how the service itself and its dependent objects are created, and need to manually maintain dependencies. If the service itself needs to rely on multiple objects, this will increase the difficulty of use and maintenance costs. For the above problems, we can consider introducing a dependency injection framework. Let’s take a look at how the overall process changes with the introduction of a dependency injection framework.
4.2 Using a dependency injection framework
With the dependency injection framework, services in the system are uniformly registered with the IoC container, and if services have dependencies on other services, they also need to declare their dependencies. When a user needs to use a particular service, the IoC container is responsible for creating and managing that service and its dependent objects. The specific process is shown in the figure below:
Now that we have introduced the concepts and characteristics of IoC and DI, let’s look at the application of DI.
5. Application of DI
DI has applications on both the front end and the server side, such as AngularJS and Angular on the front end, and NestJS on the server side, which is well-known in the Node.js ecosystem. Next, I’ll take a look at DI in AngularJS/Angular and NestJS.
5.1 DI in AngularJS
Dependency injection is one of the core features of AngularJS. There are three ways to declare dependencies in AngularJS:
Method 1: Use the $Inject annotation method
let fn = function (a, b) {};
fn.$inject = ['a'.'b'];
// Approach 2: Use array-style annotations
let fn = ['a'.'b'.function (a, b) {}];
// Method 3: use implicit declaration
let fn = function (a, b) {}; / / do not recommend
Copy the code
The above code is familiar to any AngularJS guy. DI is a powerful core AngularJS feature, but as AngularJS grows in popularity and complexity, problems with AngularJS DI systems are exposed.
Here are a few issues with AngularJS DI:
- Internal caching: All dependencies in AngularJS applications are singletons, and we can’t control whether new instances are used;
- Namespace conflicts: In the system we use strings to identify the name of the service. If we already have a CarService in the project and the same service is introduced in a third-party library, confusion can arise.
AngularJS DI was redesigned in subsequent Angular versions because of the above problems.
5.2 DI in Angular
Taking the previous example of the car, we can think of the car, engine, chassis, and body as a “service,” so they are registered with the DI system as a service provider. In order to distinguish between different services, we need to use different tokens to identify them. We then create an injector object based on the registered service provider.
Later, when we need to get the specified service, we can get the token dependent object from the injector object through the token corresponding to that service. The details of the above process are shown in the figure below:
Ok, so that’s the process. Let’s see how to build a car using Angular’s built-in DI system.
5.2.1 car. Ts
// car.ts
import { Injectable, ReflectiveInjector } from '@angular/core';
/ / configure the Provider
@Injectable({
providedIn: 'root',})export class Body {}
@Injectable({
providedIn: 'root',})export class Chassis {}
@Injectable({
providedIn: 'root',})export class Engine {
start() {
console.log('The engine started.'); }}@Injectable(a)export default class Car {
// Use construct injection to inject dependent objects
constructor(
private engine: Engine,
private body: Body,
private chassis: Chassis
) {}
run() {
this.engine.start(); }}const injector = ReflectiveInjector.resolveAndCreate([
Car,
Engine,
Chassis,
Body,
]);
const car = injector.get(Car);
car.run();
Copy the code
In the above code, we call the resolveAndCreate method of ReflectiveInjector object to manually create the injector, and then obtain the corresponding dependent object based on the corresponding Token of the vehicle. By looking at the code above, you can see that we no longer need to manually manage and maintain dependent objects; the “dirty work” and “back-breaking work” has been handed over to the injector.
In addition, to obtain the Car object normally, we need to declare the Car Provider in the app.module.ts file as follows:
5.2.2 app. The module. Ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import Car, { Body, Chassis, Engine } from './car';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
providers: [{ provide: Car, deps: [Engine, Body, Chassis] }],
bootstrap: [AppComponent],
})
export class AppModule {}
Copy the code
5.3 DI application in NestJS
NestJS is a framework for building efficient, extensible Node.js Web applications. It uses modern JavaScript or TypeScript (preserving compatibility with pure JavaScript) and combines elements of OOP (object-oriented programming), FP (functional programming), and FRP (functional responsive programming).
At the bottom, Nest uses Express, but also provides compatibility with various other libraries, such as Fastify, which makes it easy to use the various third-party plug-ins available.
In recent years, thanks to Node.js, JavaScript has become the “common language” for both front-end and back-end Web applications, leading to refreshing projects like Angular, React, and Vue that increase developer productivity. Makes it possible to quickly build testable and extensible front-end applications. On the server side, however, there are many excellent libraries, helpers, and Node tools, but none of them effectively address the main problem — architecture.
NestJS is designed to provide an out-of-the-box application architecture that allows you to easily create highly testable, extensible, loosely coupled, and easily maintainable applications. NestJS also provides the dependency injection function for us developers, here we use the official website to demonstrate the dependency injection function.
5.3.1 app. Service. Ts
import { Injectable } from '@nestjs/common';
@Injectable(a)export class AppService {
getHello(): string {
return 'Hello World! '; }}Copy the code
5.3.2 app. Controller. Ts
import { Get, Controller, Render } from '@nestjs/common';
import { AppService } from './app.service';
@Controller(a)export class AppController {
constructor(private readonly appService: AppService) {}
@Get(a)@Render('index')
render() {
const message = this.appService.getHello();
return{ message }; }}Copy the code
In the AppController, we inject the AppService object by constructing an injection. When the user visits the home page, we call the getHello method of the AppService object to get ‘Hello World! ‘message, and returns the message to the user. Providers and controllers should be declared in the AppModule to make dependency injection work.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [].controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Copy the code
DI is not unique to AngularJS/Angular or NestJS. If you want to use DI/IoC features in other projects, Arbogo recommends using InversifyJS. It is a powerful, lightweight IoC container for JavaScript and Node.js applications.
If you are interested in InversifyJS, you can take a look at it yourself. Next, let’s get to the focus of this article, which is how to use TypeScript to implement a simple IoC container that does what the following figure shows:
Six, handwritten IoC container
In order for you to better understand the IoC container implementation code, Po Ge to introduce some related pre-knowledge.
6.1 a decorator
If you’ve ever used Angular or NestJS, you’re familiar with the following code.
@Injectable(a)export class HttpService {
constructor(
private httpClient: HttpClient
){}}Copy the code
In the above code, we used the Injectable decorator. This decorator is used to indicate that this class can inject its dependencies automatically. The @ symbol in @Injectable() is syntactic sugar.
A decorator is a function that wraps a class, function, or method and adds behavior to it. This is useful for defining metadata associated with an object. There are four categories of decorators:
- Class decorators
- Property decorators
- Method decorators
- Parameter decorators
The @Injectable() decorator used in the previous example is a class decorator. In the HttpService class decorated by this class decorator, we inject the HttpClient dependency object used to handle HTTP requests by constructing an injection.
6.2 reflection
@Injectable(a)export class HttpService {
constructor(
private httpClient: HttpClient
){}}Copy the code
If you set the compile target to ES5, the following code will be generated:
// Ignore code such as __pipeline
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect= = ="object" && typeof Reflect.metadata === "function")
return Reflect.metadata(k, v);
};
var HttpService = / * *@class * / (function () {
function HttpService(httpClient) {
this.httpClient = httpClient;
}
var _a;
HttpService = __decorate([
Injectable(),
__metadata("design:paramtypes"[typeof (_a = typeofHttpClient ! = ="undefined" && HttpClient)
=== "function" ? _a : Object])
], HttpService);
returnHttpService; } ());Copy the code
Looking at the code above, you can see that the types of the httpClient parameters in the HttpService constructor are erased because JavaScript is a weakly typed language. So how do you ensure that the right type of dependent object is injected at run time? TypeScript uses a third-party library called reflect-metadata to store additional type information.
Reflect-metadata the reflect-metadata library provides a number of apis for manipulating meta information, and we will briefly introduce a few of the commonly used apis:
// define metadata on an object or property
Reflect.defineMetadata(metadataKey, metadataValue, target);
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);
// check for presence of a metadata key on the prototype chain of an object or property
let result = Reflect.hasMetadata(metadataKey, target);
let result = Reflect.hasMetadata(metadataKey, target, propertyKey);
// get metadata value of a metadata key on the prototype chain of an object or property
let result = Reflect.getMetadata(metadataKey, target);
let result = Reflect.getMetadata(metadataKey, target, propertyKey);
// delete metadata from an object or property
let result = Reflect.deleteMetadata(metadataKey, target);
let result = Reflect.deleteMetadata(metadataKey, target, propertyKey);
// apply metadata via a decorator to a constructor
@Reflect.metadata(metadataKey, metadataValue)
class C {
// apply metadata via a decorator to a method (property)
@Reflect.metadata(metadataKey, metadataValue)
method(){}}Copy the code
A brief overview of the above API is required. We will explain how to use it in the following sections. Here we need to pay attention to the following two issues:
- For classes or functions, we need to decorate them with decorators so that metadata can be saved.
- Only classes, enumerations, or raw data types can be logged. Interfaces and union types appear as “objects.” This is because these types disappear completely after compilation, while the classes remain.
6.3 Defining tokens and Providers
With the basics of decorators and reflection behind us, let’s start implementing the IoC container. Our IoC container will use two main concepts: tokens and providers. Tokens are identifiers for objects that the IoC container creates, and providers are used to describe how these objects are created.
The minimal public interface of the IoC container is as follows:
export class Container {
addProvider<T>(provider: Provider<T>) {} // TODO
inject<T>(type: Token<T>): T {} // TODO
}
Copy the code
Let’s define tokens first:
// type.ts
interface Type<T> extends Function {
new(... args:any[]): T;
}
// provider.ts
class InjectionToken {
constructor(public injectionIdentifier: string){}}type Token<T> = Type<T> | InjectionToken;
Copy the code
The Token type is a union type that can be either a function type or an InjectionToken type. Using strings as tokens in AngularJS can cause conflicts in some cases. Therefore, to solve this problem, we define the InjectionToken class to avoid naming conflicts.
After defining the Token types, let’s define three different types of providers:
- ClassProvider: provides a class that creates dependent objects.
- ValueProvider: Provides an existing value as a dependent object.
- FactoryProvider: Provides a factory method for creating dependent objects.
// provider.ts
export type Factory<T> = () = > T;
export interface BaseProvider<T> {
provide: Token<T>;
}
export interface ClassProvider<T> extends BaseProvider<T> {
provide: Token<T>;
useClass: Type<T>;
}
export interface ValueProvider<T> extends BaseProvider<T> {
provide: Token<T>;
useValue: T;
}
export interface FactoryProvider<T> extends BaseProvider<T> {
provide: Token<T>;
useFactory: Factory<T>;
}
export type Provider<T> =
| ClassProvider<T>
| ValueProvider<T>
| FactoryProvider<T>;
Copy the code
To make it easier to distinguish the three different types of providers, we have defined three types of guard functions:
// provider.ts
export function isClassProvider<T> (
provider: BaseProvider<T>
) :provider is ClassProvider<T> {
return (provider as any).useClass ! = =undefined;
}
export function isValueProvider<T> (
provider: BaseProvider<T>
) :provider is ValueProvider<T> {
return (provider as any).useValue ! = =undefined;
}
export function isFactoryProvider<T> (
provider: BaseProvider<T>
) :provider is FactoryProvider<T> {
return (provider as any).useFactory ! = =undefined;
}
Copy the code
6.4 Defining decorators
As mentioned earlier, for classes or functions, we need to decorate them with decorators in order to hold metadata. So, let’s create Injectable and Inject decorators, respectively.
6.4.1 Injectable decorator
The Injectable decorator is used to indicate that this class can inject its dependencies automatically. It is a class decorator. In TypeScript, class decorators are declared as follows:
declare type ClassDecorator = <TFunction extends Function>(target: TFunction)
=> TFunction | void;
Copy the code
Class decorators are, as the name suggests, used to decorate classes. It takes a single argument: Target: TFunction, which represents the class to be decorated. Let’s look at the implementation of the Injectable decorator:
// Injectable.ts
import { Type } from "./type";
import "reflect-metadata";
const INJECTABLE_METADATA_KEY = Symbol("INJECTABLE_KEY");
export function Injectable() {
return function(target: any) {
Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
return target;
};
}
Copy the code
In the above code, a new function is returned when the Injectable function is called. In the new function, we use the defineMetadata API provided by the reflect-Metadata library to store meta information, which is used as follows:
// define metadata on an object or property
Reflect.defineMetadata(metadataKey, metadataValue, target);
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);
Copy the code
The Injectable class decorator is also easy to use, applying the @Injectable() syntax sugar above the decorator class:
@Injectable(a)export class HttpService {
constructor(
private httpClient: HttpClient
){}}Copy the code
In the example above, we inject an HttpClient object of Type Type. But in real projects, it’s often more complicated. In addition to needing to inject dependencies of Type Type, we may inject other types of dependencies, such as the API address of the remote server that we want to inject into the HttpService service. In this case, we need to use the Inject decorator.
6.4.2 Inject Decorator
Next we create the Inject decorator, which belongs to the parameter decorator. In TypeScript, parameter decorators are declared as follows:
declare type ParameterDecorator = (target: Object,
propertyKey: string | symbol, parameterIndex: number ) = > void
Copy the code
The parameter decorator is, as its name suggests, used to decorate function arguments. It accepts three arguments:
- Target: Object — decorated class;
- PropertyKey: string | symbol – the method name;
- ParameterIndex: number — Index values of parameters in a method.
Here we look at the concrete implementation of Inject decorator:
// Inject.ts
import { Token } from './provider';
import 'reflect-metadata';
const INJECT_METADATA_KEY = Symbol('INJECT_KEY');
export function Inject(token: Token<any>) {
return function(target: any, _ :string | symbol, index: number) {
Reflect.defineMetadata(INJECT_METADATA_KEY, token, target, `index-${index}`);
return target;
};
}
Copy the code
In the above code, a new function is returned after the Inject function is called. In the new function, we use the defineMetadata API provided by the reflect-Metadata library to hold meta information about the parameters. Save index index information and Token information.
After defining the Inject decorator, we can use it to Inject the API address of the remote server we mentioned earlier as follows:
const API_URL = new InjectionToken('apiUrl');
@Injectable(a)export class HttpService {
constructor(
private httpClient: HttpClient,
@Inject(API_URL) private apiUrl: string
){}}Copy the code
6.5 Implement IoC containers
So far, we have defined Token, Provider, Injectable, and Inject decorators. Let’s implement the AFOREMENTIONED IoC container API:
export class Container {
addProvider<T>(provider: Provider<T>) {} // TODO
inject<T>(type: Token<T>): T {} // TODO
}
Copy the code
6.5.1 Implementing the addProvider method
The implementation of the addProvider() method is simple. We use a Map to store the relationship between the Token and Provider:
export class Container {
private providers = new Map<Token<any>, Provider<any> > (); addProvider<T>(provider: Provider<T>) {this.assertInjectableIfClassProvider(provider);
this.providers.set(provider.provide, provider); }}Copy the code
In addProvider () method inside in addition to the Token with the corresponding information saved to the Provider will object, we define a assertInjectableIfClassProvider method, Make sure the ClassProvider you add is injectable. The concrete implementation of this method is as follows:
private assertInjectableIfClassProvider<T>(provider: Provider<T>) {
if(isClassProvider(provider) && ! isInjectable(provider.useClass)) {throw new Error(
`Cannot provide The ${this.getTokenName(
provider.provide
)} using class The ${this.getTokenName(
provider.useClass
)}.The ${this.getTokenName(provider.useClass)} isn't injectable`); }}Copy the code
In assertInjectableIfClassProvider method in the body, we use the front has introduced isClassProvider type guard function to determine whether to ClassProvider, if it is, The ClassProvider is determined to be injectable using the isInjectable () function, which is defined as follows:
export function isInjectable<T> (target: Type<T>) {
return Reflect.getMetadata(INJECTABLE_METADATA_KEY, target) === true;
}
Copy the code
In the isInjectable function, we use the getMetadata API provided by the reflect-Metadata library to retrieve meta information stored in the class. To understand this code, let’s look back at the Injectable decorator:
const INJECTABLE_METADATA_KEY = Symbol("INJECTABLE_KEY");
export function Injectable() {
return function(target: any) {
Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
return target;
};
}
Copy the code
If you add a Provider that is a ClassProvider, but the Provider’s corresponding class is not injectable, an exception will be thrown. To make exception messages more user-friendly and intuitive. We define a getTokenName method to get the Token’s corresponding name:
private getTokenName<T>(token: Token<T>) {
return token instanceof InjectionToken
? token.injectionIdentifier
: token.name;
}
Copy the code
Now that we have implemented the addProvider method of the Container class, we can use it to add three different types of providers:
const container = new Container();
const input = { x: 200 };
class BasicClass {}
/ / register ClassProvider
container.addProvider({ provide: BasicClass, useClass: BasicClass});
/ / register ValueProvider
container.addProvider({ provide: BasicClass, useValue: input });
/ / register FactoryProvider
container.addProvider({ provide: BasicClass, useFactory: () = > input });
Copy the code
Note that the three different types of providers registered in the above example use the same Token for demonstration purposes only. Implement the Core Inject method in the Container class.
6.5.2 Implement Inject method
Before we look at the concrete implementation of the Inject method, let’s take a look at what the method implements:
const container = new Container();
const input = { x: 200 };
container.addProvider({ provide: BasicClass, useValue: input });
const output = container.inject(BasicClass);
expect(input).toBe(output); // true
Copy the code
According to the above test cases, the Inject method in the Container class obtains the corresponding object based on the Token. In the addProvider method implemented earlier, we store the Token and the corresponding Provider in the Providers Map object. Therefore, in inject method, we can first obtain the Provider object corresponding to the Token from providers object, and then obtain the corresponding object according to different types of providers.
Here we look at the concrete implementation of inject method:
inject<T>(type: Token<T>): T {
let provider = this.providers.get(type);
// Handles classes decorated with the Injectable decorator
if (provider === undefined && !(type instanceof InjectionToken)) {
provider = { provide: type.useClass: type };
this.assertInjectableIfClassProvider(provider);
}
return this.injectWithProvider(type, provider);
}
Copy the code
In the above code, in addition to processing the normal flow. We also dealt with a particular scenario where the Provider is not registered with the addProvider method, but instead decorates a class with the Injectable decorator. For this particular scenario, we create a Provider object from the type parameter passed in, and then call injectWithProvider to create the object. This method is implemented as follows:
private injectWithProvider<T>(type: Token<T>, provider? : Provider<T>): T {if (provider === undefined) {
throw new Error(`No provider for type The ${this.getTokenName(type)}`);
}
if (isClassProvider(provider)) {
return this.injectClass(provider as ClassProvider<T>);
} else if (isValueProvider(provider)) {
return this.injectValue(provider as ValueProvider<T>);
} else {
return this.injectFactory(provider asFactoryProvider<T>); }}Copy the code
Inside the injectWithProvider method, we use the type-guard function defined earlier to distinguish between the three different types of providers to handle the different providers. If ValueProvider is injected, injectValue () is called to retrieve the corresponding object.
// { provide: API_URL, useValue: 'https://www.semlinker.com/' }
private injectValue<T>(valueProvider: ValueProvider<T>): T {
return valueProvider.useValue;
}
Copy the code
If a FactoryProvider is found to be a FactoryProvider, injectFactory is called to retrieve the corresponding object. This method is simple:
// const input = { x: 200 };
// container.addProvider({ provide: BasicClass, useFactory: () => input });
private injectFactory<T>(valueProvider: FactoryProvider<T>): T {
return valueProvider.useFactory();
}
Copy the code
Finally, let’s look at how to handle ClassProvider. For ClassProvider classes, we can get the constructor directly by using the Provider object’s useClass property. The simplest case is that the class does not depend on other objects, but in most scenarios, the service class to be instantiated will depend on other objects. So before instantiating a service class, we need to construct its dependent objects.
So now the question is, how do you get the object that the class depends on? Let’s first examine the following code:
const API_URL = new InjectionToken('apiUrl');
@Injectable(a)export class HttpService {
constructor(
private httpClient: HttpClient,
@Inject(API_URL) private apiUrl: string
){}}Copy the code
If you set the compile target to ES5, the following code will be generated:
// The __pipeline function definition is omitted
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect= = ="object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }};var HttpService = / * *@class * / (function () {
function HttpService(httpClient, apiUrl) {
this.httpClient = httpClient;
this.apiUrl = apiUrl;
}
var _a;
HttpService = __decorate([
Injectable(),
__param(1, Inject(API_URL)),
__metadata("design:paramtypes"[typeof (_a = typeofHttpClient ! = ="undefined" && HttpClient)
=== "function" ? _a : Object.String])
], HttpService);
returnHttpService; } ());Copy the code
Do you feel a little dizzy looking at the code above? Don’t worry, Bob will analyze the two parameters in HttpService one by one. First, let’s analyze the apiUrl parameter:
We can clearly see in the figure that the Token corresponding to the API_URL is eventually saved via the Reflect. DefineMetadata API using Symbol(‘INJECT_KEY’). For the other parameter, httpClient, the Key is “Design: ParamTypes”, which modifies the parameter types of the target object method.
In addition to “design: Paramtypes “, there are other metadatakeys, such as Design: Type and Design: ReturnType, which modify the type of the target object and the type of the value returned by the target object method, respectively.
As you can see from the figure above, the parameter types of the HttpService constructor are eventually stored using the Reflect.metadata API. GetInjectedParams getInjectedParams getInjectedParams getInjectedParams getInjectedParams getInjectedParams getInjectedParams
type InjectableParam = Type<any>;
const REFLECT_PARAMS = "design:paramtypes";
private getInjectedParams<T>(target: Type<T>) {
// Get the type of the parameter
const argTypes = Reflect.getMetadata(REFLECT_PARAMS, target) as (
| InjectableParam
| undefined) [];if (argTypes === undefined) {
return [];
}
return argTypes.map((argType, index) = > {
// The reflect-metadata API fails on circular dependencies, and will return undefined
// for the argument instead.
if (argType === undefined) {
throw new Error(
`Injection error. Recursive dependency detected in constructor for type ${target.name}
with parameter at index ${index}`
);
}
const overrideToken = getInjectionToken(target, index);
const actualToken = overrideToken === undefined ? argType : overrideToken;
let provider = this.providers.get(actualToken);
return this.injectWithProvider(actualToken, provider);
});
}
Copy the code
Because our Token Type is a Type of < T > | InjectionToken joint Type, so we also want to consider in the getInjectedParams method InjectionToken situation, So we define a getInjectionToken method to get tokens registered with the @Inject decorator. The implementation of this method is simple:
export function getInjectionToken(target: any, index: number) {
return Reflect.getMetadata(INJECT_METADATA_KEY, target, `index-${index}`) as Token<any> | undefined;
}
Copy the code
Now that we can get the objects that the class constructor depends on, based on the getInjectedParams method we defined earlier, we’ll define an injectClass method that instantiates the class registered by the ClassProvider.
// { provide: HttpClient, useClass: HttpClient }
private injectClass<T>(classProvider: ClassProvider<T>): T {
const target = classProvider.useClass;
const params = this.getInjectedParams(target);
return Reflect.construct(target, params);
}
Copy the code
Now that the two methods defined in the IoC container are implemented, let’s look at the full IoC container code:
// container.ts
type InjectableParam = Type<any>;
const REFLECT_PARAMS = "design:paramtypes";
export class Container {
private providers = new Map<Token<any>, Provider<any> > (); addProvider<T>(provider: Provider<T>) {this.assertInjectableIfClassProvider(provider);
this.providers.set(provider.provide, provider);
}
inject<T>(type: Token<T>): T {
let provider = this.providers.get(type);
if (provider === undefined && !(type instanceof InjectionToken)) {
provider = { provide: type.useClass: type };
this.assertInjectableIfClassProvider(provider);
}
return this.injectWithProvider(type, provider);
}
private injectWithProvider<T>(type: Token<T>, provider? : Provider<T>): T {if (provider === undefined) {
throw new Error(`No provider for type The ${this.getTokenName(type)}`);
}
if (isClassProvider(provider)) {
return this.injectClass(provider as ClassProvider<T>);
} else if (isValueProvider(provider)) {
return this.injectValue(provider as ValueProvider<T>);
} else {
// Factory provider by process of elimination
return this.injectFactory(provider asFactoryProvider<T>); }}private assertInjectableIfClassProvider<T>(provider: Provider<T>) {
if(isClassProvider(provider) && ! isInjectable(provider.useClass)) {throw new Error(
`Cannot provide The ${this.getTokenName(
provider.provide
)} using class The ${this.getTokenName(
provider.useClass
)}.The ${this.getTokenName(provider.useClass)} isn't injectable`); }}private injectClass<T>(classProvider: ClassProvider<T>): T {
const target = classProvider.useClass;
const params = this.getInjectedParams(target);
return Reflect.construct(target, params);
}
private injectValue<T>(valueProvider: ValueProvider<T>): T {
return valueProvider.useValue;
}
private injectFactory<T>(valueProvider: FactoryProvider<T>): T {
return valueProvider.useFactory();
}
private getInjectedParams<T>(target: Type<T>) {
const argTypes = Reflect.getMetadata(REFLECT_PARAMS, target) as (
| InjectableParam
| undefined) [];if (argTypes === undefined) {
return [];
}
return argTypes.map((argType, index) = > {
// The reflect-metadata API fails on circular dependencies, and will return undefined
// for the argument instead.
if (argType === undefined) {
throw new Error(
`Injection error. Recursive dependency detected in constructor for type ${target.name}
with parameter at index ${index}`
);
}
const overrideToken = getInjectionToken(target, index);
const actualToken = overrideToken === undefined ? argType : overrideToken;
let provider = this.providers.get(actualToken);
return this.injectWithProvider(actualToken, provider);
});
}
private getTokenName<T>(token: Token<T>) {
return token instanceofInjectionToken ? token.injectionIdentifier : token.name; }}Copy the code
Finally, let’s simply test the IoC container we developed earlier. The specific test code is as follows:
// container.test.ts
import { Container } from "./container";
import { Injectable } from "./injectable";
import { Inject } from "./inject";
import { InjectionToken } from "./provider";
const API_URL = new InjectionToken("apiUrl");
@Injectable(a)class HttpClient {}
@Injectable(a)class HttpService {
constructor(
private httpClient: HttpClient,
@Inject(API_URL) private apiUrl: string
){}}const container = new Container();
container.addProvider({
provide: API_URL,
useValue: "https://www.semlinker.com/"}); container.addProvider({provide: HttpClient, useClass: HttpClient });
container.addProvider({ provide: HttpService, useClass: HttpService });
const httpService = container.inject(HttpService);
console.dir(httpService);
Copy the code
After the above code runs successfully, the console prints the following:
HttpService {
httpClient: HttpClient {},
apiUrl: 'https://www.semlinker.com/' }
Copy the code
This result is clearly what we expected and indicates that our IoC container is working properly. Of course, in practical projects, a mature IoC container needs to be considered a lot of things, if you want to use it in the project, you can consider using the Library InversifyJS.
7. Reference resources
- Wikipedia – Inversion of control
- Stackblitz – Car-Demo
- Github – reflect-metadata
- Metadata Proposal – ECMAScript
- typescript-dependency-injection-in-200-loc