Da macro a micro, tutto!

Costruiamo un’app con i micro-frontends ed i Moduli Federati

Davide D'Antonio
weBeetle
14 min readOct 26, 2020

--

In questo articolo daremo uno sguardo pratico alla progettazione dell’interfaccia utente, utilizzando i micro-frontends, del progetto descritto nell’articolo precedente, nel quale ho creato una semplice API per un’applicazione di ticketing in Node.js.

Prima di iniziare ad addentrarmi nello sviluppo vero è proprio, mi sono fermato a ragionare su alcuni fattori:

Il primo è stato sicuramente quello di capire quali dovevano comporre interfaccia. Il modo migliore per farlo è stato quello di dare uno sguardo allo schema dei microservizi:

Dando uno sguardo veloce allo schema, mi sono subito reso conto di dover creare diverse sezioni:

  • Una sezione esterna all’applicazione in cui un utente può effettuare l’accesso o la registrazione
  • Una sezione interna all’applicazione in cui un utente può visualizzare, creare ed eliminare gli utenti che hanno accesso all’applicazione.
  • Un’altra sezione interna all’applicazione in cui un utente può visualizzare, creare ed eliminare i ticket creati.

In pochissimo tempo ho individuato tre micro-frontends: auth, users e tickets.

Il secondo fattore su cui ho ragionato ha riguardato la scelta del framework, decisione che ho preso in anticipo. Non ho la necessità di sviluppare le diverse sezioni della nostra applicazione con diversi framework e di conseguenza ho avuto la necessità di averne un altro per quel che riguarda i micro-frontends (Single SPA, mosaic etc.). Ho utilizzato solo ed esclusivamente React.

Decision Framework

Come già accennato nel precedente articolo in cui abbiamo analizzato i micro-frontends, prima di iniziare a scrivere il codice bisogna prendere alcune decisioni in anticipo. Questa serie di decisioni prendono il nome di Decision Framework, ideato da Luca Mezzalira (Vice President of Architecture in DAZN). Il decision framework si basa su quattro aree decisionali:

  • Definizione: come verrà suddivisa l’applicazione?
  • Composizione: dove avverrà la composizione dei micro-frontends?
  • Routing: chi si occuperà del routing?
  • Comunicazione: se ho la necessità di far comunicare i micro-frontends, come avverrà questa comunicazione?

Definizione

Mi è capitato almeno un milione di volte di sentire domande del tipo: “Come vogliamo organizzare l’interfaccia?”, oppure “vogliamo ricaricare tutta la pagina ad ogni click dell’utente sul menu?”, o addirittura “ma menu lo mettiamo subito sotto l’App Bar o usiamo un menu a comparsa da sinistra?”.

Queste sono solo alcune delle domande che affliggono i progettisti di interfacce utente!

Diciamo che in una tipologia di software come quello descritto non è il massimo ricaricare la pagina ad ogni click dell’utente su una voce di menu. I moderni framework come Angular, React o Vue forniscono tutti gli strumenti necessari per far sì che il contenuto di una pagina venga suddiviso e gestito in modo tale che al click di un utente su una voce di menu venga ricaricata solo una parte del DOM e non l’intera pagina.

Questo mi ha spinto verso la prima decisione importante, e cioè quella di suddividere in modo orizzontale l’interfaccia: una App Bar sopra, sulla sinistra un menu accessibile da un pulsante con cui accedere al menu laterale, nel caso in cui sia nascosto, ed infine il contenuto delle diverse sezioni disponibile nel restante spazio.

Questo mi ha fatto rendere conto che avrei avuto più micro-frontends sulla stessa interfaccia ed inoltre mi ha consentito di identificare un’altra applicazione responsabile della condivisione di due componenti (uno per l’app bar ed un altro per il drawer), e che si sarebbe occupata della navigazione all’interno dell’applicazione, ossia nav. Schematicamente:

Suddivisione orizzontale dell’interfaccia

Composizione

In questa applicazione le diverse sezioni saranno dinamiche e dipendono dall’utente loggato. Quindi ho deciso che la composizione dell’interfaccia sarebbe avvenuta lato client e che, di conseguenza, avrei avuto la necessità di un’application shell avrebbe avuto il compito di incorporare i diversi micro-frontends all’interno della pagina:

Composizione lato client

Routing

Penso che questa decisione sia vittima delle scelte fatte in precedenza. Il routing viene fatto lato client e se ne occuperà react-router .

Comunicazione

I diversi micro-frontends non avranno la necessità di comunicare fra loro.

Che framework utilizzare?

Questa è stata una scelta difficile! Ne esistono tantissimi in circolazione e, per la tipologia di composizione e routing che ho scelto, i più famosi sono:

In verità Module Federation non è un vero e proprio framework ma un nuovo modulo Webpack 5 che consente la condivisione di codice tra le applicazioni in fase di esecuzione.

La sottile differenza che esiste tra questa metodologia introdotta da Webpack rispetto ai framework, che si occupano di micro-frontends, in circolazione è la seguente:

Mentre un micro-frontend framework ci permette di condividere applicazioni, il plugin FederationModule di Webpack 5 ci permette di condividere codice tra le applicazioni.

Una differenza sottile ma che è doveroso fare! Ovviamente i moduli federati di Webpack 5 consentono non solo di condividere piccoli moduli software ma anche intere sezioni di un’interfaccia software. Se non avete mai utilizzato i moduli federati di Webpack 5 vi consiglio di leggere questo articolo:

Per questo progetto quindi ho deciso di utilizzare questa metodologia per costruire l’applicazione.

Micro-frontends framework VS Moduli Federati

Un framework micro-frontend solitamente lavora in coppia con i cosiddetti “code loaders” (caricatori di codice), come SystemJS utilizzato dal framework single-spa, che vengono utilizzati insieme a Babel e Webpack. In pratica abbiamo un’application shell, che utilizza un code loader come SystemJS, che carica il codice del micro-frontend richiesto all’interno del DOM. Schematicamente accade quanto segue:

Utilizzare i moduli federati di Webpack fa in modo che uno sviluppatore riesca ad importare piccoli moduli software, o interfacce complete, come un semplice import , senza avvalersi di un code loader. Quindi, l’application shell potrebbe a sua volta condividere moduli software come un qualsiasi altro micro-frontend all’interno del sistema.

Impostazione del progetto

I micro-frontends identificati finora sono i seguenti:

  • app-shell: il micro-frontend che si preoccuperà del routing e del caricamento in fase di esecuzione degli altri micro-frontends (o moduli).
  • nav: questo micro-frontend metterà a disposizione dell’app-shell due moduli ossia l’application bar (AppBar)e il menu laterale (Drawer).
  • auth: il responsabile dell’autenticazione e della registrazione. Questo micro-frontend metterà a disposizione dell’app-shell il modulo Signin e Signup .
  • users: questo micro-frontend metterà a disposizione l’intera sezione per la gestione degli utenti.
  • tickets: infine il micro-frontend che metterà a disposizione l’intera gestione dei tickets.

Cosa hanno in comune questi micro-frontends?

Tutti i micro-frontends verranno implementati utilizzando:

  • React
  • Material-UI
  • MobX

In particolare uno store mobx che viene inizializzato nell’application shell ed iniettato all’interno di tutti i micro-frontends. Questo store ha due responsabilità:

  • Gestire lo stato del menu sulla sinistra (aperto, chiuso).
  • Centralizzare i Growl che verranno visualizzati all’interno dell’applicazione.

Template del Micro-frontend

Prima di iniziare a scrivere codice ho definito un progetto template, ossia la struttura iniziale dalla quale partire per scrivere tutti i micro-frontends di cui vi parlerò in seguito. Sostanzialmente la struttura base sarà la seguente:

.
├── public
│ └── index.ejs
├── src
│ ├── components
│ │ ├── App.js
│ │ └── bootstrap.js
│ ├── index.js
│ └── store
│ └── app.store.js
├── webpack.config.js
├── babel.config.json
└── package.json

Per ognuno dei progetti le dipendenze da installare sono le seguenti

...
"devDependencies": {
"@babel/core": "7.11.4",
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/plugin-proposal-decorators": "^7.10.5",
"@babel/preset-react": "7.10.4",
"babel-loader": "8.1.0",
"find-up": "5.0.0",
"html-webpack-plugin": "git://github.com/ScriptedAlchemy/html-webpack-plugin#master",
"serve": "11.3.2",
"webpack": "5.0.0",
"webpack-cli": "3.3.12",
"webpack-dev-server": "3.11.0"
},
"dependencies": {
"@material-ui/core": "^4.9.10",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"classnames": "^2.2.6",
"dotenv": "^8.2.0",
"history": "^5.0.0",
"mobx": "^5.15.6",
"mobx-react": "^6.3.0",
"prop-types": "^15.7.2",
"react": "^16.13.0",
"react-dom": "^16.13.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0"
}
...

webpack.config.js

Tutti i file di configurazione di Webpack sono pressoché uguali. Le uniche informazioni che vengono modificate all’interno di questo file sono quelle evidenziate in grassetto:

devServer: {
host: '0.0.0.0',
contentBase: path.join(__dirname, 'dist'),
port: dotenv.parsed.APP_PORT,
historyApiFallback: true,
hot: false,
hotOnly: false
},
...
plugins: [
new ModuleFederationPlugin({
name: '<mfe-name>',
library: { type: 'var', name: '<mfe-name>' },
filename: '<remote-file-name>.js',
remotes: {...},
exposes: {...},

shared: [{
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
}],
}),
new HtmlWebpackPlugin({
template: './public/index.ejs',
templateParameters: {
...dotenv.parsed
},
})
]
...

Nello specifico:

  • mfe-name: questo è il nome dell’applicazione. Non deve esserci nessun altro modulo federato con lo stesso nome all’interno del sistema.
  • filename: il nome del file che l’applicazione utilizza per condividere con le altre applicazioni i moduli. È obbligatorio solo nel caso in cui la nostra applicazione espone moduli.
  • remotes: è un oggetto chiave/valore dove vengono specificati i moduli remoti che la nostra applicazione utilizza.
  • exposes: anche questo è un oggetto chiave/valore dove la chiave identifica il nome con il quale vogliamo che il nostro modulo venga condiviso con le altre applicazioni ed il valore è il path locale all’applicazione del modulo che vogliamo condividere.
  • APP_PORT: in realtà questa è una costante che viene definita all’interno del file di environment env.local . Identifica la porta su cui girerà la nostra applicazione.

.env.local

In ogni applicazione è presente un file .env.local dove sono presenti tutte le costanti utilizzate dall’applicazione, tipo APP_PORT , che identifica la porta su cui verrà esposto il micro-fronted. Di seguito la lista completa delle variabili d’ambiente che vengono utilizzate:

APP_PORT=9001
APP_SHELL='http://localhost:9001/appShell.js'
APP_AUTH='http://localhost:9002/auth.js'
APP_USERS='http://localhost:9003/users.js'
APP_TICKETS='http://localhost:9004/tickets.js'
APP_NAV='http://localhost:9006/nav.js'

index.js e bootstrap.js

Un altro importante suggerimento per la sicurezza è eseguire il “bootstrap” dell’applicazione. Questo significa che il punto di ingresso principale dovrebbe essere un file il cui compito è caricare in modo asincrono l’applicazione principale. Per esempio il file index.js, che è il punto di ingresso principale per Webpack, e il suo contenuto è il seguente:

import("./components/bootstrap")

