Çalışan Bir Java Uygulamasında Bytekod Nasıl Değiştirilir?

yazar:

kategori:

Java uygulamaları bytekoduna derlendikten sonra Java sanal makine (JVM – Java Virtual Machine) bünyesinde koşturulur. Bu yazımda çalışan bir Java uygulamasında mevcut bytekodun nasıl değiştirilebileceğini bir örnek üzerinde göstermek istiyorum.

Hangi durumlarda çalışan bir uygulama için bytekod değiştirme işlemi gerekli olabilir? Benim aklıma gelenler:

  • Kaynak dosyaları olmayan yabancı kütüphaneler üzerinde değişiklik yapılmak istendiğinde,
  • Loglama ve transaksiyon yönetimi gibi işletme mantığının doğrudan parçası olmayan işlemlerin yapılması gerektiğinde,
  • Performans ölçümleri için,
  • Kodu tekrar derlemek mümkün olmadığında,
  • Uygulama hakkında istatistiksel bilgiler toplanmak istendiğinde.

Bytekodun nasıl değiştirilebildiğini şimdi küçük bir örnek üzerinde inceleyelim. Aşağıda bytekodunu değiştirmek istediğimiz HelloWorld sınıfı yer alıyor.

[source language=”Java”]
package com.pratikprogramci.jvm.agent;

public class HelloWorld {
public static void main(final String args[]) {

new HelloWorld().run();
}

public void run() {
System.out.println("Merhaba Dünya");
}
}
[/source]

run() metodunun hangi zaman diliminde koştuğunu tespit etmek istediğimizi ve bu sınıfın koduna sahip olmadığımızı düşünelim. Eğer bu sınıfın kaynak dosyasına sahip olsaydık, şöyle bir kod değişikliği ile run() metodunun hangi zaman diliminde koşturulduğunu ölçebilirdik:

[source language=”Java”]
package com.pratikprogramci.jvm.agent;

public class HelloWorld {
public static void main(final String args[]) {
new HelloWorld().run();
}

public void run() {
long executionTime = System.currentTimeMillis();
System.out.println("Merhaba Dünya");
executionTime = System.currentTimeMillis() – executionTime;
System.out.println("Metot " + executionTime + " ms icinde tamamlandi");
}
}
[/source]

Yukarıda yer alan kod örneğinde görüldüğü gibi run() metoduna executionTime isminde yeni bir değişken ekledik. System.currentTimeMillis() ile metoda girdiğimizde mevcut zamanı tutarak, metot son bulmadan önce gecen zamanı hesapladık.

Kodsal seviyede yaptığımız bu değişiklikleri, bytekod seviyesinde yapabilir miyiz? Evet! Bunun için iki şeye ihtiyacımız var:

  • Java Instrumentation API
  • Bytekodunu değiştirmek için kullanılabilecek Javasist ya da ASM çatısı

Önce Java Instrumentation API ile başlayalım. Bir sınıfın sahip olduğu bytekodu değiştirebilmek için bu sınıfa JVM bünyesinde erişmemiz gerekmektedir. Bu işlem için bir agent sınıfı kullanabiliriz. JVM bünyesindeki bir sınıf bir classloader tarafından yüklendiği zaman, aşağıda yer alan agent sınıfının premain() metodu devreye girer.

[source language=”Java”]
package com.pratikprogramci.jvm.agent;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class LoggerAgent implements ClassFileTransformer {

public static void premain(final String agentArguments,
final Instrumentation instrumentation) {
instrumentation.addTransformer(new LoggerAgent());
}

public byte[] transform(final ClassLoader classloader,
final String className, final Class<?> clazz,
final ProtectionDomain domain, final byte[] bytes)
throws IllegalClassFormatException {
return null;
}
}
[/source]

Mevcut bir sınıfın sahip oldugu bytekodu değiştirebilmek icin Java Instrumentation API’sinin bir parçası olan ClassFileTransformer interface sınıfını implemente etmemiz gerekiyor. Yukarıda yer alan LoggerAgent sınıfı hem bir JVM agent olma özelliğine sahiptir hem de ClassFileTransformer interface sınıfını implemente ederek, bytekodu değiştirme işleminden sorumlu sınıf haline gelmektedir.

ClassFileTransformer interface sınıfında transform() isminde, yüklenen sınıf üzerinde gerekli değişikliklerin yapıldığı bir metot bulunmaktadır. Bu metodun parametrelerine göz attığımızda, ClassLoader tipinde bir parametrenin olduğunu görmekteyiz. Bu değiştirmek istediğimiz sınıfı yükleyen sınıf yükleyicisidir. Bunun yanı sıra transform() metoduna yüklenen sınıfın ismi ve Class nesnesi olarak kendisi parametre olarak verilmektedir. ProtectionDomain sınıfı belli sınıflardan ve bu sınıflara olan erişim haklarının tanımlandığı bir uygulama alanını (domain) temsil etmektedir. byte[] parametresi yüklenen Java sınıfının bytekodunu byte formatında ihtiva etmektedir. transform() metodu değişikliğe uğrayabilecek sınıfı yine byte[] veri tipinde geriye vermektedir.

Şimdi Javasist bytekod değiştirme çatısı yardımı ile HelloWorld sınıfı üzerinde düşündüğümüz değişiklikleri yapalım. Bu amaçla transform() metodunu aşağıdaki şekilde implemente ediyoruz:

[source language=”Java”]
public byte[] transform(final ClassLoader classloader, final String className,
final Class<?> clazz,
final ProtectionDomain domain, final byte[] bytes)
throws IllegalClassFormatException {

if (className.equals("com/pratikprogramci/jvm/agent/HelloWorld")) {
try {

System.out.println(">>Bytecode enjeksiyonu basliyor…..");

final ClassPool cp = ClassPool.getDefault();
final CtClass cc = cp.get("com.pratikprogramci.jvm.agent.HelloWorld");
final CtMethod m = cc.getDeclaredMethod("run");
m.addLocalVariable("executionTime", CtClass.longType);

m.insertBefore("executionTime = System.currentTimeMillis();");
m.insertAfter("{executionTime = System.currentTimeMillis() – "
+ "executionTime;"
+ "System.out.println(\">>Metot \" + executionTime + "
+ "\" ms icinde tamamlandi.\" );}");

final byte[] byteCode = cc.toBytecode();
cc.detach();
return byteCode;

} catch (final Exception ex) {
ex.printStackTrace();
}
}
return null;
}
[/source]

transform() metodunda değiştirmek istediğimiz sınıfı if komutu ile tespit ettikten sonra, değiştirmek istediğimiz metodu getDeclaredMethod() ile lokalize ediyor ve run() metoduna addLocalVariable() ile executionTime isminde yeni bir değişken ekliyoruz. insertBefore() ile metoda giriş yapılmadan önce executionTime değişkenine içinde bulunduğumuz zamanı atıyoruz. insertAfter() ile run() metodu son bulduktan sonra geçen zamanı hesaplayacak kodu oluşturuyoruz. Bu değişikliklerin ardından bytekodunu değiştirdiğimiz sınıfı byte[] olarak cc.toBytecode() metodu yardımıyla geri veriyoruz. Bu noktada itibaren JVM oluşturduğumuz yeni sınıfı koşturmaya başlıyor.

LoggerAgent sınıfı için bir MANIFEST.MF dosyası oluşturmamız gerekiyor. Aşağıda yer alan MANIFEST.MF dosyasında Main-Class parametresi ile koşturmak istediğimiz sınıfı tanımlıyoruz. Premain-Class parametresi agent sınıfını tanımlamak için kullanılmaktadır. Boot-Class-Path parametresi ile agent tarafından kullanılan kütüphaneleri tanımlamak mümkün. Bizim örneğimizde kullanılan tek kütüphane Javasist kütüphanesidir.

[source language=”Java”]
Main-Class: com.pratikprogramci.jvm.agent.HelloWorld
Premain-Class: com.pratikprogramci.jvm.agent.LoggerAgent
Boot-Class-Path: lib/javassist-3.18.2-GA.jar
[/source]

Şimdi oluşturduğumuz tüm sınıfların ve MANIFEST.MF dosyasının yer aldığı bir Jar dosyası oluşturmamız gerekiyor. Jar dosyasını oluşturmak ve gerekli bağımlılıkları yönetmek için bir Maven projesi oluşturdum. Pom.xml şu yapıda:

[source language=”Java”]
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.pratikprogramci</groupId>
<artifactId>jvm.agent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.18.2-GA</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestFile>src/main/resources/
META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<phase>install</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/
lib</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
[/source]

Proje yapısı aşağıda görüldüğü şekilde olacaktır. Projenin ana dizini içinde mvn clean install ile uygulamayı derleyerek, bir Jar dosyası haline getirebiliriz.

[source language=”Java”]
acar@acarnb:~/Development/workspace/jvm.agent> dir
-rw-r–r– 1 acar users 1205 31. Okt 16:15 pom.xml
drwxr-xr-x 4 acar users 4096 31. Okt 15:07 src
drwxr-xr-x 7 acar users 4096 31. Okt 16:23 target
acar@acarnb:~/Development/workspace/jvm.agent>
[/source]

Jar dosyası target dizininde yer almaktadır. cd target ile bu dizine geçiyoruz:

[source language=”Java”]
acar@acarnb:~/Development/workspace/jvm.agent/target> dir
drwxr-xr-x 4 acar users 4096 31. Okt 16:23 classes
-rw-r–r– 1 acar users 4476 31. Okt 16:23 jvm.agent-0.0.1-SNAPSHOT.jar
drwxr-xr-x 2 acar users 4096 31. Okt 16:23 lib
drwxr-xr-x 2 acar users 4096 31. Okt 16:23 maven-archiver
drwxr-xr-x 2 acar users 4096 31. Okt 16:23 surefire
drwxr-xr-x 2 acar users 4096 31. Okt 16:23 test-classes
acar@acarnb:~/Development/workspace/jvm.agent/target>
[/source]

jvm.agent-0.0.1-SNAPSHOT.jar oluşturduğumuz Java sınıflarını ihtiva eden Jar dosyasıdır. Bunun yanı sıra lib dizini içinde javassist-3.18.2-GA.jar kütüphanesi yer almaktadır. Maven maven-dependency-plugin aracılığı ile bu kütüphanenin target/lib dizinine kopyalanmasını sağladık. MANIFEST.MF dosyasına tekrar göz attığımızda, Boot-Class-Path parametresinin lib dizininde bulunan javassist-3.18.2-GA.jar dosyasına işaret ettiğini görmekteyiz. LoggerAgent sınıfının bytekodu işlemlerini yapabilmesi için javassist-3.18.2-GA.jar kütüphanesine ihtiyaç duymaktadır.

Agent sınıfının JVM tarafından yüklenebilmesi için -javaagent parametresi kullanılmaktadır. -javaagent parametresi değer olarak içinde agent sınıfının ve MANIFEST.MF dosyasının yer aldığı Jar dosyasını almaktadır. MANIFEST.MF dosyası bünyesinde premain() metodunu taşıyan sınıf tanımlandığından, JVM hangi agent sınıfını yüklemesi gerektiğini bilmektedir.

HelloWorld uygulamasını şu şekilde koşturabiliriz:

[source language=”Java”]
acar@acarnb:~/Development/workspace/jvm.agent/target> java -jar jvm.agent-0.0.1-SNAPSHOT.jar
Merhaba Dünya
acar@acarnb:~/Development/workspace/jvm.agent/target>
[/source]

Görüldügü gibi sadece Merhaba Dünya çıktısını aldık. Şimdi uygulamamızı bytekod manipülasyonu için oluşturduğumuz LoggerAgent sınıfı ile koşturalım:

[source language=”Java”]
acar@acarnb:~/Development/workspace/jvm.agent/target> java -javaagent:jvm.agent-0.0.1-SNAPSHOT.jar -jar jvm.agent-0.0.1-SNAPSHOT.jar
>>Bytecode enjeksiyonu basliyor…..
Merhaba Dünya
>>Metot 2000 ms icinde tamamlandi.
acar@acarnb:~/Development/workspace/jvm.agent/target>
[/source]

Görüldüğü gibi LoggerAgent sınıfının transform() metodu devreye girdi ve çalışan bir uygulamada gerekli bytekod değişikliğini yaptı. Ekran çıktısında metodun koşma zamanını 2000 ms olarak görmekteyiz. HelloWorld sınıfının run() metoduna uygulamayı 2 saniye durduran Thread.sleep(2000); eklentisini yaptım. Bu eklenti olmadan 0 ms değerini görülürüz, cünkü run() metodu JVM tarafından bir milisaniyenin bile altında bir zaman diliminde koşturulmaktadır.

Maven projesini aşağıdaki linkten indirebilirsiniz:

[wpdm_file id=20]

EOF (End Of Fun)
Özcan Acar