Création d'une architecture de microservices en single-spa (migration d'un projet existant)

image



Ceci est le premier article sur ce sujet, un total de 3 sont prévus:



  1. * Créez une application racine à partir de votre projet existant, ajoutez-y 3 micro-applications (vue, réaction, angulaire)
  2. Communication entre micro-applications
  3. Travailler avec git (déployer, mises à jour)


Table des matières



  1. une partie commune
  2. Pourquoi est-ce nécessaire
  3. Créez un conteneur racine (voir la définition ci-dessous) à partir de votre monolithe
  4. Créer une micro-application VUE (vue-app)
  5.  Créer une micro-application REACT (react-app)
  6.  Créer une micro-application ANGULAIRE (angular-app)


1. Partie générale



L'objectif de cet article est d'ajouter la possibilité d'utiliser un projet monolithique existant en tant que conteneur racine pour une architecture de microservice.



Le projet existant est réalisé sur angular 9.



Pour l'architecture des microservices, nous utilisons la bibliothèque single-spa .



Vous devez ajouter 3 projets au projet racine, nous utilisons différentes technologies: vue-app, angular-app, react-app (voir p. 4, 5, 6).



En parallèle de la création de cet article, j'essaye d'implémenter cette architecture dans un projet de production sur lequel je travaille actuellement. Par conséquent, je vais essayer de décrire toutes les erreurs que j'ai au cours du processus de développement et leurs solutions.



Application racine (ci-après racine) - la racine (conteneur) de notre application. Nous y mettrons (enregistrerons) tous nos microservices. Si vous avez déjà un projet et que vous souhaitez y implémenter cette architecture, votre projet existant sera l'application racine, d'où, au fil du temps, vous essaierez de ronger des parties de votre application, de créer des microservices séparés et de l'enregistrer dans ce conteneur.



Cette approche de création d'un conteneur racine fournira une excellente opportunité de migrer vers une autre technologie sans trop de peine.



Par exemple, nous avons décidé de passer complètement de l'angulaire à la vue, mais le projet est audacieux et pour le moment, il apporte beaucoup d'argent à l'entreprise.



Sans l'architecture des microservices, cela ne serait pas apparu dans nos pensées, uniquement pour les personnes désespérées qui croient aux licornes et que nous sommes tous un hologramme.

Pour passer en réalité à une nouvelle technologie, il est nécessaire de réécrire l'ensemble du projet, et ce n'est qu'alors que nous pourrons nous défoncer de son apparition au combat.



Une autre option est l'architecture des microservices. Vous pouvez créer un projet racine à partir de votre monolithe, y ajouter un nouveau projet sur la même vue, configurer l'itinérance à la racine, vous avez terminé. Vous pouvez vous lancer dans la bataille, découper progressivement de petits morceaux à la racine du projet et les transférer dans votre micro-projet de vue. Cela ne laisse que les fichiers dans votre conteneur racine qui sont nécessaires pour importer votre nouveau projet.



Cela peut être fait ici et maintenant, sans perte, sans sang, et le plus important est réel.

J'utiliserai angular comme root, car le projet existant y a été écrit.



L'interface générale dans laquelle l'application d'une seule page sera enveloppée:



bootstrap (mounter, bus) - appelée après le chargement du service, elle indiquera quel élément de la maison vous devez monter, lui donnera un bus de messages auquel le microservice s'abonnera et pourra écouter et envoyer des requêtes et la commande de



montage () - monter l'application depuis home



unmount () - démonter l'application



unload () - décharger l'application



Dans le code, je vais à nouveau décrire le fonctionnement de chaque méthode localement sur le lieu d'utilisation.



2. Pourquoi est-ce nécessaire



Commençons à ce stade strictement dans l'ordre.



Il existe 2 types d'architecture:



  1. Monolithe
  2. Architecture de microservices


image



Avec le monolithe, tout est assez simple et aussi familier que possible pour nous tous. Une forte cohésion, d'énormes blocs de code, un référentiel partagé, un tas de méthodes.



Au départ, l'architecture monolithique est aussi pratique et rapide que possible. Il n'y a aucun problème ni difficulté à créer des fichiers d'intégration, des intercalaires, des modèles d'événements, des bus de données, etc.



Le problème apparaît lorsque votre projet se développe, de nombreuses fonctionnalités distinctes et complexes à des fins différentes apparaissent. Toutes ces fonctionnalités commencent à être liées au sein du projet à certains modèles généraux, états, utilitaires, interfaces, méthodes, etc.



