Javassist: manipulando el bytecode de una aplicación Java.

2
3153

En este tutorial vamos a ver cómo podemos manipular el bytecode de una aplicación Java con el soporte de la librería javassist, también dentro del ciclo de vida de una aplicación Spring Boot.

0. Índice de contenidos.


1. Introducción.

Javassist es una librería que facilita la modificación del bytecode de una aplicación java. Para ello hace uso de reflexión,
permitiendo modificar la implementación de una clase en tiempo de ejecución.

A diferencia de otras librerías del mismo estilo, javassist proporciona dos niveles de api:

  • a nivel de código fuente: que permite editar una clase sin tener conocimientos específicos del bytecode de java, solo
    necesitas conocimientos del API java, pudiendo insertar bytecode como código fuente normal puesto que javassist lo compila
    al vuelo,
  • y a nivel de bytecode: que permite editar la clase ya compilada.

Frameworks como Spring e Hibernate han usado librerías como javassist o cglib para generar proxies dinámicos desde sus
inicios y desde hace tiempo la propia JDK incluye soporte para la generación de proxies dinámicos. Este tipo de librerías son
las que permiten que tanto estos frameworks, como las arquitecturas que se basan en los mismos, puedan hacer su parte de magia,
facilitando la vida a los programadores:

  • abriendo una transacción con base de datos cuando un método está anotado con @Transactional o
  • accediendo a base de datos con la simple invocación a un método getUsers de una entidad, si está marcado como lazy.

En este tutorial vamos a ver un ejemplo muy sencillo de su uso que nos va a permitir modificar en tiempo de ejecución el
comportamiento de un método de una clase. Después veremos también cómo hacerlo dentro del ciclo de vida de una aplicación
Spring Boot.

Estando bajo el paraguas de un framework que soporte Spring o CDI siempre tendremos otras alternativas a nivel de AOP
que nos permitirán no tener que llegar a tan bajo nivel. E incluso sin dicho soporte, siempre podríamos hacer uso de AspectJ,
modificando el código de nuestra clase para manipularla en tiempo de compilación; pero ¿y si lo que queremos manipular no está bajo nuestro contexto o
no lo hemos compilado nosotros?…


2. Entorno.

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2.5 GHz Intel Core i7, 16GB DDR3).
  • Sistema Operativo: Mac OS Sierra 10.12.5
  • Oracle Java: 1.8.0_25
  • Apache Maven 3.2.3
  • Javassist 3.21.0-GA
  • Spring Boot 1.5.6.RELEASE


3. Manipulando el código de un método en tiempo de carga.

Vamos a ver un ejemplo muy sencillo, un método que recibe una cadena y devuelve un resultado concatenando el parámetro de entrada:

package com.sanchezjm.tuto.javassist;

public class HelloWorld {
	
	public String sayHi(String name){
		return "Hi " + name+ "!";
	}
	
}

A continuación vamos a hacer un test, también muy sencillo:

package com.sanchezjm.tuto.javassist;

import static org.junit.Assert.assertEquals;

import java.io.IOException;

import org.junit.Test;

public class HelloWorldTest {
	
	@Test
	public void mustSayHi(){
		HelloWorld helloWorld = new HelloWorld();	
		String text = helloWorld.sayHi("Tip");
		assertEquals("Hi Tip!", text);
	}
	
	@Test(expected=IllegalArgumentException.class)
	public void mustThrowsIllegalArgumentExceptionIfTheName(){
		HelloWorld helloWorld = new HelloWorld();
		helloWorld.sayHi("Coll");
		fail("must throw IllegalArgumentException");
	}
	
}

En este punto el primer método del test pasa pero el segundo no,
el método no devuelve nunca una excepción, siempre saluda del mismo modo al argumento de entrada.

A continuación vamos a manipular el bycode del método para lanzar una excepción concreta
en función del parámetro de entrada, en un método anotado con beforeClass

package com.sanchezjm.tuto.javassist;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;

import java.io.IOException;

import org.junit.BeforeClass;
import org.junit.Test;

import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.NotFoundException;

public class HelloWorldTest {

	@BeforeClass
	public static void setUp() throws NotFoundException, CannotCompileException, IOException{
		final ClassPool pool = ClassPool.getDefault();
		final CtClass cc = pool.get("com.sanchezjm.tuto.javassist.HelloWorld");
		final CtMethod m = cc.getDeclaredMethod("sayHi");
		m.insertBefore("System.out.println(\" param: \" + $1); if (\"Coll\".equals($1)){ throw new IllegalArgumentException(); }");
		cc.toClass();
	}
	
	@Test
	public void mustSayHi(){
		HelloWorld helloWorld = new HelloWorld();	
		String text = helloWorld.sayHi("Tip");
		assertEquals("Hi Jon!", text);
	}
	
	@Test(expected=IllegalArgumentException.class)
	public void mustThrowsIllegalArgumentExceptionIfTheName(){
		HelloWorld helloWorld = new HelloWorld();
		helloWorld.sayHi("Coll");
		fail("must throw IllegalArgumentException");
	}
	
}

