본문 바로가기

기술스택/ASM - [Bytecode]

[Agent / ASM] agent의 transform 메서드에 ASM 바이트코드 적용하기 1

목차

    ClassReader - ClassWriter - ClassVisitor의 관계

     

    먼저 이전 시간에 학습했던 내용을 다시 되짚으려고 한다.

     

    1. ClassReader
    ClassReader는 주어진 바이트 배열(byte[])로부터 클래스 정보를 읽는 역할을 한다.

    즉, .class 파일이나 ClassFileTransformer 인터페이스를 통해 전달받은 바이트코드(중요)를 읽고 해석하는 데 사용된다.


    ClassReader는 클래스 파일의 구조를 순차적으로 읽지만, 직접 바이트코드를 수정하지는 않는다.

    대신, 읽어들인 정보를 ClassVisitor에 전달하여 분석이나 조작을 가능하게 한다.


    2. ClassWriter

    ClassReader로부터 읽거나 새로운 정보를 바탕으로, 새로운 바이트코드를 만들거나 기존 코드를 수정하는 역할을 한다.


    ClassWriter는 ClassVisitor와 연결될 수 있으며

    ClassVisitor를 통해 전달받은 변경 사항을 바탕으로 최종 바이트코드를 생성한다.


    3. ClassVisitor
    ClassVisitor는 ClassReader로부터 읽어들인 클래스 정보를 방문(visit)하는 역할을 한다.

    특히 이 부분은 디자인 패턴 중 하나인 방문자 패턴(Visitor Pattern)을 사용하여 구현된다.


    ClassVisitor는 클래스 파일의 다양한 구성 요소(예: 클래스 자체, 메서드, 필드 등)를 방문할 때 호출되는 메서드들을 정의한다.

    사용자는 이 메서드들을 오버라이드하여 특정 이벤트가 발생했을 때 원하는 작업(분석, 조작 등)을 수행할 수 있다.
    ClassVisitor는 바이트코드를 직접 조작하지 않는 대신, 방문하는 동안의 정보를 수집하거나 변경 사항을 ClassWriter에 전달하여 바이트코드를 조작한다.

     

     

    상호 작용

    - ClassReader는 클래스 파일로부터 바이트코드를 읽어들이고, 이 정보를 ClassVisitor에게 전달한다.

    - 개발자는 ClassVisitor를 상속받은 클래스를 정의하고, 특정 이벤트(예: 메서드 방문)에서 필요한 작업을 수행하는 로직을 구현한다.

    - ClassVisitor의 구현체는 변경하고자 하는 정보를 ClassWriter에 전달한다.
    이 때, ClassWriter는 최종 바이트코드를 생성하거나 기존 바이트코드를 수정한다.

    - 결과적으로, ClassReader와 ClassWriter는 바이트코드를 읽고 쓰는 역할을 수행하며, ClassVisitor는 이들 사이에서 바이트코드를 분석하고 조작하는 교량 역할을 한다.

     

     

    Instrumentation

    premain 메서드는 Instrumentation 객체를 매개변수로 받는다.

    이 객체는 JVM이 제공하는 인스트루멘테이션 기능에 접근할 수 있게 해준다. Pinpoint는 이 객체를 사용하여 바이트코드 조작이 가능한 에이전트를 등록할 수 있다.

     

    동작

    premain을 통해 ClassFileTransformer 구현체를 등록하고, 클래스 로딩 시 모든 클래스를 호출할때마다 ClassFileTransformer 구현체의 transform이 한번씩 실행된다.

     

     

     

    Premain

    package org.agent;
    
    import org.agent.util.Banner;
    import org.agent.util.CallThread;
    
    import java.lang.instrument.Instrumentation;
    import java.time.LocalDateTime;
    
    public class MyAgent {
    
        /**
         *  premain 메서드는 Instrumentation 객체를 매개변수로 받는다.
         * 이 객체는 JVM이 제공하는 인스트루멘테이션 기능에 접근할 수 있게 해준다.
         */
        public static void premain(String agentArgs, Instrumentation instrumentation) {
            Banner.send(); //로그 찍기
            System.out.println("nowTime : " + LocalDateTime.now() + " my Agent has been invoked with args: " + agentArgs);
            CallThread.run(); //쓰레드 생성 후 동작하는 다른 메서드
    
    
            /**
             *  Instrumentation 객체의 addTransformer를 통해 인터페이스 구현 클래스를 '등록'한다.
             *  - 1. premain은 유일하게 단 한번 실행된다. 이 아래 구문을 통해 SimpleClassTransformer를 JVM의 Instrumentation에 등록했다.
             *  - 2. 이 인터페이스의 구현체(transform)은 JVM이 존재하는 클래스를 로드할 때마다 호출되며, 이 시점에서 바이트코드를 조사하고 변경할 수 있다.
             */
            instrumentation.addTransformer(new SimpleClassTransformer());
        }
    }

     

     

    구현체

    package org.agent;
    
    import java.lang.instrument.ClassFileTransformer;
    import java.lang.instrument.IllegalClassFormatException;
    import java.security.ProtectionDomain;
    
    public class SimpleClassTransformer implements ClassFileTransformer {
    
        //이 인터페이스의 구현체(transform)은 JVM이 존재하는 클래스를 로드할 때마다 호출되며, 이 시점에서 바이트코드를 조사하고 변경할 수 있다.
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                                ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
            
            // 바이트코드 변경 로직
            // 예를 들어, className이 "com/myapp/MyClass" 일 때 변경 로직을 적용
            if (className.equals("com/dummy/jdbcserver/restapi/controller/InputController")) {
                System.out.println("특정 클래스 : " + className + " 에 대한 로직 수행");
                ReadClass.read(classfileBuffer); //ASM 로직
            }
    
            return classfileBuffer;
        }
    
    
    }

     

     

    ReadClass (ASM 바이트코드 적용)

    package org.agent.util.asm;
    
    import org.objectweb.asm.ClassReader;
    import org.objectweb.asm.ClassVisitor;
    import org.objectweb.asm.ClassWriter;
    import org.objectweb.asm.MethodVisitor;
    import org.objectweb.asm.Opcodes;
    import org.objectweb.asm.commons.AdviceAdapter;
    import org.objectweb.asm.util.TraceClassVisitor;
    
    import java.io.PrintWriter;
    
    import static org.objectweb.asm.Opcodes.ASM9;
    
    
    /**
     * ASM Library 사용.
     * 이 클래스의 Byte코드를 읽는다.
     */
    public class ReadClass {
    
        //classfileBuffer는 ClassFileTransformer 구현체의 입력 parameter로 받은 byte[] classfileBuffer이다.
        public static void read(byte[] classfileBuffer) {
    
            //ClassReader는 주어진 바이트 배열(classfileBuffer)에서 클래스 정보를 읽기 위한 객체
            ClassReader classReader = new ClassReader(classfileBuffer);
    
            //읽은 클래스 정보에 기반하여 새로운 바이트코드를 생성하거나 기존 바이트코드를 수정하기 위한 객체
            ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
    
            /*
            클래스의 구조(예: 클래스 자체, 메서드, 필드 등)를 순회하면서 필요한 작업을 수행할 수 있는 추상 클래스
            실사용시에는 visitMethod 등을 오버라이드해서 사용해야 한다.
            classWriter가 들어간 것 역시 이벤트 발생 시 작성해야 하기 때문.
            */
            ClassVisitor classVisitor = new ClassVisitor(ASM9, classWriter) {};
    
            //실제 visitor 구현체의 예시 : AddLoggingClassAdapter를 사용하여 클래스 방문 시작
            classReader.accept(new AddLoggingClassAdapter(classWriter), 0);
    
    
            /*
            ClassVisitor 객체(classVisitor)를 accept 메서드의 인자로 넘기는 이유?
            클래스 파일을 순회하며 발생하는 각종 이벤트(예: 클래스 방문 시작, 메서드 방문, 필드 방문 등)를 처리하기 위함.
    
            ClassVisitor는 이러한 이벤트들을 받아서 처리하는 콜백 메서드들의 집합을 제공한다.
             */
            //classReader.accept(classVisitor, 0);
    
    
    
        }
    }
    

     

    AddLoggingClassAdapter와 ClassVisitor

    해당 코드가 좀 난이도가 있는 것 같아, 쉬운 버전의 포스팅을 추가적으로 첨부한다.

    https://csg1353.tistory.com/176

     

     

    package org.agent.util.asm;
    
    import org.objectweb.asm.*;
    
    public class AddLoggingClassAdapter extends ClassVisitor {
        public AddLoggingClassAdapter(ClassVisitor cv) {
            super(Opcodes.ASM9, cv);
        }
    
        /**
         - visitMethod 메서드는 클래스의 메서드를 방문할 때마다 호출된다.
         - 이 메서드는 메서드의 메타데이터(접근 지시자, 이름, 설명자, 시그니처, 예외 등)를 포함한 정보와 함께 호출된다.
         - visitMethod의 주요 목적은 방문 중인 메서드에 대한 정보를 기반으로 특정 작업을 수행하기 위한 MethodVisitor 인스턴스를 생성하고 반환하는 것
         즉, visitMethod는 메서드의 구조를 정의하며, 반환하는 MethodVisitor 인스턴스를 통해 해당 메서드의 바이트코드 내부에 추가적인 조작이나 분석이 가능
    
         < 파라미터의 의미 ></>
         1. int access: 메서드의 접근 지시자(access modifiers).
         메서드가 public, private, protected, static 등 어떤 접근 수준이며 어떤 속성(예: final, synchronized)인지 나타내는 비트 필드이다.
         2. String name: 방문하고 있는 메서드의 이름
         3. String desc: 메서드의 서술자(descriptor)로, 메서드의 파라미터 타입과 반환 타입을 나타내는 문자열. ex : (Ljava/lang/String;I)V
         4. String signature: 제네릭 타입을 사용하는 메서드의 경우, 메서드의 서명(signature)
         5. String[] exceptions: 메서드가 던질 수 있는 예외들의 내부 이름
         이러한 방식으로 각 메서드를 방문할 때마다 메서드에 대한 정보가 visitMethod에 전달되고, 이를 기반으로 메서드의 바이트코드를 조작
         */
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            //MethodVisitor 선언 후 Adapter Return
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
            return new AddLoggingMethodAdapter(Opcodes.ASM9, mv, access, name, desc);
        }
    
        static class AddLoggingMethodAdapter extends MethodVisitor {
            private String methodName;
    
            public AddLoggingMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc) {
                super(api, mv);
                this.methodName = name;
            }
    
            /**
             - visitCode 메서드는 MethodVisitor에서 오버라이드되며, 메서드의 바이트코드 순회가 시작될 때 호출
             - 이는 메서드의 실제 바이트코드(명령어)를 방문하기 직전의 단계에 해당(진입점)
             - 주요 목적 : 바이트코드 조작을 시작하기 전에 필요한 초기화나 준비 작업을 수행하는 것
             - 실제 본문 코드에서는 '메서드 시작 시 로그를 출력하고자, visitCode 내에서 로그 출력 관련 바이트코드를 삽입' 하였음.
    
             *
             * 동작 과정 :
             * visitFieldInsn : System.out 객체를 스택에 푸시. GETSTATIC은 정적 필드에 접근하는 명령어로, System.out 정적 필드(표준 출력 스트림)를 참조.
             * visitLdcInsn : 로그 메시지 문자열을 스택에 푸시.
             * visitMethodInsn : PrintStream.println(String) 메서드를 호출하여 스택에 있는 문자열(로그 메시지)를 출력. INVOKEVIRTUAL은 가상 메서드를 호출하는 명령어.
             */
            @Override
            public void visitCode() {
                mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("Entering method: " + methodName);
                mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
                super.visitCode();
            }
        }
    }

     

    주석 없는 코드

    public class AddLoggingClassAdapter extends ClassVisitor {
        public AddLoggingClassAdapter(ClassVisitor cv) {
            super(Opcodes.ASM9, cv);
        }
    
       
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            //MethodVisitor 선언 후 Adapter Return
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
            return new AddLoggingMethodAdapter(Opcodes.ASM9, mv, access, name, desc);
        }
    
        static class AddLoggingMethodAdapter extends MethodVisitor {
            private String methodName;
    
            public AddLoggingMethodAdapter(int api, MethodVisitor mv, int access, String name, String desc) {
                super(api, mv);
                this.methodName = name;
            }
    
            @Override
            public void visitCode() {
                mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("Entering method: " + methodName);
                mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
                super.visitCode();
            }
        }
    }

     

    AddLoggingClassAdapter ClassVisitor를 확장하여, 클래스의 각 메서드를 방문할 때 로깅 기능을 추가하는 MethodVisitor 인스턴스(AddLoggingMethodAdapter)를 생성하고 반환한다.

    visitMethod 메서드는 각 메서드에 대한 메타데이터와 함께 호출되며, 로깅 기능을 추가할 MethodVisitor를 반환한다.

     

    visitMethod (ClassVisitor에 정의됨)

    • 역할: 클래스의 각 메서드를 방문할 때 호출된다.
      메서드의 메타데이터(접근 지시자, 이름, 설명자, 시그니처, 예외 등)를 받아, 이 정보를 기반으로 메서드에 대한 작업을 수행할 수 있는 MethodVisitor 인스턴스를 생성하고 반환한다.
    • 동작: visitMethod는 클래스의 각 메서드를 순회하는 동안 ASM이 자동으로 호출한다.
      이 메서드를 오버라이드하여, 특정 메서드를 방문했을 때 수행할 추가적인 조작이나 분석을 정의할 수 있다. 반환된 MethodVisitor 인스턴스를 통해 해당 메서드의 바이트코드 내부에 로직을 삽입하거나 변경할 수 있다.

    visitCode (MethodVisitor에 정의됨)

    • 역할: 메서드의 바이트코드 순회가 시작될 때 호출된다. 이는 메서드의 바이트코드를 방문하기 직전, 즉 메서드의 명령어들을 처리하기 전에 호출되는 초기화 단계이다.
    • 동작: visitCode는 메서드의 바이트코드를 조작하기 시작하는 진입점으로 작용한다. 사용자는 이 메서드를 오버라이드하여, 메서드의 바이트코드 순회를 시작하기 전에 필요한 초기화 작업이나 준비 작업을 수행할 수 있다. 예를 들어, 메서드 시작 시 로그를 출력하는 로직을 여기에 삽입할 수 있다.

     

     

     

     

    MethodVisitor

      .. 코드 생략
      
      
      /**
       * Visits a method of the class. This method <i>must</i> return a new {@link MethodVisitor}
       * instance (or {@literal null}) each time it is called, i.e., it should not return a previously
       * returned visitor.
       *
       * @param access the method's access flags (see {@link Opcodes}). This parameter also indicates if
       *     the method is synthetic and/or deprecated.
       * @param name the method's name.
       * @param descriptor the method's descriptor (see {@link Type}).
       * @param signature the method's signature. May be {@literal null} if the method parameters,
       *     return type and exceptions do not use generic types.
       * @param exceptions the internal names of the method's exception classes (see {@link
       *     Type#getInternalName()}). May be {@literal null}.
       * @return an object to visit the byte code of the method, or {@literal null} if this class
       *     visitor is not interested in visiting the code of this method.
       */
      public MethodVisitor visitMethod(
          final int access,
          final String name,
          final String descriptor,
          final String signature,
          final String[] exceptions) {
        if (cv != null) {
          return cv.visitMethod(access, name, descriptor, signature, exceptions);
        }
        return null;
      }

     

    ClassReader 내부

    ClassReader 내부를 보면 마찬가지로 visitMethod가 존재하는 것을 확인할 수 있다.