De plus, le nombre de répertoires et de fichiers dans le projet devient énorme avec le temps, il y a des problèmes pour trouver et comprendre le projet dans son ensemble, la «vue de dessus» est perdue, ce qui clarifie ce que nous faisons, où ce qui se trouve et qui en a besoin.



En plus de tout cela, la loi d'Eagleson est à l'œuvre , qui dit que votre code, que vous n'avez pas regardé depuis 6 mois ou plus, ressemble à quelqu'un d'autre l'a écrit.



Le plus douloureux est que tout va croître de manière exponentielle, en conséquence, des béquilles vont commencer, ce qui doit être ajouté en raison de la complexité de la maintenance du code en relation avec ce qui précède et, au fil du temps, des vagues de termes irresponsables qui se produisent.



En conséquence, si vous avez un projet en direct qui évolue constamment, cela deviendra un gros problème, le mécontentement éternel de votre équipe, un grand nombre de personnes - des heures pour apporter des modifications mineures au projet, un seuil d'entrée bas pour les nouveaux employés et beaucoup de temps pour déployer le projet dans la bataille. Tout cela mène au désordre, eh bien, on aime l'ordre?



Cela se produit-il toujours avec un monolithe?



Bien sûr que non! Tout dépend du type de votre projet, des problèmes qui surviennent lors du développement de l'équipe. Votre projet n'est peut-être pas si grand, pour effectuer une tâche commerciale complexe, c'est normal et je pense que c'est correct.



Tout d'abord, nous devons prêter attention aux paramètres de notre projet. 



Je vais essayer de retirer les points par lesquels vous pouvez comprendre si nous avons vraiment besoin d'une architecture de microservice:



  • 2 équipes ou plus travaillent sur le projet, le nombre de développeurs front-end est de 10+;
  • Votre projet se compose de 2 modèles commerciaux ou plus, par exemple, vous avez une boutique en ligne avec un grand nombre de produits, de filtres, de notifications et la fonctionnalité de distribution de livraison par courrier (2 modèles commerciaux distincts, pas de petite taille, qui interfèrent les uns avec les autres). Tout cela peut vivre séparément et ne pas dépendre les uns des autres.
  • L'ensemble des fonctionnalités de l'interface utilisateur augmente chaque jour ou chaque semaine sans affecter le reste du système.


Les micro-fronts sont utilisés pour:



  • Des parties distinctes du frontend pourraient être développées, testées et déployées indépendamment;
  • Des parties de l'interface peuvent être ajoutées, supprimées ou remplacées sans réassemblage;
  •   .
  • , - «», - ( ) -.
  • ,
  • .


single-spa ?



  • (, React, Vue Angular) , .
  • Single-spa , , .
  • .


Microservice, à ma connaissance, est une application indépendante d'une seule page qui ne résoudra qu'une seule tâche utilisateur. Cette application n'a pas non plus à résoudre toute la tâche de l'équipe. 



SystemJS est une bibliothèque JS open source couramment utilisée comme polyfill pour les navigateurs.



Le polyfill est un morceau de code JS utilisé pour fournir des fonctionnalités modernes aux navigateurs plus anciens qui ne le prennent pas en charge.



L'une des fonctionnalités de SystemJS est la mappe d'importation, qui vous permet d'importer un module sur le réseau et de le mapper à un nom de variable.



Par exemple, vous pouvez utiliser une carte d'importation pour une bibliothèque React qui est chargée via un CDN:



MAIS!



Si vous créez un projet à partir de zéro, même en tenant compte du fait que vous avez déterminé tous les paramètres de votre projet, vous avez décidé que vous auriez un énorme méga super projet avec une équipe de plus de 30 personnes, attendez!



J'aime beaucoup l'idée du fondateur notoire de l'idée de microservices - Martin Fowler .



Il a proposé de combiner une approche monolithique et des microservices en un seul (MonolithFirst). Son idée principale est la suivante: 

vous ne devez pas démarrer un nouveau projet avec des microservices, même si vous êtes convaincu que la future application sera suffisamment grande pour justifier cette approche


Je décrirai également les inconvénients de l'utilisation d'une telle architecture ici:



  • L'interaction entre les fragments ne peut pas être obtenue avec des méthodes de tube standard (DI, par exemple).
  • Qu'en est-il des dépendances courantes? Après tout, la taille de l'application augmentera à pas de géant, si elle n'est pas retirée des fragments.
  • Quelqu'un doit toujours être responsable du routage dans l'application finale.
  • On ne sait pas quoi faire avec le fait que différents microservices peuvent être situés sur différents domaines
  • Que faire si l'un des fragments n'est pas disponible / ne peut pas être rendu.


