Eseguire il refactoring di un monolite in microservizi

Last reviewed 2024-06-26 UTC

Questa guida di riferimento è la seconda di una serie in quattro parti sulla progettazione, sulla creazione e sul deployment dei microservizi. Questa serie descrive i vari elementi di un'architettura a microservizi. La serie include informazioni sui vantaggi e sugli svantaggi del pattern di architettura di microservizi e su come applicarlo.

  1. Introduzione ai microservizi
  2. Eseguire il refactoring di un monolite in microservizi (questo documento)
  3. Comunicazione tra servizi in una configurazione di microservizi
  4. Monitoraggio distribuito in un'applicazione di microservizi

Questa serie è rivolta a sviluppatori e architetti di applicazioni che progettano e implementano la migrazione per eseguire il refactoring di un'applicazione monolitica in un'applicazione di microservizi.

Il processo di trasformazione di un'applicazione monolitica in microservizi è una forma di modernizzazione delle applicazioni. Per eseguire la modernizzazione delle applicazioni, ti consigliamo di non eseguire il refactoring di tutto il codice contemporaneamente. Ti consigliamo invece di eseguire il refactoring incrementale della tua applicazione monolitica. Quando esegui il refactoring incrementale di un'applicazione, crei gradualmente una nuova applicazione composta da microservizi ed esegui questa applicazione insieme all'applicazione monolitica. Questo approccio è noto anche come pattern di fico strangolatore. Nel tempo, la quantità di funzionalità implementata dall'applicazione monolitica si riduce fino a scomparire del tutto o a diventare un altro microservice.

Per disaccoppiare le funzionalità da un monolite, devi estrarre con attenzione i dati, la logica e i componenti rivolti agli utenti della funzionalità e reindirizzarli al nuovo servizio. È importante comprendere bene lo spazio del problema prima di passare allo spazio della soluzione.

Quando comprendi lo spazio dei problemi, comprendi i confini naturali nel dominio che forniscono il giusto livello di isolamento. Ti consigliamo di creare servizi più grandi anziché più piccoli finché non hai compreso a fondo il dominio.

La definizione dei confini dei servizi è un processo iterativo. Poiché questa procedura richiede un lavoro non banale, devi valutare continuamente il costo del disaccoppiamento rispetto ai vantaggi che ottieni. Di seguito sono riportati i fattori che ti aiuteranno a valutare l'approccio al disaccoppiamento di un monolite:

  • Evita di eseguire il refactoring di tutto contemporaneamente. Per dare la priorità al disaccoppiamento dei servizi, valuta i costi rispetto ai vantaggi.
  • I servizi in un'architettura di microservizi sono organizzati in base alle esigenze aziendali e non a quelle tecniche.
  • Quando esegui la migrazione incrementale dei servizi, configura la comunicazione tra i servizi e il monolito in modo che passi attraverso contratti API ben definiti.
  • I microservizi richiedono molta più automazione: pensa in anticipo a integrazione continua (CI), deployment continuo (CD), logging e monitoraggio centralizzati.

Le sezioni seguenti illustrano varie strategie per disaccoppiare i servizi e eseguire la migrazione incrementale dell'applicazione monolitica.

Scollegamento tramite progettazione basata sui domini

I microservizi devono essere progettati in base alle funzionalità aziendali, non a livelli orizzontali come l'accesso ai dati o la messaggistica. I microservizi devono inoltre avere un accoppiamento sfuso e un'elevata coesione funzionale. I microservizi sono a basso accoppiamento se puoi modificare un servizio senza richiedere l'aggiornamento di altri servizi contemporaneamente. Un microservizio è coeso se ha uno scopo unico e ben definito, come la gestione degli account utente o l'elaborazione dei pagamenti.

Il design basato sul dominio (DDD) richiede una buona conoscenza del dominio per cui è scritta l'applicazione. Le conoscenze di dominio necessarie per creare l'applicazione risiedono nelle persone che le comprendono, ovvero gli esperti del dominio.

