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