Refactoring di un monolite in microservizi

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

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

Questa serie è destinata agli sviluppatori di applicazioni e agli architetti che progettano e implementano la migrazione per 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 modernizzare le applicazioni, ti consigliamo di non refactoring tutto il codice contemporaneamente. Ti consigliamo invece di refactoring incrementale la tua applicazione monolitica. Quando esegui il refactoring incrementale di un'applicazione, crei gradualmente una nuova applicazione composta da microservizi e la esegui insieme alla tua applicazione monolitica. Questo approccio è noto anche come pattern Strangler Fig. Nel tempo, la quantità di funzionalità implementata dall'applicazione monolitica si riduce fino a quando non scompare completamente o non diventa un altro microservizio.

Per disaccoppiare le funzionalità da un monolite, devi estrarre attentamente i dati, la logica e i componenti rivolti agli utenti della funzionalità e reindirizzarli al nuovo servizio. È importante che tu abbia una buona comprensione dello spazio dei problemi prima di passare a quello delle soluzioni.

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é servizi più piccoli fino a quando non avrai capito a fondo il dominio.

La definizione dei confini dei servizi è un processo iterativo. Poiché si tratta di 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 tutti i dati contemporaneamente. Per dare priorità al disaccoppiamento dei servizi, valuta il costo rispetto ai vantaggi.
  • I servizi in un'architettura di microservizi sono organizzati in base a problemi aziendali e non tecnici.
  • Quando esegui la migrazione incrementale dei servizi, configura la comunicazione tra i servizi e il monolite per passare attraverso contratti API ben definiti.
  • I microservizi richiedono molta più automazione: pensa in anticipo all'integrazione continua (CI), al deployment continuo (CD), al logging centrale e al monitoraggio.

Le seguenti sezioni descrivono varie strategie per disaccoppiare i servizi ed eseguire la migrazione incrementale della tua applicazione monolitica.

Disaccoppia in base a un design basato sul dominio

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

La progettazione basata sul dominio (DDD) richiede una buona comprensione del dominio per il quale viene scritta l'applicazione. La conoscenza del dominio necessaria per creare l'applicazione risiede nelle persone che lo comprendono, ovvero gli esperti di dominio.

Puoi applicare l'approccio DDD in modo retroattivo a un'applicazione esistente nel seguente modo:

  1. Identifica un linguaggio onnipresente, un vocabolario comune condiviso da tutti gli stakeholder. In qualità di sviluppatore, è importante utilizzare nel codice termini comprensibili per una persona non tecnica. Quello che il tuo codice cerca di raggiungere deve rispecchiare i processi della tua azienda.
  2. Identifica i moduli pertinenti nell'applicazione monolitica, quindi applica il vocabolario comune a quei moduli.
  3. Definisci contesti limitati in cui applichi limiti espliciti ai moduli identificati con responsabilità chiaramente definite. I contesti limitati che identifichi sono candidati al refactoring in microservizi più piccoli.

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

I contesti limitati vengono applicati a un'applicazione.

Figura 1. Le funzionalità delle applicazioni sono separate in contesti limitati che migrano ai servizi.

Nella figura 1, le funzionalità dell'applicazione di e-commerce sono separate in contesti limitati e migrano ai servizi come segue:

  • Le funzionalità di gestione ed evasione degli ordini sono associate alle seguenti categorie:
    • La funzionalità di gestione degli ordini viene migrata al servizio degli ordini.
    • La funzionalità di gestione della distribuzione della logistica viene migrata al servizio di consegna.
    • La funzionalità dell'inventario viene migrata al servizio di inventario.
  • Le attività di contabilità sono legate a una singola categoria:
    • Le funzionalità di consumatori, venditori e terze parti sono legate e migrano al servizio dell'account.

Dai priorità ai servizi per la migrazione

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

  • Il tipo di dipendenza: dipendenze da dati o altri moduli.
  • La portata della dipendenza: in che modo una modifica nel modulo identificato potrebbe influire su altri moduli.

La migrazione di un modulo con dipendenze pesanti per i dati è in genere un'attività non banale. Se esegui prima la migrazione delle funzionalità ed esegui la migrazione dei relativi dati in un secondo momento, è possibile che tu stia leggendo e scrivendo temporaneamente i dati da più database. Pertanto, devi tenere conto delle difficoltà relative all'integrità dei dati e alla sincronizzazione.