Puoi applicare l'approccio DDD in modo retroattivo a un'applicazione esistente come segue:

  1. Identifica un linguaggio onnipresente, ovvero un vocabolario comune condiviso da tutti gli stakeholder. In qualità di sviluppatore, è importante utilizzare nel codice termini comprensibili anche per persone non tecniche. Lo scopo del codice deve rispecchiare le procedure della tua azienda.
  2. Identifica i moduli pertinenti nell'applicazione monolitica, quindi applica il vocabolario comune a questi moduli.
  3. Definisci contesti delimitati in cui applichi confini espliciti ai moduli identificati con responsabilità ben chiare. I contesti delimitati che identifichi sono candidati per essere sottoposti a refactoring in microservizi più piccoli.

Il seguente diagramma mostra come applicare contesti delimitati a un'applicazione di e-commerce esistente:

I contesti delimitati vengono applicati a un'applicazione.

Figura 1. Le funzionalità dell'applicazione sono separate in contesti delimitati che vengono migrati ai servizi.

Nella figura 1, le funzionalità dell'applicazione di e-commerce sono separate in contesti delimitati e la migrazione ai servizi avviene nel seguente modo:

  • Le funzionalità di gestione e evasione degli ordini sono raggruppate nelle seguenti categorie:
    • La funzionalità di gestione degli ordini viene migrata al servizio degli ordini.
    • La funzionalità di gestione della consegna della logistica viene migrata al servizio di consegna.
    • La funzionalità di inventario viene migrata al servizio di inventario.
  • Le funzionalità di contabilità sono raggruppate in un'unica categoria:
    • Le funzionalità per consumatori, venditori e terze parti vengono unite e migrate al servizio account.

Dare la priorità ai servizi per la migrazione

Un punto di partenza ideale per disaccoppiare i servizi è identificare i moduli con a basso accoppiamento nella tua applicazione monolitica. Puoi scegliere un modulo con a basso accoppiamento come uno dei primi candidati alla conversione in un microservizio. Per completare un'analisi delle dipendenze di ogni modulo, esamina quanto segue:

  • Il tipo di dipendenza: dipendenze da dati o altri moduli.
  • L'entità della dipendenza: in che modo una modifica del modulo identificato potrebbe influire su altri moduli.

La migrazione di un modulo con dipendenze di dati elevate in genere non è un'attività banale. Se esegui prima la migrazione delle funzionalità e poi quella dei dati correlati, potresti dover leggere e scrivere temporaneamente i dati in più database. Pertanto, devi tenere conto delle sfide di integrità e sincronizzazione dei dati.

Ti consigliamo di estrarre i moduli con requisiti di risorse diversi rispetto al resto del monolite. Ad esempio, se un modulo ha un database in memoria, puoi convertirlo in un servizio, che può essere implementato su host con una memoria più elevata. Quando trasformi i moduli con requisiti di risorse specifici in servizi, puoi scalare molto più facilmente la tua applicazione.

Dal punto di vista operativo, il refactoring di un modulo in un proprio servizio comporta anche la modifica delle strutture di team esistenti. Il percorso migliore per una chiara assunzione di responsabilità è dare potere a piccoli team che possiedono un intero servizio.

Altri fattori che possono influire sulla definizione delle priorità dei servizi per la migrazione includono la criticità aziendale, la copertura completa dei test, la posizione di sicurezza dell'applicazione e l'accettazione da parte dell'organizzazione. In base alle tue valutazioni, puoi classificare i servizi come descritto nel primo documento di questa serie in base al vantaggio che ricevi dal refactoring.

Estrarre un servizio da un monolite

Dopo aver identificato il servizio candidato ideale, devi trovare un modo per far coesistere i moduli sia dei microservizi sia dei monoliti. Un modo per gestire questa convivenza è introdurre un adattatore di comunicazione tra processi (IPC), che può aiutare i moduli a lavorare insieme. Nel tempo, il microservizio assume il carico ed elimina il componente monolitico. Questo processo incrementale riduce il rischio di passare dall'applicazione monolitica al nuovo microservizio perché consente di rilevare i bug o i problemi di prestazioni in modo graduale.

Il seguente diagramma mostra come implementare l'approccio IPC:

Viene implementato un approccio IPC per aiutare i moduli a lavorare insieme.

Figura 2. Un adattatore IPC coordina la comunicazione tra l'applicazione monolitica e un modulo di microservizi.

Nella figura 2, il modulo Z è il servizio candidato che vuoi estrarre dall'applicazione monolitica. I moduli X e Y dipendono dal modulo Z. I moduli microservizio X e Y utilizzano un adattatore IPC nell'applicazione monolitica per comunicare con il modulo Z tramite un'API REST.

Il documento successivo di questa serie, Comunicazione tra servizi in una configurazione di microservizi, descrive il pattern Strangler Fig e come decostruire un servizio dal monolito.

Gestire un database monolitico

In genere, le applicazioni monolitiche hanno i propri database monolitici. Uno dei principi di un'architettura basata su microservizi è avere un database per ogni microservizio. Pertanto, quando esegui la modernizzazione dell'applicazione monolitica in microservizi, devi suddividere il database monolitico in base ai confini del servizio che identifichi.

Per determinare dove suddividere un database monolitico, analizza innanzitutto le mappature del database. Nell'ambito dell'analisi di estrazione dei servizi , hai raccolto alcune informazioni sui microservizi che devi creare. Puoi utilizzare lo stesso approccio per analizzare l'utilizzo del database e mappare tabelle o altri oggetti del database ai nuovi microservizi. Strumenti come SchemaCrawler, SchemaSpy, e ERBuilder possono aiutarti a eseguire un'analisi di questo tipo. Le tabelle di mappatura e altri oggetti ti aiutano a comprendere l'accoppiamento tra gli oggetti del database che si estendono oltre i potenziali confini dei microservizi.

Tuttavia, la suddivisione di un database monolitico è complessa perché potrebbe non esserci una separazione chiara tra gli oggetti del database. Devi anche prendere in considerazione altri problemi, come la sincronizzazione dei dati, l'integrità delle transazioni, le unioni e la latenza. La sezione successiva descrive i pattern che possono aiutarti a rispondere a questi problemi quando dividi il database monolitico.

Tabelle di riferimento

Nelle applicazioni monolitiche, è normale che i moduli accedano ai dati richiesti da un altro modulo tramite una join SQL alla tabella dell'altro modulo. Il seguente diagramma utilizza l'esempio di applicazione di e-commerce precedente per mostrare questa procedura di accesso all'unione SQL:

Un modulo utilizza un join SQL per accedere ai dati di un altro modulo.

Figura 3. Un modulo unisce i dati alla tabella di un altro modulo.

Nella figura 3, per ottenere le informazioni sui prodotti, un modulo di ordine utilizza una product_id chiave esterna per unire un ordine alla tabella dei prodotti.

Tuttavia, se decostruisci i moduli come singoli servizi, ti consigliamo di non fare in modo che il servizio degli ordini chiami direttamente il database del servizio dei prodotti per eseguire un'operazione di join. Le sezioni seguenti descrivono le opzioni che puoi prendere in considerazione per separare gli oggetti del database.

Condividere i dati tramite un'API

Quando separi le funzionalità o i moduli di base in microservizi, solitamente utilizzi le API per condividere ed esporre i dati. Il servizio a cui si fa riferimento espone i dati come API di cui ha bisogno il servizio chiamante, come mostrato nel seguente diagramma:

I dati vengono esposti tramite un'API.

Figura 4. Un servizio utilizza una chiamata API per recuperare i dati da un altro servizio.