3. Création d'un conteneur racine



Et donc, assez de théorie, il est temps de commencer.



Aller à la console



ng add single-spa-angular
npm i systemjs@6.1.4,
npm i -d @types/systemjs@6.1.0,
npm import-map-overrides@1.8.0


Dans ts.config.app.json, importez globalement les déclarations (types)



// ts.config.app.json

"compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": [
(+)     "systemjs"
    ]
},




Ajoutez à app-routing.module.ts toutes les micro-applications que nous ajoutons à la racine



// app-routing.module.ts

{
    path: 'vue-app',
    children: [
        {
            path: '**',
            loadChildren: ( ) => import('./spa-host/spa-host.module').then(m => m.SpaHostModule),
            data: { app: '@somename/vue-app' }
        }
    ]
},
{
    path: 'angular-app',
    children: [
        {
            path: '**',
            loadChildren: ( ) => import('./spa-host/spa-host.module').then(m => m.SpaHostModule),
            data: { app: '@somename/angular-app' }
        }
    ]
},
{
    path: 'react-app',
    children: [
        {
            path: '**',
            loadChildren: ( ) => import('./spa-host/spa-host.module').then(m => m.SpaHostModule),
            data: { app: '@somename/react-app' }
        }
    ]
},


Vous devez également ajouter une configuration



// extra-webpack.config.json

module.exports = (angularWebpackConfig, options) => {
    return {
        ...angularWebpackConfig,
        module: {
            ...angularWebpackConfig.module,
            rules: [
                ...angularWebpackConfig.module.rules,
            {
                parser: {
                    system: false
                }
             }
           ]
        }
    };
}


Modifions le fichier package.json, ajoutons-y tout le nécessaire pour le travail ou



// package.json

"dependencies": {
      ...,
(+) "single-spa": "^5.4.2",
(+) "single-spa-angular": "^4.2.0",
(+) "import-map-overrides": "^1.8.0",
(+) "systemjs": "^6.1.4",
}
"devDependencies": {
      ...,
(+)  "@angular-builders/custom-webpack": "^9",
(+)  "@types/systemjs": "^6.1.0",
}


Ajoutez les bibliothèques requises à angular.json



// angular.json

{
    ...,
    "architect": {
        "build": {
            ...,
            "scripts": [
                ...,
(+)            "node_modules/systemjs/dist/system.min.js",
(+)            "node_modules/systemjs/dist/extras/amd.min.js",
(+)            "node_modules/systemjs/dist/extras/named-exports.min.js",
(+)            "node_modules/systemjs/dist/extras/named-register.min.js",
(+)            "node_modules/import-map-overrides/dist/import-map-overrides.js"
             ]
        }
     }
},


Créez un dossier single-spa à la racine du projet . Ajoutons-y 2 fichiers.



1. route-reuse-strategy.ts - notre fichier de routage de microservices.

Si une application enfant effectue un routage en interne, cette application l'interprète comme un changement d'itinéraire.



    Par défaut, cela détruira le composant actuel et le remplacera par une nouvelle instance du même composant spa-host.



Cette stratégie de réutilisation d'itinéraire examine routeData.app pour déterminer si la nouvelle route doit être traitée comme la même route que la précédente, en veillant à ne pas remonter l'application enfant lorsque l'application enfant spécifiée est acheminée en interne.



// route-reuse-strategy.ts

import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router';
import { Injectable } from '@angular/core';
@Injectable()
export class MicroFrontendRouteReuseStrategy extends RouteReuseStrategy {
    shouldDetach(): boolean {
        //   
        return false;
    }
    store(): void { }
    shouldAttach(): boolean {
        return false;
    }
    //   
    retrieve(): DetachedRouteHandle {
        return null;
    }
    shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
        return future.routeConfig === curr.routeConfig || (future.data.app && (future.data.app === curr.data.app));
    }
}


2. Service single-spa.service.ts



Le service stockera la méthode de montage (montage) et de démontage (démontage) des applications micro-frontales.



    mount est une fonction de cycle de vie qui sera appelée chaque fois qu'une application enregistrée n'est pas montée, mais sa fonction d'activité renvoie true. Lorsqu'elle est appelée, cette fonction doit regarder l'URL pour déterminer la route active, puis créer des éléments DOM, des événements DOM, etc.



    unmount est une fonction de cycle de vie qui sera appelée chaque fois qu'une application enregistrée est montée, mais sa fonction d'activité renvoie false. Lorsqu'elle est appelée, cette fonction doit effacer tous les éléments DOM.



