Programmazione con Jolie

Una “terza via” tra monoliti e microservizidi

Un falso dilemma?

Monolite o sistema a microservizi? Questo è uno dei dubbi che assilla oggi tanti programmatori, ingegneri e architetti. Più si approfondisce il problema e più si comprende come sia bene fare tale scelta fin dall’inizio dello sviluppo e non demandarla a un secondo momento.

La maggior parte di noi è incuriosita e attratta dai microservizi e vorrebbe utilizzarli per le proprie applicazioni. Essi infatti possono essere facilmente utilizzati in un sistema cloud e possono essere scalati dipendentemente dall’utilizzo che se ne fa e indipendentemente dall’infrastruttura sottostante.

Tuttavia, il prezzo da pagare in termini di complessità, progettazione e mantenimento nel tempo sembra fare spesso propendere per la via più tradizionale rappresentata dal monolite. Anche se poi qualche mese dopo…

Il dubbio tra monolite o microservizi potrebbe quasi essere visto come una moderna versione del dilemma amletico “Essere o non essere…”.

Uscire dall’angolo

Ognuno di noi ha chiaramente maturato la sua opinione a riguardo e, probabilmente, nessuna di queste è quella definitiva. C’è chi è strenuamente a favore del monolite e chi invece pensa che tutto possa essere rappresentato con i microservizi. Il dibattito è sicuramente aperto e affatto chiuso.

Cosa succederebbe però se scoprissimo che questo dilemma in realtà non esiste? Cosa succederebbe se scoprissimo che possiamo iniziare a sviluppare un monolite e poi decidere successivamente di distribuirne le sue componenti? Esiste una possibilità simile? Ossia, esiste davvero la possibilità di passare da un sistema monolitico ad uno a microservizi e viceversa senza dover pagare il prezzo di pesanti interventi sul codice?

Figura 1 – Una terza via è possibile.

Figura 1 – Una terza via è possibile.

 

Il prezzo di trasformare le mele in pere

Dietro a ciascun dilemma si nasconde quasi sempre una rivoluzione nel modo di pensare che, una volta intrapresa, fa svanire l’illusione di cui il dilemma è la rappresentazione esteriore. Nel nostro caso quale sarebbe l’illusione? L’illusione è data dal fatto che apparentemente non è possibile costruire un monolite come composizione di componenti di tipo message passing.

Questa illusione è la conseguenza diretta del fatto che, tipicamente affrontiamo la programmazione partendo da un paradigma incentrato sulla computazione invece che da un paradigma incentrato sulla comunicazione. Quando programmiamo infatti, siamo molto concentrati sulle logiche del processo e consideriamo la pubblicazione delle componenti e la loro eventuale distribuzione come aspetto secondario.

Per meglio comprendere questo aspetto consideriamo il caso di una porzione di codice Java che deve essere trasformato in un servizio REST. In questo caso dobbiamo passare da una logica scritta utilizzando un paradigma a oggetti ad un componente basato su scambio di messaggi che nello specifico utilizzano il protocollo HTTP con formato JSON. Di fatto stiamo “trasformando le mele in pere”.

Infatti, all’interno del componente Java (le mele) siamo costretti a ragionare utilizzando lo schema di riferimento che ci offre la programmazione a oggetti, mentre all’esterno del componente (le pere) siamo costretti a ragionare utilizzando uno schema di riferimento orientato ai servizi.

Purtroppo questa trasformazione non avviene gratis ma ha un prezzo da pagare. In particolare, siamo costretti ad introdurre degli strati software appositi che si occupano di realizzarla.

Figura 2 – “Trasformare le mele in pere”, almeno in termini di programmazione.

Figura 2 – “Trasformare le mele in pere”, almeno in termini di programmazione.

 

Cambiare il nostro modo di pensare

Che cosa succede allora se iniziamo a programmare considerando la comunicazione prima della computazione?

Figura 3 – Comunicazione e computazione.

Figura 3 – Comunicazione e computazione.

 

Se accettiamo l’idea che questo sia possibile, dobbiamo partire considerando che tutte le componenti che programmiamo interagiscono tra loro utilizzando un paradigma a scambio di messaggi.

Dobbiamo cioè immaginare che quando programmiamo stiamo direttamente specificando componenti di quel tipo. E ciò deve valere per tutte le componenti che realizziamo indipendentemente dalla loro dimensione e dalla loro funzione. È un concetto che si deve applicare a tutto, perché tutto ciò che faremo saranno servizi e saranno intrinsecamente distribuiti.

Così come nel paradigma a oggetti tutto ciò che programmiamo sono classi e composizioni di classi, nel paradigma a servizi tutto ciò che realizziamo sono servizi o composizioni di servizi.

Dal design al deployment

Se partissimo da questo punto di vista, il nostro dilemma iniziale — se costruire un monolite oppure un sistema a microservizi — diventerebbe una questione di deployment, ossia diventerebbe il problema di capire se eseguire il sistema di servizi come un un’unica applicazione o come un sistema distribuito.

Figura 4 – Anche ricomporre servizi separati in un monolite non rappresenta un ostacolo.

Figura 4 – Anche ricomporre servizi separati in un monolite non rappresenta un ostacolo.

 

La scelta non impatterebbe sulla parte di design o di programmazione ma solamente sulla parte di esecuzione.

Un approccio linguistico

Sono quasi certo che ora avrete intuito dove voglio arrivare. E che state cercando di immaginare il modo in cui raggiungere questo obiettivo usando un qualche tipo di framework. Ma qui l’idea è proprio di non usare alcun tipo di framework dal momento che non vogliamo pagare il peso della loro introduzione.

L’obiettivo qui è quello di dimostrare che in realtà è possibile ottenere questo risultato passando a una nuova generazione di linguaggi di programmazione che cristallizzino i concetti base dell’elaborazione orientata ai servizi in costrutti sintattici immediati da utilizzare. Una generazione di linguaggi di programmazione che siano focalizzati sulla comunicazione invece che sulla computazione e che ci permettano di programmare applicazioni distribuite in modo nativo ed intuitivo.

 

Jolie

Jolie è il linguaggio di programmazione su cui lavoriamo dal 2006. Inizialmente è stato concepito come una formalizzazione degli standard SOA come WS-BPEL e WSDL, ma poi ci ha subito mostrato che si trattava di una tecnologia innovativa per affrontare la programmazione distribuita basata su servizi.

Il termine “microservices” temporalmente è nato qualche anno dopo il nostro primo rilascio e, quando abbiamo scoperto questa nuova onda che arrivava da oltreoceano, in essa abbiamo visto un sacco di concetti che avevamo già sperimentato in Jolie. L’unica differenza era che noi li avevamo all’interno di un unico linguaggio invece che in un mix di tecnologie.

Jolie ha una propria sintassi, e il motore attuale è un progetto open source sviluppato in Java. Purtroppo, non c’è abbastanza spazio qui per discutere tutte le caratteristiche del linguaggio: chi è interessato può approfodire visitando il nostro sito [1].

Per adesso, lasciate che vi mostri perché programmando in Jolie il dilemma se scegliere un monolite o un sistema a microservizi semplicemente non si pone.

 

Un esempio in Java

Prendiamo un esempio molto semplice: una classe Java che funge da calcolatrice e che fornisce un unico metodo per eseguire un’operazione aritmetica utilizzando la relativa implementazione a seconda del parametro passato durante l’invocazione.

Nell’esempio di codice qui sotto riportato c’è una classe chiamata Calculator che fornisce un metodo statico, calculate, il quale consente di eseguire una somma o una sottrazione a seconda del parametro passato.

public class Calculator {
  public enum CalcType {
    SUM,
    SUBT
  }
  public static int calculate( int x, int y, CalcType operation ) throws Exception {
    switch( operation ) {
      case SUM:
      return new Sum( x, y ).execute();
      case SUBT:
      return new Subt( x, y ).execute();
      default:
      throw new Exception("OperationNotSupported");
    }
  }
}

Le classi Sum and Subt implementano una classe astratta chiamata OperationAbstract riportata qui sotto:

public abstract class OperationAbstract {
  protected int X;
  protected int Y;
  public OperationAbstract(int x, int y) {
   X = x;
   Y = y;
  }
  abstract int execute();
}

Cosa succede ora se, per qualsiasi ragione, abbiamo bisogno di estrarre la classe Sum e distribuirla esternamente come servizio REST? Quale framework sceglieremo per effettuare questa trasformazione? Introdurremo un’interfaccia Swagger? E cosa succede all’esecuzione del metodo execute all’interno della classe Calculator? Dobbiamo trasformarla in una chiamata a servizio? Che tipo di modifiche dobbiamo apportare?

 

Un esempio in Jolie

Prima di tutto si noti che in Jolie tutto è un servizio, quindi i componenti incaricati di calcolare la somma e la sottrazione (Sum e Subt) devono anch’essi essere servizi. Ogni servizio necessita di un’interfaccia che descrive come poterli invocare. Qui di seguito riporto quella che entrambi devono implementare. È paragonabile a OperationAbstract in Java anche se ha una sintassi diversa.

type ExecuteRequest: void {
  .x: int
  .y: int
}
interface OperationServiceInterface {
  RequestResponse:
  execute( ExecuteRequest )( int )
}

L’interfaccia si chiama OperationServiceInterface e definisce un’operazione RequestResponse chiamata execute. Un’operazione di tipo RequestResponse è un’operazione che riceve un messaggio di richiesta e risponde con un messaggio di risposta. In questo caso, il tipo di messaggio request è definito da ExecuteRequest, che contiene due sottonodi: x e y. Entrambi sono interi. Dall’altro lato la risposta invece è rappresentata da un solo intero.

Il servizio Sum

Vediamo ora come si presenta un servizio che implementa questa interfaccia, per esempio il servizio Sum:

include "OperationServiceInterface.iol"
execution{ concurrent }
inputPort Sum {
  Location: "socket://localhost:9000"
  Protocol: sodep
  Interfaces: OperationServiceInterface
}
main {
  execute( request )( response ) {
   response = request.x + request.y
  }
}

La riga 1 indica che stiamo includendo il file in cui è definita l’interfaccia OperationServiceInterface. L’inclusione in Jolie è solo un modo per organizzare meglio il codice in file separati, all’atto pratico è una semplice macro che copia il codice contenuto nel file laddove è specificata la sua inclusione.

A riga 3, la direttiva execution{ concurrent } stabilisce che tutte le sessioni avviate devono essere eseguite concorrentemente, in modo che il servizio possa servire più chiamate contemporaneamente.

Nelle righe 5-9, viene dichiarato l’endpoint di ricezione (in Jolie si chiama inputPort) dove vengono ricevuti i messaggi per il servizio Sum. Si noti che una inputPort richiede una Location, che specifica dove il messaggio deve essere inviato, un Protocol, che specifica il modo in cui un messaggio viene inviato — sodep è un protocollo binario che si può usare tra i servizi Jolie — e un insieme di Interfaces disponibili a quell’endpoint.

Infine, lo scope main alle righe 11-15 definisce il codice da eseguire alla ricezione di un messaggio su una specifica operation. I parametri del messaggio in ingresso vengono memorizzati nella variabile request, mentre la risposta verrà prelevata dal contenuto della variabile response che sarà inviata automaticamente a conclusione del corpo della RequestResponse.

Il servixio Subt

Il servizio Subt per la sottrazione, è identico a Sum, a parte il corpo dell’operazione eseguita — una sottrazione invece che una somma — e della Location che sarà diversa in quanto i due servizi sono eseguiti indipendentemente.

Finalizzare l’architettura

Fatti i servizi Sum e Subt, ora abbiamo bisogno di un servizio che svolga lo stesso ruolo della classe Calculator e che finalizzi l’architettura come riportato in figura 5.

Figura 5 – Lo schema della nostra architettura.

Figura 5 – Lo schema della nostra architettura.

 

Di seguito, ecco il codice