Nella figura 4, un modulo ordini utilizza una chiamata API per recuperare i dati da un modulo prodotto. Questa implementazione presenta evidenti problemi di prestazioni a causa di chiamate aggiuntive alla rete e al database. Tuttavia, la condivisione dei dati tramite un'API funziona bene quando le dimensioni dei dati sono limitate. Inoltre, se il servizio chiamato restituisce dati con una frequenza di variazione ben nota, puoi implementare una cache TTL locale sul chiamante per ridurre le richieste di rete al servizio chiamato.

Replica dei dati

Un altro modo per condividere i dati tra due microservizi separati è replicare i dati nel database del servizio dipendente. La replica dei dati è di sola lettura e può essere ricostruita in qualsiasi momento. Questo pattern consente al servizio di essere più coeso. Il seguente diagramma mostra come funziona la replica dei dati tra due microservizi:

I dati vengono replicati tra i microservizi.

Figura 5. I dati di un servizio vengono replicati in un database di servizi dipendente.

Nella figura 5, il database del servizio di prodotto viene replicato nel database del servizio di ordini. Questa implementazione consente al servizio degli ordini di ottenere i dati di prodotto senza dover effettuare chiamate ripetute al servizio.

Per creare la replica dei dati, puoi utilizzare tecniche come le viste materializzate, la tecnologia Change Data Capture (CDC) e le notifiche di eventi. I dati replicati sono in definitiva coerenti, ma può esserci un ritardo nella replica dei dati, pertanto esiste il risico di pubblicare dati non aggiornati.

Dati statici come configurazione

I dati statici, come i codici paese e le valute supportate, cambiano lentamente. Puoi iniettare questi dati statici come configurazione in un microservizio. I microservizi e i framework cloud moderni forniscono funzionalità per gestire questi dati di configurazione utilizzando server di configurazione, store key-value e vault. Puoi includere queste funzionalità in modo dichiarativo.

Dati mutabili condivisi

Le applicazioni monolitiche hanno un pattern comune noto come stato mutabile condiviso. In una configurazione dello stato mutabile condiviso, più moduli utilizzano una singola tabella, come mostrato nel seguente diagramma:

Una configurazione dello stato mutabile condiviso rende una singola tabella disponibile per più moduli.

Figura 6. Più moduli utilizzano una singola tabella.

Nella figura 6, le funzionalità di ordine, pagamento e spedizione dell'applicazione di e-commerce utilizzano la stessa tabella ShoppingStatus per mantenere aggiornato lo stato dell'ordine del cliente durante il percorso di acquisto.

Per eseguire la migrazione di un monolite dello stato mutabile condiviso, puoi sviluppare un microservizio ShoppingStatus separato per gestire la tabella di database ShoppingStatus. Questo microservizio espone API per gestire lo stato degli acquisti di un cliente, come mostrato nel seguente diagramma:

Le API sono esposte ad altri servizi.

Figura 7. Un microservizio espone le API a più altri servizi.

Nella figura 7, i microservizi di pagamento, ordine e spedizione utilizzano le API del microservizio ShoppingStatus. Se la tabella del database è strettamente correlata a uno dei servizi, ti consigliamo di spostare i dati in quel servizio. Puoi quindi esporre i dati tramite un'API per consentirne l'utilizzo da parte di altri servizi. Questa implementazione ti consente di assicurarti di non avere troppi servizi granulari che si richiamano spesso. Se dividi i servizi in modo errato, devi rivedere la definizione dei confini dei servizi.

Transazioni distribuite

Dopo aver isolato il servizio dal monolite, una transazione locale nel sistema monolitico originale potrebbe essere distribuita tra più servizi. Una transazione che interessa più servizi è considerata una transazione distribuita. Nell'applicazione monolitica, il sistema di database garantisce che le transazioni siano atomiche. Per gestire le transazioni tra vari servizi in un sistema basato su microservizi, devi creare un coordinatore delle transazioni globale. Il coordinatore delle transazioni gestisce il rollback, le azioni di compensazione e altre transazioni descritte nel documento successivo di questa serie, Comunicazione tra servizi in una configurazione di microservizi.

Coerenza dei dati

