Generazione di codice dinamico per la realizzazione di catene di

Generazione di codice dinamico per la
realizzazione di catene di servizi componibili
Silvia Cereda
matr. 0000260292
Sommario
Si parla di generazione dinamica di codice per contesti in cui il
codice eseguibile è generato a tempo d’esecuzione e non predisposto
in compilazione. I vantaggi immediati di tale tecnica risiedono nella
possibilità di ottimizzare il codice sulla base di dati disponibili solo
in corso d’esecuzione e non noti a priori e di apportare modifiche
all’applicazione senza interferire con la disponibilità di servizio. Il
livello di astrazione di linguaggi a codice intermedio come Java rende
pi‘u accessibile l’approccio a tale tecnica.
Si vuole esplorare uno scenario in cui servizi dislocati su più nodi all’interno di un container Java Enterprise Edition possono essere
composti a caldo in classi persistenti.
1
Introduzione
L’obiettivo dell’applicazione a cui fa riferimento questo documento è quello
di creare un ambiente di integrazione di servizi off-the-shelf nel contesto Java
EE, arricchito da un supporto per la generazione dinamica del codice di
invocazione.
Il sistema compone una serie ordinata di servizi registrati, creando una
classe Java a tempo d’esecuzione attraverso la generazione dinamica di bytecode. Tale classe, definita Chain (catena), si occupa di invocare la lista
di servizi scelti componendo ed adattando in maniera opportuna i parametri di ingresso e di uscita di ciascun servizio. L’integrazione delle catene
create nel contesto esecutivo avviene a caldo con l’appoggio del sistema di
“classloading” di Java. Le classi create vengono rese persistenti e caricate su
richiesta.
Il sistema è intrinsecamente dinamico: l’utente esprime delle richieste
relative all’utilizzo di servizi atomici che non hanno alcuna predisposizione
1
all’integrazione. Se da una parte una soluzione basata su “reflection” permetterebbe una elevata duttilità, dall’altra l’utilizzo di tecniche di generazione
dinamica di bytecode, sfruttate nella realizzazione del sistema, rende possibile un notevole guadagno in termini di efficienza a fronte di un maggior
impegno nello sviluppo del sistema.
2
Manipolazione dinamica del bytecode
La manipolazione dinamica di codice eseguibile può essere un requisito essenziale in sistemi aperti a variazioni, in cui entità attive devono essere generate
o modificate a tempo d’esecuzione. In genere la decisione di adottare un generatore dinamico di codice piuttosto che un compilatore è dettata da vincoli
di efficienza e dal fatto che la configurazione di un compilatore è macchinosa.
Predisporre una classe staticamente capace di comporre dei servizi è possibile utilizzando il supporto alla “reflection” di Java, chiamata in causa
perché il nome dei metodi o il numero/tipo di parametri non è conosciuto a
priori. L’uso della reflection però è poco adatto ad invocazioni frequenti e per
applicazioni con esigenze di efficienza; l’uso di codice generato, al contrario,
offre garanzie di invocazioni performanti.
La generazione del codice infatti non avviene sulla base di un codice di
più alto livello, bensı̀ attraverso l’invocazione statica o dinamica (a tempo di
esecuzione) delle API del framework utilizzato: è questa la differenza principale tra i due metodi, che si traduce in una grande differenza di prestazioni e
di efficienza. L’utilizzo della tecnica discussa rende inoltre possibile agire su
codice preesistente per modifiche, o per iniettare nuove funzionalità, creare
dinamicamente stub, analizzare le proprietà del codice.
Nel sistema sviluppato e discusso in questo documento la generazione dinamica del codice ha un ruolo centrale, dato il requisito di sviluppare catene
di servizi componibili massimizzando le prestazioni d’esecuzione a regime e
di limitare l’uso di risorse.
Sono disponibili una serie di framework che permettono di modicare il
bytecode delle classi Java: ne sono stati analizzati alcuni per capire quale si
potesse meglio adattare alle esigenze del progetto. Metro di valutazione per
giudicare tali framework è la loro facilità di utilizzo, la velocità di generazione
del codice di bytecode, e la memoria utilizzata.
2
2.1
BCEL
Byte Code Engineering Library[1] è una libreria Java che permette di analizzare, creare e manipolare i file .class. Le classi sono rappresentate da
oggetti che contengono tutte le informazioni simboliche della classe data:
metodi, campi e istruzioni bytecode.
Gli oggetti possono essere letti da file esistenti, trasformati a run-time
mediante un classloader e salvati ancora nel file, oppure creati da zero.
Le API di BCEL permettono di implementare le caratteristiche desiderate
ad un alto livello di astrazione senza dover maneggiare i dettagli interni,
propri del formato delle classi Java. Le API sono suddivise in due sezioni:
API statiche: Questa parte delle API di BCEL contiene le classi che descrivono i vincoli “statici” delle classi Java: tutti i componenti binari
e le strutture dati dichiarate nelle specifiche JVM[2] sono mappati in
classi contenute in questo package.
La classe principale è JavaClass: rappresenta la struttura dati di alto
livello, ovvero un file .class, e include tutte le strutture dati, costanti,
campi, metodi e comandi contenuti in un file .class. Supporta inoltre
il pattern Visitor: è quindi possibile scrivere dei visitor personalizzati
per analizzare il contenuto delle classi.
API generiche: Questo package permette di creare e trasformare dinamicamente i metodi o gli oggetti JavaClass: permette infatti di creare
una classe dal nulla o di leggere classi esistenti e di modificarle.
La classe centrale in queste API è ClassGen: essa permette di caricare
una classe esistente o di crearne una nuova e di aggiungere, cercare e
rimuovere dinamicamente attributi e metodi.
2.2
Serp
Serp[3] è un framework opensource per la manipolazione di bytecode Java,
il cui obiettivo principale è quello di ottenere la massima potenza per la
modifica del bytecode riducendo i costi.
Tra le peculiarità di questo progetto troviamo la facilità di utilizzo e la
“potenza” del framework in quanto nonostante le API siano di alto livello,
non viene pregiudicata la possibilità di lavorare anche di accedere ai dettagli
di basso livello e di poterli manipolare. Tuttavia Serp presenta lentezza nella
generazione delle classi e richiede un grande dispendio in termini di memoria:
queste condizioni non rendono il framework adatto alle esigenze di progetto.
3
2.3
ASM
ASM[4] è un framework per la manipolazione di bytecode Java. Può essere usato per generare dinamicamente classi stub o altre classi proxy direttamente in forma binaria, o per modificare classi esistenti a tempo di
caricamento.
L’elaborazione delle classi è sequenziale, basata su eventi e sull’uso del
pattern Visitor e non mantiene strutture persistenti in memoria.
Le API fornite si basano su un meccanismo di elaborazione a catena in
cui possono essere interconnessi componenti di tre tipi:
ClassReader si occupano del “parsing” del bytecode;
ClassAdapter filtrano l’output di una sorgente dato in ingresso;
ClassWriter scrivono le classi Java.
Si consideri il caso di una modifica al codice di una classe: il ClassReader effettua il parsing della classe fornita in ingresso sottoforma di array di
byte e genera in maniera sequenziale un evento in corrispondenza di ogni elemento costitutivo della classe (signature della classe, dei metodi e istruzioni
elementari), il ClassAdapter connesso in uscita filtra gli eventi, in modo da
modificare il corrispondente elemento costitutivo della classe, ed li inoltra al
ClassWriter che genera la sequenza di byte della classe modificata. Nel caso
di generazione di una classe ex novo invece, può essere utilizzato soltanto un
oggetto di tipo ClassWriter.
La Tabella 1 permette di effettuare un confronto tra le performance dei
tre framework analizzati misurando il tempo richiesto da ognuno dei framework per estrarre un elevato numero di classi da un file .jar avvalendosi
dei seguenti mezzi[5]:
(A) utilizzando un ClassLoader standard;
(B) deserializzando e ri-serializzando ogni classe prima di compilarla;
(C) dopo aver eseguito il passo (B), ricalcolando la massima dimensione
dello stack per ogni metodo;
(D) aggiungendo ad ogni classe, prima di ri-serializzare e caricare ogni classe, un contatore e le istruzioni per incrementare tale contatore all’inizio
di ogni metodo.
4
Framework
ASM
BCEL
Serp
Caso A Caso B
1.98s
3.22s
1.98s
16.6s
1.98s
24.3s
Caso C
3.29s
19.2s
34.6s
Caso D
3.26s
16.8s
28.0s
Tabella 1: Confronto tra le performance di ASM, BCEL e Serp: misura del
tempo di caricamento per l’estrazione di classi da un archivio jar
Benché ASM offra funzionalità simili a BCEL e Serp, le analisi sulle performance mostrano che quest’ultimo è un tool molto leggero e veloce: depone
a suo favore il fatto di non mantenere in memoria le strutture persistenti e
inoltre l’overhead del tempo di caricamento di una classe trasformata è dell’ordine di circa il 60%, mentre è di oltre il 700% con BCEL e di più di 1100%
con Serp.
La leggerezza e la velocità di ASM impongono questa come la scelta più
adeguata per il progetto.
3
Architettura
Il sistema considera un insieme di servizi atomici invocabili come metodi di
componenti EJB, disponibili anche in remoto, che possono essere interconnessi in sequenza per creare una catena di servizi, detta Chain.
Ogni servizio atomico è inserito previa registrazione: vengono richiesti
nome e tipo di servizio e una sua breve descrizione, nome JNDI dell’EJB che
offre il servizio, nodo su cui risiede il servizio, porta su cui fare la lookup
e una lista ordinata di parametri richiesti per adempiere al servizio: queste
informazioni sono salvate in modo persistente all’interno di una struttura
chiamata Brick. Per terminare la registrazione del servizio è inoltre necessario
trasmettere l’interfaccia remota del servizio, in modo da renderne possibile
l’invocazione del container da remoto.
L’utente che richiede la formazione di un determinato servizio come cascata di Brick invia al sistema la lista dei servizi atomici (di cui vengono
passati gli identificativi) unitamente alla lista ordinata dei parametri per
questi ultimi.
Ogni catena è univocamente individuata dalla sequenza ordinata degli
identificativi dei Brick che ne fanno parte: compito del Coordinator, avvalendosi di componenti ServiceLocator, è quello di interrogare il database dei
servizi, ricostruire il nome della catena e reperirla se esiste, o viceversa delegare ad un ASMBlackSmith la creazione e la gestione della persistenza del
5
nuovo Chain. Questa operazione è trasparente all’utente sia per quanto riguarda il processo di ricerca dei Chain esistenti, sia per il ritardo causato
dalla creazione e dal caricamento di una nuova catena.
3.1
Generazione del bytecode per le catene
Come dichiarato dalle specifiche JVM[2], il bytecode di una classe Java è
formato da una parte “statica” per l’inizializzazione del sistema e da una
parte “dinamica” dipendente dai metodi e dalle proprietà dei servizi.
L’ASMBlackSmith si comporta allo stesso modo per la generazione delle
catene all’interno è sviluppato in questo senso: il metodo
ArrayList<Byte> build(ArrayList<Brick> bricks, String chainName)
genera rispettivamente la parte statica del bytecode invocando il metodo
void createStaticPart(String chainName)
che si preoccupa di creare la “signature” della classe e il costruttore, e la
parte dinamica attraverso il metodo
void createExecute()
che crea il codice relativo al resto della classe e ai brick che compongono la
nuova catena e stampa il bytecode ottenuto in un file mediante un oggetto
di tipo ClassWriter (cw). L’output del metodo è un array di byte che rappresenta la classe generata e deve essere caricata nell’ambiente d’esecuzione
(mediante il ClassLoader) per venire successivamente utilizzata.
Nella fase di creazione statica vegono anche inserite due annotazioni
av0 = cw.visitAnnotation("Ljavax/ejb/Stateless;", true);
av0 = cw.visitAnnotation("Ljavax/ejb/Remote;", true);
che permettono che gli oggetti della classe generata possano essere utilizzati come oggetti enterprise, in particolare queste due annotazioni sono inserite affinché JBoss proceda al deploy dei servizi di supporto stateless al
bean (@Stateless) e che predisponga i meccanismi di invocazione remota
(@Remote).
Le classi generate estendono la classe Chain (che a sua volta realizza
l’interfaccia IChain) che a sua volta implementa la parte comune a tutte le
catene e sovrascrivono il metodo
Object execute (ArrayList actualParam)
6
che viene creato con il metodo createExecute() in formato bytecode tramite
l’istruzione
mv.visitMethodInsn(INVOKESPECIAL, "it/retils/asmBlackSmith/Chain",
"execute", "(Ljava/util/ArrayList;)Ljava/lang/Object;");
Il comando INVOKESPECIAL infatti permette di invocare i metodi d’istanza della classe selezionata: in questo caso il comando sopracitato permette di
invocare il metodo execute, all’interno della classe Chain che appunto richiede
in input un ArrayList e restituisce come parametro un oggetto Object.
Il metodo execute esegue la catena dei servizi, e actualParam contiene
i parametri dell’invocazione. Al fine di agevolare il passaggio dei parametri
tra servizi consecutivi, ovvero per inserire come input di servizio l’output del
servizio che lo precede, è stato definito il parametro %i che per convenzione
collega rispettivamente l’uscita e l’ingresso di due servizi consecutivi. In
uscita il metodo execute restituisce il risultato di tale catena; si noti che
il parametro “Object” di ritorno permette la massima generalizzazione dei
servizi sul tipo di ritorno.
Figura 1: Diagramma di sequenza della richiesta di una catena mai generata
e conseguente creazione della catena stessa (metodo askChain).
In figura 1 è mostrato un diagramma di sequenza che mostra il funzionamento del metodo askChain del Coordinator nel caso in cui il ServiceLocator
non abbia trovato una catena preesistente, e quindi la catena venga costruita
ex-novo. Si ricordi che nel caso in cui il ServiceLocator trovi una catena
7
già esistente, non viene eseguita l’operazione 3, ma il Coordinator reperisce
direttamente la catena esistente mediante l’operazione 4 (loadClass()).
Per creare dinamicamente il codice relativo a ognuno dei Brick richiesti
nella catena, all’interno del metodo createExecute() vi è un ciclo che ripete,
per ogni Brick della catena, il metodo createBrick, il cui compito è quello
di creare il bytecode relativo ad ogni servizio da inserire nella della catena.
Il metodo reperisce i dati del Brick che gli serviranno per effettuare la
lookup; in seguito si provvede ad ottenere i parametri del servizio: è infatti
necessario anche controllare e garantire, nel caso in cui uno dei parametri sia
%i, che il parametro diventi il risultato del servizio precedente.
Ottenuti ed esplicitati tutti i parametri richiesti dal servizio, si provvede
ad invocare il metodo del servizio attraverso la chiamata
mv.visitMethodInsn(INVOKEINTERFACE, thisinterfacefullyqualifiedname,
serviceName, desc);
che provvede ad invocare il metodo sull’interfaccia definita dal parametro
thisinterfacefullyqualifiedname. Il nome del metodo è il nome del Brick
ed è identificato in modo univoco dal serviceName e suoi parametri di input
e output sono descritti da desc che vengono presi dalla cima dello stack di
esecuzione del metodo.
Al termine dell’invocazione il risultato del servizio viene salvato tramite
il comando
mv.visitVarInsn(ASTORE, 3);
In questo modo si rende possibile restituire il risultato di tale servizio o come
risultato finale, o come parametro di ingresso per un servizio successivo,
identificato da %i.
4
Interfaccia utente
E’ stata realizzata un’interfaccia Web che permette di inserire i parametri
richiesti (compresa l’interfaccia del servizio) e di effettuare la registrazione dei
nuovi servizi. Questo tipo di registrazione è asincrono, e avviene attraverso
l’invio di un messaggio. E’ tuttavia possibile impostare una registrazione
sincrona dei servizi atomici. Il prototipo consente, in modo non mediato da
meccanismi di sicurezza (sviluppabili in futuro), di inviare i file di interfaccia
del servizio necessari.
8
Gli scenari di utilizzo dell’applicazione sviluppata non sono limitati alle
specifiche richieste che hanno guidato il progetto: oltre a comporre dei servizi in catene per ottenere un risultato immediato dall’esecuzione, le catene
composte possono essere restituite come EJB all’interno di un file .jar (comprensivo di interfacce e classi collegate) contenente il necessario per effettuare
il deploy in un container JBoss diverso. In questi modo è possibile utilizzare
la catena come servizio elementare a cui è possibile aggiungere altri servizi
in sequenza per modificarne le funzionalità. L’applicazione si presta dunque
al supporto di servizi da estendere a caldo (sviluppo di “catene di catene”).
I servizi composti hanno poche limitazioni: devono essere dei “Session
Bean” e possono essere localizzati ovunque. L’interrogazione supportata è
di tipo REV (Remote EValuation). Possono essere gestiti non solo servizi di
trasformazione, ma anche servizi di “input” che estraggano informazioni da
un database o dal filesystem locale, oppure servizi di “output” per disporre
la presentazione del risultato ottenuto.
La flessibilità fornita consente anche di utilizzare l’applicazione come
supporto al test distribuito di applicazioni.
5
Conclusioni e Sviluppi Futuri
Il prototipo sviluppato sfrutta con successo meccanismi di generazione dinamica di codice per comporre servizi integrati nel contesto di Java Enterprise
Edition a tempo di esecuzione. La naturale prosecuzione del lavoro andrebbe
ad arricchire il servizio di registrazione nella direzione di evitare la compilazione di campi informativi del servizio. E’ possibile infatti ispezionare con
ASM un’interfaccia fornita dall’utente per ricavare le informazioni necessarie per l’invocazione, avvalendosi eventualmente di annotazioni. Un’ulteriore
estensione essenziale è la realizzazione di un supporto di sicurezza alle varie
fasi di utilizzo. Altri sviluppi potrebbero essere indirizzati alla creazione di
un meta-linguaggio di composizione dei servizi sulla scia di analoghi strumenti adottati nel mondo dei Web Service come BPEL4WS o DecSerFlow.
Attraverso l’integrazione di nuovi servizi orientati ad un utilizzo più pratico
e ad estensioni funzionali si può arrivare ad un sistema pi‘u orientato a scopi
commerciali. Il prototipo infine costituisce un buon punto di partenza per lo
sviluppo di un framework vero e proprio di composizione di codice.
9
Riferimenti bibliografici
[1] BCEL homepage - http://jakarta.apache.org/bcel/
[2] The Java Virtual Machine Specification - Chapter 4: The class File
Format
http://java.sun.com/docs/books/jvms/second edition/html/ClassFile.doc.html
[3] Serp Homepage - http://serp.sourceforge.net
[4] ASM Homepage - http://asm.objectweb.org/
[5] E. Bruneton, R. Lenglet, T. Coupaye - ASM: a code manipulation tool
to implement adaptable systems
http://asm.objectweb.org/current/asm-eng.pdf
10