include "OperationServiceInterface.iol"
type CalculateRequest: void {
  .x: int
  .y:int
  .op: string
}
interface CalculatorInterface {
  RequestResponse:
  calculate( CalculateRequest )( int )
  throws OperationNotSupported
}
execution{ concurrent }
outputPort Operation {
  Protocol: sodep
  Interfaces: OperationServiceInterface
}
inputPort Calculator {
  Location: "socket://localhost:8999"
  Protocol: sodep
  Interfaces: CalculatorInterface
}
main {
  calculate( request )( response ) {
   if ( request.op == "SUM" ) {
    Operation.location = "socket://localhost:9000"
   } else if ( request.op == "SUBT" ) {
    Operation.location = "socket://localhost:9001"
   } else {
    throw( OperationNotSupported )
   }
   ;
   undef( request.op );
   execute@Operation( request )( response )
  }
}

Si noti che alle righe 22-26, c’è la definizione di inputPort del servizio Calculator che è in ascolto sulla porta 8999 dove l’interfaccia CalculatorInterface è definita nelle righe 3-13. Diversamente dai servizi Sum e Subt, qui la richiesta contiene anche il sottonodo .op:string, che permette di specificare il tipo di operazione che si vuole venga svolta.

Da notare che in Jolie è anche possibile specificare un errore inviato come risposta, come è stato fatto alla riga 12, dove viene definito che l’operazione calculate può anche rispondere con l’errore OperationNotSupported.

Nelle righe 17-20, definiamo un endpoint target per il servizio Calculator per mezzo di una primitiva chiamata outputPort. Di solito una outputPort richiede gli stessi parametri di una inputPort (Location, Protocol, Interfaces), ma qui la Location è omessa perché viene dinamicamente assegnata a runtime dipendentemente dal valore del nodo op della richiesta.

Infatti, alle righe 31 e 33 viene assegnata la porta a una location diversa a seconda che si chiami il servizio Sum o il servizio Subt.

Alla riga 39 chiamiamo effettivamente il servizio di esecuzione dell’operazione aritmetica che ora è correttamente legata all’operazione di servizio selezionata. Nella riga 38 viene cancellato il nodo .op dalla richiesta per riutilizzarlo poi come messaggio di richiesta per il servizio di operation.

 

Un sistema distribuito

Come si può notare, tale sistema è intrinsecamente distribuito. Potremmo collocare i tre servizi in macchine diverse sulla stessa rete e tutto funzionerebbe senza problemi. Ma che dire del nostro dilemma iniziale?

Avevamo detto che il problema tra monolite e sistema a microservizi non sarebbe più esistito. Come si ottiene questo in Jolie? La soluzione è molto semplice: è sufficiente eseguire i servizi Calculator, Sum e Subt all’interno della stessa macchina virtuale come se si trattasse di un’unica applicazione.

In Jolie, possiamo raggiungere questo obiettivo incorporando i servizi Sum and Subt all’interno di Calculator e tutto funzionerà esattamente come abbiamo programmato.

Servizio Calculator… in versione monolite

Di seguito si può vedere come deve essere modificato il servizio Calculator per ottenere un monolite:

/* interface definition does not change */
execution{ concurrent }
outputPort Operation {
  Protocol: sodep
  Interfaces: OperationServiceInterface
}
embedded {
  Jolie:
  "sum.ol",
  "subt.ol"
}
inputPort Calculator {
  Location: "socket://localhost:8999"
  Protocol: sodep
  Interfaces: CalculatorInterface
}
main {
  calculate( request )( response ) {
    if ( request.op == "SUM" ) {
    Operation.location = "local://Sum"
  } else if ( request.op == "SUBT" ) {
    Operation.location = "local://Subt"
  } else {
    throw( OperationNotSupported )
  }
  ;
  undef( request.op );
  execute@Operation( request )( response )
  }
}

Le uniche differenze sono alle linee 10-14, 25 e 27. Nelle righe 10-14 viene usata una primitiva di Jolie chiamata embedding che permette di eseguire servizi esterni all’interno di quello padre. In questo caso, il servizio Calculator incorpora i servizi di destinazione Sum e Subt definiti rispettivamente nei file sum.ol e subt.ol.

Alle righe 25 e 27 vengono specificate le nuove locations per i servizi Sum e Subt che in questo caso fanno riferimento a locazioni di memoria invece che definire un indirizzo di rete. Vale la pena notare che, ovviamente, anche le loro inputPort devono essere modificate in modo coerente. Ad esempio, la inputPort del servizio Sum ora diventa:

inputPort Sum {
  Location: "local://Sum"
  Protocol: sodep
  Interfaces: OperationServiceInterface
}

 

La struttura del software non cambia

Il fatto più importante d notare qui è che la struttura del software non cambia a seconda che l’applicazione sia distribuita o un monolite. Tale risultato è ottenuto grazie all’approccio “linguistico”: abbiamo cristallizzato i concetti chiave della programmazione dei servizi in un insieme coerente di primitive.

Il software è intrinsecamente concepito per essere distribuito e tutte le sue componenti nascono come servizi. Inoltre, come dimostra l’esempio, lo sforzo da compiere in termini di linee di codice da programmare è dello stesso ordine di grandezza del caso Java. Quindi è facile ipotizzare come, nello stesso tempo in cui programmiamo la logica di un servizio, otteniamo anche il servizio stesso senza la necessità di effettuare ulteriori sforzi. Nella nostra esperienza i tempi di produzione vengono ridotti considerevolmente.

In Jolie l’unità di base del software programmabile è un servizio e il programmatore può solo creare servizi senza doversi preoccupare di renderli distribuibili… poiché lo sono già. Per questo motivo, utilizzando Jolie, decidere se costruire un monolite o meno non è una scelta tra la vita e la morte ma diventa una semplice scelta di deployment che può essere facilmente rinviata a una fase successiva.

Monoliti e sistemi distribuiti sono comunque diversi

Anche se in Jolie la differenza tra un monolite e un sistema distribuito è evanescente, non possiamo dimenticare che un sistema distribuito è intrinsecamente diverso da uno monolitico. È evidente, infatti, che quando si distribuisce un componente bisogna sempre tener conto degli errori legati alle comunicazioni — ad esempio, l’affidabilità della rete — che non sono presenti all’interno di un monolite dove tutte le interazioni sono operate in memoria.

In alcuni casi l’affidabilità è così importante che è necessario introdurre del codice aggiuntivo per gestire le eccezioni di comunicazione programmando opportune attività di recupero. Questo è vero anche in Jolie; tuttavia va comunque detto che in Jolie tale sforzo è mitigato ed estremamente facilitato grazie ai meccanismi di gestione degli errori forniti dal linguaggio stesso che, oltretutto, comprendono anche primitive specifiche per affrontare la terminazione e il recovery, molto preziosi in caso di scenari distribuiti complessi.

 

Conclusioni

Con questo articolo spero di aver dimostrato come esista la possibilità di concepire direttamente il software come una composizione distribuita di servizi senza dover introdurre framework specifici anche nel caso di un monolite.

Se consideriamo l’idea di sfruttare un linguaggio di programmazione dedicato per farlo, la nostra prospettiva potrebbe cambiare così tanto che il dilemma di scegliere un approccio monolitico o meno potrebbe scomparire. E questo succede solo perché il software è già distribuito e il monolite diventa solo un modo per distribuirlo.

Il progetto di creare un nuovo linguaggio di programmazione come Jolie è molto entusiasmante e ci ha dato molte soddisfazioni anche quando utilizzato in ambienti di produzione. Abbiamo sperimentato tutti i vantaggi della programmazione nativa di applicazioni distribuite e siamo molto interessati alle nuove possibilità che un tale approccio comporta ogni giorno. Se siete curiosi, potete trovare altre informazioni su Jolie sul suo sito ufficiale. Saremo lieti di ricevere i vostri commenti e suggerimenti!

 

Riferimenti

[1] Jolie, sito ufficiale

https://www.jolie-lang.org/

 

Condividi

Pubblicato nel numero
242 settembre 2018
Claudio Guidi è un ricercatore ed un imprenditore nell‘area dei microservices. Co-Leader del progetto Jolie (http://www.jolie-lang,org), ha conseguito il suo Ph.D. in computer science presso l‘Università di Bologna con una tesi sulla formalizzazione dei linguaggi per il Service Oriented Computing. Insieme a Fabrizio Montesi, l‘altro creatore di Jolie, ha fondato…
Ti potrebbe interessare anche