Además de lanzar la excepción hemos lanzado a consola el parámetro de entrada; ahora los dos tests pasan.

Para manipular el bycode debemos recuperar la clase del pool del classloader en una instancia de una clase CtClass
(compile-time class), acceder al método en cuestión, manipular el código e invocar al método toClass para convertir
la instancia de la clase CtClass a una clase compilada de nuevo.

$1 es un identificador para hacer referencia al primer argumento de un método.

El API es mucho más extesa que este simple ejemplo que hemos hecho, podemos modificar la interfaz de una clase, añadir un constructor, añadir un método, un atributo,…


4. Manipulando el código dentro de una aplicación Spring Boot.

Si en el ejemplo anterior alguien hubiese hecho cualquier tipo de referencia a la clase HelloWorld con anterioridad
a los métodos de tests, el compilador de javassist indicaría que la clase ya ha sido cargada y el ciclo de vida de la
manipulación del código sería más complejo, puesto que una vez cargada la clase queda marcada como «congelada».

Si la clase que necesitamos manipular es cargada dentro de ciclo de vida del arranque de una aplicación Spring Boot
a continuación mostramos un ejemplo basado en la configuración de un listener de arranque.

package com.sanchezjm.tuto.javassist.listener;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringApplicationRunListener;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.ConfigurableEnvironment;

import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.LoaderClassPath;
import javassist.NotFoundException;

public class JavassistRunListener implements SpringApplicationRunListener {

	private static boolean isExecuted; // because can be executed twice, in different instances, is static

	private final SpringApplication application;

	public JavassistRunListener(SpringApplication application, String[] args) {
		this.application = application;
	}
    
	public void markAsIllegalArgumentException() {
		try {
			final ClassPool pool = ClassPool.getDefault();
			pool.appendClassPath(new LoaderClassPath(application.getClassLoader()));
			final CtClass cc = pool.get("com.sanchezjm.tuto.javassist.HelloWorld");
			final CtMethod m = cc.getDeclaredMethod("sayHi");
			cc.defrost();
			m.insertBefore("if (\"Coll\".equals($1)){ throw new IllegalArgumentException(); }");
			cc.toClass();
			cc.detach();
		} catch (NotFoundException | CannotCompileException e) {
			throw new IllegalStateException(e);
		}
	}
    
	public void starting() {
		if (isExecuted){
			return;
		}
		markAsIllegalArgumentException();
		isExecuted = true;
	}
	
	public void contextLoaded(ConfigurableApplicationContext arg0) {
		// do nothing...		
	}

	public void contextPrepared(ConfigurableApplicationContext arg0) {
		// do nothing...		
	}

	public void environmentPrepared(ConfigurableEnvironment arg0) {
		// do nothing...		
	}

	public void finished(ConfigurableApplicationContext arg0, Throwable arg1) {
		// do nothing...		
	}
}

La clave aquí está en la recuperación del classloader de spring boot y su asignación al class path de javassist.
Sin esa asignación, fuera del contexto de tests, ejecutándose como una aplicación Spring Boot,
no encontraría la clase a manipular.

El listener hay que declararlo en un fichero spring.factories dentro de src/main/resources/META-INF/

org.springframework.boot.SpringApplicationRunListener=\
com.sanchezjm.tuto.javassist.listener.JavassistRunListener

Y, a continuación, un test de integración para comprobar que todo funciona bajo un contexto Spring Boot:

package com.sanchezjm.tuto.javassist;

import static org.junit.Assert.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RunWith(SpringJUnit4ClassRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = HelloWorldIntegrationTest.Application.class, webEnvironment = WebEnvironment.RANDOM_PORT)
public class HelloWorldIntegrationTest {
	
	@Autowired
	private MockMvc mockMvc;
	
	@Configuration
	@EnableAutoConfiguration
	@RestController
	protected static class Application {

		private HelloWorld helloWorld = new HelloWorld();
		@GetMapping("/hi/{name}")
		public String sayHi(@PathVariable("name") String name) {
			return helloWorld.sayHi(name);
		}
	}
	
	@Test
	public void mustSayHi() throws Exception{
		final MvcResult result = mockMvc.perform(get("/hi/Tip")).andExpect(status().isOk()).andReturn();
		final String content = result.getResponse().getContentAsString();
		assertEquals("Hi Tip!", content);

	}
	
	public void mustThrowsIllegalArgumentExceptionIfTheName() throws Exception{
		mockMvc.perform(get("/hi/Coll")).andExpect(status().is5xxServerError());
	}
	
}

Et voilà.


5. Referencias.


6. Conclusiones.

Poco uso… y mucho cuidado con el abuso.

Un saludo.

Jose

2 COMENTARIOS

  1. Muy buen artículo. Conocía los proxies dinámicos que ya de por si son muy útiles para hacer metaprogramación. Pero poder editar una clase en tiempo de ejecución es ya otro nivel. Gracias por el articulo!

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad