Skip to content

Instantly share code, notes, and snippets.

@Romakita
Last active April 28, 2023 10:36
Show Gist options
  • Save Romakita/fb86225dffcfa2f28bbcbce9490e3668 to your computer and use it in GitHub Desktop.
Save Romakita/fb86225dffcfa2f28bbcbce9490e3668 to your computer and use it in GitHub Desktop.
How to build and IOC. This exemple explore different way to resolve dependencies
import {Provider} from "./interfaces";
import {parseSignature} from "./parseSignature";
export function getDeps(provider: Provider) {
// resolving deps strategies
// 1. by provider deps
if (provider.deps?.length) {
return provider.deps;
}
// 2. by constructor
if (provider.useClass) {
if (Reflect) {
// 2a. by typescript metadata (remove this if you don't use typescript emitDecoratorMetadata)
return Reflect.getMetadata("design:paramtypes", provider.useClass) || [];
}
// 2b. by parsing constructor signature
return parseSignature(provider.useClass.toString());
}
// 3. try to parse factory signature
return parseSignature(provider.useFactory || provider.useAsyncFactory);
}
/**
* Typescript legacy decorator to inject provider
*/
export function Inject(token?: TokenProvider | () => TokenProvider) {
return (target: any, property: string, index: number) => {
const deps = Reflect.getMetadata("design:paramtypes", target.prototype) || [];
deps[index] = token;
Reflect.defineMetadata("design:paramtypes", deps, target.prototype, propertyKey);
}
}
import {registerProvider} from "./registerProvider";
import {Provider} from "./interfaces";
// decorator legacy
export function Injectable(opts: Partial<Omit<Provider, "useClass" | "useFactory" | "useAsyncFactory">) {
return (target: any) => {
registerProvider({token: target, ...opts});
}
}
import {Providern, TokenProvider, LocalsContainer, Type} from "./interfaces";
import {getDeps} from "./getDeps";
export class InjectorService {
#cache = new Map<TokenProvider, any>();
constructor() {
// make injector service to be injectable
this.set(InjectorService, this);
}
private set(token: TokenProvider, instance: any) {
const name = nameOf(token);
this.#cache.set(InjectorService, this);
this.#cache.set(name, this); // required is you use angular.js convention
}
has(token: TokenProvider) {
return this.has(token) || this.has(nameOf(token));
}
get<T>(token: TokenProvider): T {
return (this.#cache.get(token) || this.#cache.get(nameOf(token))) as T;
}
async loadAsync(locals: LocalsContainer) {
for (const [, provider] of providersRegistry) {
if (!this.has(provider.token) && provider.useAsyncFactory) {
await this.invoke(provider.token, locals);
}
}
}
loadSync(locals: LocalsContainer) {
for (const [, provider] of providersRegistry) {
if (!this.has(provider.token)) {
this.invoke(provider.token, locals);
}
}
}
async load(locals: Map<TokenProvider, any> = new Map()) {
// build async and sync provider
await this.loadAsync(locals);
// load sync provider
this.loadSync(locals);
await this.emit("$onInit()");
}
invoke<T = any>(token: TokenProvider, locals: LocalsContainer = new Map(), opts: { rebuild?: boolean } = {}): T {
const provider = providersRegistry.get(token);
if (!provider) {
throw new Error(`Provider not found for token ${String(token)}`);
}
// 1. resolve from locals first
const local = locals.get(token);
if (local) {
return local;
}
if (!rebuild){
// 2. return already resolved instance
const instance = this.#cache.get(provider.token);
if (instance) {
return instance;
}
}
const invokable = this.resolve(provider, locals, opts);
return invokable();
}
async clear() {
await this.emit("$onDestroy()");
this.#cache.clear();
}
emit(event: string, ...args: any[]) {
const promises = [...providersRegistry.values()].map((provider) => {
const instance = this.get<any>(provider.token);
if (instance) {
if (event in instance) {
return instance[event](...args);
}
if (event in provider.hooks) {
return provider.hooks[event](instance, ...args);
}
}
});
return Promise.all(promises);
}
private resolve(provider: Provider, locals: LocalsContainer, opts?: { rebuild?: boolean }) {
const tokenDependencies = getDeps(provider);
const args = tokenDependencies.map((token) => this.invoke(token, locals, opts));
if (provider.useClass) {
return () => {
const instance = new provider.useClass(...args);
locals.set(provider.token, instance);
this.set(provider.token, instance);
return instance;
};
}
if (provider.useFactory) {
return async () => {
const resolved = await Promise.all(args);
const instance = provider.useFactory(...resolved);
this.set(provider.token, instance);
locals.set(provider.token, instance);
return instance;
};
}
if (provider.useAsyncFactory) {
return () => {
const instance = provider.useAsyncFactory(...args);
this.set(provider.token, instance);
locals.set(provider.token, instance);
return instance;
};
}
return undefined;
}
}
/**
* An example of a `Type` is `MyCustomComponent` filters, which in JavaScript is be represented by
* the `MyCustomComponent` constructor function.
*/
export interface Type<T = any> extends Function {
new(...args: any[]): T;
}
export const Type = Function;
export type TokenProvider = string | symbol | Type;
export interface Provider<InstanceType = any> {
token: TokenProvider,
deps?: TokenProvider[],
useClass?: Type<InstanceType>,
hooks?: Record<string, (instance: InstanceType, ...args: any[]) => any | Promise<any>>
useAsyncFactory?(...args: any[]): Promise<any>,
useFactory?(...args: any[]): any,
}
export type LocalsContainer = Map<TokenProvider, any>;
export function parseSignature(token: any): string[] {
const content = token.toString();
let signature;
const extract = str => str.split("{")[0].split(")")[0].split("(").at(-1);
if (content.startsWith("class ")) {
const ctr = content.split("constructor")[1];
if (!ctr) {
return parseSignature(Object.getPrototypeOf(token));
}
signature = extract(content.split("constructor")[1]);
} else {
signature = extract(content.split("=>")[0]);
}
return signature
.split(",")
.map(param => param.trim())
.filter(Boolean);
}
import {Provider} from "./interfaces";
export function registerProvider(providerOpts: Provider) {
providersRegistry.set(providerOpts.token, providerOpts);
}
class TestUtils {
static injector: InjectorService;
static async create(locals: { token: TokenProvider, use: any }[] = []) {
TestUtils.injector = new InjectorService();
const map = locals.reduce((locals, { token, use }) => {
return locals.set(token, use);
}, new Map<TokenProvider, any>());
await TestUtils.injector.load(map);
}
static async invoke<T : any>(token: TokenProvider, locals: { token: TokenProvider, use: any }[] = []): Promise<T> {
const map = locals.reduce((locals, { token, use }) => {
return locals.set(token, use);
}, new Map<TokenProvider, any>());
return TestUtils.injector.invoke(token, map, {rebuild: true});
}
static async reset() {
const injector = new InjectorService();
await injector.load();
return injector;
}
}
@Romakita
Copy link
Author

Romakita commented Apr 24, 2023

Usage

In software engineering, inversion of control (IoC) is a design pattern in which custom-written portions of a computer program receive the flow of control from a generic framework.

The current code example, is an extract of the @tsed/di without Ts.ED framework specificity. So this example can be reused in any project and can be customized to feat your needs. The idea is also to understand the mechanics of the DI and its possible variants (see the different use cases).

The DI implement support the followings features:

  • Invoke class, factory and async factory
  • mock dependencies
  • emit events
  • Support introspection by the typescript metadata (Angular/Ts.ED/Nest.js like).
  • Support declarative dependencies (For a pure.js usage).
  • Support implicit dependencies by parsing the constructor signature (angular.js like).

Usage with class (explicit dependencies)

This is the most simple usage, but it's declarative.

Advantage: It's compatible with a pure Js approach.

Problem: The double declaration on deps field and constructor can be the cause of error if the dev isn't rigorous about the synchronization of these two elements.

import {registerProvider} from "my-di";
import {OtherService} from "./OtherService";

export class MyService {
   constructor(otherService: OtherService) {
   
   }
}

registerProvider({
   token: MyService,
   deps: [OtherService],
   useClass: MyService
})

Usage with class using TS decorators

This version use the Reflect metadata to introspect dependencies.

Advantage: It the most powerful version of the DI, because, introspection is directly based on the TypeScript metadata and so on the code you write.

Problem: It's based on the Typescript legacy decorator and shouldn't be used for new project. TypeScript considers this version of decorator as legacy, official JavaScript decorators doesn't support this approach.

import {Injectable, Inject} from "my-di";

@Injectable()
class MyService {
   constructor(myOtherService: MyOtherService, @Inject(MyFactory) myFactory: MyFactory) {}
}

Usage with class using JS decorators

Note: The code example doesn't cover this usage

This version use a factory to get the Injectable and Inject decorators. This step is necessary in order to create a context for the class and overcome the lack of metadata carried by the official implementation proposed by the TC39.

Advantage: It's compliant with the future decorators proposal.

Problem:

  • It's a bit verbose rather than the TypeScript legacy decorators.
  • The parameter decorator isn't supported by TC39.
  • Isn't compatible with TypeScript legacy decorators.
  • Depending on the DI implementation, the constructor cannot be used to call injected dependencies.
import {createInjection} from "my-di";
const {Injectable, Inject} = createInjection();

@Injectable()
class MyService {
   @Inject(MyOtherService)
   myOtherService: MyOtherService;
   
   @Inject(MyFactory)
   myFactory: MyFactory;
   
   constructor() {
      console.log(this.myFactory) // undefined
   }
   
   $onInit() {
     console.log(this.myFactory) // defined
   }
}

Usage with factory (explicit dependencies)

Factory let developer to use a function to build the service.

Advantage: Allows advanced usage. Allows the developer to make a library that was not initially injectable.

import {Injectable} from "my-di";
import NativeLib from "native-lib";
import {SettingsService} from "./SettingsService";

// small typescript trick to use const as interface in a class constructor
export const NativeLibService = Symbol.for("nativelib:service");
export type NativeLibService = NativeLib;

registerProvider({
   token: NativeLibService, // can be also a symbol
   deps: [SettingsService], // declarative deps
   useFactory(settingsService: SettingsService) {
     return  new NativeLib(settingsService.get("nativelib"));
   }
});

Usage with explicit declaration:

class MyService {
   constructor(myService: NativeLibService){}
}

registerProvider({
   token: MyService,
   useClass: MyService,
   deps: [NativeLibService]
});

Usage with TS decorators:

@Injectable()
class MyService {
   constructor(@Inject(NativeLibService) nativeLibService: NativeLibService){}
}

Usage with factory (implicit dependencies)

This version use the "angular.js aka Angular 1" principle. The DI will parse the function signature to detect the dependencies.

Problem:

  • This approach adding an extra bootstrap time coast.
  • It's incompatible with a bundler that minify the code.
  • Doesn't allow to have two service with the same token name.
import {registerProvider} from "my-di";

registerProvider({
   token: 'myOtherFactory',
   useFactory() {   
     return "World";
   }
})

registerProvider({
   token: 'myFactory', // can be also a symbol
   useFactory(myOtherFactory: string) { // injector parse signature to inject dependencies   
     // do something
     
     return  "Hello " + myOtherFactory
   }
})

Declare async factory

Async factory allow declaring function that build something asynchronous (like a db connection).

Advantage: Support promise resolution. Allows the developer to make a library that was not initially injectable.

import {registerProvider} from "my-di";

registerProvider({
   token: 'redisConnection'
   async useAsyncFactory() {
     const redis = new Redis({})

     await redis.connect()
     
     return  redis;
   }
})

Load injector

import {InjectorService} from "my-di";
/// import all provider using import "./services/MyService";

async function boostrap() {
  const injectorService = new InjectorService();
  
  await injectorService.load();
  
  injectorService.emit("$onReady");
}

Hooks

Hooks allows event emitting across injectable service. This pattern let developer to extend his API functionality by implement a hook collection and create his own app lifecycle.

For example a basic usage, is to create a database connection and destroy it when the app is shutdown. With an async factory implemented in our DI, it can be done by listening $onDestroy hook:

registerProvider({
   token: 'redisConnection',
   async useAsyncFactory() {
     const client = new Redis({})

     await client.connect()
     
     return  client
   },
  hooks: {
     $onDestroy(client: Redis) {
         return client.close()
     }
  }
})

His equivalent with a class:

@Injectable()
class RedisClient {
   #redis: Redis;

   constructor(settings: SettingsService) {
        this.#redis = new Redis(settings.get('redis'))
   }
   async $onInit() {
     await this.redis.connect();
   }
   
   async $onDestroy() {
      await this.redis.close();
   }
}

Testing

This the most advantage of this pattern. Your DI must implement a TestUtils to facilitate testing and create mock instances. Using a DI without this part would be nonsense.

Here is simple example on how to mocking dependencies using the DI and TestUtils:

import {Injectable, Inject} from "my-di";

@Injectable()
class MyRepository {
   constructor(@Inject(DbClient) private dbClient: DbClient) {}
   
   get(id: string) {
      return this.dbClient.collection("collectionName").findOne(id);
   }
}
describe('MyRepository', () => {
   beforeEach(() => PlatformTest.create([
      {
        token: DbClient,
        use: {
           collection: jest.fn().mockReturnThis(),
           findOne: jest.fn().mockResolvedValue('hello')
        }
      }
   ]));
   afterEach(() => PlatformTest.reset());
   
   describe('get()', () => {
     it('should return an item his id', await () => {
        const service = PlatformTest.get<MyRepository>(MyRepository);
        const dbClient = PlatformTest.get<DbClient>(DbClient);
        
        const result = await service.get('id');
        expect(result).to('hello');
        expect(dbClient.collection).toHaveBeenCalledWith('collectionName');
        expect(dbClient.findOne).toHaveBeenCalledWith('id');
     }); 
   });
});

PlatformTest.create create an injector sandbox which will be cleaned a this end of the test using PlatformTest.reset. It's really important to work in a sandbox and clean it later to retrieve a stable and fresh test context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment