Creating a microservice architecture using single-spa (migration of an existing project)

image



This is the first article on this topic, a total of 3 are planned:



  1. * Create root application from your existing project, add 3 micro-applications to it (vue, react, angular)
  2. Communication between micro-applications
  3. Working with git (deploy, updates)


Table of contents



  1. a common part
  2. Why is it needed
  3. Create a root container (see definition below) from your monolith
  4. Create a VUE micro-app (vue-app)
  5.  Create a micro-application REACT (react-app)
  6.  Create a micro-application ANGULAR (angular-app)


1. General part



The goal of this article is to add the ability to use an existing monolithic project as a root container for a microservice architecture.



The existing project is made on angular 9.



For microservice architecture, we use the single-spa library .



You need to add 3 projects to the root project, we use different technologies: vue-app, angular-app, react-app (see p. 4, 5, 6).



In parallel with the creation of this article, I am trying to implement this architecture into a production project that I am currently working on. Therefore, I will try to describe all the errors that I have in the development process and their solutions.



Root application (hereinafter root) - the root (container) of our application. We will put (register) all our microservices into it. If you already have any project and want to implement this architecture in it, then your existing project will be the root application, from where over time you will try to gnaw out pieces of your application, create separate microservices and register it in this container.



This approach of creating a root container will provide an excellent opportunity to migrate to another technology without much pain.



For example, we decided to move from angular to vue completely, but the project is fat, and at the moment it brings a lot of money to the business.



Without microservice architecture, this would not have appeared in our thoughts, only for desperate people who believe in unicorns and that we are all a hologram.

In order to switch to a new technology in reality, it is necessary to rewrite the entire project, and only then we could get high from its appearance in battle.



Another option is microservice architecture. You can create a root project from your monolith, add a new project there on the same vue, set up roaming in root, you're done. You can pour into battle, gradually cut small pieces from the root of the project and transfer them to your vue micro-project. This leaves only the files in your root container that are needed to import your new project.



This can be done right here and now, without loss, blood, and most importantly is real.

I will use angular as root, since the existing project was written in it.



The general interface into which the single page application will be wrapped:



bootstrap (mounter, bus) - called after loading the service, will tell which element of the house you need to mount, give it a message bus to which the microservice will subscribe and will be able to listen and send requests and the



mount command () - mount the application from home



unmount () - dismantle the application



unload () - unload the application



In the code, I will once again describe the operation of each method locally at the place of use.



2. Why is it needed



Let's start at this point strictly in order.



There are 2 types of architecture:



  1. Monolith
  2. Microservice architecture


image



With the monolith, everything is quite simple and as familiar as possible to all of us. Strong cohesion, huge blocks of code, shared repository, tons of methods.



At the start, monolithic architecture is as convenient and fast as possible. There are no problems and difficulties in creating any integration files, interlayers, event models, data buses, etc.



The problem appears when your project grows, a lot of separate, complex functional for different purposes appears. All this functionality begins to be tied within the project to some kind of common models, states, utilities, interfaces, methods, etc.



Also, the number of directories and files in the project becomes huge over time, there are problems of finding and understanding the project as a whole, the "top view" is lost, which gives clarity to what we are doing, where what lies and who needs it.



In addition to all this, Eagleson's Law is at work , which says that your code, which you have not looked at for 6 months or more, looks like someone else wrote it.



The most painful thing is that everything will grow exponentially, as a result, crutches will begin, which must be added because of the complexity of maintaining the code in connection with the above and, over time, the waves of irresponsible terms occurring.



As a result, if you have a live project that is constantly evolving, it will become a big problem, the eternal discontent of your team, a huge number of people - hours to make minor changes to the project, a low entry threshold for new employees and a lot of time to roll out the project into battle. This all leads to disorder, well, we love order?



Does this always happen with a monolith?



Of course not! It all depends on the type of your project, on the problems that arise during team development. Your project may not be so big, to perform some one complex business task, this is normal and I believe it is correct.



First of all, we need to pay attention to the parameters of our project. 



I'll try to take out the points by which you can understand whether we really need a microservice architecture:



  • 2 or more teams are working on the project, the number of front-end developers is 10+;
  • Your project consists of 2 or more business models, for example, you have an online store with a huge amount of goods, filters, notifications, and the functionality of courier delivery distribution (2 separate, not small business models that will interfere with each other). All this can live separately and not depend on each other.
  • The set of UI capabilities grows daily or weekly without affecting the rest of the system.


Microfronts are used to:



  • Separate parts of the frontend could be developed, tested, and deployed independently;
  • Parts of the frontend could be added, removed or replaced without re-assembly;
  •   .
  • , - «», - ( ) -.
  • ,
  • .


single-spa ?



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


Microservice, in my understanding, is an independent single page application that will solve only one user task. This application also does not have to solve the entire task of the team. 



SystemJS is an open source JS library commonly used as a polyfill for browsers.



The polyfill is a piece of JS code used to provide modern functionality for older browsers that don't support it.



One of the features of SystemJS is the import map, which allows you to import a module over the network and map it to a variable name.



For example, you can use an import map for a React library that is loaded via a CDN:



BUT!



If you are creating a project from scratch, even taking into account that you have determined all the parameters of your project, you have decided that you will have a huge Mega super project with a team of 30+ people, wait!



I really like the idea of ​​the notorious founder of the idea of ​​microservices - Martin Fowler .



He proposed to combine monolithic approach and microservices into one (MonolithFirst). Its main idea is as follows: 

you should not start a new project with microservices, even if you are fully confident that the future application will be large enough to justify this approach


I will also describe the disadvantages of using such an architecture here:



  • Interaction between fragments cannot be achieved with standard tube methods (DI, for example).
  • What about common dependencies? After all, the size of the application will grow by leaps and bounds if they are not taken out of the fragments.
  • Someone should still be responsible for routing in the final application.
  • It is not clear what to do with the fact that different microservices can be located on different domains
  • What to do if one of the fragments is not available / cannot be rendered.


3. Creating a root container



And so, enough theory, it's time to start.



Go to the 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


In ts.config.app.json, globally import declarations (types)



// ts.config.app.json

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




Add to app-routing.module.ts all micro-applications that we add to root



// 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' }
        }
    ]
},


You also need to add config



// extra-webpack.config.json

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


Let's change the package.json file, add to it all necessary for work or



// 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",
}


Add the required libraries to 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"
             ]
        }
     }
},


Create a single-spa folder at the root of the project . Let's add 2 files to it.



1. route-reuse-strategy.ts - our microservices routing file.

If a child application is routing internally, that application interprets this as a route change.



    By default, this will destroy the current component and replace it with a new instance of the same spa-host component.



This route reuse strategy looks at routeData.app to determine if the new route should be treated as the same route as the previous one, ensuring that we don't remount the child app when the specified child app routes internally.



// 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



The service will store the method for mounting (mount) and unmount (unmount) micro-frontend applications.



    mount is a lifecycle function that will be called whenever a registered application is not mounted, but its activity function returns true. When called, this function should look at the URL to determine the active route and then create DOM elements, DOM events, etc.



    unmount is a lifecycle function that will be called whenever a registered application is mounted, but its activity function returns false. When called, this function should clear all DOM elements.



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


Next, we create a directory container / app / spa-host .



This module will implement registration and mapping of our micro frontend applications to root.



Let's add 3 files to the module.



1. The spa-host.module.ts module itself



//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. Component spa-host.component.ts - coordinates the installation and dismantling of micro-frontend applications



// 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 - checks if the application name in the route is different, parse the previous service, if it is too, just go to it. 



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


We register everything that we added to the app.module



// app.module.ts

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


Let's change 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 => {});


Next, we create a file import-map.json in the share folder. The file is needed to add import maps.

At the moment, we will have it empty and fill up as applications are added to 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. Create a VUE micro-application (vue-app)



Now that we have added the ability to become a root application to our monolithic project, it's time to create our first external micro-application with single-spa.



First, we need to globally install create-single-spa, a command line interface that will help us create new single-spa projects with simple commands.



Go to the console



npm install --global create-single-spa


Create a simple vue app using a command in the console



create-single-spa


The command line interface will prompt you to select a directory, project name, organization and type of application to create



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 


We launch our micro-application



npm i 
npm run serve --port 8000


When we enter the path in the browser localhost : 8080 / , in the case of vue, we will see a blank screen. What happened? 

As there is no index.js file in the generated micro-app.  



Single-spa provides a playground from which to download the application over the internet, so let's use it first.



Add to index.js 

single-spa-playground.org/playground/instant-test?name=@some-name/vue-app&url=8000
When creating the root application, we added a map in advance to load our vue project. 



{
"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"
}
}


Ready! Now from our angular root project, we can load micro-applications written in vue.



5. Create a micro-application REACT (react-app)



We create a similarly simple react application using the command in the console



create-single-spa


Organization name: somename



Project name: 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 


Let's check if we have added an import map in our root application



{
"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",
	}
}


Done! Now, on our react-app route, we load the react micro-project. 



6. Create a micro-application ANGULAR (angular-app)



We create an Angular micro-application in exactly the same way as the previous 2



create-single-spa


Organization name: somename



Project name: 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 


Let's check if we have added an import map in our root application



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


We launch, check, everything should work.



This is my first post on Habré, I will be very grateful for your comments.



All Articles