//single-spa.service.ts

import { Injectable } from '@angular/core';
import { mountRootParcel, Parcel, ParcelConfig } from 'single-spa';
import { Observable, from, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class SingleSpaService {
    private loadedParcels: {
        [appName: string]: Parcel;
    } = {};
    mount(appName: string, domElement: HTMLElement): Observable<unknown> {
        return from(System.import<ParcelConfig>(appName)).pipe(
            tap((app: ParcelConfig) => {
                this.loadedParcels[appName] = mountRootParcel(app, {
                    domElement
                });
            })
        );
    }
    unmount(appName: string): Observable<unknown> {
        return from(this.loadedParcels[appName].unmount()).pipe(
            tap(( ) => delete this.loadedParcels[appName])
        );
    }
}


Ensuite, nous créons un répertoire container / app / spa-host .



Ce module implémentera l'enregistrement et le mappage de nos applications micro frontend vers root.



Ajoutons 3 fichiers au module.



1. Le module spa-host.module.ts lui-même



//spa-host.module.ts

import { RouterModule, Routes } from '@angular/router';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { SpaUnmountGuard } from './spa-unmount.guard';
import { SpaHostComponent } from './spa-host.component';
const routes: Routes = [
    {
        path: '',
        canDeactivate: [SpaUnmountGuard],
        component: SpaHostComponent,
    },
];
@NgModule({
    declarations: [SpaHostComponent],
    imports: [CommonModule, RouterModule.forChild(routes)]
})
export class SpaHostModule {}


2. Composant spa-host.component.ts - coordonne l'installation et le démontage des applications micro-frontales



// spa-host.component.ts 

import { Component, OnInit, ViewChild, ElementRef, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import {SingleSpaService} from '../../single-spa/single-spa.service';
@Component({
selector: 'app-spa-host',
template: '<div #appContainer></div>',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SpaHostComponent implements OnInit {
    @ViewChild('appContainer', { static: true })
    appContainerRef: ElementRef;
    appName: string;
    constructor(private singleSpaService: SingleSpaService, private route: ActivatedRoute) { }
    ngOnInit() {
        //    
        this.appName = this.route.snapshot.data.app;
        this.mount().subscribe();
    }
     //       
    mount(): Observable<unknown> {
        return this.singleSpaService.mount(this.appName, this.appContainerRef.nativeElement);
    }
    // 
    unmount(): Observable<unknown> {
        return this.singleSpaService.unmount(this.appName);
    }
}


3. spa-unmount.guard.ts - vérifie si le nom de l'application dans la route est différent, analyse le service précédent, si c'est aussi le cas, allez-y. 



// spa-unmount.guard.ts

import { Injectable } from '@angular/core';
import { CanDeactivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SpaHostComponent } from './spa-host.component';
@Injectable({ providedIn: 'root' })
export class SpaUnmountGuard implements CanDeactivate<SpaHostComponent> {
    canDeactivate(
        component: SpaHostComponent,
        currentRoute: ActivatedRouteSnapshot,
        currentState: RouterStateSnapshot,
        nextState: RouterStateSnapshot
    ): boolean | Observable<boolean> {
        const currentApp = component.appName;
        const nextApp = this.extractAppDataFromRouteTree(nextState.root);
        
        if (currentApp === nextApp) {
            return true;
        }
        return component.unmount().pipe(map(_ => true));
    }
    private extractAppDataFromRouteTree(routeFragment: ActivatedRouteSnapshot): string {
        if (routeFragment.data && routeFragment.data.app) {
            return routeFragment.data.app;
        }
        if (!routeFragment.children.length) {
            return null;
        }
        return routeFragment.children.map(r => this.extractAppDataFromRouteTree(r)).find(r => r !== null);    
    }
}


Nous enregistrons tout ce que nous avons ajouté à l'app.



// app.module.ts

providers: [
      ...,
      {
(+)     provide: RouteReuseStrategy,
(+)     useClass: MicroFrontendRouteReuseStrategy
      }
]


Changeons main.js.



// main.ts

import { enableProdMode, NgZone } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { start as singleSpaStart } from 'single-spa';
import { getSingleSpaExtraProviders } from 'single-spa-angular';
import { AppModule } from './app/app.module';
import { PlatformLocation } from '@angular/common';
if (environment.production) {
    enableProdMode();
}
singleSpaStart();
//  

const appId = 'container-app';

//      ,     getSingleSpaExtraProviders. 
platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule).then(module => {
    NgZone.isInAngularZone = () => {
    // @ts-ignore
        return window.Zone.current._properties[appId] === true;
    };
    const rootPlatformLocation = module.injector.get(PlatformLocation) as any;
    const rootZone = module.injector.get(NgZone);
    // tslint:disable-next-line:no-string-literal
    rootZone['_inner']._properties[appId] = true;
    rootPlatformLocation.setNgZone(rootZone);
})
.catch(err => {});


Ensuite, nous créons un fichier import-map.json dans le dossier de partage. Le fichier est nécessaire pour ajouter des cartes d'importation.

Pour le moment, nous le viderons et le remplirons au fur et à mesure que les applications seront ajoutées à root.



<head>
<!doctype html>
<html lang="en">
<head>
       <meta charset="utf-8">
       <title>My first microfrontend root project</title>
       <base href="/">
       ...
(+)  <meta name="importmap-type" content="systemjs-importmap" />
    <script type="systemjs-importmap" src="/assets/import-map.json"></script>
</head>
<body>
    <app-root></app-root>
    <import-map-overrides-full></import-map-overrides-full>
    <noscript>Please enable JavaScript to continue using this application.</noscript>
</body>
</html>
    

4. Créez une micro-application VUE (vue-app)



Maintenant que nous avons ajouté la possibilité de devenir une application racine à notre projet monolithique, il est temps de créer notre première micro-application externe avec single-spa.



Tout d'abord, nous devons installer globalement create-single-spa, une interface de ligne de commande qui nous aidera à créer de nouveaux projets single-spa avec des commandes simples.



Aller à la console



npm install --global create-single-spa


Créez une application vue simple à l'aide d'une commande dans la console



create-single-spa


L'interface de ligne de commande vous invite à sélectionner un répertoire, un nom de projet, une organisation et un type d'application à créer



image



? Directory for new project vue-app 
? Select type to generate single-spa application / parcel 
? Which framework do you want to use? vue 
? Which package manager do you want to use? npm 
? Organization name (use lowercase and dashes) somename 


Nous lançons notre micro-application



npm i 
npm run serve --port 8000


Lorsque nous entrons le chemin dans le navigateur localhost : 8080 / , dans le cas de vue, nous verrons un écran vide. Qu'est-il arrivé? 

Comme il n'y a pas de fichier index.js dans la micro-application générée.  



Single-spa fournit une aire de jeux à partir de laquelle télécharger l'application sur Internet, alors utilisons-la d'abord.



Ajouter à index.js 

single-spa-playground.org/playground/instant-test?name=@some-name/vue-app&url=8000
Lors de la création de l'application racine, nous avons ajouté une carte à l'avance pour charger notre projet vue. 



{
"imports": {
    ... ,
    "vue": "https://unpkg.com/vue",     
    "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js",
    "@somename/vue-app": "//localhost:8080/js/app.js"
}
}


Prêt! Désormais à partir de notre projet de racine angulaire, nous pouvons charger des micro-applications écrites en vue.



5. Créez une micro-application REACT (react-app)



Nous créons une application de réaction tout aussi simple à l'aide de la commande dans la console



create-single-spa


Nom de l'organisation: somename



Nom du projet: react-app



? Directory for new project react-app 
? Select type to generate single-spa application / parcel 
? Which framework do you want to use? react 
? Which package manager do you want to use? npm 
? Organization name (use lowercase and dashes) somename 


Vérifions si nous avons ajouté une carte d'importation dans notre application racine



{
"imports": {
    ... ,
       "react": "https://cdn.jsdelivr.net/npm/react@16.13.1/umd/react.development.js",
       "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.development.js",
       "@somename/react-app": "//localhost:8081/somename-projname.js",
	}
}


Terminé! Maintenant, sur notre route react-app, nous chargeons le micro-projet react. 



6. Créez une micro-application ANGULAIRE (angular-app)



Nous créons une micro-application angulaire exactement de la même manière que les 2 précédentes



create-single-spa


Nom de l'organisation: somename



Nom du projet: angular-app



? Directory for new project angular-app 
? Select type to generate single-spa application / parcel 
? Which framework do you want to use? angular 
? Which package manager do you want to use? npm 
? Organization name (use lowercase and dashes) somename 


Vérifions si nous avons ajouté une carte d'importation dans notre application racine



{
    "imports": {
        ... ,
       "@somename/angular-app": "//localhost:8082/main.js",
     }
}


Nous lançons, vérifions, tout devrait fonctionner.



C'est mon premier post sur Habré, je serai très reconnaissant pour vos commentaires.



All Articles