Jolie è un linguaggio di programmazione nativo per microservizi che permette di superare il dilemma tra monoliti e sistemi distribuiti. In questo articolo vedremo come, grazie a un approccio linguistico, sia possibile programmare servizi che possono essere eseguiti sia come sistema distribuito sia come monolite senza modificare il codice, semplicemente cambiando la configurazione di deployment.
Per comprendere il funzionamento, partiamo da un esempio concreto: un servizio Calculator che espone un'operazione aritmetica e delega l'esecuzione a due servizi specializzati (Sum e Subt) a seconda dell'operatore richiesto.
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 nel codice qui sopra c'è la definizione di inputPort del servizio Calculator che è in ascolto sulla porta 8999. 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 nella definizione di CalculatorInterface, dove viene definito che l'operazione calculate può anche rispondere con l'errore OperationNotSupported. Nell'outputPort definiamo un endpoint target per il servizio Calculator. 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, nel main viene assegnata la porta a una location diversa a seconda che si chiami il servizio Sum o il servizio Subt. Viene poi chiamato effettivamente il servizio di esecuzione dell'operazione aritmetica che ora è correttamente legata all'operazione di servizio selezionata. Il nodo .op viene cancellato 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:
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 l'uso della 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. 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 da 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/