ByteCode
바이트코드란 가상컴퓨터에서 돌아가는 실행프로그램을 위한 이진 표현법입니다.
자바의 JVM을 학습하게 되면서 알게 된 개념으로, 기계어로 변환되기 이전에 JVM에서 동작하기위해 생성되는 .class 파일이 이 바이트코드에 해당합니다.
D:\IdeaProjects\JavaLearn\src\main\java\me\ddings73>javap -c App.class
Compiled from "App.java"
public class me.ddings73.App {
public me.ddings73.App();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello Java!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
바이트코드는 커맨드라인에서 javap -c 명령을 이용하여 확인이 가능합니다.
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package me.ddings73;
public class App {
public App() {
}
public static void main(String[] args) {
System.out.println("Hello Java!");
}
}
이 외에 intellij에서는 target/classes/에서 사람이 알아보기 쉽게 디컴파일된 바이트코드를 확인할 수 있습니다.
바이트코드로 뭘 할수 있을까?
자바에서 바이트코드는 사용자가 입력한 소스코드가 반드시 거쳐야하는 경유지에 해당합니다.
어떤 소스코드를 작성하든 JVM에서 실행되기 위해서는 .class파일로의 변환이 이루어져야 하기 때문입니다.
만약 바이트코드를 조작할 수 있다면 어떤일이 가능할까? 라는 생각을 해볼만한 가치는 충분하다고 생각합니다.
바이트코드가 생성되는 그 순간에 코드를 조작하는 것이 가능하다면, 프로그램의 실행흐름을 변경하거나 기존에 존재하지않던 코드를 추가하는 등의 작업이 가능할 것입니다.
그 예시로 코드 커버리지를 들 수 있습니다.
코브 커버리지?
코드 커버리지란, 내가 작성한 테스트가 실제 코드를 얼마나 검증하는지를 나타내는 것을 의미합니다.
관련된 툴들은 다음과 같은 것들이 존재합니다.
- Java : Atlassian Clover, Coberturo, JaCoCo
- Javascript : istanbul
- PHP : PHPUnit
- Python : Coverage.py
- Ruby : SimpleCov
어떤 동작을 할까?
저는 현재 자바 공부를 하고있기 떄문에 자바의 코드 커버리지 툴인 JaCoCo를 사용해서 간단한 코드를 대상으로 실행해보았습니다.
public class Univ {
private String name;
private int Student_no;
public Univ(String name, int student_no) {
this.name = name;
Student_no = student_no;
}
public boolean Student_no_Check(int Student_no){
if(this.Student_no != Student_no){
return false;
}else{
return true;
}
}
}
public class UnivTest {
@Test
public void teststudent_no_Check() {
Univ univ = new Univ("ddings", 1111111);
assertTrue(univ.Student_no_Check(1111111));
}
}
먼저 간단한 코드와 테스트코드를 작성하였습니다.
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
이후에 JaCoCo를 사용하기 위하여 플러그인을 pom.xml 파일에 설정해주고나서 메이븐에서 clean -> verify를 하고나면 target/site에 jacoco관련 디렉토리가 생기는 것을 확인할 수 있습니다.
바이트코드 조작 라이브러리
앞서 살펴본 코드 커버리지로 바이트코드 조작을 통해 어떤 작업을 할 수 있는지 살펴볼 수 있었습니다.
이러한 바이트코드를 조작하는 라이브러리는 여러가지 종류가 존재합니다.
이러한 라이브러리등을 이용하여 바이트코드를 조작할 수 있습니다.
한번 살펴보자
public class People {
public String name;
public int age;
public People(String name, int age) {
this.name = name;
this.age = age;
}
public String WhoAmI(){
return "";
}
}
public class Introduce
{
public static void main( String[] args )
{
People people = new People("ddings", 24);
System.out.println(people.WhoAmI());
}
}
바이트코드 라이브러리를 간단하게 사용해보기 위하여 People, Introduce 클래스를 만들고, WhoAmI 메소드를 실행하면 이름 : 나이 의 꼴로 출력 되는것을 목표로 진행해 봤습니다.
어떤 라이브러리를 사용해야 할까?
어떤 라이브러리를 사용해야 하는가에 있어서는 내부적으로 ASM을 의존하고있으며 배우기 쉬운 Bytebuddy를 사용하기로 결정했습니다.
진짜 부딪혀보기
<!-- https://mvnrepository.com/artifact/net.bytebuddy/byte-buddy -->
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.10.19</version>
</dependency>
먼저 Bytebuddy를 사용하기 위하여 의존성을 추가해줬습니다.
그 후에는 사용법을 알아야하기 때문에 공식문서와 API를 조금 살펴보았습니다.
목표설정
public class PeopleTest {
@Test
public void test_whoAmI() {
People people = new People("ddings", 25);
assertEquals(people.name + " : " + people.age , people.WhoAmI());
}
}
먼저 ByteBuddy를 이용하여 최종적으로 통과되기를 원하는 테스트를 작성해 줬습니다.
물론 지금의 WhoAmI()는 아무것도 반환하지 않기때문에 테스트가 통과하지 않습니다.
목표에 다가가기
테스트에 통과하기 위해서는 WhoAmI() 의 리턴값을 바꿔주어야하며, 그 방법은 다음과 같습니다.
people = new People("ddings", 25);
try {
new ByteBuddy().redefine(People.class)
.method(named("WhoAmI")).intercept(FixedValue.value(people.name + " : " + people.age))
.make()
.saveIn(new File("target/classes/"));
} catch (IOException e) {
e.printStackTrace();
}
ByteBuddy()를 이용하여 People.class의 WhoAmI 메소드를 재정의하여 생성되는 target/classes/경로에 저장하겠다는 코드입니다.
public class PeopleTest {
People people;
@Before
public void setup(){
people = new People("ddings", 25);
try {
new ByteBuddy().redefine(People.class)
.method(named("WhoAmI")).intercept(FixedValue.value(people.name + " : " + people.age))
.make()
.saveIn(new File("target/classes/"));
} catch (IOException e) {
e.printStackTrace();
}
}
@Test
public void test_whoAmI() {
assertEquals(people.name + " : " + people.age , people.WhoAmI());
}
}
해당하는 코드를 테스트 실행이전에 먼저 실행해줘서 .class파일을 생성해주면 성공적으로 테스트를 통과할 수 있습니다.
이를 통하여 ByteBuddy를 이용하여 Runtime에 코드를 수정하고 클래스파일을 생성해내는 모습을 볼 수 있었습니다.
public class Introduce
{
public static void main( String[] args )
{
People people = new People("ddings", 24);
try {
new ByteBuddy().redefine(People.class)
.method(named("WhoAmI")).intercept(FixedValue.value(people.name + " : " + people.age))
.make()
.saveIn(new File("target/classes/"));
} catch (IOException e) {
e.printStackTrace();
}
System.out.println(people.WhoAmI());
}
}
바이트코드를 이용한 이 코드에서 원하는 결과를 얻을 수 없습니다.
그 이유는 ByteBuddy를 이용하여 메소드를 재정의하기 전에 이미 WhoAmI()호출로 인하여 해당 클래스파일이 만들어지기 때문입니다.
이 문제는 ByteBuddyAgent 의존성을 추가하여 클래스를 reload 시키는 방법과 직접 나만의 javaagent를 만들어서 의존성을 추가시키는 방법으로 해결이 가능합니다.
ByteBuddyAgent
<!-- https://mvnrepository.com/artifact/net.bytebuddy/byte-buddy-agent -->
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.10.19</version>
<scope>test</scope>
</dependency>
먼저 해당 의존성을 추가해줍니다.
package me.ddings73;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.agent.ByteBuddyAgent;
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy;
import net.bytebuddy.implementation.FixedValue;
import java.io.File;
import java.io.IOException;
import static net.bytebuddy.matcher.ElementMatchers.named;
public class Introduce
{
public static void main( String[] args )
{
People people = new People("ddings", 24);
ByteBuddyAgent.install();
new ByteBuddy().redefine(People.class)
.method(named("WhoAmI")).intercept(FixedValue.value(people.name + " : " + people.age))
.make()
.load(People.class.getClassLoader(), ClassReloadingStrategy.fromInstalledAgent());
System.out.println(people.WhoAmI());
}
}
그 후에 코드에 클래스로더를 이용하여 reload를 시켜주게되면 원하는 대로 결과가 출력됩니다.
javaagent
javaagent를 만들기위해서 관련 문서와 백기선님의 더 자바, 코드를 조작하는 다양한 방법을 참조하였습니다.
아까 ByteBuddyAgent 도 그렇고 지금의 javaagent도 그렇고 일단 이게 정확히 무엇인지 모르기때문에 사용하는데 거부감이 생겼었습니다.
javaagent는 JVM내에서 실행중인 자바 프로그램에 간섭할 수 있는 서비스를 제공합니다.
즉, 실행중인 애플리케이션을 가로채서 바이트코드를 조작할 수 있는 기능을 제공합니다.
javaagent는 Java Instrumentation API의 일부입니다.
즉, 실행중에 프로그램에 간섭하여 바이트코드를 조작하기 위해서 javaagent를 만들어야했고 그를 위해서 java Instrumentation을 참조하였습니다.
package me.ddings73;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.dynamic.DynamicType;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.matcher.ElementMatchers;
import net.bytebuddy.utility.JavaModule;
import java.lang.instrument.Instrumentation;
import static net.bytebuddy.matcher.ElementMatchers.named;
public class PeopleAgent {
public static void premain(String agentArgs, Instrumentation inst) {
new AgentBuilder.Default()
.type(ElementMatchers.any())
.transform(new AgentBuilder.Transformer() {
@Override
public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassLoader classLoader, JavaModule javaModule) {
return builder.method(named("WhoAmI")).intercept(FixedValue.value("transformed"));
}
}).installOn(inst);
}
}
agent를 만들기 위한 코드
이 코드를 패키징하여 생성되는 .jar파일을 의존성으로 넘겨줍니다.
VM Option에 생성된 jar파일 경로를 설정해주고 실행합니다.
package me.ddings73;
public class Introduce
{
public static void main( String[] args )
{
People people = new People("ddings", 24);
System.out.println(people.WhoAmI());
}
}
transformed
원래 클래스내의 필드값을 참조하여 출력하게 만들고 싶었으나 필드값 참조를 어떻게 해야할지 잘 모르겠어서 문자열로 출력하였습니다.