Apprenez à créer votre propre outil d'injection de dépendances

Préparation avant la lecture

Avant de lire ce document, vous pouvez d'abord comprendre ces connaissances, afin de pouvoir suivre le contenu du document :

  • Connaissances conceptuelles : Inversion de Contrôle, Injection de Dépendances, Inversion de Dépendances ;

  • Connaissances techniques : décorateur Décorateur, réflexion Réfléchir ;

  • Définition de TSyringe : jeton, fournisseur https://github.com/microsoft/tsyringe#injection-token .

Toutes les pratiques d'implémentation de cet article sont écrites dans codesandbox. Si vous êtes intéressé, vous pouvez cliquer pour voir le code source https://codesandbox.io/s/di-playground-oz2j9.

Qu'est-ce que l'injection de dépendances

exemple simple

Nous implémentons ici un exemple simple pour expliquer ce qu'est l'injection de dépendance : un étudiant conduit un véhicule de chez lui à l'école.

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

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

Ensuite, dans la vraie vie, les élèves éloignés choisiront de se rendre à l'école en voiture, et les élèves proches choisiront d'aller à l'école en vélo. Nous pouvons alors continuer à résumer le code ci-dessus et l'écrire comme suit :

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

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

Cela répond effectivement aux besoins des étudiants qui sont loin du trajet en voiture pour se rendre à l'école, mais il y a ici un problème. Les étudiants ont également leurs propres choix et préférences spécifiques. Certaines personnes aiment BMW, d'autres aiment Tesla. Afin de résoudre de tels problèmes, nous pouvons continuer à utiliser l'héritage et continuer à faire abstraction, afin d'avoir des étudiants riches qui aiment BMW et Tesla.

Tout le monde pense probablement qu'écrire du code de cette manière est totalement irréalisable et que le degré de couplage est trop élevé : chaque type d'étudiant est directement lié à un moyen de transport spécifique lorsqu'il est abstrait. Les moyens de transport appartenant à un étudiant ne sont pas créés par l'étudiant, mais déterminés en fonction de sa situation familiale et de ses préférences, du type de moyen de transport qu'il utilise pour se rendre à l'école ; il peut même y avoir beaucoup de voitures à la maison, et il se rend à l'école en voiture tous les jours selon son humeur.

Ensuite, afin de réduire le couplage et de créer des dépendances selon des états et des conditions spécifiques, nous devons parler des modèles suivants.

inversion de contrôle

L'inversion de contrôle (Inversion of Control, en abrégé IoC ) est un principe de conception qui réduit le couplage entre les codes en inversant la logique du programme.

Un conteneur d'inversion de contrôle (conteneur IoC) est un outil ou un cadre spécifique utilisé pour exécuter une logique de code inversée à partir d'un programme interne, améliorant ainsi la réutilisabilité et la lisibilité du code. L'outil DI que nous utilisons souvent joue le rôle de conteneur IoC, connectant tous les objets et leurs dépendances.

e29967fd9ce06208ccbaabb3f939ff71.png

Reportez-vous à l'article de Martin Fowler sur l'inversion de contrôle et l'injection de dépendances https://martinfowler.com/articles/injection.html

injection de dépendance

L'injection de dépendances est une implémentation spécifique de l'inversion de contrôle. En abandonnant le contrôle de la création de la vie des objets à l'intérieur du programme, les objets dépendants sont créés et injectés depuis l'extérieur.

Il existe quatre méthodes principales d’injection de dépendances :

  • Basé sur une interface. Implémentez une interface spécifique pour les conteneurs externes afin d'injecter des objets du type dépendant.

  • Basé sur la méthode définie. Implémentez la méthode public set d'une propriété spécifique pour permettre au conteneur externe d'appeler l'objet du type dépendant.

  • Basé sur le constructeur. Implémentez un constructeur avec des paramètres spécifiques et transmettez un objet du type dépendant lors de la création d'un nouvel objet.

  • En fonction des annotations, ajoutez des annotations telles que « @Inject » avant les variables privées, afin que les outils ou les frameworks puissent analyser les dépendances et injecter automatiquement des dépendances.

Les deux premières méthodes ne seront pas utilisées dans les outils DI couramment utilisés dans le front-end. Nous présentons ici principalement les deux dernières.

S'il est transmis depuis le constructeur, il peut être écrit comme ceci :

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()

