Un famoso detto siciliano recita “c’è chi la vuole cotta e chi la vuole cruda”, che significa che accontentare tutti è impossibile, poiché le persone hanno gusti ed esigenze sempre diverse e in contrasto tra loro. Questa è un po’ la situazione che ci si trova ad affrontare quando, in ambito software, necessitiamo di sviluppare un’applicazione che si adatti alle esigenze dei clienti. Come possiamo gestire queste casistiche all’interno di un’applicazione Angular?
Immaginiamo di avere un’applicazione che contiene una pagina che mostra le informazioni dell’utente
La stessa applicazione la vogliamo deployare e distribuire per più clienti. La versione standard contiene le caratteristiche presenti in figura, tuttavia due clienti hanno richiesto delle modifiche: il primo desidera che, oltre alle informazioni sopra riportate, venga aggiunta anche l’azienda a cui l’utente appartiene; il secondo cliente chiede che il componente card della pagina venga sostituito con una tabella.
Ci troviamo di fronte a tre distribuzione differenti dell’app:
Forkare l’applicazione standard o creare delle branch ad hoc per cliente, apportando così i dovuti cambiamenti, potrebbe essere la soluzione più veloce, ma a tendere, con l’aumentare della complessità e delle personalizzazioni, risulterà molto oneroso tenere in sync le versioni personalizzate per cliente con la standard.
Se guardiamo al problema da un’altra prospettiva, possiamo vedere l’applicazione Standard come la principale, mentre le personalizzazioni come delle librerie che si “installano” on top. In questo scenario ci vengono in aiuto i Multi-Projects di Angular.
1ng new angular-flavor --no-create-application
Un workspace multi-project è adatto quando si utilizza un singolo repository e una configurazione globale per più progetti Angular (monorepo). Un workspace multi-project supporta anche lo sviluppo di librerie. Questo è esattamente ciò che ci serve, da qui possiamo creare l’applicazione standard e le due librerie, una per il Cliente X e l’altra per il Cliente Y.
1ng generate application devmy
2ng generate library customer-x
3ng generate library customer-y
Configuriamo l’applicazione “devmy” affinché abbia una pagina di dettaglio che mostri le informazioni dell’utente (come da immagine sopra riportata).
Per consentire al Cliente X di aggiungere il campo “skills” alle informazioni standard dell’utente, creiamo un sistema a “plugin” per estendere il comportamento base della card di dettaglio. Il funzionamento si baserà sulla presenza di “segnaposti”, che, applicati all’interno della pagina, indicano i punti in cui si prevede la possibilità di estendere il comportamento. Per prima cosa, definiamo il modello dei dati:
1// file: plugin.model.ts (devmy)
2export interface PluginOptions {
3 groupId: string;
4}
5
6export interface PluginConfig<T> extends PluginOptions {
7 target: T;
8}
9
10export type Plugins<T = any> = Record<string, PluginConfig<T>[]>;
Successivamente creiamo un decoratore che verrà applicato alle classi dei componenti che vogliamo iniettare all’interno della pagina standard:
1// file: plugin.decorator.ts (devmy)
2import { PluginOptions } from '../models/plugin.model';
3import { PluginRegistryService } from '../services/plugin-registry.service';
4
5export function Plugin<T>(opts: PluginOptions) {
6 return (target: T): void => {
7 const registry = PluginRegistryService.getInstance();
8
9 registry.push({ ...opts, target });
10 };
11}
Il decoratore riceve un oggetto options
che indica, attraverso la proprietà groupId, l’identificativo del segnaposto dove iniettare il componente. Il componente viene registrato all’interno del servizio PluginRegistryService
, il quale contiene la lista di tutti i componenti registrati come plugin.
1// file: plugin-registry.service.ts (devmy)
2import { Injectable } from '@angular/core';
3
4import { PluginConfig, Plugins } from '../models/plugin.model';
5
6@Injectable({
7 providedIn: 'root',
8})
9export class PluginRegistryService {
10 private plugins: Plugins = {};
11 private static instance: PluginRegistryService;
12
13 static getInstance(): PluginRegistryService {
14 if (!this.instance) {
15 this.instance = new PluginRegistryService();
16 }
17
18 return this.instance;
19 }
20
21 contains(groupId: string): boolean {
22 return !!this.plugins[groupId];
23 }
24
25 findBy<T>(groupId: string): PluginConfig<T>[] {
26 if (this.contains(groupId)) {
27 return this.plugins[groupId];
28 }
29
30 return [];
31 }
32
33 push<T>(config: PluginConfig<T>): void {
34 const plugins = this.plugins[config.groupId] ?? [];
35 const updated = [...plugins, config];
36
37 this.plugins = { ...this.plugins, [config.groupId]: updated };
38 }
39}
Per iniettare dinamicamente i componenti all’interno del segnaposto, viene utilizzata la direttiva strutturale PluggableDirective
. Questa riceve in input il riferimento dove iniettare i componenti e l’identificativo del gruppo di componenti da iniettare. Utilizzando il servizio sopra indicato, vengono recuperati e creati dinamicamente tutti i componenti registrati per il gruppo.
1// file: pluggable.directive.ts (devmy)
2import {
3 Directive,
4 effect,
5 inject,
6 input,
7 TemplateRef,
8 Type,
9 ViewContainerRef,
10} from '@angular/core';
11
12import { PluginRegistryService } from '../services/plugin-registry.service';
13
14@Directive({
15 selector: '[libPluggable]',
16})
17export class PluggableDirective {
18 private vcr = inject(ViewContainerRef);
19 private template = inject<TemplateRef<unknown>>(TemplateRef);
20
21 libPluggable = input<unknown>();
22 libPluggableGroupId = input<string>('placeholder');
23
24 constructor() {
25 effect(() => {
26 const registry = PluginRegistryService.getInstance();
27 const plugins = registry.findBy<Type<unknown>>(
28 this.libPluggableGroupId(),
29 );
30
31 if (!plugins.length) {
32 this.vcr.createEmbeddedView(this.template);
33
34 return;
35 }
36
37 plugins.forEach(({ target }) => {
38 this.vcr.createComponent(target);
39 });
40 });
41 }
42}
Prima di creare la personalizzazione per il Cliente X occorre indicare il segnaposto in cui iniettare i componenti. All’interno della pagina di dettaglio dell’utente, aggiungiamo il segnaposto attraverso la direttiva ng-container
.
(N.B: occorre aver prima importato la direttiva all’interno del file .ts)
1<ng-container *libPluggable="this; groupId: 'details-page'" />
Utilizzo ng-container
così da non aggiungere elementi del DOM che potrebbero aver dello stile applicato sull’elemento.
A questo punto tutto è pronto per applicare le personalizzazioni per i due clienti. Per il Cliente X creo un componente che aggiunge, all’interno del segnaposto details-page, un campo “Skills”. A questo componente applico il decoratore @Plugin specificando il gruppo in cui desidero creare il componente.
1// file: skills-user-info.component.ts (customer-x)
2import { ChangeDetectionStrategy, Component } from '@angular/core';
3import { Plugin } from '@devmy/decorators/plugin.decorator';
4
5// il gruppo DEVE corrispondere con quello del segnaposto
6@Plugin({ groupId: 'details-page' })
7@Component({
8 selector: 'app-skills-user-info',
9 template: `<div class="user-info">
10 <span class="user-info-text">Skills:</span>
11 <span class="user-info-text">HTML, CSS, TypeScript, Angular</span>
12 </div>`,
13 changeDetection: ChangeDetectionStrategy.OnPush,
14})
15export class SkillsUserInfoComponent {}
Successivamente creo una funzione per fornire la libreria verso l’esterno:
1// file: public-api.ts (customer-x)
2import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
3
4import { SkillsUserInfoComponent } from './lib/components/skills-user-info/skills-user-info.component';
5
6export function provideCustomer(): EnvironmentProviders {
7 return makeEnvironmentProviders([SkillsUserInfoComponent]);
8}
Anche per la personalizzazione del Cliente Y creo una funzione per fornire la libreria verso l’esterno:
1// file: public-api.ts (customer-y)
2import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
3import { provideRouter } from '@angular/router';
4
5export function provideCustomer(): EnvironmentProviders {
6 return makeEnvironmentProviders([
7 provideRouter([
8 {
9 path: 'details',
10 loadComponent: () => {
11 return import('./lib/pages/details-page/details-page.component');
12 },
13 },
14 ]),
15 ]);
16}
L’ultima parte consiste nel configurare il file main.ts
dell’applicazione standard. La modifica consiste nell’importare dinamicamente il file public-api.ts
del cliente proveniente dalla variabile d’ambiente customerName
:
1// file: main.ts (devmy)
2import {
3 EnvironmentProviders,
4 makeEnvironmentProviders,
5 provideBrowserGlobalErrorListeners,
6 provideZonelessChangeDetection,
7} from '@angular/core';
8import { bootstrapApplication } from '@angular/platform-browser';
9import { provideRouter } from '@angular/router';
10
11import { App } from './app/app';
12import { routes } from './app/app.routes';
13import { environment } from './environments/environment';
14
15async function provideCustomer(): Promise<EnvironmentProviders> {
16 if (environment.customerName) {
17 const module = await import(
18 `../../${environment.customerName}/src/public-api.ts`
19 );
20
21 return makeEnvironmentProviders([module.provideCustomer()]);
22 }
23
24 return makeEnvironmentProviders([]);
25}
26
27async function runApplication(): Promise<void> {
28 bootstrapApplication(App, {
29 providers: [
30 await provideCustomer(),
31 provideBrowserGlobalErrorListeners(),
32 provideZonelessChangeDetection(),
33 provideRouter(routes),
34 ],
35 }).catch((err) => console.error(err));
36}
37
38runApplication();
N.B.: la funzione è stata resa async per una corretta importazione delle eventuali rotte sovrascritte.
Configurando il file di environment
e NON indicando la proprietà customerName
, l’applicazione servita sarà la standard (senza personalizzazioni)
Modificando il customerName
su customer-x
, l’applicazione fornita conterrà il campo aggiuntivo “skills”:
Impostando il customerName
su customer-y
, l’applicazione mostrerà la tabella al posto della card per la visualizzazione delle informazioni utente:
Apportando qualche modifica al main.ts
possiamo rendere le librerie come delle “guarnizioni” da importare a seconda del cliente. Immaginiamo di aver previsto due personalizzazioni: una che aggiunge il campo “skills” e l’altra che aggiunge il campo “team” alle informazioni dell’utente. Immaginiamo uno scenario in cui:
Il Cliente X chiede di avere il SOLO campo “skills” Il Cliente Y chiede di avere il SOLO campo “team” il Cliente Z chiede di avere sia il campo “skills” che “team”
In questo caso possiamo creare due librerie che registrano distintivamente, come fatto in precedenza, i due campi indicati
1ng generate library skills-user-info
2ng generate library team-user-info
Possiamo trasformare l’import dinamico delle librerie aspettandoci di ricevere non più un singolo customerName
, ma un array di librerie da importare:
1// file main.ts (devmy)
2import {
3 EnvironmentProviders,
4 makeEnvironmentProviders,
5 provideBrowserGlobalErrorListeners,
6 provideZonelessChangeDetection,
7} from '@angular/core';
8import { bootstrapApplication } from '@angular/platform-browser';
9import { provideRouter } from '@angular/router';
10
11import { App } from './app/app';
12import { routes } from './app/app.routes';
13import { environment } from './environments/environment';
14
15async function provideModules(): Promise<EnvironmentProviders> {
16 let providers: EnvironmentProviders[] = [];
17
18 for (const customerName of environment.customerName) {
19 const module = await import(`../../${customerName}/src/public-api.ts`);
20 providers = [...providers, module.provideCustomer()];
21 }
22
23 return makeEnvironmentProviders(providers);
24}
25
26async function runApplication(): Promise<void> {
27 bootstrapApplication(App, {
28 providers: [
29 await provideModules(),
30 provideBrowserGlobalErrorListeners(),
31 provideZonelessChangeDetection(),
32 provideRouter(routes),
33 ],
34 }).catch((err) => console.error(err));
35}
36
37runApplication();
Se l’environment è stato configurato in modo da ricevere due librerie da importare, entrambi i campi verranno aggiunti alle informazioni dell’utente:
1export const environment: Environment = {
2 production: false,
3 customerName: ['skills-user-info', 'team-user-info'],
4};
Repo: https://github.com/mirkoacadevmy/angular-flavor/tree/feature/multi-module-import
Ed eccoci arrivati alla fine di questa avventura nel mondo delle personalizzazioni su Angular! Spero che questo articolo ti abbia dato qualche spunto interessante su come gestire le personalizzazioni delle tue applicazioni in maniera pulita ed efficiente. Non c'è più bisogno di impazzire con mille branch o forking selvaggi, grazie ai Multi-Projects di Angular possiamo avere la nostra app standard e poi "iniettare" le personalizzazioni a runtime.
Se ti è piaciuto quello che hai letto e vuoi approfondire trovi tutto il repository su GitHub: https://github.com/mirkoacadevmy/angular-flavor
Developer @Devmy, PUG Catania Organizer
Da sempre considera programmare più come una passione che come un lavoro. D'altra parte, questa è la ragione principale che spinge i programmatori a imparare nuovi linguaggi, nuove tecnologie e metodologie ogni giorno. Senza questa "fame" di sapere non è possibile raggiungere grandi conquiste in questa professione.
La sua passione lo ha portato allo sviluppo di applicazioni web usando tecnologie quali PHP, MySQL, Symfony, JavaScript, Node, Angular, tuttavia sperimenta volentieri anche con altri linguaggi e tecnologie come Vue, VB e Flutter. Grande sostenitore della metodologia Agile e dei principi presenti all'interno del manifesto che lo hanno fortemente ispirato nella creazione di una serie di libri che ben esprimono i vantaggi di seguire questi principi.
Precedentemente docente presso PED Academy.