05 - Adapter, Flyweight, ecc
Problemi
Abbiamo finito l'altra volta con il nostro codice che torna correttamente le impostazioni di fatturazione, ci sono però alcuni problemi:
- la logica è correttamente nel provider, ma se voglio avere le impostazioni dell'utente o del progetto devo replicarne una parte
- fa troppe chiamate a db
Una logica più generale
Ad ogni livello della nostra gerarchia dobbiamo andare a leggere le impostazioni del livello a cui siamo ed eventualmente unirle a quelle del livello superiore.
Immaginiamo di voler raggiungere il comportamento standard per cui a partire dall'id dell'entità in esame torniamo le impostazioni al suo livello.
Andiamo quindi a creare una interfaccia che rappresenti questa funzionalità.
amount-settings.ds.ts
import { Injectable } from "@nestjs/common";
import { AmountSettings } from "./amount-settings.entity";
@Injectable()
export abstract class AmountSettingsDataSource implements AmountSettingsProvider {
abstract getAmountSettings(entityId: string): Promise<AmountSettings>;
}
export interface AmountSettingsProvider {
getAmountSettings(entityId: string): Promise<AmountSettings>
}
A questo punto sappiamo che dobbiamo sempre unire il risultato corrente a quello superiore, possiamo estrarre questa logica in una classe astratta, qual è il comportamento comune?
- chiamare il livello precedente
- tornare l'entità corrente
- unire i risultati
Cosa cambia invece da una entità all'altra? - la proprietà che mi indica l'id del livello precedente
- potenzialmente la proprietà che contiene le impostazioni
amount-settings.merger.ts
import { DataSource } from "@modules/utils/datasource";
import { AmountSettings } from "../amount-settings.entity";
import { AmountSettingsProvider } from "./amount-settings.provider";
export abstract class AmountSettingsMerger<T> implements AmountSettingsProvider {
constructor(protected previousSource: AmountSettingsProvider,
protected datasource: DataSource<T, unknown>,
protected prevEntityFn: (entity: T) => Promise<string>){}
protected abstract extractSettings(entity: T): Promise<Partial<AmountSettings>>;
protected mergeSettings(prevSettings: AmountSettings, currSettings: Partial<AmountSettings>): AmountSettings {
return Object.assign(prevSettings, currSettings);
}
async getAmountSettings(entityId: string): Promise<AmountSettings> {
const currEntity = await this.datasource.get(entityId);
const currSettings = await this.extractSettings(currEntity);
const prevId = await this.prevEntityFn(currEntity);
const prevSettings = await this.previousSource.getAmountSettings(prevId);
return this.mergeSettings(prevSettings, currSettings);
}
}
Come possiamo vedere la classe si occupa già di chiamare il datasource, di chiamare il livello precedete e di unire i risultati. Rimane astratto il metodo per estrarre dall'entità le impostazioni (per sicurezza l'ho definito anche asincrono, non si sa mai che siano necessarie altre operazioni).
Da notare anche che c'è un altro comportamento non ancora definito, che viene stavolta delegato all'utilizzatore della classe tramite la funzione passata al costruttore.
Quella funzione ha il compito di tornare l'id dell'elemento di livello superiore, come mai non l'abbiamo definita astratta come l'altra? Perché mentre possiamo immaginare che tutti gli utenti abbiano le impostazioni salvate nella stessa proprietà non possiamo sapere in anticipo quale gerarchia la nostra applicazione vuole implementare. Quindi extractSettings verrà implementata dalle classi specifiche che andremo a scrivere, mentre la funzione verrà passata tramite costruttore a runtime, permettendoci di definire gerarchie diverse.
Vediamo che nella classe usiamo il tipo generico DataSource, andiamo a crearlo e facciamo in modo che tutti i nostri servizi lo implementino.
datasource.ts
export interface DataSource<T, DTO> {
list(): Promise<T[]>;
get(id: string): Promise<T>;
create(data: DTO): Promise<T>;
}
Modifichiamo tutti i datasource abstract che abbiamo creato per le nostre entità e facciamo in modo che implementino questa interfaccia. Non dovrebbero essere necessarie altre modifiche.
Partiamo adesso con l'implementazione delle classi specifiche di Company e User:
company-amount-settings.ds.ts
import { CompanyDataSource } from "@modules/company";
import { Injectable } from "@nestjs/common";
import { AmountSettings } from "../amount-settings.entity";
import { AmountSettingsProvider } from "../amount-settings.ds";
@Injectable()
export class CompanyAmountSettings implements AmountSettingsProvider {
constructor(protected dataSource: CompanyDataSource) {
}
async getAmountSettings(entityId: string): Promise<AmountSettings> {
const entity = await this.dataSource.get(entityId);
return entity.settings.amount;
}
}
Company non ha bisogno di estendere la classe astratta perché è sempre la radice della nostra configurazione. Se volessi tenere il comportamento generico (ad esempio prevedere anche una configurazione generale dell'applicazione) potrei modificare questa classe o cambiare la logica di quella astratta per rendere opzionale il riferimento al prev.
user-amount-settings.ds.ts
import { AmountSettingsMerger } from "../amount-settings.merger";
import { User } from "@modules/user";
import { AmountSettings } from "../amount-settings.entity";
export class UserAmountSettings extends AmountSettingsMerger<User> {
protected async extractSettings(entity: User): Promise<Partial<AmountSettings>> {
return entity.settings.amount;
}
}
Questa volta vediamo che estendiamo la classe astratta, quello che andiamo ad implementare è solo la logica per passare dalla entità alle impostazioni, il costruttore rimane quello della classe astratta e il resto della logica è implementata.
Vediamo come configurarlo ora nel modulo:
providers:[
CompanyAmountSettings,
{
provide: UserAmountSettings,
useFactory: (prev: CompanyAmountSettings, curr: UserDataSource) => {
return new UserAmountSettings(prev, curr, async entity => entity.company);
},
inject: [CompanyAmountSettings, UserDataSource]
}
],
exports: [
CompanyAmountSettings,
UserAmountSettings
]
Qui possiamo vedere come possiamo configurare il comportamento della nostra app, allo UserAmountSettings decidiamo noi di andare a passare CompanyAmountSettings come sorgente del livello superiore e gli diciamo che l'id della company da andare a richiedere è entity.company
Template Method
Per separare la logica tra classe astratta e le implementazioni specifiche abbiamo usato il Design Pattern Template Method.
Serve a risolvere quei casi in cui c'è un algoritmo generale che rimane sempre lo stesso ma alcuni passaggi (di solito minori) cambiano da caso a caso.
Suggerisce di separare il codice comune in una classe astratta e definire come astratti i metodi da lasciar definire alle specifiche implementazioni.
Un esempio è quello di analizzare dati provenienti da diverse sorgenti, il modo in cui vengono elaborati i dati non cambia, ma si possono implementare diverse classi per andare a leggere/scrivere dati da file diversi, ad esempio .csv e .xlsx
Decorator
doc
In una forma un po' particolare abbiamo anche usato questo design pattern.
Viene utilizzato per aggiungere comportamenti nuovi a oggetti incapsulandoli dentro ad altri.
Nel nostro caso il nostro programma potrebbe funzionare anche solo con le impostazioni della company, abbiamo però esteso questo comportamento passando il provider della company a quello dell'utente, che espone la stessa interfaccia (i due oggetti vengono visti entrambi come AmountServiceProvider) ma aggiunge/modifica il comportamento base senza modificare company.
Nel Decorator Pattern l'oggetto originale viene mandato direttamente alla classe che lo incapsula e questa esegue sempre il funzionamento base (a differenza del proxy che vediamo dopo, dove l'oggetto base può essere passato oppure creato internamente e non è detto che le sue funzioni vengano eseguite).
Problemi con project
Project ha una interfaccia che non è la stessa che abbiamo usato finora. I dati sono strutturati diversamente e non ha modo di tornarmi delle configurazioni complete senza sapere a che utente sono interessato.
Implementiamo quindi la classe senza estendere il merger e vediamo dove sorgono i problemi.
project-amount-settings.ds.ts
import { Injectable } from "@nestjs/common";
import { ProjectDataSource } from "@modules/project";
import { AmountSettings } from "../amount-settings.entity";
import { AmountSettingsProvider } from "../amount-settings.ds";
@Injectable()
export class ProjectAmountSettings {
constructor(protected previousSource: AmountSettingsProvider,
protected datasource: ProjectDataSource) {}
protected mergeSettings(prevSettings: AmountSettings, currSettings: Partial<AmountSettings>): AmountSettings {
return Object.assign(prevSettings, currSettings);
}
async getAmountSettings(entityId: string, userId: string): Promise<AmountSettings> {
const project = await this.datasource.get(entityId);
const projectUserSettings = project.settings.amount.userSettings.find(i => i.userId.toString() === userId);
const projectSettings = projectUserSettings ? projectUserSettings.settings : {};
const prevSettings = await this.previousSource.getAmountSettings(userId);
return this.mergeSettings(prevSettings, projectSettings);
}
}
Anche se ha sempre il source precedente ho rotto la "catena" che stavo creando con i decorator, perché l'interfaccia è cambiata. Me ne accorgo in particolare quando vado a creare anche il provider del TimeEntry e provo a mettere assieme i pezzi.
time-entry-amount-settings.ds.ts
import { TimeEntry } from "@modules/time-entry";
import { AmountSettingsMerger } from "../amount-settings.merger";
import { AmountSettings } from "../amount-settings.entity";
import { AmountSettingsProvider } from "../amount-settings.ds";
export class TimeEntryAmountSettings extends AmountSettingsMerger<TimeEntry> {
protected async extractSettings(entity: TimeEntry): Promise<Partial<AmountSettings>> {
return entity.settings.amount
}
}
Quando provo nel modulo a passare ProjectAmountSettings al costruttore del TimeEntry mi da giustamente errore perché l'interfaccia è diversa.
Per risolvere questo problema ho bisogno di un modo per convertire l'interfaccia del Project in una compatibile.
Non è un problema da poco perché per riuscire ad avere i dati ho bisogno dello userId, che ho a disposizione solamente una volta che so su che dati sto lavorando.
In questi casi di solito la strategia corretta è quella di avere un provider che è una factory, richiamabile in esecuzione e che mi crea al momento una nuova istanza configurata secondo le mie esigenze.
import { TimeEntry } from "@modules/time-entry";
import { AmountSettingsMerger } from "../amount-settings.merger";
import { AmountSettings } from "../amount-settings.entity";
import { AmountSettingsProvider } from "../amount-settings.ds";
export class TimeEntryAmountSettings extends AmountSettingsMerger<TimeEntry> {
protected async extractSettings(entity: TimeEntry): Promise<Partial<AmountSettings>> {
return entity.settings.amount
}
}
export const TIME_ENTRY_AMOUNT_SETTINGS_FACTORY = 'TIME_ENTRY_AMOUNT_SETTINGS_FACTORY';
export type TimeEntryAmountSettingsFactory = {get(userId: string): AmountSettingsProvider};
nel modulo a questo punto riesco ad avere una cosa del tipo:
{
provide: ProjectAmountSettings,
useFactory: (prev: UserAmountSettings, curr: ProjectDataSource) => {
return new ProjectAmountSettings(prev, curr);
},
inject: [UserAmountSettings, ProjectDataSource]
},
{
provide: TIME_ENTRY_AMOUNT_SETTINGS_FACTORY,
useFactory: (original: ProjectAmountSettings, curr: TimeEntryDataSource) => {
return {
get(userId: string): AmountSettingsProvider {
...
return new TimeEntryAmountSettings(...);
}
}
},
inject: [ProjectAmountSettings, TimeEntryDataSource]
}
Ma ho ancora il problema di dover trasformare il provider del project in qualcosa accettato dal costruttore.
Per fare questo posso crearmi una classe di appoggio che implementa l'interfaccia AmountSettingsProvider e accetta l'id dell'utente.
import { Injectable } from "@nestjs/common";
import { ProjectDataSource } from "@modules/project";
import { AmountSettings } from "../amount-settings.entity";
import { AmountSettingsProvider } from "../amount-settings.ds";
@Injectable()
export class ProjectAmountSettings {
...
}
export class ProjectAmountSettingsAdapter implements AmountSettingsProvider {
constructor(protected baseSettingsProvider: ProjectAmountSettings, protected userId: string) {}
async getAmountSettings(entityId: string): Promise<AmountSettings> {
return this.baseSettingsProvider.getAmountSettings(entityId, this.userId);
}
}
Nel mio modulo adesso posso fare:
{
provide: ProjectAmountSettings,
useFactory: (prev: UserAmountSettings, curr: ProjectDataSource) => {
return new ProjectAmountSettings(prev, curr);
},
inject: [UserAmountSettings, ProjectDataSource]
},
{
provide: TIME_ENTRY_AMOUNT_SETTINGS_FACTORY,
useFactory: (original: ProjectAmountSettings, curr: TimeEntryDataSource) => {
return {
get(userId: string): AmountSettingsProvider {
const prevSource = new ProjectAmountSettingsAdapter(original, userId);
return new TimeEntryAmountSettings(prevSource, curr, async entity => entity.project);
}
}
},
inject: [ProjectAmountSettings, TimeEntryDataSource]
}
Quindi la strategia è stata quella di creare una funzione che, a partire dall'id dell'utente, crea un adapter usando il ProjectAmountSettings e l'utente, questo oggetto è perfettamente compatibile con la mia catena di Decorator e quindi posso passarlo a TimeEntry.
L'unico svantaggio di questo metodo è che TimeEntryAmountSettings non è più disponibile globalmente come singleton, mi devo occupare di chiamare il metodo .get della factory ogni volta che ho bisogno di utilizzarlo. Purtroppo non ho alternative visto che lo userId dipende dalla entity ed è una informazione che ho solo a runtime.
Adapter
Questa strategia che abbiamo utilizzato è chiamata appunto Adapter. Ha come scopo quello di mettere in comunicazione oggetti che altrimenti avrebbero interfacce incompatibili.
Come struttura è molto semplice, consiste in una classe (o una funzione) che espone al client una interfaccia compatibile e internamente chiama le funzioni del service usando le sue interfacce originali.
Mettiamo assieme i pezzi
Una volta configurato il modulo il nostro result-calculator diventa:
@Injectable()
export class TimeEntryResultCalculator {
constructor(
protected readonly durationSettingsSrv: DurationSettingsDataSource,
protected readonly durationStrategySelector: DurationStrategySelectorService,
protected readonly resultFactorySrv: TimeEntryResultFactory,
@Inject(TIME_ENTRY_AMOUNT_SETTINGS_FACTORY) protected timeEntrySettingsFactory: TimeEntryAmountSettingsFactory
){ }
...
async calcResult(userId: string, items: TimeEntry[]): Promise<TimeEntryResultDTO[]>;
async calcResult(userId: string, item: TimeEntry): Promise<TimeEntryResultDTO>;
async calcResult(userId: string, arg: TimeEntry | TimeEntry[]) {
const isArray = Array.isArray(arg);
const items = isArray ? arg : [arg];
const durationSrv = await this.getDurationService(userId);
const results: TimeEntryResultDTO[] = [];
for(const item of items) {
const amountSettingsDs = this.timeEntrySettingsFactory.get(item.user);
const amountSettings = await amountSettingsDs.getAmountSettings(item.id);
console.log(amountSettings);
const amountSrv = this.getAmountService(amountSettings, durationSrv, item);
const resultFactory = this.resultFactorySrv.getFactory(durationSrv, amountSrv);
results.push(resultFactory(item));
}
return isArray ? results : results[0];
}
}
Altro problema: Cache
Abbiamo ancora però il problema delle continue chiamate al db, anzi l'abbiamo peggiorato perché adesso richiediamo ancora anche i TimeEntry.
Esercizio
Provate a gruppi ad implementare un sistema di cache (o almeno a ragionare su una soluzione),
tenendo presente che:
- i servizi che abbiamo appena creato non richiedono modifiche
- dovete utilizzare una soluzione che prevede un Proxy