sábado, 10 de abril de 2021

Java Agents e Instrumentación: Tutorial

Últimamente hemos estado viendo en los posts como realizar tareas de monitorización. Hoy veremos los Java Agents, que son y como funcionan. Los cuales también pueden ayudarnos a realizar tareas de monitorización, entre otras cosas. Algo que intentaremos explicar en este post, primero con un poco de teoría y luego con la ayuda de ejemplos. 

¿Que son los Java Agents?, son básicamente librerías Java en las cuales se incluyen clases que implementan la API de Instrumentación de Java. La cual esta disponible desde la JDK 1.5. Esta es una API muy sencilla pero a la vez muy potente. Y establece las bases de la instrumentación de aplicaciones. 

La funcionalidad principal de esta instrumentación, es que se nos permitirá la adición de código a determinados métodos. Y con esta adición, podremos recompilar datos para ser utilizados con fines principalmente de monitorización y análisis o registro de eventos. Permitiéndonos obtener información vital sobre la aplicación que nos puede ayudar a resolver problemas u obtener datos que no serían posibles de otra forma. 

La principal cualidad de esta API de instrumentación es la alteración del byte-code de las clases que están siendo ejecutadas por la maquina virtual. A través de esta alteración podremos realizar las acciones anteriormente indicadas. 

Pero centrémonos, ¿Que necesitamos para crear un Java Agent?, seguir estos tres pasos:

  1. Desarrollar un método concreto.
  2. Crear y configurar correctamente el fichero MANIFEST.MF
  3. Indicar a la máquina virtual que tenga en cuenta a nuestra librería con el Java Agent. 

El método a desarrollar depende de cuando queramos que sea asociado el agente a la ejecución de aplicaciones en la JVM. La API de instrumentación pondrá a disposición de nosotros dos métodos diferentes:

  • premain: Permitirá la ejecución del Java Agent y su método asociado antes de la ejecución de cualquier otra aplicación en la JVM. Para lo cual el Java Agent debe indicarse en el arranque de la JVM. 
  • agentmain: Permitirá la ejecución del Java Agent una vez que la JVM ya se encuentre ejecutando una aplicación. Esta asociación debe realizarse de forma programática. 
La creación del fichero MANIFEST.MF debe realizarse manualmente, aunque se puede incluir en el jar asociado a través de un plugin de maven. En este fichero deberemos configurar distintas propiedades. Por un lado cual es la clase que contiene al método del Java Agent y propiedades acerca del comportamiento del mismo. Las principales son:
  • Premain-Class: Para indicar la clase que implemente el método premain.
  • Agent-Class: Para indicar la clase que implemente el método agentmain.
  • Can-Redefine-Classes: Permite indicar si el Java Agent puede o no redefinir las clases que interfiera.
  • Can-Retransform-Classes: Permite indicar si el Java Agent puede o no transformar la clase que interfiera. 
Una vez que tenemos desarrollado el método que realice la lógica y hayamos configurado el fichero MANIFEST correctamente, debemos generar la librería. Para ello podemos hacer uso de un par de plugins, los cuales nos facilitarán el trabajo:
  • maven-shade-plugin: nos permite crear la librería incluyendo las dependencias en el caso de que las tenga. 
  • maven-jar-plugin: nos permite asociar el fichero MANIFEST a la librería a crear. 
<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <executions>
            <execution>
                <phase>package</phase>
                <goals>
                    <goal>shade</goal>
                </goals>
            </execution>
        </executions>
    </plugin>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.2.0</version>
        <configuration>
            <archive>
                <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
            </archive>
        </configuration>
    </plugin>
</plugins>

El tercer paso, una vez tengamos la librería, será asociarla a la JVM. Tal y como hemos indicado anteriormente, se puede realizar de dos formas:

Si hemos implementado el método premain y configurado la propiedad Premain-Class, deberemos indicar la librería en el momento de arrancar la aplicación que queramos interferir. Ejemplo:

java -javaagent:/full/path/to/javaAgent.jar -jar app.jar

En el caso de que implementemos el método agentmain y configurado la propiedad Agent-Class, deberemos asociar el Java Agent de forma programática a través de la Java Attach API.

File agentFile = Paths.get("agent.jar").toFile();
VirtualMachine jvm = VirtualMachine.attach(VirtualMachine.list().get(0).id());
jvm.loadAgent(agentFile.getAbsolutePath());
// jvm.detach(); //to finish association

Ahora ya sabemos la teoría por lo que podemos ir a la practica. Vamos a ver un par de ejemplos. Uno sencillo y básico y otro algo más complejo. 

En el ejemplo más sencillo simplemente implementaremos el método premain, y veremos un par de logs asociados a su ejecución. 