Ti consigliamo di estrarre moduli con requisiti in termini di risorse diversi rispetto al resto del monolite. Ad esempio, se un modulo ha un database in memoria, puoi convertirlo in un servizio di cui eseguire il deployment su host con più memoria. Quando trasformi i moduli con particolari requisiti di risorse in servizi, puoi rendere la tua applicazione molto più facile da scalare.

Dal punto di vista delle operazioni, il refactoring di un modulo per renderlo un servizio proprio significa anche modificare le strutture dei team esistenti. Il modo migliore per avere una maggiore responsabilizzazione è offrire ai piccoli team che possiedono un intero servizio.

Altri fattori che possono influire sulla priorità dei servizi per la migrazione includono la criticità aziendale, la copertura dei test completi, il livello di sicurezza dell'applicazione e il coinvolgimento 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.

Estrai un servizio da un monolite

Dopo aver identificato il candidato ideale per il servizio, devi identificare un modo in cui i moduli di microservizi e monolitici possano coesistere. Un modo per gestire questa coesistenza è introdurre un adattatore di comunicazione tra processi (IPC), che può aiutare i moduli a collaborare. Nel tempo, il microservizio prende il carico ed elimina il componente monolitico. Questo processo incrementale riduce il rischio di passare dall'applicazione monolitica al nuovo microservizio, perché puoi rilevare in modo graduale i bug o i problemi di prestazioni.

Il seguente diagramma mostra come implementare l'approccio IPC:

Viene implementato un approccio IPC per aiutare i moduli a collaborare.

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 X e Y del microservizio utilizzano un adattatore IPC nell'applicazione monolitica per comunicare con il modulo Z tramite un'API REST.

Il prossimo documento di questa serie, La comunicazione tra servizi in una configurazione di microservizi, descrive il pattern Strangler Fig e come scomporre un servizio dal monolite.

Gestisci 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. Di conseguenza, quando modernizzi l'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 dell'estrazione dei servizi , hai raccolto alcuni insight sui microservizi che devi creare. Puoi utilizzare lo stesso approccio per analizzare l'utilizzo dei database e mappare tabelle o altri oggetti di database ai nuovi microservizi. Strumenti come SchemaCrawler, SchemaSpy ed ERBuilder possono aiutarti a eseguire questa analisi. La mappatura di tabelle e altri oggetti consente di comprendere l'accoppiamento tra oggetti di database che si estende oltre i potenziali confini dei microservizi.

Tuttavia, la suddivisione di un database monolitico è complessa perché potrebbe non esserci una chiara separazione tra gli oggetti del database. Devi inoltre considerare altri problemi, come la sincronizzazione dei dati, l'integrità transazionale, i join e la latenza. La prossima sezione descrive i pattern che possono aiutarti a rispondere a questi problemi quando suddividi il database monolitico.

Tabelle di riferimento

Nelle applicazioni monolitiche, è frequente che i moduli accedano ai dati richiesti di un modulo diverso tramite un join SQL alla tabella dell'altro modulo. Il seguente diagramma utilizza l'esempio di applicazione di e-commerce precedente per mostrare questo processo di accesso al join 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 informazioni sul prodotto, un modulo d'ordine utilizza una chiave esterna product_id per unire un ordine alla tabella dei prodotti.

Tuttavia, se scomponi i moduli come singoli servizi, ti consigliamo di non far sì che il servizio degli ordini non chiami direttamente il database del servizio del prodotto per eseguire un'operazione di join. Le seguenti sezioni descrivono le opzioni che puoi prendere in considerazione per segregare gli oggetti del database.

Condividere i dati tramite un'API

Quando suddividi le funzionalità o i moduli di base in microservizi, di solito utilizzi le API per condividere ed esporre i dati. Il servizio a cui viene fatto riferimento espone i dati come API necessari per il servizio di chiamata, come mostrato nel diagramma seguente:

I dati vengono esposti attraverso un'API.

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

Nella Figura 4, un modulo di ordine utilizza una chiamata API per ottenere dati da un modulo del prodotto. Questa implementazione presenta evidenti problemi di prestazioni dovuti a chiamate di rete e database aggiuntive. 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 modifica 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 dei servizi dipendenti. La replica dei dati è di sola lettura e può essere ricreata 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 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 è replicato nel database del servizio di ordine. Questa implementazione consente al servizio degli ordini di ottenere i dati di prodotto senza chiamate ripetute.

Per creare la replica dei dati, puoi utilizzare tecniche come le viste materializzate, il CDC (Change Data Capture) e le notifiche di eventi. I dati replicati sono alla fine coerenti, ma potrebbe esserci un ritardo nella replica dei dati, quindi c'è il rischio di fornire dati inattivi.

Dati statici come configurazione

I dati statici, come i codici paese e le valute supportate, vengono modificati lentamente. Puoi inserire questi dati statici come configurazione in un microservizio. I microservizi e i framework cloud moderni offrono funzionalità per gestire questi dati di configurazione utilizzando server di configurazione, archivi di coppie chiave-valore e cassaforte. Puoi includere queste funzionalità in modo dichiarativo.

Dati modificabili condivisi

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

Una configurazione dello stato modificabile condivisa rende disponibile una singola tabella a più moduli.

Figura 6. Più moduli utilizzano una singola tabella.

Nella Figura 6, le funzionalità relative a ordine, pagamento e spedizione dell'applicazione di e-commerce utilizzano la stessa tabella ShoppingStatus per mantenere lo stato dell'ordine del cliente lungo tutto il percorso di acquisto.

Per eseguire la migrazione di un monolite a stato modificabile condiviso, puoi sviluppare un microservizio ShoppingStatus separato per gestire la tabella di database ShoppingStatus. Questo microservizio espone le API per gestire lo stato di acquisto di un cliente, come mostrato nel diagramma seguente:

Le API sono esposte ad altri servizi.

Figura 7. Un microservizio espone le API a diversi 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 in modo che altri servizi utilizzino. Questa implementazione ti aiuta a evitare di avere troppi servizi granulari che si chiamano spesso tra loro. Se suddividi 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, la compensazione delle azioni e altre transazioni descritte nel prossimo documento di questa serie, Comunicazione tra servizi in una configurazione di microservizi.

Coerenza dei dati

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

Al contrario, puoi considerare una transazione a più passaggi in un'architettura basata su microservizi. Se una qualsiasi transazione di servizio non va a buon fine, i dati devono essere riconciliati eseguendo il rollback dei passaggi completati su altri servizi. In caso contrario, la visualizzazione globale dei dati dell'applicazione non è coerente tra i servizi.

Può essere difficile determinare quando un passaggio che implementa la coerenza finale ha avuto esito negativo. Ad esempio, un passaggio potrebbe non riuscire immediatamente, ma potrebbe essere bloccato o in timeout. Pertanto, potrebbe essere necessario implementare un meccanismo di timeout. Se i dati duplicati sono inattivi quando il servizio chiamato vi accede, anche i dati memorizzati nella cache o replica tra i servizi per ridurre la latenza di rete possono causare dati incoerenti.

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

Progetta la comunicazione tra i servizi

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

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

  • Interazione one-to-one: ogni richiesta del client viene elaborata da un solo servizio.
  • Interazioni one-to-many: ogni richiesta viene elaborata da più servizi.

Considera inoltre se l'interazione è sincrona o asincrona:

  • Sincrono: il client si aspetta una risposta tempestiva dal servizio e potrebbe bloccarsi durante l'attesa.
  • Asincrono: il client non blocca il blocco mentre attende una risposta. La risposta, se presente, non viene necessariamente inviata immediatamente.

La tabella seguente mostra le combinazioni di stili di interazione:

one-to-one One-to-many
Sincrono Richiesta e risposta: invia una richiesta a un servizio e attendi una risposta.
Asincrono Notifica: viene inviata una richiesta a un servizio, ma non è prevista alcuna risposta, né è stata inviata alcuna risposta. Pubblica e abbonati: il client pubblica un messaggio di notifica e nessuno o più servizi interessati utilizzano il messaggio.
Richiesta e risposta asincrona: invia una richiesta a un servizio, che risponde in modo asincrono. Il client non blocca. Pubblica e risposte asincrone: il client pubblica una richiesta e attende le risposte dei servizi interessati.

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

