Aunque parezca una contradicción, la combinación de Micro Frontends y Monorepos puede ser realmente muy tentadora: No hay conflictos de versiones por diseño, fácil intercambio de código y paquetes optimizados son algunos de los beneficios que se obtienen. Además, todavía puede desplegar Micro Frontends por separado y aislarlos entre sí.
Recuerda que puedes ampliar el contenido de angular en mi blog
Este artículo compara las consecuencias de utilizar varios repositorios ("Micro Frontends by the book") y un único monorepo. Después de todo este es un ejemplo de cómo utilizar Module Federation en un monorepo Nx. 🤩
Github 📂
Un gran agradecimiento al impresionante Tobias Koppers que me dio valiosos conocimientos sobre este tema y al único Dmitriy Shekhovtsov que me ayudó usando la integración de Angular CLI/webpack 5 para esto.
Importante: Este artículo está escrito para Angular 14.x y superior. Por lo tanto, también necesitas Nx 14.2.x o superior.
Traducción en español del artículo original de Manfred Steyer Using Module Federation with (Nx) Monorepos and Angular actualizado el 11-06-2021
Múltiples Repos vs. Monorepos
Sé que la discusión sobre el uso de múltiples repos vs. monorepos puede ser bastante emotiva ademas se de personas con experiencias diferentes aplicando ambos enfoques. Sin embargo, puedo decir: He visto ambos trabajando en grandes proyectos del mundo real. Sin embargo, ambos vienen con diferentes consecuencias, que voy a discutir en las siguientes dos secciones.
Al final del día, tienes que evaluar estas consecuencias contra la situación y los objetivos de tu proyecto. De esto se trata la arquitectura de software.
Repositorios múltiples: Micro Frontends por el Libro
Un enfoque tradicional utiliza un repositorio separado por Micro Frontend:
Esto también es bastante habitual para los Micro Servicios y proporciona las siguientes ventajas:
Los Micro Frontends y por tanto los dominios de negocio individuales están aislados unos de otros. Como no hay dependencias entre ellos, diferentes equipos pueden evolucionarlos por separado.
Cada equipo puede concentrarse en su Micro Frontend. Sólo tienen que centrarse en su propio repositorio.
Cada equipo tiene la máxima libertad en su repositorio. Pueden ir con sus propias decisiones de arquitectura, pilas de tecnología y procesos de build. Además, deciden por sí mismos cuándo actualizar a versiones más nuevas.
Cada Micro Frontend puede ser desplegado por separado. 😏
Como esto se ajusta a las ideas originales de Micro Frontends, llamo a este enfoque "Micro Frontends por el libro". Sin embargo, también hay algunas desventajas:
Tenemos que versionar y distribuir las dependencias compartidas a través de npm. Esto puede convertirse en una sobrecarga, ya que después de cada cambio tenemos que asignar una nueva versión, publicarla e instalarla en los respectivos Micro Frontends.
Como cada equipo puede usar su propia pila tecnológica, podemos terminar con diferentes frameworks y diferentes versiones de ellos. Esto puede llevar a conflictos de versiones en el navegador y a un aumento del tamaño de los paquetes. 😵
Por supuesto, hay enfoques para compensar estos inconvenientes: Por ejemplo, podemos automatizar la distribución de las bibliotecas compartidas para minimizar la sobrecarga. Además, podemos evitar los conflictos de versiones no compartiendo bibliotecas entre diferentes Micro Frontends. Envolver estos Micro Frontends en componentes web abstrae aún más las diferencias entre los frameworks.
Aunque esto evita los conflictos de versiones, todavía tenemos un mayor tamaño de los paquetes. Además, podríamos necesitar algunas soluciones aquí o allá, ya que Angular no está diseñado para trabajar con otra versión de sí mismo en la misma ventana del navegador.
No hace falta decir que el equipo de Angular no apoya esta idea.
Si crees que las ventajas de este enfoque son mayores que las desventajas, aquí encontrarás una solución para mezclar y combinar diferentes frameworks y versiones.
Sin embargo, si crees que las desventajas pesan más, las siguientes secciones muestran una alternativa.
Micro Frontends con Monorepos
Casi todas las desventajas señaladas anteriormente pueden evitarse poniendo todos los Micro Frontends en un único monorepo:
Ahora, compartir bibliotecas es fácil y sólo hay una versión de todo, por lo que no acabamos con conflictos de versiones en el navegador. También podemos mantener algunas de las ventajas señaladas anteriormente:
Los Micro Frontends pueden ser aislados unos de otros usando reglas de linting. Evitan que un Micro Frontend dependa de otros. Por lo tanto, los equipos pueden evolucionar por separado su Micro Frontend.
Los Micro Frontends pueden ser desplegados por separado.
Ahora, la pregunta es, ¿dónde está el truco? Bueno, la cosa es que ahora estamos renunciando a parte de la libertad: Los equipos tienen que acordar una versión de dependencias como Angular y un ciclo de actualización común para ellas. Por decirlo de otra manera: Cambiamos algo de libertad para evitar conflictos de versiones y aumentar el tamaño de los paquetes.
Una vez más, tienes que evaluar todas estas consecuencias para tu propio proyecto. Por lo tanto, necesitas conocer tus objetivos de arquitectura y priorizarlos. Como he mencionado, he visto ambas cosas funcionando en varios proyectos. Se trata de las diferentes consecuencias.
Ejemplo de Monorepo
Después de discutir las consecuencias del enfoque mostrado aquí, vamos a echar un vistazo a una implementación.
El ejemplo utilizado aquí es un monorepo Nx con un shell Micro Frontend (shell) y un Micro Frontend (mfe1, "micro frontend 1"). Ambos comparten una biblioteca común para la autenticación (auth-lib) que también se encuentra en el monorepo. Además, mfe1 utiliza la biblioteca mfe1-domain-logic
.
Si no has usado Nx antes, sólo supone un CLI con toneladas de características adicionales. Puedes encontrar más información sobre Nx en nuestro tutorial. (Ingles)
Para visualizar la estructura del monorepo, se puede utilizar el CLI de Nx para solicitar un gráfico de dependencias:
nx graph
Si no tienes instalado este CLI, puedes conseguirlo fácilmente a través de npm (npm i -g nx). El gráfico que se muestra tiene este aspecto:
La librería auth proporciona dos componentes. Uno de ellos es el registro de usuarios y el otro muestra el usuario actual. Son utilizados por ambos, el shell y mfe1:
Además, la auth-lib almacena el nombre del usuario actual en un servicio.
Como es habitual en Nx y Angular monorepos, las librerías se referencian con mapeos de rutas definidos en tsconfig.base.json
(Nx) o tsconfig.json
(Angular CLI):
"paths": {
"@demo/auth-lib": [
"libs/auth-lib/src/index.ts"
]
},
El shell y mfe1 (así como otros Micro Frontends que podamos añadir en el futuro) necesitan ser desplegados separados y cargados en tiempo de ejecución.
Sin embargo, ¡no queremos cargar el auth-lib dos o varias veces! empaquetar esto con un paquete npm no es tan difícil. Esta es una de las características más obvias y fáciles de usar de Module Federation. En las siguientes secciones se discute cómo hacer lo mismo con las bibliotecas de un monorepo.
Compartiendo auth-lib
Antes de profundizar en la solución, echemos un vistazo a la auth-lib y esta contiene un AuthService
que inicia la sesión del usuario y lo recuerda mediante la propiedad _userName
:
@Injectable({
providedIn: 'root'
})
export class AuthService {
// tslint:disable-next-line: variable-name
private _userName: string = null;
public get userName(): string {
return this._userName;
}
constructor() { }
login(userName: string, password: string): void {
// Authentication for honest users
// (c) Manfred Steyer
this._userName = userName;
}
logout(): void {
this._userName = null;
}
}
Además de este servicio, también hay un AuthComponent
con la UI para el inicio de sesión del usuario y un UserComponent
que muestra el nombre del usuario actual. Ambos componentes están registrados con el NgModule
de la biblioteca:
@NgModule({
imports: [
CommonModule,
FormsModule
],
declarations: [
AuthComponent,
UserComponent
],
exports: [
AuthComponent,
UserComponent
],
})
export class AuthLibModule {}
Como toda biblioteca, también tiene un index.ts
(a veces también llamado public-api.ts
) que sirve como punto de entrada. Exporta todo lo que los consumidores pueden utilizar:
export * from './lib/auth-lib.module';
export * from './lib/auth.service';
// No olvides exportar los componentes!
export * from './lib/auth/auth.component';
export * from './lib/user/user.component';
Ten en cuenta que index.ts
también está exportando los dos componentes aunque ya están registrados con el también exportado AuthLibModule
. En el escenario que se discute aquí, esto es vital para asegurarse de que es detectado y compilado por Ivy.
Supongamos que el shell utiliza el AuthComponent
y mfe1
utiliza el UserComponent
. Como nuestro objetivo es cargar el auth-lib
sólo una vez, esto también permite compartir información sobre el usuario conectado.
La configuración de Modulo Federation
Al igual que en el artículo anterior, vamos a utilizar el plugin @angular-architects/module-federation
para activar el module federation para el shell
y mfe1
. Para ello, basta con ejecutar los siguientes comandos:
npm i @angular-architects/module-federation -D
npm g @angular-architects/module-federation:init --project shell --port 4200 --type host
npm g @angular-architects/module-federation:init --project mfe1 --port 4201 --type remote
Nx también incluye su propio soporte para Module Federatio, pero su forma es muy similar al plugin utilizado aquí.
Esto genera una configuración de webpack para Module Federation. Desde la versión 14.3, el withModuleFederationPlugin
proporciona una propiedad sharedMappings
. Aquí, podemos registrar las librerías internas de monorepo que queremos compartir en tiempo de ejecución:
/ apps/shell/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
remotes: {
'mfe1': "http://localhost:4201/remoteEntry.js"
},
shared: shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
sharedMappings: ['@demo/auth-lib'],
});
Como compartir es siempre una opción en Module Federation, también necesitamos el mismo ajuste en la configuración del Micro Frontend:
// apps/mfe1/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
name: "mfe1",
exposes: {
'./Module': './apps/mfe1/src/app/flights/flights.module.ts',
},
shared: shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
sharedMappings: ['@demo/auth-lib'],
});
Desde la versión 14.3, el plugin Module Federation comparte por defecto todas las bibliotecas del monorepo. Para obtener este comportamiento por defecto, simplemente omita la propiedad sharedMappings
. Si la utiliza, sólo se comparten las librerías que son mencionadas.
Momento de Prueba
Para probarlo, basta con iniciar las dos aplicaciones. Como usamos Nx, esto se puede hacer con el siguiente comando:
nx run-many --target serve --all
El switch --all inicia todas las aplicaciones del monorepo. En su lugar, también puedes utilizar el modificador --projects
para iniciar sólo un subconjunto de ellas:
nx run-many --target serve --projects shell,mfe1
--project toma una lista de nombres de proyectos separada por comas. No se admiten espacios.
Después de iniciar las aplicaciones, inicia el shell y haga que se cargue mfe1. Si ves el nombre de usuario conectado en mfe1, demuestra de que auth-lib sólo se carga una vez y se comparte entre las aplicaciones.
Aislamiento de Micro Frontends
Un objetivo importante de una arquitectura de Micro Frontend es aislar los Micro Frontends unos de otros. Sólo si no dependen unos de otros, pueden ser evolucionados por equipos autónomos.
Para ello, Nx proporciona reglas de linting. Una vez establecidas, nos dan errores cuando referenciamos directamente código perteneciente a otro Micro Frontend y, por tanto, a otro dominio de negocio.
En el siguiente ejemplo, el shell intenta acceder a una biblioteca perteneciente a mfe1:
Para que estos mensajes de error aparezcan en tu IDE, necesitas el soporte de eslint. En el caso de Visual Studio Code, esto puede instalarse mediante una extensión.
Además de comprobar las reglas de linting en su IDE, también se puede llamar al linter en la línea de comandos:
La parte buena: Si funciona en la línea de comandos, se puede automatizar. Por ejemplo, su proceso de build podría ejecutar este comando y evitar el merge de una característica en la rama principal si estas reglas de linting fallan.
Para configurar estas reglas de linting, necesitamos añadir etiquetas a cada app y lib en nuestro monorepo. Para ello, puedes ajustar el project.json
en la carpeta de la aplicación o de la librería.
Por ejemplo, el project.json
para el shell se puede encontrar aquí: apps/shell/project.json
. Al final, se encuentra una etiqueta de propiedad, he puesto a scope:shell:
{
[...]
"tags": ["scope:shell"]
}
El valor de las etiquetas son sólo cadenas. Por lo tanto, se puede establecer cualquier valor posible. He repetido esto para mfe1 (scope:mfe1) y el auth-lib (scope:auth-lib).
Una vez que las etiquetas están en su lugar, puede utilizarlas para definir las restricciones en su configuración global de eslint (.eslintrc.json):
"@nrwl/nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "scope:shell",
"onlyDependOnLibsWithTags": ["scope:shell", "scope:shared"]
},
{
"sourceTag": "scope:mfe1",
"onlyDependOnLibsWithTags": ["scope:mfe1", "scope:shared"]
},
{
"sourceTag": "scope:shared",
"onlyDependOnLibsWithTags": ["scope:shared"]
}
]
}
]
Después de cambiar los archivos de configuración global como el .eslintrc.json
, es una buena idea reiniciar su IDE (o al menos los servicios afectados de su IDE). Esto asegura que los cambios sean respetados.
Puedes encontrar más información sobre estas ideas y su implementación con Nx en mi serie de blogs sobre (Domain-driven Design) y Angular.
Los Builds Incrementales
Para hacer el build todas las aplicaciones, puedes utilizar el comando run-many de Nx:
nx run-many --target build --all
Sin embargo, esto no significa que Nx siempre reconstruya todos los Micro Frontends así como el shell. En cambio, sólo reconstruye las aplicaciones modificadas. Por ejemplo, en el siguiente caso mfe1 no se ha modificado. Por lo tanto, sólo se reconstruye el shell:
El uso de la caché de compilación para recompilar únicamente las aplicaciones modificadas puede acelerar drásticamente los tiempos de los mismos.
Esto también funciona para las pruebas, e2e-tests y linting out of the box. Si una aplicación (o librería) no ha sido modificada, no se vuelve a probar ni se vuelve a imprimir. En su lugar, el resultado se toma de la caché de compilación de Nx.
Por defecto, la caché de build se encuentra en node_modules/.cache/nx
. Sin embargo, hay varias opciones para configurar cómo y dónde almacenar la caché.
Desplegando
Como normalmente las librerías no tienen versiones en un monorepo, siempre debemos redesplegar todos los Micro Frontends cambiados juntos. Afortunadamente, Nx ayuda a averiguar qué aplicaciones/micro frontales han sido cambiadas o afectadas por un cambio:
nx print-affected --type app --select projects
También es posible que quieras detectar las aplicaciones modificadas como parte de tu proceso de compilación.
Redistribuir todas las aplicaciones que han sido cambiadas o afectadas por un cambio (lib) es vital, si compartes bibliotecas en tiempo de ejecución. Si tienes una release branch, es suficiente con redistribuir todas las aplicaciones que han sido cambiadas en esta rama.
Si quieres tener una representación gráfica de las partes cambiadas de tu monorepo, puedes solicitar un gráfico de dependencias con el siguiente comando:
nx affected:graph
Suponiendo que cambiáramos la librería lógica de dominio utilizada por mfe1, el resultado sería el siguiente:
Por defecto, los comandos mostrados comparan su directorio de trabajo actual con la rama principal. Sin embargo, puede utilizar estos comandos con los modificadores --base y --head.
nx print-affected --type app --select projects --base branch-or-commit-a --head branch-or-commit-b
Estos toman un hash de commit o el nombre de una rama. En este último caso, la última confirmación de la rama mencionada para la comparación.
Gracias por llegar hasta el final de este blog quiero recordarte que todo esto es gratis y posible gracias a que tu compartes. Un fuerte abrazo y recuerda que el conocimiento es poder.
Invertir en conocimientos produce siempre los mejores beneficios. (Benjamín Franklin)