Mentre il contenuto di bootstrap.js , invece, sarà:

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

Questa metodologia offre a Webpack l’opportunità di elaborare il resto delle importazioni prima di eseguire l’applicazione ed eviterà potenziali condizioni di competizione durante l’importazione di tutto il codice.

mf-react-template

Il codice sorgente del template appena descritto lo trovate qui

Inizializziamo il progetto

Dopo aver definito lo scheletro di ogni singolo microfrontend, ho creato lo scheletro dell’intero sistema. Alla fine l’intero progetto si è presentato nel seguente modo:

./from-macro-micro-microfrontends
├── app-shell
├── nav
├── auth
├── users
├── tickets
└── package.json

il contenuto delle singole directory è lo stesso che abbiamo descritto nel paragrafo precedente. Il package.json nella directory root non contiene altro che degli script di utility per velocizzare l’installazione e l’esecuzione dell’intero sistema:

In pratica, utilizzando concurrently effettuo l’installazione di tutte le dipendenze con il comando yarn run install:all e avvio in modo concorrente tutti i processi micro-frontends in un ambiente di sviluppo con il comando yarn run dev:all .

Nav

Iniziamo con l’implementazione del primo micro-frontend. In questo caso il micro-frontend espone due moduli federati. Il primo sarà il componente AppBar che sarà implementato nel seguente modo.

Mentre il componente AppDrawer sarà qualcosa tipo

Entrambi i componenti utilizzano uno store MobX centralizzato nell’application shell e che viene iniettato alla creazione dei componenti. Lo store espone una variabile booleana drawerOpen che indica se il drawer sulla sinistra è aperto o chiuso.

Una volta implementati i primi due componenti li ho esposti per renderli disponibili all’application shell. Per farlo ho semplicemente modificato il webpack.config.js del progetto nav:

new ModuleFederationPlugin({
name: 'nav',
library: { type: 'var', name: 'nav' },
filename: 'nav.js',
remotes: {},
exposes: {
'./AppBar': './src/components/AppBar',
'./AppDrawer': './src/components/AppDrawer'
}
,
shared: [...],
})

Perfetto ora passiamo al progetto auth

auth

Questo progetto è praticamente identico al precedente. Fatta eccezione per il fatto che espone due diversi componenti, ossia Signin e Signup . Sostanzialmente quello che andremo ad implementare graficamente sarà qualcosa come il seguente screenshot per il login:

Questo componente avrà un metodo che invocherà un servizio di autenticazione e, in caso di esito positivo, l’utente verrà reindirizzato all’interno dell’applicazione:

signin = async () => {
const { username, password } = this.state;
if (!username || !password) {
this.setState({ error: 'Username and password required' });
return;
}
try {
const response = await axios.post(`${APP_API}/signin`, {
username: username,
password: password
});
if (response.status === 200) {
localStorage.setItem('@mf-tickets/token', response.data.token)
window.location.href = '/';
}
} catch (e) {
this.setState({ error: e.response.data.message });
}
}

Notiamo che l’invocazione dell’endpoint esposto dai microservizi è una variabile, anch’essa presente nel file .env.local . Nel nostro caso il contenuto della variabile sarà il seguente:

APP_API=http://localhost:3000

Il componente della registrazione è simile a quello del login. Tranne per il fatto che in questo caso i dati inviati al servizio di autenticazione saranno diversi:

Il routing tra le due pagine viene gestito dall’application shell con react-router-dom .

Ora che i nostri componenti sono pronti non dobbiamo fare altro che renderli disponibili all’application shell. Anche in questo caso esponiamo i moduli configurando opportunamente il webpack.config.js :

new ModuleFederationPlugin({
name: 'nav',
library: { type: 'var', name: 'auth' },
filename: 'auth.js',
remotes: {},
exposes: {
'./Signin': './src/components/Signin',
'./Signup': './src/components/Signup'
}
,
shared: [...],
})

users e tickets

I micro-frontends users e tickets sono pressoché uguali. Sono banalmente due CRUD per le due rispettive entità. In entrambi i progetti l’approccio utilizzato è il seguente:

  • Implementare un container principale dove vengono visualizzati in una tabella tutte le entità presenti a sistema.
  • Nel container principale è disponibile un FabButton in basso a destra utile alla creazione dell’entità di riferimento (nello specifico user).
  • Su ogni riga della tabella è presente un pulsante per visualizzare i dettagli dell’entità in una modale. All’interno della modale è disponibile un pulsante per l’eliminazione.
  • Entrambi i componenti esportati utilizzano un loro store MobX per gestire lo stato interno dell’applicazione.

Una volta definiti i componenti esponiamo all’application shell i container principali delle applicazioni. Quindi per il micro-frontend user modifichiamo il webpack.config.js nel seguente modo:

new ModuleFederationPlugin({
name: 'users',
library: { type: 'var', name: 'users' },
filename: 'users.js',
remotes: {},
exposes: {
'./Users': './src/components/Users'

},
shared: [...],
})

Allo stesso modo ho modificato il webpack.config.js di tickets:

new ModuleFederationPlugin({
name: 'tickets',
library: { type: 'var', name: 'tickets' },
filename: 'tickets.js',
remotes: {},
exposes: {
'./Tickets': './src/components/Tickets'

},
shared: [...],
})

app-shell

Finalmente siamo giunti alla nostra application shell, ossia il micro-frontend che si occuperà di mettere insieme tutti i micro-frontends definiti finora ossia: nav , auth , users e tickets .

.env.local

Partiamo dal basso questa volta! La prima cosa da fare e settare tutte le variabili d’ambiente che serviranno all’ app-shell. Come già anticipato questo micro-frontends si occuperà di “unire i pezzi”, quindi ha bisogno di tutti i “remote entry” delle altre applicazioni. Di conseguenza il suo file .env.local sarà il seguente:

APP_PORT=9001APP_AUTH='http://localhost:9002/auth.js'
APP_USERS='http://localhost:9003/users.js'
APP_TICKETS='http://localhost:9004/tickets.js'
APP_VUE='http://localhost:9005/vue.js'
APP_NAV='http://localhost:9006/nav.js'

webpack.config.js

Passiamo ora alla configurazione di Webpack. L’applicazione in questo caso non esporrà nulla ma, al contrario, usufruirà di moduli remoti. Quindi la configurazione sarà la seguente:

new ModuleFederationPlugin({
name: 'tickets',
library: { type: 'var', name: 'tickets' },
filename: 'tickets.js',
remotes: {
nav: "nav",
auth: "auth",
tickets: "tickets",
users: "users"
},

exposes: {},
shared: [...],
})

Un’altra configurazione importante all’interno della sezione plugins sarà la seguente:

new HtmlWebpackPlugin({
template: './public/index.ejs',
templateParameters: {
...dotenv.parsed
},
})

Il modulo HtmlWebpackPlugin usa il template ejs public/index.ejs e fa in modo che ospiti la nostra applicazione React. Oltre a fare ciò inietta le variabili d’ambiente descritte in precedenza.

<!DOCTYPE html>
<html>
<head>
<script src="<%= APP_NAV %>"></script>
<script src="<%= APP_SHELL %>"></script>
<script src="<%= APP_AUTH %>"></script>
<script src="<%= APP_USERS %>"></script>
<script src="<%= APP_TICKETS %>"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

AppShell.js

Il punto d’ingresso della nostra applicazione non fa altro che istanziare lo store MobX e il tema Material-UI.

const AppShell = () => {
return (
<Provider store={appShellStore}>
<ThemeProvider theme={theme}>
<MainApp />
</ThemeProvider>
</Provider>
);
};
export default AppShell;

MainApp.js