Le transazioni distribuite presentano la sfida di mantenere la coerenza dei dati tra i servizi. Tutti gli aggiornamenti devono essere eseguiti in modo atomico. In un'applicazione monolitica, le proprietà delle transazioni garantiscono che una query restituisca una vista coerente del database in base al relativo livello di isolamento.

Al contrario, prendiamo in considerazione una transazione in più passaggi in un'architettura basata su microservizi. Se una transazione di un servizio non va a buon fine, i dati devono essere riconciliati tramite il rollback dei passaggi andati a buon fine negli altri servizi. In caso contrario, la visione globale dei dati dell'applicazione non è coerente tra i servizi.

Può essere difficile determinare quando un passaggio che implementa la coerenza finale non è riuscito. Ad esempio, un passaggio potrebbe non avere esito negativo immediatamente, ma bloccarsi o scadere. Pertanto, potrebbe essere necessario implementare un qualche tipo di meccanismo di timeout. Se i dati duplicati non sono aggiornati quando il servizio chiamato vi accede, anche la memorizzazione nella cache o la replica dei dati tra i servizi per ridurre la latenza della rete può comportare dati incoerenti.

Il documento successivo della serie, Comunicazione tra servizi in una configurazione di microservizi, fornisce un esempio di pattern per gestire le transazioni distribuite tra microservizi.

Progettare la comunicazione tra servizi

In un'applicazione monolitica, i componenti (o i moduli dell'applicazione) si richiamano tra loro direttamente tramite chiamate di funzione. Al contrario, un'applicazione basata su microservizi è composta da più servizi che interagiscono tra loro sulla rete.

Quando progetti la comunicazione tra servizi, pensa innanzitutto a come si prevede che i servizi interagiscano tra loro. Le interazioni con i servizi possono essere una delle seguenti:

  • Interazione uno a uno: ogni richiesta del client viene elaborata da esattamente un servizio.
  • Interazioni uno a molti: ogni richiesta viene elaborata da più servizi.

Tieni inoltre presente se l'interazione è sincrona o asincrona:

  • Sincrona: il client si aspetta una risposta tempestiva dal servizio e potrebbe bloccarsi in attesa.
  • Asincrono: il client non si blocca in attesa di una risposta. La risposta, se presente, non viene necessariamente inviata immediatamente.

La tabella seguente mostra le combinazioni di stili di interazione:

Uno a uno One-to-many
Sincrona Richiesta e risposta: invia una richiesta a un servizio e attendi una risposta.
Asincrona Notifica: viene inviata una richiesta a un servizio, ma non è prevista o inviata alcuna risposta. Pubblica e iscriviti: il client pubblica un messaggio di notifica e zero o più servizi interessati lo consumano.
Richiesta e risposta asincrona: invia una richiesta a un servizio, che risponde in modo asincrono. Il client non si blocca. Pubblicazione e risposte asincrone: il client pubblica una richiesta e aspetta le risposte dei servizi interessati.

In genere, ogni servizio utilizza una combinazione di questi stili di interazione.

Implementare la comunicazione tra servizi

Per implementare la comunicazione tra servizi, puoi scegliere tra diverse tecnologie IPC. Ad esempio, i servizi possono utilizzare meccanismi di comunicazione basati su richiesta/risposta sincrona come REST, gRPC o Thrift basati su HTTP. In alternativa, i servizi possono utilizzare meccanismi di comunicazione asincroni basati su messaggi come AMQP o STOMP. Puoi anche scegliere tra diversi formati di messaggio. Ad esempio, i servizi possono utilizzare formati basati su testo e leggibili da persone, come JSON o XML. In alternativa, i servizi possono utilizzare un formato binario come Avro o Protocol Buffers.