En l'absence d'outils, la méthode de définition basée sur le constructeur peut être écrite à la main, mais bien que la méthode d'écriture ici appartienne à l'injection de dépendances, trop d'instanciations manuelles fastidieuses seront un cauchemar pour les développeurs, en particulier Car L'objet lui-même peut dépendre de différents pneus, différentes instances de moteurs.

Outils pour l’injection de dépendances

L'outil d'injection de dépendances est une sorte de conteneur IoC. En analysant automatiquement les dépendances, le processus d'instanciation d'objet initialement effectué manuellement est complété dans l'outil.

@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()

Si vous utilisez des annotations, vous pouvez l'écrire comme ceci :

@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()

La différence entre les deux réside dans la dépendance à l'égard de l'outil. La classe définie à partir du constructeur peut toujours s'exécuter normalement même si elle est créée manuellement, mais la classe définie par annotation ne peut être créée que par l'outil, et ne peut pas être créé manuellement.

Inversion de dépendance

L'un des six principes des modèles de conception logicielle, Dependency Inversion Principe, abréviation anglaise DIP , nom complet Dependence Inversion Principe.

Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau, les deux doivent dépendre de leurs abstractions ; les abstractions ne doivent pas dépendre des détails, les détails doivent dépendre des abstractions.

Dans le scénario avec un conteneur loC, le contrôle de la création d'objets n'est pas entre nos mains, mais créé dans l'outil ou le cadre. Le fait qu'un étudiant conduise une BMW ou une Tesla lorsqu'il est à l'école est déterminé par l'environnement d'exploitation. Dans l'environnement opérationnel réel de JS, le modèle du canard est suivi, qu'il s'agisse d'un véhicule ou non, tant qu'il peut être conduit, n'importe quelle voiture peut être utilisée.

Nous pouvons donc modifier le code comme suit, en nous appuyant sur une abstraction plutôt que sur une implémentation spécifique.

// 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()
  }
}

Pourquoi est-il si important de s’appuyer sur des abstractions plutôt que sur des implémentations ? Dans une architecture complexe, une abstraction raisonnable peut nous aider à maintenir la simplicité, à améliorer la cohésion au sein des limites de domaine, à réduire le couplage entre différentes frontières et à guider les projets pour les diviser en structures de répertoires raisonnables. Lors de la mise en œuvre de la capacité composite de SSR et CSR, lorsque le client est en cours d'exécution, il doit demander des données via HTTP, tandis que sur le serveur, il suffit d'appeler directement DB ou RPC pour obtenir les données.

