Creazione di codice veramente modulare senza dipendenze

Pubblicato: 2022-03-11

Lo sviluppo di software è fantastico, ma... penso che siamo tutti d'accordo sul fatto che può essere un po' un ottovolante emotivo. All'inizio è tutto fantastico. Aggiungi nuove funzionalità una dopo l'altra in pochi giorni se non ore. Sei a posto!

Avanti veloce di alcuni mesi e la tua velocità di sviluppo diminuisce. È perché non stai lavorando duramente come prima? Non proprio. Andiamo avanti ancora di qualche mese e la tua velocità di sviluppo diminuisce ulteriormente. Lavorare a questo progetto non è più divertente ed è diventato una seccatura.

La situazione peggiora. Inizi a scoprire più bug nella tua applicazione. Spesso, la risoluzione di un bug ne crea due nuovi. A questo punto puoi iniziare a cantare:

99 piccoli bug nel codice. 99 piccoli bug. Eliminane uno, rattoppalo,

…127 piccoli bug nel codice.

Come ti senti a lavorare a questo progetto adesso? Se sei come me, probabilmente inizi a perdere la motivazione. È solo una seccatura sviluppare questa applicazione, poiché ogni modifica al codice esistente può avere conseguenze imprevedibili.

Questa esperienza è comune nel mondo del software e può spiegare perché così tanti programmatori vogliono buttare via il loro codice sorgente e riscrivere tutto.

Motivi per cui lo sviluppo del software rallenta nel tempo

Allora qual è la ragione di questo problema?

La causa principale è la crescente complessità. In base alla mia esperienza, il contributo maggiore alla complessità complessiva è il fatto che, nella stragrande maggioranza dei progetti software, tutto è connesso. A causa delle dipendenze di ogni classe, se modifichi del codice nella classe che invia e-mail, i tuoi utenti improvvisamente non possono registrarsi. Perché? Perché il tuo codice di registrazione dipende dal codice che invia le email. Ora non puoi cambiare nulla senza introdurre bug. Semplicemente non è possibile tracciare tutte le dipendenze.

Così il gioco è fatto; la vera causa dei nostri problemi è l'aumento della complessità derivante da tutte le dipendenze del nostro codice.

Big Ball of Mud e come ridurlo

La cosa divertente è che questo problema è noto da anni ormai. È un anti-modello comune chiamato "grande palla di fango". Ho visto quel tipo di architettura in quasi tutti i progetti su cui ho lavorato negli anni in diverse aziende.

Allora, qual è esattamente questo anti-pattern? In parole povere, ottieni una grande palla di fango quando ogni elemento ha una dipendenza con altri elementi. Di seguito, puoi vedere un grafico delle dipendenze dal noto progetto open source Apache Hadoop. Per visualizzare la grande palla di fango (o meglio, la grande palla di lana), disegna un cerchio e posiziona le classi del progetto in modo uniforme su di esso. Basta tracciare una linea tra ogni coppia di classi che dipendono l'una dall'altra. Ora puoi vedere l'origine dei tuoi problemi.

Una visualizzazione della "grande palla di fango" di Apache Hadoop, con poche dozzine di nodi e centinaia di linee che li collegano tra loro.

La "grande palla di fango" di Apache Hadoop

Una soluzione con codice modulare

Allora mi sono posto una domanda: sarebbe possibile ridurre la complessità e divertirmi ancora come all'inizio del progetto? A dire il vero, non puoi eliminare tutta la complessità. Se vuoi aggiungere nuove funzionalità, dovrai sempre aumentare la complessità del codice. Tuttavia, la complessità può essere spostata e separata.

Come altre industrie stanno risolvendo questo problema

Pensa all'industria meccanica. Quando una piccola officina meccanica sta creando macchine, acquista una serie di elementi standard, ne crea alcuni personalizzati e li assembla. Possono realizzare quei componenti completamente separatamente e assemblare tutto alla fine, apportando solo alcune modifiche. Com'è possibile? Sanno come ogni elemento si incastrerà tra loro in base a standard di settore come le dimensioni dei bulloni e decisioni anticipate come la dimensione dei fori di montaggio e la distanza tra di loro.

Un diagramma tecnico di un meccanismo fisico e di come i suoi pezzi si incastrano. I pezzi sono numerati in base al quale attaccare successivamente, ma quell'ordine da sinistra a destra va 5, 3, 4, 1, 2.

Ogni elemento dell'assieme di cui sopra può essere fornito da un'azienda separata che non ha alcuna conoscenza del prodotto finale o dei suoi altri pezzi. Finché ogni elemento modulare è prodotto secondo le specifiche, sarai in grado di creare il dispositivo finale come previsto.