La configurazione dei servizi in modo che chiamino direttamente altri servizi comporta un'elevata interdipendenza tra i servizi. Ti consigliamo invece di utilizzare la messaggistica o la comunicazione basata su eventi:

  • Messaggistica: quando implementi la messaggistica, non è più necessario che i servizi si chiamino direttamente tra loro. Tutti i servizi conoscono un broker di messaggi e inviano i messaggi a questo broker. Il broker di messaggi salva questi messaggi in una coda di messaggi. Altri servizi possono iscriversi ai messaggi che li interessano.
  • Comunicazione basata su eventi: quando implementi l'elaborazione basata su eventi, la comunicazione tra i servizi avviene tramite gli eventi prodotti dai singoli servizi. I singoli servizi scrivono i propri eventi in un broker di messaggi. I servizi possono ascoltare gli eventi di interesse. Questo pattern mantiene i servizi a basso accoppiamento perché gli eventi non includono payload.

In un'applicazione basata su microservizi, consigliamo di utilizzare la comunicazione asincrona tra servizi anziché la comunicazione sincrona. La richiesta-risposta è un pattern di architettura ben compreso, quindi la progettazione di un'API sincrona potrebbe sembrare più naturale rispetto alla progettazione di un sistema asincrono. La comunicazione asincrona tra i servizi può essere implementata utilizzando la messaggistica o la comunicazione basata su eventi. L'utilizzo della comunicazione asincrona offre i seguenti vantaggi:

  • Accoppiamento lasco: un modello asincrono suddivide l'interazione richiesta-risposta in due messaggi separati, uno per la richiesta e un altro per la risposta. Il consumatore di un servizio avvia il messaggio di richiesta e attende la risposta, mentre il fornitore di servizi attende i messaggi di richiesta a cui risponde con messaggi di risposta. Questa configurazione significa che il chiamante non deve attendere il messaggio di risposta.
  • Isolamento degli errori: il mittente può continuare a inviare messaggi anche se il consumatore a valle non riesce. Il consumatore recupera le richieste in sospeso ogni volta che si riprende. Questa funzionalità è particolarmente utile in un'architettura di microservizi, perché ogni servizio ha il proprio ciclo di vita. Tuttavia, le API sincrone richiedono che il servizio a valle sia disponibile o l'operazione non andrà a buon fine.
  • Tempi di risposta: un servizio a monte può rispondere più velocemente se non deve attendere i servizi a valle. Se esiste una catena di dipendenze dei servizi (il servizio A chiama B, che chiama C e così via), l'attesa delle chiamate sincrone può aggiungere quantità inaccettabili di latenza.
  • Controllo del flusso: una coda di messaggi funge da buffer, in modo che i destinatari possano elaborare i messaggi alla propria velocità.

Tuttavia, di seguito sono riportate alcune difficoltà nell'utilizzo efficace della messaggistica asincrona:

  • Latenza: se il broker di messaggi diventa un collo di bottiglia, la latenza end-to-end potrebbe aumentare.
  • Overhead in fase di sviluppo e test: a seconda della scelta dell'infrastruttura di messaggistica o di eventi, è possibile che si verifichino messaggi duplicati, il che rende difficile rendere le operazioni idempotenti. Inoltre, può essere difficile implementare e testare la semantica di richiesta-risposta utilizzando la messaggistica asincrona. Devi avere un modo per correlare i messaggi di richiesta e risposta.
  • Throughput: la gestione dei messaggi asincroni, che utilizza una coda centralizzata o un altro meccanismo, può diventare un collo di bottiglia nel sistema. I sistemi di backend, come code e consumatori a valle, devono essere scalabili in base ai requisiti di throughput del sistema.
  • Complica la gestione degli errori: in un sistema asincrono, chi chiama non sa se una richiesta è andata a buon fine o meno, pertanto la gestione degli errori deve essere gestita out of band. Questo tipo di sistema può rendere difficile implementare logiche come i tentativi di nuovo o i backoff esponenziali. La gestione degli errori è ulteriormente complicata se sono presenti più chiamate asincrone in catena che devono tutte riuscire o fallire.

Il documento successivo della serie, Comunicazione tra servizi in una configurazione di microservizi, fornisce un'implementazione di riferimento per risolvere alcune delle sfide menzionate nell'elenco precedente.

Passaggi successivi