Java Lombok @Get 직접 구현해보면서 프로세서 분석하기.
롬복을 만들어보면서 어노테이션이 어떻게? 동작하는지 알아보자.
참고자료: https://catch-me-java.tistory.com/49 => 원론적인 부분
https://catsbi.oopy.io/78cee801-bb9c-44af-ad1f-dffc5a541101
다른 API를 이용하여 아예 클래스를 컴파일단계에서 새로 만드는 방법을 제시
기존에 있는것을 수정이 불가능함
🔅 환경구성
- Intellij: 2021.2.1
- Language: Java 8
- ProjectName: customLombok
- 구현내용: @Get(getter)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?xml version="1.0" encoding="UTF-8"?>
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.8</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>me.kms</groupId>
<artifactId>customLombok</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>customLombok</name>
<description>customLombok</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<auto-service.version>1.0-rc4</auto-service.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>${auto-service.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.olivergondza</groupId>
<artifactId>maven-jdk-tools-wrapper</artifactId>
<version>0.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 스프링 부트 3버전은 Java17버전부터 지원하므로 그 아래 단계를 선택
🔅1. Annotation Get
1.1 @Get 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package mylombok;
import java.lang.annotation.*;
/**
* @Target의 ElementType.TYPE에 의해 클래스/인터페이스/열거/레코드 타입에 어노테이션을 붙일 수 있게 되었다.
* @Get의 기본방식은 컴파일 이후에는 쓰이지 않게 구성되어 있으므로, RentetionPolicy를 SOURCE로 해둬서 컴파일 이전까지 쓰이게 한다.
*
* @author kms
* */
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Get {
}
1.2 Get 어노테이션 프로세스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SupportedAnnotationTypes("me.kms.anno.Get")
@AutoService(Processor.class)
public class GetProcessor extends AbstractProcessor {
@Override
public SourceVersion getSupportedSourceVersion(){
return SourceVersion.RELEASE_8;
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
//logic
return true;
}
}
@SupportedAnnotationTypes
- 인자로 들어온 값의 경로를 찾고 해당 어노테이션의 값을 사용하여, 어떠한 처리를 할 수 있게 도와준다.
- 이는
Processor
라는 인터페이스에 정의된getSupportedAnnotationTypes
라는 메서드를 통해 인자로 들어온 어노테이션의 이름을 반환하여 작업을 할 수 있게 도와준다. - 우리가 extends한
AbstractProcessor
클래스는 위의Processor
를 구현한 구현체이며, 우리가 필요한것들만 오버라이드 해서 사용할 것이다.
- 이는
- Annotation Processor를 지원하는 어노테이션 인터페이스를 나타내는 어노테이션임을 뜻한다.
- Annotation Processor란 어노테이션을 처리하는 일종의 프로그램이다.
- 이를통해 Annotation Processor는 특정 어노테이션 인터페이스를 사용하는 클래스를 검색하여 처리할 수 있게 된다.
즉, 코드에서는 mylombok
패키지에 있는 Get
어노테이션을 처리하도록 도와주는 소스이다.
@SupportedSourceVersion(SourceVersion.RELEASE_8)
- 밑의
getSupportedSourceVersion()
로 대체가 가능하다. - 특정 버전에서의 자바에서만 사용가능하도록 지정할 수가 있다. 위의 예시는 자바8버전에서만 사용가능하다고 명시한 예시다.
- 여러 버전을 지정하고 싶다면
@SupportedSourceVersion(value = {RELEASE_8,RELEASE_11,RELEASE_14})
이런식으로 사용이 가능하다.
- 여러 버전을 지정하고 싶다면
@AutoService(Processor.class)
원래는 resources / META-INF /javax.annotation.processing.Processor
파일에 다음과 같은 내용을 써주고 컴파일 해야한다.
1
me.kms.anno.GetProcessor
이렇게 작성하고 mvn clean install
을 수행하면 에러가 발생하는데, 메이븐이 소스 컴파일 하는 시점에 프로세서가 동작하려고 하니, 아직 컴파일 되지 않은 소스를 읽으려 하면서 에러가 나오는 것이다.
그래서 위의 문구를 주석처리하고, 다시 컴파일 하면 된다.
그리고 다시 주석을 풀고, mvn install
을 수행하면 된다.
이런 일련의 과정이 귀찮기에 @AutoService
어노테이션을 사용하면 위의 문제를 해결해줄 수 있다. 컴파일 시점에 어노테이션 프로세서를 활용해서 파일을 자동으로 생성해주는 것이다.
이제 process()
에 로직을 구성해줘야한다.
1.3 init()
구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private ProcessingEnvironment processingEnvironment;
private Trees trees;
private TreeMaker treeMaker;
private Names names;
private Context context;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
JavacProcessingEnvironment javacProcessingEnvironment = (JavacProcessingEnvironment) processingEnv;
super.init(processingEnv);
this.processingEnvironment = processingEnv;
this.trees = Trees.instance(processingEnv);
this.context = javacProcessingEnvironment.getContext();
this.treeMaker = TreeMaker.instance(context);
this.names = Names.instance(context);
}
process()
를 구현하기 전에 필요한 정보들을 설정해야한다. 그에 대한 정보를 맞춰주는 메서드다.
JavacProcessingEnvironment javacProcessingEnvironment = (JavacProcessingEnvironment) processingEnv;
이 부분 코드 때문에 에러가 발생한다.
이러한 에러로, https://github.com/mplushnikov/lombok-intellij-plugin/issues/988를 참고하여 해결하면 된다.
javacProcessingEnvironment.getContext()
에서 반환하는 컨택스트는 컴파일러 정보, 등을 반환하고 또한 해당 컨택스트를 통해 메서드나 클래스, 어노테이션 프로세서를 만들 수 있게 도와주는 정보를 제공한다.
다른 변수들은 밑에서 어떻게 쓰이는지 확인하면서 찾아가보는게 낫겠다 생각했다.
1.4 process()
구현
1
2
3
4
5
6
7
8
9
10
11
12
for (final Element element : roundEnv.getElementsAnnotatedWith(Get.class)) {
System.out.println("element:" + element);
if(element.getKind() != ElementKind.CLASS){
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "@Get annotation cant be used on" + element.getSimpleName());
}else{
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "@Get annotation Processing " + element.getSimpleName());
final TreePath path = trees.getPath(element);
scanner.scan(path, path.getCompilationUnit());
}
}
이 코드는 어렵지는 않다. 저 for
문은 프로젝트 내에 있는 모든 파일의 클래스를 검사해 Get
어노테이션이 붙어 있는것만 가져오는 문장이다.
이렇듯 다른 패키지나 다른 곳에 클래스가 있어도
컴파일할 때 @Get
어노테이션이 있으면 가져온다.
가져오고 나서는 element.getKind() != ElementKind.CLASS
라는 문구를 통해 해당 어노테이션이 클래스타입에 붙어있는지 확인하고 맞다면 else
문을, 아니면 if
문을 진행하여 에러를 발생시키고 끝내버린다.
내가 InterfaceLombok
이라는 인터페이스를 만들고, @Get
어노테이션을 달았다면 컴파일할 때 에러가 뜰 것이다.
final TreePath path = trees.getPath(element);
TreePath란 자바 프로그램에서 사용되는 코드, 메서드, 클래스, 변수등을 하나의 트리형태의 자료구조로 관리하는 클래스이다.
그래서 getPath()
메소드를 통해 개발자가 원하는 특정 노드의 경로를 찾을 수 있다.
이를 출력하려면 getPathCoponent()
메서드를 통해 호출 할 수 있다.
1
2
3
4
//..위에 for문으로 @Get이 달린 모든 요소들을 element에 넣고 있다.
final TreePath path = trees.getPath(element);
System.out.println(path.getCompilationUnit());
//scanner.scan(path, path.getCompilationUnit());
실제로 이를 호출해보면
@Get
어노테이션이 달린 파일의 소스코드를 모두 가지고 있음을 알 수 있다. 여담으로 컴파일러가 생성자가 없는 경우에는 기본 생성자도 알아서 만들어주고, 한글 문자가 유니코드로 변환됨을 알 수 있다.
scanner.scan(path, path.getCompilationUnit());
scanner는 위에서 변수로 작성되어있는데, 어떻게 작성되어있는지 보겠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
TreePathScanner<Object, CompilationUnitTree> scanner = new TreePathScanner<Object, CompilationUnitTree>(){
@Override
public Trees visitClass(ClassTree classTree, CompilationUnitTree unitTree){
JCTree.JCCompilationUnit compilationUnit = (JCTree.JCCompilationUnit) unitTree;
// .java 파일인지 확인후 accept 를 통해 treeTransLator, 작성 메소드 생성
if (compilationUnit.sourcefile.getKind() == JavaFileObject.Kind.SOURCE){
compilationUnit.accept(new TreeTranslator() {
@Override
public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
super.visitClassDef(jcClassDecl);
// Class 내부에 정의된 모든 member 를 싹다 가져옴.
List<JCTree> members = jcClassDecl.getMembers();
// Syntax tree 에서 모든 member 변수 get
for(JCTree member : members){
if (member instanceof JCTree.JCVariableDecl){
// member 변수에 대한 getter 메서드 생성
List<JCTree.JCMethodDecl> getters = createGetter((JCTree.JCVariableDecl) member);
for(JCTree.JCMethodDecl getter : getters){
jcClassDecl.defs = jcClassDecl.defs.prepend(getter);
}
}
}
}
});
}
return trees;
}
};
이 코드를 하나씩 까볼 필요가 있다.
TreePathScanner<Object, CompilationUnitTree>
이 클래스는 자바 컴파일러API가 제공하는 클래스로, 자바 소스 트리 구조를 순회하면서 개발자의 명령어를 수행할 수 있는 트리구조이다.
각 요소에 대해서, 첫 번째 자료형은 해당 클래스의 리턴타입을 말하고, 두 번째 자료형은 트리의 루트 요소 타입을 말한다.
즉, 이 클래스는 Object
형을 반환할 것이고, 트리의 루트 요소 타입은 ComilationUnitTree
타입이 될 것이다.
그리고 이는 visitClass()
를 오버라이드 하게 되어있는데 이는
scanner.scan(path, path.getCompilationUnit());
이 소스에서 호출하고 있다.
디버그를 찍어보면
scan()
메소드의 accept()
라는 메서드가 있는데, 이 부분을 들어가게 되면
오버라이드한 visitClass()
를 호출하게 되어있다.
public Trees visitClass(ClassTree classTree, CompilationUnitTree unitTree)
에서 그러면 classTree
는 무엇이고, unitTree
는 무엇인가
classTree
는 패키지, 임포트 문을 제외한 자바 소스코드 그 자체이고, unitTree
는 패키지, 임포트문을 포함한 자바 소스 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//classTree
@Get()
public class Test {
public Test() {
super();
}
}
//unitTree
package me.kms.animal;
import me.kms.anno.Get;
@Get()
public class Test {
public Test() {
super();
}
}
그 밑은 자바 코드인지 확인하고 맞다면 TreeTranslator
클래스를 만들어서 특정 행동을 수행하게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// java파일인지 확인
if (compilationUnit.sourcefile.getKind() == JavaFileObject.Kind.SOURCE){
compilationUnit.accept(new TreeTranslator() {
@Override
public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
super.visitClassDef(jcClassDecl);
// Class 내부에 정의된 모든 member 를 싹다 가져옴.
List<JCTree> members = jcClassDecl.getMembers();
// Syntax tree 에서 모든 member 변수 get
for(JCTree member : members){
if (member instanceof JCTree.JCVariableDecl){
// member 변수에 대한 getter 메서드 생성
List<JCTree.JCMethodDecl> getters = createGetter((JCTree.JCVariableDecl) member);
for(JCTree.JCMethodDecl getter : getters){
jcClassDecl.defs = jcClassDecl.defs.prepend(getter);
}
}
}
}
});
}
즉, 우리는 다시 visitClassDef()
가 언제 호출되는지, 호출 내용에 대해 알 필요가 있다.
- 먼저,
compilationUnit.accept
로 들어갈 필요가 있다.
1
2
3
public void accept(JCTree.Visitor var1) {
var1.visitTopLevel(this);
}
이렇게 구현되어있으며, 이를 또 타고 들어가면
translate()
함수는
1
2
3
4
5
6
7
8
9
10
11
public <T extends JCTree> List<T> translate(List<T> var1) {
if (var1 == null) {
return null;
} else {
for(List var2 = var1; var2.nonEmpty(); var2 = var2.tail) {
var2.head = this.translate((JCTree)var2.head);
}
return var1;
}
}
이렇게 구현되어있다. var
는 import문과 소스코드를 나눈 스트링을 리스트로 가지고 있다.
1
2
3
4
5
6
7
8
9
10
11
//var1[0]
import me.kms.anno.Get;
//var1[1]
@Get()
public class Test {
public Test() {
super();
}
}
그래서 실질적으로 var1
을 돌면서 this.translate((JCTree)var2.head)
이 문장을 수행해준다.
여기서 head
란 인덱스로 봐도 무방하다. tail
은 끝의 인덱스를 지칭한다.
그래서 this.translate()
도 봐야하는데,
1
2
3
4
5
6
7
8
9
10
public <T extends JCTree> T translate(T var1) {
if (var1 == null) {
return null;
} else {
var1.accept(this);
JCTree var2 = this.result;
this.result = null;
return var2;
}
}
이렇게 구현이 되어있다.
내부는 좀 더 복잡하지만, 간단하게 import
로 선언된 경로를 하나하나 파싱해가면서 찾아가는 역할을한다. me.kms.anno.Get으로 되어있다면 me를 들리고 그다음 kms를 들리고 .. 이런 작업을 진행한다.
그래서 import
문을 이렇게 찾아가고 위의 코드에서 var1[1]
인 소스코드 부분도 accept()
함수를 호출하게 처리하게 된다.
이는 좀 더 다르게 동작하는데, 그 이유는 둘의 자료형이 다르기 때문이다.
그래서 var1[1]
은 JCClassDecl
의 accept()
를 호출하게 되는데 다음과 같이 구현되어있다.
1
2
3
public void accept(JCTree.Visitor var1) {
var1.visitClassDef(this);
}
여기서 우리가 오버라이드해준 vistiClassDef()
가 호출이 되는 것이다.
그럼 다시 돌아가야한다.
그래서 일단 오버라이드한 함수 인자로 임포트문을 제외한 소스코드가 있다고 인지하고 시작하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
super.visitClassDef(jcClassDecl);
// Class 내부에 정의된 모든 member 를 싹다 가져옴.
List<JCTree> members = jcClassDecl.getMembers();
// Syntax tree 에서 모든 member 변수 get
for(JCTree member : members){
if (member instanceof JCTree.JCVariableDecl){
// member 변수에 대한 getter 메서드 생성
List<JCTree.JCMethodDecl> getters = createGetter((JCTree.JCVariableDecl) member);
for(JCTree.JCMethodDecl getter : getters){
jcClassDecl.defs = jcClassDecl.defs.prepend(getter);
}
}
}
}
내부 구현을 보자
super.visitClassDef()
는 다음과 같이 구현되어 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public void visitClassDef(JCClassDecl var1) {
//어노테이션 정보를 가지고 있다.
var1.mods = (JCModifiers)this.translate((JCTree)var1.mods);
//없음
var1.typarams = this.translateTypeParams(var1.typarams);
//없음
var1.extending = (JCExpression)this.translate((JCTree)var1.extending);
//없음
var1.implementing = this.translate(var1.implementing);
//함수에 관한 정보
var1.defs = this.translate(var1.defs);
this.result = var1;
}
인자로 넘어온 값들을 가지고 초기화를 수행한다.
기존에는 빈 클래스를 예시르 들었지만, 이해를 돕기 위해 이번에는 멤버변수를 3개 가지고 있는 클래스를 가지고 디버그를 할 것이다.
List<JCTree> members = jcClassDecl.getMembers();
이 구문은 간단하게 구현되어있다.
1
2
3
public List<JCTree> getMembers() {
return this.defs;
}
이렇게 간단하게 구현되어, 멤버 변수, 멤버 함수를 반환한다.
그 후, 멤버 변수면 해당 멤버 변수를 가지고 Getter
를 만들게 되는데, 이 부분은 개발자가 직접 작성해주어야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public List<JCTree.JCMethodDecl> createGetter(JCTree.JCVariableDecl var){
// 필드 이름 변수에 앞문자 대문자로 변경 해주기
String str = var.name.toString();
String upperVar = str.substring(0,1).toUpperCase()+str.substring(1,var.name.length());
return List.of(
/**
* treeMaker.Modifiers -> syntax tree node 에 접근하여 수정및 삽입하는 역할
* @Parm : treeMaker.Modifiers flag 1-> public , 2-> private, 0-> default
* @Parm : methodName & Type, return 정의
*/
treeMaker.MethodDef(
treeMaker.Modifiers(1), // public
names.fromString("get".concat(upperVar)), // 메서드 명
(JCTree.JCExpression) var.getType(), // return type
List.nil(),
List.nil(),
List.nil(),
// 식생성 this.a = a;
treeMaker.Block(1, List.of(treeMaker.Return((treeMaker.Ident(var.getName()))))),
null));
}
대부분의 내용은 주석을 보면 이해가 되지만 treeMaker.Block()
메서드 생성 부분은 짚고 넘어갈만하다.
이 함수는 함수 블럭을 만드는 함수로,
- 첫 번째 인자값은 접근 지정자이다. 1은 public을 의미한다.
- 두 번째 인자값은 리스트를 주어야하고, 블록 한 줄을 작성할 수 있게 한다. 저 구문에서는 리턴 값에 쓸 문자열을 지정해준것이다.
결국,
1
2
3
public {
return age;
}
이런 구문이 만들어질 것이다.
그래서 결국, treeMaker에서 제공하는 MethodDef()
메서드를 통해
1
2
3
public int getAge() public {
return age;
}
를 반환하게 된다.
이렇게 모든 멤버변수를 돌면서 getter
를 만들고, 이를 JCTree에 넣어준다.
그렇게 트리로 만들어진 코드는 컴파일 되어서 클래스 파일로 만들어진다.
다음엔 이 방식으로 Getter를 만들어보겠다.