Questo è il vero cuore dell’applicazione. In cima al file la prima cosa che dobbiamo fare è importare tutti i moduli remoti con React.lazy .

const AppBar = React.lazy(() => import("nav/AppBar"));
const AppDrawer = React.lazy(() => import("nav/AppDrawer"));
const SigninService = React.lazy(() => import("auth/Signin"));
const SignupService = React.lazy(() => import("auth/Signup"));
const UsersService = React.lazy(() => import("users/Users"));
const TicketsService = React.lazy(() => import("tickets/Tickets"));

Il passo successivo è quello di definire le regole di routing. Nel caso in cui l’utente non è loggato, la nostra applicazione sarà la seguente:

<React.Suspense fallback={"Loading"}>
<Routes>
<Route path="/signin" element={<SigninService />} />
<Route path="/signup" element={<SignupService />} />
<Route
path="*"
element={<Navigate to="/signin" replace />}
/>
</Routes>
</React.Suspense>

In questo caso l’utente verrà rediretto verso la pagina di login. In caso contrario l’applicazione si presenterà nella sua interezza, quindi saranno visibili la barra superiore e il menu laterale con al centro la pagina che intendiamo visualizzare (default /tickets):

<Box display="flex" flex={1}>
<React.Suspense fallback={"Loading"}>
<React.Fragment>
<AppBar appShellStore={store} />
<AppDrawer appShellStore={store} />

<Routes>
<Route
path="/"
element={<TicketsService appShellStore={store} />}
/>
<Route
path="users/*"
element={<UsersService appShellStore={store} />}
/>
<Route
path="tickets/*"
element={<TicketsService appShellStore={store} />}
/>
<Route
path="*"
element={<Navigate to="/" replace />}
/>
</Routes>
</React.Fragment>
</React.Suspense>
</Box>

Il codice sorgente

Il codice sorgente dell’intero progetto è disponibile su github all’indirizzo:

Mentre il progetto inerente ai micro servizi che i micro-frontends invocano lo trovate qui:

Installare i progetti in locale

Partiamo dal progetto from-macro-micro-microservices . Prima di iniziare però assicuratevi di avere le seguenti dipendenze installate:

  • git
  • Node.js
  • Docker

Clonate il progetto in locale ed eseguitelo con i seguenti comandi:

$ git clone git@github.com:davidedantonio/from-macro-micro-microservices.git$ cd from-macro-micro-microservices$ docker-compose up -d

Una volta finita l’installazione delle diverse immagini docker, dovreste avere up & running un’API gateway che risponde sulla porta 3000. Ora che abbiamo installato l’ambiente server procediamo all’installazione dell’ambiente client. Dal terminale digitiamo:

$ git clone git@github.com:davidedantonio/from-macro-micro-microfrontends.git$ cd from-macro-micro-microfrontends$ docker-compose up -d

Anche in questo caso se tutto è andato a buon fine e navigate con il vostro browser preferito all’indirizzo http://localhost:9001 dovreste avere l’applicazione funzionante.

Concludendo

In questi quattro articoli “Da macro a micro, tutto!” abbiamo visto come è possibile affrontare lo sviluppo delle nostre applicazioni utilizzando l’approccio micro sia per sviluppare le nostre API con Node.js ed i microservizi, ma anche per progettare interfacce mediante l’utilizzo dei micro-frontends e dei Moduli Federati di Webpack 5. Tanti sono gli approcci in entrambi gli ambiti e, a mio parere, non esiste mai quello sbagliato. Bisogna solo trovare quello giusto per l’obiettivo che si vuole perseguire. Sicuramente continuerò a scrivere su questi argomenti, ma nel frattempo…

                           HAPPY CODING!

Bibliografia e link utili

--

--

Davide D'Antonio
weBeetle

👨‍🎓Degree in computer science 💑 Married with Milena 🤓 Huge Nerd! 💻 Code lover 👨🏻‍💻 Fullstack developer