@Log4j2
public class BasicAgentExample {
	public static void premain(final String agentArgs, final Instrumentation inst) {
		log.info("Start from premain. agentArgs: " + agentArgs);
		inst.addTransformer((loader, className, classBeingRedefined, protectionDomain, classfileBuffer) -> {
			log.info(String.format("Classname: %s", className));
			return null;
		});
	}
}

Si ejecutamos un método static void main, por ejemplo, y añadimos como argumento el java agent, podremos ver una salida similar a esta:

[INFO ] 2021-03-04 [main] BasicAgentExample - Start from premain. agentArgs: null
[INFO ] 2021-03-04 [main] BasicAgentExample - Classname: sun/misc/PostVMInitHook
....

Como podemos ver, si miramos un poco los métodos disponibles en el objeto Instrumentation, tendremos la posibilidad de transformar o redefinir el funcionamiento de una clase. Pero este debe realizarse con operaciones complejas de modificación de los bytes de las clases. 

En el siguiente ejemplo, algo más complejo, transformaremos la clase que vayamos a interferir. Pero como hemos indicado estas operaciones son complejas. Y para llevarlas a cabo más fácilmente haremos uso de la librería Byte Buddy.  

La idea de este ejemplo, es crear un Java Agent que nos permita medir cuantas veces se ha invocado un método determinado. Aparte, también filtraremos que clases queremos intervenir, evitando que se realicen tareas sobre clases que no nos interesan, como por ejemplo las de la JVM. En nuestro ejemplo intervendremos solo aquellas clases que extiendan de una clase abstracta. 

Primero implementamos el método premain

@Log4j2
public class CounterAgentExample {
  public static void premain(final String agentArgs, final Instrumentation inst) {
    log.info("Start from premain. agentArgs: " + agentArgs);
    Advice advice = Advice.to(CounterMeasureAdvice.class);
    new AgentBuilder.Default()
      .type(ElementMatchers.hasSuperType(ElementMatchers.isAbstract()))
      .transform((builder, type, classLoader, module) -> builder.visit(advice.on(ElementMatchers.isMethod())))
      .installOn(inst);
  }
}

El constructor AgentBuilder nos permitirá indicar que elementos queremos intervenir a través del método type y de la utilidad ElementMatchers. El método transform nos permitirá indica sobre que elemento de esa clase queremos realizar la transformación, en este caso sobre los métodos de las clases que cumplan hayan pasado el filtro. 

public class CounterMeasureAdvice {
  public final static Logger log = LogManager.getRootLogger();
  public static Map<String, Long> map = new HashMap<>();

  @Advice.OnMethodEnter
  public static void enter(@Advice.Origin final String origin) {
	log.info("CounterMeasureAdvice: Enter into: " + origin);
	if (map.containsKey(origin)) {
		map.put(origin, map.get(origin) + 1);
	} else {
		map.put(origin, 1L);
	}
	log.info(String.format("Origin %s invoked %d times", origin, map.get(origin)));
  }
}

Crearemos tambien un advice, que es un elemento asociado a la programación de aspecto, que nos permite ejecutar nuestro código en un punto específico. Los advice que creemos pueden ser de distinto tipo, en función de cuando queremos que se tengan en cuenta respecto a ese punto fijo: antes, o después de ese determinado punto. Para el ejemplo, ejecutaremos nuestro advice antes de acceder a los métodos que cumplan los filtros. 

public class ExampleThree extends AbstractClass {
  public static int fibonacci(final int n) {
	if (n > 1) {
		return fibonacci(n - 1) + fibonacci(n - 2); // función recursiva
	} else { // caso base
		return n;
	}
  }
  // java -javaagent:/path/to/agent.jar -cp *.jar the.main.ClassName
  public static void main(final String[] args) {
	System.out.println("Fibonacci de 5: " + fibonacci(5));
  }
}

Para probar nuestro Java Agent, ejecutaremos la clase que se muestra arriba. Indicando correctamente la librería del Java Agent y la clase que implementa dicho agente en el fichero MANIFEST. La salida será similar a esta:

[INFO ] 2021-03-04 [main]  - CounterMeasureAdvice: Enter into: public static int ExampleThree.fibonacci(int)
[INFO ] 2021-03-04 [main]  - Origin public static int ExampleThree.fibonacci(int) invoked 14 times
[INFO ] 2021-03-04 [main]  - CounterMeasureAdvice: Enter into: public static int ExampleThree.fibonacci(int)
[INFO ] 2021-03-04 [main]  - Origin public static int ExampleThree.fibonacci(int) invoked 15 times

Como podéis ver, el uso de Java Agent tiene muchas posibilidades. Incluso con la librería de Byte Buddy se pueden hacer cosas complejas de forma sencilla.



No hay comentarios:

Publicar un comentario