Possiamo replicarlo nell'industria del software?

Sicuro che possiamo! Utilizzando interfacce e inversione del principio di controllo; la parte migliore è il fatto che questo approccio può essere utilizzato in qualsiasi linguaggio orientato agli oggetti: Java, C#, Swift, TypeScript, JavaScript, PHP: l'elenco potrebbe continuare all'infinito. Non è necessario alcun framework di fantasia per applicare questo metodo. Devi solo attenerti ad alcune semplici regole e rimanere disciplinato.

L'inversione del controllo è tua amica

Quando ho sentito parlare per la prima volta dell'inversione del controllo, mi sono subito reso conto di aver trovato una soluzione. È un concetto di prendere le dipendenze esistenti e invertirle utilizzando le interfacce. Le interfacce sono semplici dichiarazioni di metodi. Non forniscono alcuna implementazione concreta. Di conseguenza, possono essere utilizzati come accordo tra due elementi su come collegarli. Possono essere usati come connettori modulari, se vuoi. Finché un elemento fornisce l'interfaccia e un altro elemento fornisce l'implementazione per essa, possono lavorare insieme senza sapere nulla l'uno dell'altro. È brillante.

Vediamo su un semplice esempio come possiamo disaccoppiare il nostro sistema per creare codice modulare. I diagrammi seguenti sono stati implementati come semplici applicazioni Java. Puoi trovarli su questo repository GitHub.

Problema

Supponiamo di avere un'applicazione molto semplice composta solo da una classe Main , tre servizi e una singola classe Util . Questi elementi dipendono l'uno dall'altro in molteplici modi. Di seguito, puoi vedere un'implementazione che utilizza l'approccio "big ball of mud". Le classi si chiamano semplicemente. Sono strettamente accoppiati e non puoi semplicemente eliminare un elemento senza toccare gli altri. Le applicazioni create utilizzando questo stile ti consentono inizialmente di crescere rapidamente. Credo che questo stile sia appropriato per i progetti proof-of-concept poiché puoi giocare facilmente con le cose. Tuttavia, non è appropriato per soluzioni pronte per la produzione perché anche la manutenzione può essere pericolosa e ogni singola modifica può creare bug imprevedibili. Il diagramma seguente mostra questa grande architettura a palla di fango.

Main utilizza i servizi A, B e C, ciascuno dei quali utilizza Util. Il servizio C utilizza anche il servizio A.

Perché l'iniezione di dipendenza ha sbagliato tutto

Nella ricerca di un approccio migliore, possiamo usare una tecnica chiamata dependency injection. Questo metodo presuppone che tutti i componenti debbano essere utilizzati tramite le interfacce. Ho letto affermazioni che disaccoppia elementi, ma lo fa davvero, però? No. Dai un'occhiata al diagramma qui sotto.

L'architettura precedente ma con iniezione di dipendenza. Ora Main utilizza Interface Service A, B e C, che sono implementati dai servizi corrispondenti. I servizi A e C utilizzano entrambi Interface Service B e Interface Util, che è implementato da Util. Il servizio C utilizza anche il servizio di interfaccia A. Ogni servizio insieme alla relativa interfaccia è considerato un elemento.

L'unica differenza tra la situazione attuale e una grossa palla di fango è il fatto che ora, invece di chiamare direttamente le classi, le chiamiamo attraverso le loro interfacce. Migliora leggermente la separazione degli elementi l'uno dall'altro. Se, ad esempio, desideri riutilizzare il Service A in un progetto diverso, puoi farlo eliminando il Service A stesso, insieme all'interfaccia Interface A , nonché Interface B e Interface Util . Come puoi vedere, il Service A dipende ancora da altri elementi. Di conseguenza, continuiamo ad avere problemi con la modifica del codice in un posto e il comportamento incasinato in un altro. Crea ancora il problema che se modifichi il Service B e l' Interface B , dovrai cambiare tutti gli elementi che dipendono da esso. Questo approccio non risolve nulla; secondo me, aggiunge solo uno strato di interfaccia sopra gli elementi. Non dovresti mai iniettare alcuna dipendenza, ma dovresti sbarazzartene una volta per tutte. Viva l'indipendenza!

La soluzione per il codice modulare

L'approccio che credo risolva tutti i principali mal di testa delle dipendenze lo fa non usando affatto le dipendenze. Crei un componente e il suo listener. Un listener è un'interfaccia semplice. Ogni volta che devi chiamare un metodo dall'esterno dell'elemento corrente, aggiungi semplicemente un metodo all'ascoltatore e chiamalo invece. L'elemento può solo utilizzare file, chiamare metodi all'interno del suo pacchetto e utilizzare classi fornite dal framework principale o da altre librerie utilizzate. Di seguito, puoi vedere un diagramma dell'applicazione modificata per utilizzare l'architettura degli elementi.

Un diagramma dell'applicazione modificato per utilizzare l'architettura degli elementi. Principali usi Util e tutti e tre i servizi. Main implementa anche un listener per ogni servizio, che viene utilizzato da quel servizio. Un ascoltatore e un servizio insieme sono considerati un elemento.

Si noti che, in questa architettura, solo la classe Main ha più dipendenze. Collega tutti gli elementi insieme e incapsula la logica aziendale dell'applicazione.

I servizi, invece, sono elementi completamente indipendenti. Ora puoi eliminare ogni servizio da questa applicazione e riutilizzarli da qualche altra parte. Non dipendono da nient'altro. Ma aspetta, migliora: non è necessario modificare mai più quei servizi, purché non ne modifichi il comportamento. Finché quei servizi fanno quello che dovrebbero fare, possono essere lasciati intatti fino alla fine dei tempi. Possono essere creati da un ingegnere del software professionista o da un programmatore per la prima volta compromesso con il peggior codice di spaghetti che qualcuno abbia mai cucinato con istruzioni goto mescolate. Non importa, perché la loro logica è incapsulata. Per quanto orribile possa essere, non si riverserà mai su altre classi. Ciò ti dà anche il potere di dividere il lavoro in un progetto tra più sviluppatori, in cui ogni sviluppatore può lavorare sul proprio componente in modo indipendente senza la necessità di interromperne un altro o addirittura di conoscere l'esistenza di altri sviluppatori.

Infine, puoi iniziare a scrivere codice indipendente ancora una volta, proprio come all'inizio del tuo ultimo progetto.

Modello di elemento

Definiamo il modello dell'elemento strutturale in modo da poterlo creare in modo ripetibile.

La versione più semplice dell'elemento è composta da due cose: una classe dell'elemento principale e un listener. Se vuoi usare un elemento, devi implementare il listener ed effettuare chiamate alla classe principale. Ecco uno schema della configurazione più semplice:

Un diagramma di un singolo elemento e del relativo listener all'interno di un'app. Come prima, l'App utilizza l'elemento, che utilizza il suo listener, che è implementato dall'App.

Ovviamente, alla fine dovrai aggiungere più complessità all'elemento, ma puoi farlo facilmente. Assicurati solo che nessuna delle tue classi logiche dipenda da altri file nel progetto. Possono utilizzare solo il framework principale, le librerie importate e altri file in questo elemento. Quando si tratta di file di asset come immagini, viste, suoni, ecc., anche questi dovrebbero essere incapsulati all'interno di elementi in modo che in futuro siano facili da riutilizzare. Puoi semplicemente copiare l'intera cartella in un altro progetto e il gioco è fatto!

Di seguito, puoi vedere un grafico di esempio che mostra un elemento più avanzato. Si noti che consiste in una vista che sta utilizzando e non dipende da nessun altro file dell'applicazione. Se vuoi conoscere un metodo semplice per controllare le dipendenze, guarda la sezione di importazione. Ci sono file esterni all'elemento corrente? In tal caso, è necessario rimuovere tali dipendenze spostandole nell'elemento o aggiungendo una chiamata appropriata al listener.

Un semplice diagramma di un elemento più complesso. Qui, il senso più ampio della parola "elemento" consiste di sei parti: Vista; logiche A, B e C; Elemento; ed Element Listener. Le relazioni tra gli ultimi due e l'app sono le stesse di prima, ma anche l'elemento interno utilizza le logiche A e C. La logica C utilizza le logiche A e B. La logica A utilizza la logica B e Visualizza.

Diamo anche un'occhiata a un semplice esempio "Hello World" creato in Java.

 public class Main { interface ElementListener { void printOutput(String message); } static class Element { private ElementListener listener; public Element(ElementListener listener) { this.listener = listener; } public void sayHello() { String message = "Hello World of Elements!"; this.listener.printOutput(message); } } static class App { public App() { } public void start() { // Build listener ElementListener elementListener = message -> System.out.println(message); // Assemble element Element element = new Element(elementListener); element.sayHello(); } } public static void main(String[] args) { App app = new App(); app.start(); } }

Inizialmente, definiamo ElementListener per specificare il metodo che stampa l'output. L'elemento stesso è definito di seguito. Quando si chiama sayHello sull'elemento, stampa semplicemente un messaggio usando ElementListener . Si noti che l'elemento è completamente indipendente dall'implementazione del metodo printOutput . Può essere stampato nella console, in una stampante fisica o in un'interfaccia utente di fantasia. L'elemento non dipende da tale implementazione. A causa di questa astrazione, questo elemento può essere riutilizzato facilmente in diverse applicazioni.

Ora dai un'occhiata alla classe App principale. Implementa l'ascoltatore e assembla l'elemento insieme all'implementazione concreta. Ora possiamo iniziare a usarlo.

Puoi anche eseguire questo esempio in JavaScript qui

Architettura degli elementi

Diamo un'occhiata all'utilizzo del modello di elemento in applicazioni su larga scala. Una cosa è mostrarlo in un piccolo progetto, un'altra è applicarlo al mondo reale.

La struttura di un'applicazione Web full-stack che mi piace utilizzare è la seguente:

 src ├── client │ ├── app │ └── elements │ └── server ├── app └── elements

In una cartella del codice sorgente, inizialmente dividiamo i file client e server. È una cosa ragionevole da fare, dal momento che vengono eseguiti in due ambienti diversi: il browser e il server back-end.

Quindi dividiamo il codice in ogni livello in cartelle chiamate app ed elementi. Gli elementi sono costituiti da cartelle con componenti indipendenti, mentre la cartella dell'app collega tutti gli elementi insieme e memorizza tutta la logica aziendale.

In questo modo, gli elementi possono essere riutilizzati tra diversi progetti, mentre tutta la complessità specifica dell'applicazione è incapsulata in un'unica cartella e molto spesso ridotta a semplici chiamate agli elementi.

Esempio pratico

Credendo che la pratica abbia sempre la meglio sulla teoria, diamo un'occhiata a un esempio di vita reale creato in Node.js e TypeScript.

Esempio di vita reale

È un'applicazione web molto semplice che può essere utilizzata come punto di partenza per soluzioni più avanzate. Segue l'architettura degli elementi e utilizza un modello di elementi ampiamente strutturali.

Dai punti salienti, puoi vedere che la pagina principale è stata distinta come elemento. Questa pagina include la propria vista. Quindi, quando, ad esempio, vuoi riutilizzarlo, puoi semplicemente copiare l'intera cartella e rilasciarla in un progetto diverso. Basta collegare tutto insieme e sei a posto.

È un esempio di base che dimostra che puoi iniziare a introdurre elementi nella tua applicazione oggi stesso. Puoi iniziare a distinguere i componenti indipendenti e separare la loro logica. Non importa quanto sia disordinato il codice su cui stai attualmente lavorando.

Sviluppa più velocemente, riutilizza più spesso!

Spero che, con questo nuovo set di strumenti, sarai in grado di sviluppare più facilmente codice più manutenibile. Prima di passare all'uso pratico del pattern degli elementi, ricapitoliamo rapidamente tutti i punti principali:

  • Molti problemi nel software si verificano a causa delle dipendenze tra più componenti.

  • Apportando una modifica in un punto, puoi introdurre comportamenti imprevedibili da qualche altra parte.

Tre approcci architetturali comuni sono:

  • La grande palla di fango. È ottimo per uno sviluppo rapido, ma non così eccezionale per scopi di produzione stabile.

  • Iniezione di dipendenza. È una soluzione semicotta che dovresti evitare.

  • Architettura degli elementi. Questa soluzione consente di creare componenti indipendenti e riutilizzarli in altri progetti. È manutenibile e brillante per rilasci di produzione stabili.

Il pattern dell'elemento di base consiste in una classe principale che ha tutti i metodi principali e un listener che è una semplice interfaccia che consente la comunicazione con il mondo esterno.

Per ottenere un'architettura a elementi full-stack, devi prima separare il codice front-end dal codice back-end. Quindi crei una cartella in ciascuno per un'app e gli elementi. La cartella degli elementi è composta da tutti gli elementi indipendenti, mentre la cartella dell'app collega tutto insieme.

Ora puoi iniziare a creare e condividere i tuoi elementi. A lungo termine, ti aiuterà a creare prodotti di facile manutenzione. Buona fortuna e fammi sapere cosa hai creato!

Inoltre, se ti ritrovi a ottimizzare prematuramente il tuo codice, leggi Come evitare la maledizione dell'ottimizzazione prematura del collega Toptaler Kevin Bloch.

Correlati: Best practice JS: crea un bot Discord con TypeScript e Dependency Injection