본문 바로가기

기술스택/ASM - [Bytecode]

[A Java bytecode engineering library] - [Tree API] 6. Classes

목차

    개인 학습 정리

    Tree API?

    ASM의 Tree API는 바이트코드 조작을 위한 보다 높은 수준의 추상화를 제공한다.
    Core API가 방문자 패턴(visitor pattern)에 의존하여 바이트코드를 직접 조작하는 방식이라면, Tree API는 바이트코드를 더 쉽게 조작할 수 있도록 메모리 내 트리 구조로 클래스를 표현한다.
    이를 통해 사용자는 바이트코드 구성 요소(예: 메서드, 필드, 애너테이션)를 노드로서 조작할 수 있게 된다.
     
    Tree API의 주요 특징은 다음과 같다.

    • 고수준의 추상화:
    • Tree API를 사용하면, 바이트코드 구조를 이해하기 쉬운 객체 모델을 통해 작업할 수 있다.
    • 이는 복잡한 조작을 단순화시키고 코드의 가독성을 높여준다.
    • 쉬운 수정: 클래스 파일의 구조를 트리로 표현하기 때문에, CRUD가 간편하다.
    • 예를 들어, 메소드 또는 필드를 추가하려면 해당 노드를 생성하고 부모 노드에 추가하기만 하면 된다.
    • 분석과 변환 용이: Tree API는 바이트코드 분석과 변환 작업을 용이하게 한다.
    • 트리 구조를 통해 전체 클래스의 구조를 한눈에 파악할 수 있으며, 특정 패턴을 검색하거나 변환 로직을 적용하기가 훨씬 쉬워진다.

     
    Core API에 비해 단점 역시도 존재하는데, Tree API는 전체 클래스를 메모리에 로드하여 트리 구조로 유지하기 때문에, 더 많은 메모리를 사용할 수 있다.
    비록 30%정도 시간이 더 소요되기는 하지만, 생성 순서(Visit - VisitCode - VisitEnd 등 .. )를 지켜서 만들어야 했던 Core API와는 다르게 Tree API는 순서에 상관없이 요소들을 추가할 수 있다는 장점이 있다.
     

    ClassNode class

    public class ClassNode extends ClassVisitor {
        public int version; //클래스 파일의 버전
        public int access;  //클래스의 접근 제어자를 나타내는 비트 필드
        public String name;	//내부 형식의 클래스 이름
        public String signature;	//Generic Signature
        public String superName;	//이 클래스가 상속하는 슈퍼 클래스
        public List<String> interfaces; //이 클래스가 구현하는 인터페이스
        public String sourceFile;		//이 클래스가 정의된 소스 파일(디버깅용)
        public String sourceDebug;		//소스 파일에 대한 추가 디버깅 정보 제공 문자열
        public ModuleNode module;		//이 클래스가 속한 모듈에 대한 정보(ModuleNode)
        public String outerClass;		//내부 클래스인 경우, 외부 클래스 이름
        
        // 내부 클래스가 외부 클래스의 특정 메서드 또는 생성자 내에서 정의된 경우, 
        //해당 메서드 또는 생성자의 이름과 서술자
        public String outerMethod;		
        public String outerMethodDesc;
        
        //필드, 메서드 반환 타입, 메서드 매개변수 타입 등에 적용되는 타입 애노테이션, 
        //런타임에 보이거나 보이지 않는 것들의 목록
        public List<AnnotationNode> visibleAnnotations;
        public List<AnnotationNode> invisibleAnnotations;
        public List<TypeAnnotationNode> visibleTypeAnnotations;
        public List<TypeAnnotationNode> invisibleTypeAnnotations;
        
        public List<Attribute> attrs; //클래스에 대한 추가 속성을 나타내는 Attribute 객체의 목록
        public List<InnerClassNode> innerClasses; //이 클래스 내에서 정의된 내부 클래스에 대한 정보(InnerClassNode)
        
        //Java 11의 네스티드 클래스(nested class) 관련 정보
        public String nestHostClass;
        public List<String> nestMembers;
        
      
        public List<String> permittedSubclasses; //Java 17에서 도입된 sealed 클래스에 대한 정보
        public List<RecordComponentNode> recordComponents; //Java 14에서 도입된 Record에 대한 정보
        
        public List<FieldNode> fields; //필드 객체(FieldNode) List
        public List<MethodNode> methods; //Method 객체(MethodNode) List
    }

    예제 및 사용

    https://csg1353.tistory.com/195

    [ASM][TreeAPI]Tree API에서 주로 사용하는 클래스 및 메서드

    개요 Tree API에서 주로 사용하는 클래스 및 메서드에는 여러 가지가 있다. 기본적으로 ClassNode, MethodNode, InsnList가 중요한 역할을 하지만, 이 외에도 여러 유용한 클래스와 인터페이스가 있습니다.

    csg1353.tistory.com

     

    6. Classes

    이 장에서는 ASM 트리 API를 사용하여 클래스를 생성하고 변환하는 방법을 설명한다.
    먼저 트리 API만을 소개한 다음, 코어 API와 함께 구성하는 방법을 설명한다. 메소드, 애너테이션 및 제네릭의 내용에 대한 트리 API는 다음 장에서 설명된다.
     

    6.1. 인터페이스 및 컴포넌트


    6.1.1. 소개

    컴파일된 자바 클래스를 생성하고 변환하기 위한 ASM 트리 API는 ClassNode 클래스를 기반으로 한다(그림 6.1 참조).
     

    public class ClassNode ... {
        public int version;
        public int access;
        public String name;
        public String signature;
        public String superName;
        public List<String> interfaces;
        public String sourceFile;
        public String sourceDebug;
        public String outerClass;
        public String outerMethod;
        public String outerMethodDesc;
        public List<AnnotationNode> visibleAnnotations;
        public List<AnnotationNode> invisibleAnnotations;
        public List<Attribute> attrs;
        public List<InnerClassNode> innerClasses;
        public List<FieldNode> fields;
        public List<MethodNode> methods;
    }

    [ Figure 6.1.: The ClassNode class (only fields are shown)]
     
    이 클래스의 public 필드는 그림 2.1에서 제시된 클래스 파일 구조 섹션에 해당한다.
    이 필드의 내용은 코어 API에서와 같다. 예를 들어, name은 내부 이름이고 signature는 클래스 시그니처이다(섹션 2.1.2 및 4.1 참조).
    일부 필드에는 다른 Xxx Node 클래스가 포함되어 있는데, 이 클래스들은 다음 장에서 자세히 소개되며, 클래스 파일 구조의 하위 섹션에 해당하는 필드를 가진 유사한 구조를 가지고 있다. 예를 들어, FieldNode 클래스는 다음과 같다:
     

    public class FieldNode ...{
            public int access;
            public String name;
            public String desc;
            public String signature;
            public Object value;
            
            public FieldNode(int access, String name, String desc,
                             String signature, Object value) {
                ...
            }   
        ...
    }

     
    MethodNode 클래스도 유사하다:

    public class MethodNode ... {
        public int access;
        public String name;
        public String desc;
        public String signature;
        public List<String> exceptions;
        ...
        public MethodNode(int access, String name, String desc,
        String signature, String[] exceptions)
        {
        ...
        }
    }

     

    6.1.2. 클래스 생성

    트리 API를 사용하여 클래스를 생성하는 것은 단순히 ClassNode 객체를 생성하고 그 필드를 초기화하는 것으로 구성된다. 예를 들어, 섹션 2.2.3의 Comparable 인터페이스는 다음과 같이 구축될 수 있다. 이는 섹션 2.2.3에서와 대략 같은 코드 양이다:

    ClassNode cn = new ClassNode();
    cn.version = V1_5;
    cn.access = ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE;
    cn.name = "pkg/Comparable";
    cn.superName = "java/lang/Object";
            cn.interfaces.add("pkg/Mesurable");
    cn.fields.add(new FieldNode(ACC_PUBLIC + ACC_FINAL + ACC_STATIC,
    "LESS", "I", null, new Integer(-1)));
            cn.fields.add(new FieldNode(ACC_PUBLIC + ACC_FINAL + ACC_STATIC,
    "EQUAL", "I", null, new Integer(0)));
            cn.fields.add(new FieldNode(ACC_PUBLIC + ACC_FINAL + ACC_STATIC,
    "GREATER", "I", null, new Integer(1)));
            cn.methods.add(new MethodNode(ACC_PUBLIC + ACC_ABSTRACT,
    "compareTo", "(Ljava/lang/Object;)I", null, null));

     
    트리 API를 사용하여 클래스를 생성하는 것은 코어 API를 사용하는 것보다 약 30% 더 시간이 걸리며(부록 A.1 참조) 더 많은 메모리를 소비한다. 하지만 이를 통해 클래스 요소를 어떤 순서로든 생성할 수 있으며, 이는 일부 경우에 편리할 수 있다.
     

    6.1.3. 클래스 멤버 추가 및 제거

     
    클래스 멤버를 추가하거나 제거하는 것은 단순히 ClassNode 객체의 필드 또는 메소드 목록에 요소를 추가하거나 제거하는 것으로 구성된다. 예를 들어, 클래스 변환기를 다음과 같이 정의하여 클래스 변환기를 쉽게 구성할 수 있도록 한다면,

    public class ClassTransformer {
        protected ClassTransformer ct;
        public ClassTransformer(ClassTransformer ct) {
            this.ct = ct;
        }
        public void transform(ClassNode cn) {
            if (ct != null) {
                ct.transform(cn);
            }
        }
    }

     
    그러면 섹션 2.2.5의 RemoveMethodAdapter는 다음과 같이 구현될 수 있다:

    public class RemoveMethodTransformer extends ClassTransformer {
        private String methodName;
        private String methodDesc;
        public RemoveMethodTransformer(ClassTransformer ct,
                                       String methodName, String methodDesc) {
            super(ct);
            this.methodName = methodName;
            this.methodDesc = methodDesc;}
        @Override public void transform(ClassNode cn) {
            Iterator<MethodNode> i = cn.methods.iterator();
            while (i.hasNext()) {
                MethodNode mn = i.next();
                if (methodName.equals(mn.name) && methodDesc.equals(mn.desc)) {
                    i.remove();
                }
            }
            super.transform(cn);
        }
    }

     
    코어 API와의 주요한 차이점은 "모든 메소드를 반복해야 한다는 것"이다.
    코어 API를 사용할 때는 이 작업을 할 필요가 없다(이는 ClassReader에서 대신 수행된다).
    실제로 이 차이점은 거의 모든 트리 기반 변환에 해당한다. 예를 들어, 섹션 2.2.6의 AddFieldAdapter도 트리 API로 구현할 때 반복자가 필요하다:
     

    public class AddFieldTransformer extends ClassTransformer {
        private int fieldAccess;
        private String fieldName;
        private String fieldDesc;
        public AddFieldTransformer(ClassTransformer ct, int fieldAccess,
                                   String fieldName, String fieldDesc) {
            super(ct);
            this.fieldAccess = fieldAccess;
            this.fieldName = fieldName;
            this.fieldDesc = fieldDesc;
        }
        @Override public void transform(ClassNode cn) {
            boolean isPresent = false;
            for (FieldNode fn : cn.fields) {
                if (fieldName.equals(fn.name)) {
                    isPresent = true;
                    break;
                }
            }
            if (!isPresent) {
                cn.fields.add(new FieldNode(fieldAccess, fieldName, fieldDesc,
                        null, null));
            }
            super.transform(cn);
        }
    }

     
    클래스 생성과 마찬가지로, 트리 API를 사용하여 클래스를 변환하는 것은 더 많은 시간을 소비하고 더 많은 메모리를 사용한다. 하지만 이는 일부 변환을 더 쉽게 구현할 수 있게 한다.
    예를 들어, 클래스의 내용에 디지털 서명을 포함하는 애너테이션을 추가하는 변환의 경우,
    "코어 API를 사용하면 전체 클래스가 방문된 후에만 디지털 서명을 계산"할 수 있지만, 그 시점에는 애너테이션을 추가하기에는 너무 늦다. 왜냐하면 애너테이션은 클래스 멤버보다 먼저 방문되어야 하기 때문이다. 트리 API를 사용하면 이러한 제약이 없어진다.
     
    = 자동 방문과 순서의 차이에 따른 trade off가 있다.
     
    실제로 코어 API만을 사용하여 AddDigitialSignature 예제를 구현할 수 있지만, 그러면 클래스는 두 번의 패스를 거쳐 변환되어야 한다. 첫 번째 패스에서는 클래스의 내용을 기반으로 디지털 서명을 계산하기 위해 ClassReader(및 ClassWriter 없이)로 클래스를 방문한다.
    두 번째 패스에서는 동일한 ClassReader를 재사용하여 클래스를 두 번째로 방문한다. 이번에는 AddAnnotationAdapter가 ClassWriter에 연결된다. 이러한 논의를 일반화하면, 사실 필요한 경우 여러 패스를 사용하여 코어 API만으로 어떤 변환도 구현할 수 있다는 것을 알 수 있다. 하지만 이는 변환 코드의 복잡성을 증가시키고, 패스 간에 상태를 저장해야 한다(이는 전체 트리 표현만큼 복잡할 수 있다!), 그리고 클래스를 여러 번 파싱하는 데는 비용이 들며, 이 비용은 해당 ClassNode를 구성하는 비용과 비교해야 한다.
     
    결론적으로, 트리 API는 코어 API로 한 번의 패스로 구현할 수 없는 변환에 대해 일반적으로 사용된다. 물론 예외는 있다. 예를 들어, 난독화기는 한 번의 패스로 구현할 수 없다. 왜냐하면 모든 클래스를 파싱하기 전에는 원래 이름에서 난독화된 이름으로의 매핑이 완전히 구성될 수 없기 때문이다. 그러나 트리 API도 좋은 해결책이 아니다. 왜냐하면 난독화할 모든 클래스의 객체 표현을 메모리에 유지해야 하기 때문이다. 이 경우에는 코어 API를 두 번의 패스로 사용하는 것이 더 낫다: 하나는 원래 이름과 난독화된 이름 사이의 매핑을 계산하기 위한 것(전체 객체 표현보다 훨씬 적은 메모리를 필요로 하는 간단한 해시 테이블)이고, 다른 하나는 이 매핑을 기반으로 클래스를 변환하기 위한 것이다.
     

    6.2. 컴포넌트 구성

    지금까지는 ClassNode 객체를 생성하고 변환하는 방법만을 보았지만, 클래스의 바이트 배열 표현에서 ClassNode를 구성하는 방법이나 그 반대로 ClassNode에서 이 바이트 배열을 구성하는 방법은 보지 못했다. 실제로 이는 코어 API와 트리 API 컴포넌트를 구성함으로써 수행된다. 이 섹션에서 설명한다.

    6.2.1. 소개

    그림 6.1에 표시된 필드 외에도, ClassNode 클래스는 ClassVisitor 클래스를 확장하며, ClassVisitor를 매개변수로 받는 accept 메소드도 제공한다. accept 메소드는 ClassNode 필드 값에 기반한 이벤트를 생성하며, ClassVisitor 메소드는 반대 작업을 수행한다. 즉, 받은 이벤트를 기반으로 ClassNode 필드를 설정한다:
     

    public class ClassNode extends ClassVisitor {
    ...
        public void visit(int version, int access, String name,
                          String signature, String superName, String[] interfaces[]) {
            this.version = version;
            this.access = access;
            this.name = name;
            this.signature = signature;
    ...
        }
    ...
        public void accept(ClassVisitor cv) {
            cv.visit(version, access, name, signature, ...);
    ...
        }
    }

     
     
    ClassNode 역시도 ClassVisitor의 구현체의 일부인 것이다. 즉, 이전처럼 ClassVisitor의 역할을 하던 Core API와 유사하게 동작할 수 있다. 따라서 바이트 배열에서 ClassNode를 구성하는 것은 ClassReader와 함께 구성함으로써 수행될 수 있다.
     
    즉, ClassReader에 의해 생성된 이벤트가 ClassNode 컴포넌트에 의해 소비되어 그 필드가 초기화된다(위 코드에서 볼 수 있듯이):

    ClassNode cn = new ClassNode();
    ClassReader cr = new ClassReader(...);
    cr.accept(cn, 0);

     
    대칭적으로, ClassNode는 ClassWriter와 함께 구성함으로써 그 바이트 배열 표현으로 변환될 수 있다. 즉, ClassNode의 accept 메소드에 의해 생성된 이벤트가 ClassWriter에 의해 소비된다:

    ClassWriter cw = new ClassWriter(0);
    cn.accept(cw);
    byte[] b = cw.toByteArray();

     

    6.2.2. 패턴

    트리 API를 사용하여 클래스를 변환하는 것은 이러한 요소들을 함께 사용함으로써 수행될 수 있다:

    ClassNode cn = new ClassNode(ASM4);
    ClassReader cr = new ClassReader(...);
    cr.accept(cn, 0);
    ... // here transform cn as you want
    ClassWriter cw = new ClassWriter(0);
    cn.accept(cw);
    byte[] b = cw.toByteArray();

     
    코어 API와 함께 트리 기반 클래스 변환기를 클래스 어댑터처럼 사용하는 것도 가능하다. 이를 위해 두 가지 일반적인 패턴이 사용된다. 첫 번째는 상속을 사용한다:

    public class MyClassAdapter extends ClassNode {
        public MyClassAdapter(ClassVisitor cv) {
            super(ASM4);
            this.cv = cv;
        }
        @Override public void visitEnd() {
    // put your transformation code here
            accept(cv);
        }
    }

     
    이 클래스 어댑터가 일반적인 변환 체인에서 사용될 때:

    ClassWriter cw = new ClassWriter(0);
    ClassVisitor ca = new MyClassAdapter(cw);
    ClassReader cr = new ClassReader(...);
    cr.accept(ca, 0);
    byte[] b = cw.toByteArray();

     
     
    cr에 의해 생성된 이벤트는 ClassNode ca에 의해 소비되어, 이 객체의 필드가 초기화된다. visitEnd 이벤트가 소비될 때, ca는 변환을 수행하고, accept 메소드를 호출함으로써 변환된 클래스에 해당하는 새 이벤트를 생성한다. 이는 cw에 의해 소비된다. 해당 시퀀스 다이어그램은 그림 6.2에서 보여진다. ca가 클래스 버전을 변경한다고 가정하면.
     

    Figure 6.2.: Sequence diagram for MyClassAdapter

     
    그림 2.7의 ChangeVersionAdapter에 대한 시퀀스 다이어그램과 비교할 때, ca와 cw 사이의 이벤트가 cr과 ca 사이의 이벤트 후에 발생하는 것을 볼 수 있다. 즉, 일반 클래스 어댑터와 동시에 발생하는 대신에 발생한다. 실제로 이는 모든 트리 기반 변환에서 발생하며, 이는 그들이 이벤트 기반 변환보다 제약이 적음을 설명한다.
     
    두 번째 패턴은 상속 대신 대리를 사용하여 동일한 결과를 달성할 수 있으며, 유사한 시퀀스 다이어그램을 사용한다:

    public class MyClassAdapter extends ClassVisitor {
        ClassVisitor next;
        public MyClassAdapter(ClassVisitor cv) {
            super(ASM4, new ClassNode());
            next = cv;
        }
        @Override public void visitEnd() {
            ClassNode cn = (ClassNode) cv;
    // put your transformation code here
            cn.accept(next);
        }
    }

     
    이 패턴은 하나 대신 두 개의 객체를 사용하지만, 첫 번째 패턴과 정확히 동일한 방식으로 작동한다: 받은 이벤트는 ClassNode를 구성하는 데 사용되며, 마지막 이벤트를 받았을 때 이벤트 기반 표현으로 다시 변환된다.
     
    두 패턴 모두 트리 기반 클래스 어댑터를 이벤트 기반 어댑터와 함께 구성할 수 있게 해준다. 또한 트리 기반 어댑터를 서로 구성하는 데에도 사용될 수 있지만, 트리 기반 어댑터만 구성해야 하는 경우에는 이것이 최선의 해결책이 아니다: 이 경우 ClassTransformer와 같은 클래스를 사용하면 두 표현 사이의 불필요한 변환을 피할 수 있다.
     

    Reference

     
    https://asm.ow2.io/asm4-guide.pdf
     
    ASM USER GUIDE
     
    Copyright c 2007, 2011 Eric Bruneton All rights reserved. Redistribution and use in source (LYX format) and compiled forms (LATEX, PDF, PostScript, HTML, RTF, etc), with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code (LYX format) must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in compiled form (converted to LATEX, PDF, PostScript, HTML, RTF, and other formats) must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. The name of the author may not be used to endorse or promote products derived from this documentation without specific prior written permission.
     
    THIS DOCUMENTATION IS PROVIDED BY THE AUTHOR “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.