En esta ocasión me ha tocado a mi (por solicitud del “presi”, Abraham Otero) ponerme delante del teclado para escribir un artículo técnico, en lugar de para coordinar la evaluación de uno realizado por alguna otra persona, que es (como algunos saben) mi labor habitual.
No se trata de un manual, si no de una breve explicación sobre un problema muy concreto: ¿Cómo puedo compilar y ejecutar una clase o un trozo de código Java en memoria, sin tocar disco?
Vamos a verlo.
Desde Java 6.0 disponemos de un API estándar (y multiplataforma) que nos permite acceder al compilador del JDK para pasar de código fuente (archivos .java) a código compilado en bytecode (archivos .class) listos para ser cargados y ejecutados en la máquina virtual.
No voy a entrar en la importancia o la utilidad que a esto se le pueda dar, eso depende de cada cual...
El caso es que, aunque habitualmente esta compilación se hace sobre disco, es decir, el resultado va a ser un archivo .class almacenado en algún lugar del disco duro, esto no tiene por qué ser así. El framework de compilación es muy flexible en casi todos los aspectos involucrados en el proceso.
Uno de los muchos aspectos que se pueden alterar del proceso de compilación es la procedencia del código fuente, de este modo, podríamos hacer que el compilador lo leyese desde una base de datos, desde un puerto de comunicaciones, o como en nuestro caso, de una String. Igualmente podemos cambiar (entre otras muchas cosas, insisto) la ubicación del archivo compilado, por ejemplo, podemos decidir que el .class se guarde en una base de datos o que se envíe por red o simplemente que se quede en memoria, que es de lo que se va a tratar en este artículo.
Una vez compilado el fuente, los bytecodes que lo representan tienen que ser cargados (desde donde quiera que se encuentren) en la JVM, en su memoria de trabajo; como sabemos, esta tarea la realiza un cargador de clases. En nuestro caso, estos bytecodes tienen que pasar desde la zona de datos de memoria de la JVM a la zona de clases de la memoria de la JVM. Está claro que la JVM cuando le pide a un cargador de clases que le cargue una clase determinada, tiene que poner esta información en una zona de su memoria que le permite identificarla como “clase Java compilada”.
Aunque esto suene un poco lioso, si se piensa bien es muy simple: como sabemos las instrucciones de un programa, internamente, no se diferencian de los datos del programa salvo por quién los interpreta y cómo son interpretados: al fin y al cabo, todo se reduce a números más o menos grandes (8, 16, 32 y recientemente 64 bits) que residen en la RAM (si no estuvieran allí, alguien tiene que llevarlos allí).
Ahora vamos a ver los ficheros .java (sólo dos) que nos permiten realizar las tareas que he descrito en esta introducción.
En este fichero la clase principal es el compilador de código, a quien (por diseño del compilador) tenemos que proporcionarle una instancia de “SimpleJavaFileObject”, la cual contiene el código a compilar. Como en nuestro caso el código se encuentra en una cadena, creamos una clase auxiliar (“JavaObjectFromString”) que realiza la labor almacenar esta cadena y entregarla cuando le sea requerida.
Una vez que el compilador puede compilar código almacenado en una cadena, tenemos que instruirlo para que sea capaz de almacenar el resultado (los bytecodes) en memoria y no en disco, que sería su operación-por-defecto.
El compilador utiliza además la figura del “FileManager”, de un modo simplificado podemos decir que el compilador le entrega al “FileManager” nombre de un clase y los bytecodes de la clase una vez que la ha compilado. El “FileManager” que se utiliza por defecto, lee los .java desde disco y que almacena los bytecodes (.class) en disco.
Pero nosotros queremos un “FileManager” que almacene en memoria, por ello creamos la clase “JavaMemFileManager”, que internamente utiliza un HashMap cuyas claves (keys) son el nombre de la clase java y cuyas entradas (entry) son la secuencia de bytes que representan los bytecodes. Para hacerlo de un modo más elegante (y porque así se requiere), en lugar de hacer que las entradas sean byte[], haremos que sean instancias de la clase “ClassMemFileObject”, que esta vez sí, son básicamente byte[].
Con sólo estas cuatro mini-clases (una public y tres private) podemos compilar en memoria, vayamos ahora a la segunda parte del problema.
Esta clase es muy simple (como decimos en España “es una chorrada”).
Contiene un conjunto de métodos executeXXX(...) que permiten ejecutar código fuente Java, desde una clase completa hasta una simple línea de código. Estos métodos public acaban llamado al método private executeBlock( String , Object... ).
Esta clase se apoya (además de en el compilador) en una inner class llamada ByteArrayClassLoader, quien no es otra cosa más que un cargador de clases instruido para cargarlas desde memoria.
Además tenemos el notificador de errores más simple posible, y con esto cerramos el círculo y tenemos todas las piezas.
Como se puede ver en los ejemplos del método main de esta clase, se pueden hacer un montón de compilaciones, algunas bastante curiosas diría yo.
Estas clases son sólo un ejemplo de cómo utilizar el API de Java para compilar código “on the fly”, y su único objetivo es didáctico: no han sido concebidas para ser utilizadas en producción.
Declino toda responsabilidad sobre los resultados y efectos secundarios que éste código pueda producir y los desastres a los que arrastre al incauto que lo utilice en un proyecto real.
Así mismo estaré encantado de recibir sugerencias, mejoras y ejemplos de utilización.
Dónde encontrar el código fuente:
Se compone de:
InMemoryCompiler.java
InMemoryExecutor.java
Algunas páginas de interés:
Francisco Morero Peyrona
http://peyrona.com