Implementare la comunicazione tra i servizi

Per implementare la comunicazione tra servizi, puoi scegliere tra diverse tecnologie IPC. Ad esempio, i servizi possono utilizzare meccanismi di comunicazione sincroni basati su richiesta e risposta 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 vari formati di messaggi. Ad esempio, i servizi possono utilizzare formati testuali leggibili come JSON o XML. In alternativa, i servizi possono usare un formato binario come Avro o buffer di protocollo.

La configurazione dei servizi per chiamare direttamente altri servizi comporta un accoppiamento elevato tra i servizi. Consigliamo invece di utilizzare messaggi o comunicazioni basate sugli eventi:

  • Messaggi: quando implementi la messaggistica, elimini la necessità che i servizi si chiamino direttamente. Tutti i servizi, invece, sono a conoscenza di un broker di messaggi e inviano i messaggi a quel broker. Il broker dei messaggi salva questi messaggi in una coda di messaggi. Gli altri servizi possono iscriversi ai messaggi di loro interesse.
  • Comunicazione basata sugli eventi: quando implementi l'elaborazione basata sugli eventi, la comunicazione tra i servizi avviene tramite 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 di microservizi, consigliamo di utilizzare una comunicazione asincrona tra i servizi, anziché la comunicazione sincrona. La richiesta-risposta è un modello architetturale ben compreso, pertanto la progettazione di un'API sincrona potrebbe sembrare più naturale della progettazione di un sistema asincrono. La comunicazione asincrona tra i servizi può essere implementata utilizzando messaggi o comunicazioni basate su eventi. L'uso della comunicazione asincrona offre i seguenti vantaggi:

  • Loose accoppiamento: un modello asincrono suddivide l'interazione richiesta-risposta in due messaggi separati, uno per la richiesta e un altro per la risposta. Il consumer di un servizio avvia il messaggio di richiesta e attende la risposta, mentre il provider di servizi attende i messaggi di richiesta a cui risponde con messaggi di risposta. Grazie a questa configurazione, il chiamante non deve attendere il messaggio di risposta.
  • Errore di isolamento: il mittente può continuare a inviare messaggi anche se il consumer downstream non va a buon fine. Il consumatore riprenderà gli ordini arretrati ogni volta che si recupera. Questa capacità è particolarmente utile in un'architettura con microservizi, poiché ogni servizio ha un proprio ciclo di vita. Tuttavia, le API sincrone richiedono che il servizio downstream sia disponibile, altrimenti l'operazione non va a buon fine.
  • Reattività: un servizio upstream può rispondere più velocemente se non attende i servizi downstream. Se è presente una catena di dipendenze di servizio (il servizio A chiama B, che chiama C e così via), l'attesa di chiamate sincrone può aggiungere quantità di latenza inaccettabili.
  • 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 sfide per utilizzare la messaggistica asincrona in modo efficace:

  • Latenza: se il broker dei messaggi diventa un collo di bottiglia, la latenza end-to-end potrebbe diventare elevata.
  • Overhead in fase di sviluppo e test: in base alla scelta dell'infrastruttura di messaggistica o di eventi, potrebbe esserci la possibilità di avere messaggi duplicati, il che rende difficile rendere le operazioni idempotenti. Può anche essere difficile da implementare e testare la semantica richiesta-risposta usando la messaggistica asincrona. Hai bisogno di un modo per correlare i messaggi di richiesta e risposta.
  • Velocità effettiva: la gestione asincrona dei messaggi, tramite una coda centrale o altri meccanismi, può diventare un collo di bottiglia nel sistema. I sistemi di backend, ad esempio code e consumer downstream, devono scalare per soddisfare i requisiti di velocità effettiva del sistema.
  • Complica la gestione degli errori: in un sistema asincrono, il chiamante non sa se una richiesta è riuscita o meno, quindi la gestione degli errori deve essere gestita al di fuori della banda. Questo tipo di sistema può rendere difficile l'implementazione di logiche come nuovi tentativi o backoff esponenziali. La gestione degli errori è ulteriormente complicata se sono presenti più chiamate asincrone concatenate che devono avere esito positivo o negativo.

Il prossimo documento 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