Nous pouvons faire abstraction de l'objet qui demande des données, définir un service abstrait et implémenter la même fonction respectivement sur le client et le serveur pour demander des données :

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>`
  }
}

Utilisez-le sur une page Web :

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
})

Utilisez ceci côté serveur :

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()
  }
}

Testabilité

En plus d'obtenir la cohésion élevée et le faible couplage les plus importants en génie logiciel, l'injection de dépendances peut également améliorer la testabilité du code.

Un test général que nous pourrions écrire comme ceci :

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)
})

Bien qu'un tel test unitaire puisse s'exécuter normalement, car la fonction du Stub est envahie sur le prototype, il s'agit d'un effet secondaire global, qui affectera d'autres tests unitaires s'ils sont exécutés ici. Si les effets secondaires de sinon disparaissent à la fin du test, cela n'affectera pas le test unitaire en série, mais les tests en parallèle ne seront pas possibles. Avec la méthode d’injection de dépendances, de tels problèmes ne se poseront pas.

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)
})

dépendance circulaire

Dans le cas de dépendances circulaires, nous ne pouvons généralement pas créer d'objets, comme les deux définitions de classe suivantes. Bien qu'il soit logique d'éviter une telle situation, il est difficile de dire qu'une telle situation ne sera pas pleinement utilisée dans le processus d'écriture du code.

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

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

Avec DI, grâce à l'inversion du contrôle de la création d'IoC, lorsque l'objet est créé pour la première fois, l'instance ne sera pas réellement créée, mais un objet proxy sera donné, et l'instance sera créée lorsque l'objet est réellement utilisé, puis la dépendance circulaire sera résolue. Quant à la raison pour laquelle le décorateur Lazy doit exister ici, nous l'expliquerons plus tard lorsque nous l'implémenterons plus tard.

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

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

quelques inconvénients

Bien entendu, l’utilisation d’outils DI n’est pas totalement dénuée d’inconvénients. Les inconvénients les plus évidents incluent :

  • Cycle de vie incontrôlable, car l'instanciation de l'objet est en IoC, donc le moment où l'objet est créé n'est pas complètement déterminé par le programme actuel. Cela nous oblige donc à avoir une bonne compréhension des principes avant d'utiliser des outils ou des frameworks, et il est préférable de lire le code source à l'intérieur.

  • Lorsque les dépendances tournent mal, il est plus difficile de déterminer laquelle ne va pas. Étant donné que les dépendances sont injectées, lorsque les dépendances tournent mal, vous ne pouvez les analyser que par expérience, ou par débogage en direct sur site, et effectuer un débogage un peu en profondeur pour savoir où le contenu est erroné. Cela nécessite une grande capacité de débogage ou une capacité de contrôle globale du projet.

  • Le code ne peut pas être lu de manière cohérente. Si cela dépend de l'implémentation, vous pouvez voir l'intégralité de l'arborescence d'exécution du code depuis l'entrée jusqu'en bas ; si cela dépend de l'abstraction, la relation de connexion entre l'implémentation spécifique et l'implémentation est séparée, et les documents sont généralement nécessaires pour voir l'image globale du projet.

outils communautaires

Dans la catégorie DI de github, vous pouvez consulter certains outils DI populaires https://github.com/topics/dependency-injection?l=typescript.

InversifyJS (https://github.com/inversify/InversifyJS) : Un puissant outil d'injection de dépendances avec une implémentation stricte de l'abstraction des dépendances ; bien que l'instruction stricte soit bonne, elle est très répétitive et verbeuse à écrire.

TSyringe (https://github.com/microsoft/tsyringe) : Facile à utiliser, hérité de la définition abstraite d'angular, cela vaut la peine d'être appris.

acquérir des compétences de base

Afin de réaliser les capacités des outils DI de base, de prendre en charge la création d'objets, de réaliser l'inversion de dépendances et l'injection de dépendances, nous réalisons principalement trois capacités :

  • Analyse des dépendances : pour pouvoir créer un objet, l'outil doit connaître les dépendances existantes.

  • Créateur d'enregistrement : afin de prendre en charge différents types de méthodes de création d'instance, il prend en charge les dépendances directes, les dépendances abstraites et la création d'usine ; différents contextes enregistrent différentes implémentations.

  • Créer une instance : utilisez le créateur pour créer une instance, prenant en charge le mode singleton et le mode multi-instance.

En supposant que notre formulaire final soit le code d'exécution comme celui-ci, si vous voulez le résultat final, vous pouvez cliquer sur le lien de codage en ligne 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()

Analyse des dépendances

Afin de permettre aux outils DI d'effectuer une analyse de dépendance, il est nécessaire d'activer la fonction de décorateur de TS et la fonction de métadonnées du décorateur.

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

Décorateur

Voyons d’abord comment la dépendance du constructeur est analysée. Après avoir activé les fonctions de décorateur et de métadonnées, essayez de compiler le code précédent dans le terrain de jeu de TS, et vous pouvez voir que le code JS en cours d'exécution ressemble à ceci.

bc8f0160bd6789904a4479ba8260040d.png

On peut remarquer que les définitions de code les plus critiques sont les suivantes :

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

Si vous lisez attentivement la logique de la fonction __decorate, il s'agit en fait d'une fonction d'ordre supérieur. Afin d'exécuter ClassDecorator et Metadata Decorator dans l'ordre inverse, traduisez le code ci-dessus, qui équivaut à :

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

Ensuite, lisons attentivement la logique de la fonction __metadata, qui exécute la fonction de Reflect, qui équivaut au code :

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

réflexion

Pour les résultats du code précédent, nous pouvons temporairement ignorer la première ligne et lire la signification de la deuxième ligne. C'est exactement la capacité d'analyse des dépendances dont nous avons besoin. Reflect.metadata est une fonction de haut niveau qui renvoie une fonction de décorateur. Après exécution, les données sont définies sur le constructeur. Vous pouvez retrouver les données définies à partir de ce constructeur ou de ses successeurs via getMetadata.

eeea816ea2b2e41be0eaef3daef553f0.png

Par exemple, dans la réflexion ci-dessus, nous pouvons obtenir les données définies de la manière suivante :

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

Proposition de métadonnées réflexives : https://rbuckton.github.io/reflect-metadata/#syntax

Après avoir activé émetDecoratorMetadata, TS remplira automatiquement trois types de métadonnées lors de la compilation du lieu décoré :

  • design:type Les métadonnées de type de la propriété actuelle, qui apparaissent dans PropertyDecorator et MethodDecorator ;

  • Les métadonnées des paramètres d’entrée design:paramtypes apparaissent dans ClassDecorator et MethodDecorator ;

  • design:returntype renvoie les métadonnées de type, qui apparaissent dans MethodDecorator.

f8f7687eb577ee4fcd53d0adb001e7db.png

dépendances de balises

Pour que les outils DI collectent et stockent les dépendances, nous devons analyser les constructeurs dépendants dans Injectable, puis définir les constructeurs par réflexion et enregistrer la description des données par réflexion via une valeur de symbole.

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);
  };
}

Cela répond à deux objectifs :

  • Les données de configuration sont marquées par le symbole interne, et le constructeur superficiel est décoré avec Injectable et peut être créé par IoC.

  • Collectez et assemblez les données de configuration définies dans le constructeur, y compris les données dépendantes, les données de configuration telles qu'une instance unique et plusieurs instances qui peuvent être utilisées ultérieurement.

définir le conteneur

Avec les données définies par le décorateur en réflexion, la partie la plus importante du Container dans IoC peut être créée. Nous allons implémenter une fonction de résolution pour créer automatiquement l'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;
  }
}

La logique de base principale est la suivante :

  • Analysez les données définies par le décorateur Injectable via la réflexion du constructeur. S'il n'y a pas de données, une erreur sera générée ; une petite attention doit être accordée au fait que parce que les données de réflexion sont héritées, seul getOwnMetadata peut être utilisé pour obtenir les données de réflexion de la cible actuelle. Assurez-vous que la cible actuelle doit être décorée.

  • Créez ensuite de manière récursive des instances dépendantes via des dépendances et obtenez la liste des paramètres d'entrée du constructeur.

  • Enfin, en instanciant le constructeur, nous obtenons le résultat souhaité.

créer une instance

À ce stade, la fonction la plus élémentaire de création d'objets a été réalisée et le code suivant peut enfin s'exécuter normalement.

@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()

Vous pouvez également visiter la codesandbox et sélectionner le mode ContainerV1 sur la gauche pour voir un tel résultat.

1012a51979f1f826653d53f03fbeeb89.png

Abstraction des dépendances

Ensuite, nous avons terminé l'IoC de base, mais nous devons ensuite modifier les exigences, en espérant remplacer le véhicule par n'importe quel outil que nous voulons au moment de l'exécution, et la dépendance de Student devrait toujours être un véhicule qui peut être conduit.

Ensuite, nous le mettons en œuvre en deux étapes :

  • Remplacement d'instance : remplacez Transportation par Bicycle au moment de l'exécution.

  • Abstraction des dépendances : changez le transport de la classe à l'interface.

Avant de réaliser la capacité de remplacement d'instance et d'abstraction de dépendance, nous devons d'abord définir la relation entre la dépendance et l'implémentation de la dépendance, afin qu'IoC puisse savoir quelle instance créer pour injecter des dépendances, nous devons donc d'abord parler de jeton et de fournisseur.

Jeton

En tant que jeton unique de dépendance, il peut s'agir d'une chaîne, d'un symbole, d'un constructeur ou d'un TokenFactory. En l'absence d'abstraction dépendante, il s'agit en fait d'une dépendance directe entre différents constructeurs ; String et Symbol sont les ID de dépendance que nous utiliserons après nous être appuyés sur l'abstraction ; et TokenFactory est utilisé lorsque nous voulons réellement effectuer des références circulaires de fichiers. schémas.

Nous pouvons ignorer TokenFactory, l'autre partie de la définition Token n'a pas besoin d'être implémentée séparément, c'est juste une définition de type :

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

Fournisseur

La définition de création d'instance correspondant au jeton enregistré dans le conteneur, puis IoC peut créer l'objet d'instance correct via le fournisseur après avoir obtenu le jeton. En le subdivisant davantage, les fournisseurs peuvent être divisés en trois types :

  • Fournisseur de classe

  • Fournisseur de valeur

  • Fournisseur d'usine

Fournisseur de classe

Utilisez des constructeurs pour définir l'instanciation. Généralement, la version simple que nous avons implémentée plus tôt est en fait une version simplifiée de ce modèle ; avec une petite modification, il est facile d'implémenter cette version, et après avoir implémenté ClassProvider, nous pouvons passer La façon d'enregistrer le fournisseur est pour remplacer le moyen de transport dans l'exemple précédent.

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

Fournisseur de valeur

ValueProvider est très utile lorsqu'il existe déjà une implémentation unique au niveau mondial, mais que les dépendances abstraites sont définies en interne. Pour donner un exemple simple, en mode d'architecture concise, nous exigeons que la logique du code de base soit indépendante du contexte, donc si le front-end veut utiliser des objets globaux dans l'environnement du navigateur, il doit définir de manière abstraite, puis mettre ces objets sont transmis via ValueProvider.

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

Fournisseur d'usine

Ce fournisseur aura une fonction d'usine, puis créera une instance, ce qui est très utile lorsque nous devons utiliser le modèle d'usine.

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

Mettre en œuvre l’enregistrement et la création

Après avoir défini le jeton et le fournisseur, nous pouvons implémenter une fonction d'enregistrement via eux et connecter le fournisseur à la création. La logique est également relativement simple, avec deux points clés :

  • Utilisez Map pour former la relation de mappage entre le jeton et le fournisseur, et en même temps dédupliquez l'implémentation du fournisseur, et celles enregistrées couvriront les précédentes. TSyringe peut être enregistré plusieurs fois. Si le constructeur dépend d'un exemple de tableau, une instance sera créée pour chaque fournisseur à son tour ; cette situation est en fait rarement utilisée, et elle augmentera la complexité de la mise en œuvre du fournisseur. Très élevé, les étudiants intéressés peuvent étudier cette partie de sa mise en œuvre et de sa définition.

  • En analysant différents types de fournisseurs, créez ensuite différentes dépendances.

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);
    });
  }
 }

remplacement d'instance

Après avoir implémenté la fonction qui prend en charge l'enregistrement du fournisseur, nous pouvons remplacer le moyen de transport que les élèves utilisent pour se rendre à l'école en définissant le fournisseur de transport.

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

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

On peut donc voir l'effet suivant dans codesandbox, et enfin on peut aller à l'école à vélo.

976e7af51b5ebb713a6dd442c9d67910.png

modèle d'usine

Nous avons réalisé le remplacement des dépendances. Avant d'implémenter l'abstraction des dépendances, nous avons d'abord inséré une nouvelle exigence, car il est trop difficile d'aller à l'école à vélo, donc les conditions routières sont meilleures le week-end, et nous espérons pouvoir conduire à l'école. Grâce au modèle d'usine, nous pouvons l'implémenter de la manière suivante :

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();

Voici une introduction simple au mode usine. TSyringe et InversifyJS ont tous deux des fonctions de création en mode usine, ce qui est une méthode recommandée ; en même temps, vous pouvez également concevoir d'autres outils DI. Certains outils placeront le jugement de la fonction usine dans le Où la classe est déclarée.

Ce n'est pas impossible, il sera plus facile d'écrire lorsqu'une seule fonction est implémentée individuellement, mais nous parlerons ici du but de l'introduction de DI, pour le découplage. Le jugement logique de la fonction d'usine fait en fait partie de la logique métier, et il n'appartient pas au domaine auquel appartient l'implémentation spécifique ; et lorsque l'implémentation est utilisée dans plusieurs logiques d'usine, la logique à cet endroit deviendra très étrange.

définir l'abstraction

Ainsi, après le remplacement de l'instance, voyons comment faire de Transportation une abstraction plutôt qu'un objet d'implémentation concret. La première étape consiste donc à changer la dépendance de Student d'une logique de mise en œuvre concrète à une logique abstraite.

Ce dont nous avons besoin, c'est d'une abstraction du transport, d'un véhicule qui peut être conduit, de vélos, de motos et de voitures ; tant qu'il peut être conduit, n'importe quelle voiture peut être utilisée. Créez ensuite une nouvelle classe d'étudiant pour hériter de l'ancien objet à des fins de distinction et de comparaison.

interface ITransportation {
  drive(): string
}

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

Si vous l'écrivez ainsi, vous constaterez que l'analyse des dépendances sera erronée ; car lorsque TS est compilé, l'interface est un type, et elle deviendra l'objet de construction correspondant du type au moment de l'exécution, et la dépendance ne peut pas être correctement résolue. .

5039719890e82237db246a09ae977ed4.png

Ainsi, en plus de définir ici un type abstrait, nous devons également définir une balise unique pour ce type abstrait, qui est la chaîne ou le symbole du jeton. Nous choisissons généralement le symbole, qui est une valeur unique au monde. Ici, vous pouvez utiliser les multiples définitions de TS avec le même nom en valeur et en type, et laisser TS l'analyser par lui-même en tant que valeur et en tant que 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);
  }
}

remplacer les dépendances abstraites

Notez qu'en plus de définir la valeur symbolique de la dépendance abstraite, nous devons également ajouter un décorateur supplémentaire pour rendre le paramètre de dépendance du constructeur du marqueur et lui donner une marque Token.

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);
    }
  };
}

Dans le même temps, la logique d'Injectable doit également être modifiée pour remplacer les dépendances dans les positions correspondantes.

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);
  };
}

Enregistrer le fournisseur de résumé

Il reste encore la dernière étape ici, l'injection du fournisseur correspondant du jeton peut être utilisée, il nous suffit de changer la définition du jeton du FactoryProvider précédent, et nous avons alors atteint notre objectif.

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();

Implémenter la création paresseuse

Auparavant, nous avons implémenté la méthode d'injection de dépendances basée sur le constructeur, qui est très bonne et n'affecte pas l'utilisation normale du constructeur. Mais un problème avec cela est que toutes les instances d'objet sur l'arborescence des dépendances seront créées lors de la création de l'objet racine. Il y aura du gaspillage de cette manière, et les instances qui ne sont pas utilisées risquent de ne pas être créées à l'origine.

Afin de garantir que les instances créées sont utilisées, nous choisissons de créer des instances lors de leur utilisation au lieu d'initialiser l'objet racine.

Définir l'utilisation

Ici, nous devons modifier la fonction Inject afin qu'elle puisse prendre en charge à la fois la décoration du constructeur et la décoration de la propriété.

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()

décorateur immobilier

Jetons un coup d'œil aux caractéristiques de ParameterDecorator et PropertyDecorator en combinant les résultats de la compilation TS et les définitions de types.

Ci-dessous la description en .d.ts

acc6faa471f279101c66b456b5469b15.png

Ce qui suit est le résultat de la compilation

1c8bb607855bc7c2732659ef69631080.png

Vous pouvez constater les différences suivantes :

  • Le nombre de paramètres d'entrée est différent, car ParameterDecorator aura les données du nombre de paramètres.

  • La description de l'objet est différente. Le ParameterDecorator du constructeur décrit le constructeur et le PropertyDecorator décrit le prototype du constructeur.

Ainsi en identifiant la marque, puis en renvoyant le fichier de description de la propriété, la fonction getter de la propriété correspondante est ajoutée sur le Prototype, et la logique de création d'objet en cours d'utilisation est réalisée.

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);
    }
  };
}

Une chose à noter ici est que dans la conception de la propre description de TS, il n'est pas recommandé de revenir à PropertyDescriptor pour modifier la définition des propriétés, mais en fait, dans la mise en œuvre des normes et de TS, il l'a effectivement fait, donc ici peut être dans le futur va changer.

dépendance circulaire

Après avoir terminé la création paresseuse, parlons d'un problème de petite relation, de dépendance circulaire. En général, nous devrions éviter les dépendances circulaires de notre logique, mais si nous devons les utiliser, nous devons quand même fournir des solutions pour résoudre les dépendances circulaires.

Par exemple un tel exemple :

@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())

Pourquoi y a-t-il un problème

Le problème vient du temps d’exécution du décorateur. Le but du constructeur décorateur est de décrire le constructeur, c'est-à-dire que lorsque le constructeur est déclaré, la logique du décorateur sera exécutée immédiatement, mais pour le moment, ses dépendances n'ont pas été déclarées et la valeur obtenue n'est toujours pas définie.

a19e858fbad32dddffdf8dd630193292.png

boucle de fichier

En plus des boucles au sein des fichiers, il existe également des boucles entre les fichiers, comme dans l'exemple suivant.

ae37a45d872fd31b857290498a5afb9f.png

Ce qui suit se produira :

  • Le fichier père est lu par Node ;

  • Le fichier Père initialisera un module dans Node et l'enregistrera dans le total des modules ; mais les exportations sont toujours un objet vide, en attente d'affectation ;

  • Le fichier Père commence à exécuter la première ligne, faisant référence au résultat de Fils ;

  • Commencez à lire le fichier Son ;

  • Le fichier Son initialisera un module dans Node et l'enregistrera dans le total des modules ; mais les exportations sont toujours un objet vide, en attente d'affectation ;

  • Le fichier Son exécute la première ligne, cite le résultat de Father, puis lit le module vide enregistré par Father ;

  • Son commence à déclarer le constructeur, puis lit le constructeur de Father, mais il n'est pas défini pour le moment, et exécute la logique du décorateur ;

  • Son's Module attribue les exportations et termine l'exécution ;

  • Après que Père ait lu le constructeur de Son, il commence à déclarer le constructeur ; lit correctement le constructeur de Son et exécute la logique du décorateur.

briser le cycle

Lorsqu'une dépendance circulaire se produit, la première idée devrait être de rompre le cycle ; de laisser la dépendance devenir une logique abstraite sans cycle et de rompre la séquence d'exécution.

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())

En définissant l'abstraction publique Person, la fonction getDescription peut être exécutée normalement ; en fournissant les fournisseurs d'ISon et IFather, les implémentations spécifiques de leurs dépendances respectives sont fournies et le code logique peut s'exécuter normalement.

dépendance paresseuse

En plus de s'appuyer sur l'abstraction, si des dépendances circulaires sont vraiment nécessaires, nous pouvons toujours résoudre ce problème par des moyens techniques, c'est-à-dire en permettant à la résolution des dépendances d'être exécutée une fois le constructeur défini, plutôt que lorsqu'il est déclaré avec le constructeur. À l’heure actuelle, seule une méthode simple est nécessaire, utilisant l’exécution de fonctions, qui est la logique paresseuse que nous avons mentionnée plus tôt.

Étant donné que les variables dans le cadre de JS sont promues, les références de variables peuvent être conservées dans les fonctions. Tant que les variables ont reçu des valeurs lors de l'exécution de la fonction, les dépendances peuvent être résolues correctement.

@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())

Usine de jetons

Ce que nous devons faire est d'ajouter une nouvelle méthode d'analyse de jetons, qui peut utiliser des fonctions pour obtenir dynamiquement des dépendances.

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

Ajoutez ensuite un décorateur LazyInject et soyez compatible avec cette logique.

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 });
    }
  };
}

Enfin, rendez cette logique compatible dans Container et écrivez une version V3 de 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);
  }
}

Enfin, regardez l’effet de l’utilisation :

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

enfin

Jusqu’à présent, nous avons essentiellement implémenté un outil DI basique et utilisable. Revoyons un peu notre contenu :

  • En utilisant la logique de réflexion et de décorateur TS, nous implémentons la résolution de dépendances et la création d'objets ;

  • Grâce à la définition du fournisseur, le remplacement d'instance, l'abstraction des dépendances et le modèle d'usine sont réalisés ;

  • En utilisant PropertyDecorator pour définir la fonction getter, nous obtenons une création paresseuse ;

  • En obtenant dynamiquement des dépendances via TokenFactory, nous avons résolu les dépendances circulaires.

- FIN -

À propos de la troupe Qi Wu

Qi Wu Troupe est la plus grande équipe front-end du groupe 360 ​​et participe aux travaux des membres du W3C et de l'ECMA (TC39) au nom du groupe. La Troupe Qi Wu attache une grande importance à la formation des talents. Les employés peuvent choisir parmi des ingénieurs, des conférenciers, des traducteurs, des personnes d'interface commerciale, des chefs d'équipe et d'autres directions de développement, et complétés par une formation correspondante sur les compétences techniques, les compétences professionnelles, les compétences générales, cours de compétences en leadership, etc. La Qi Dance Troupe accueille toutes sortes de talents exceptionnels auxquels prêter attention et rejoindre la Qi Dance Troupe avec une attitude ouverte et de recherche de talents.

bd69e53b4b655e3ab8d79d03ed94843c.png

Je suppose que tu aimes

Origine blog.csdn.net/qiwoo_weekly/article/details/132288468
conseillé
Classement