扩展lombok是我一直想做的一件事。本文将浅浅介绍lombok原理着重介绍如何扩展lombok。本文前置知识是抽象语法树,不过没有也没关系。

环境准备

拉代码 git@github.com:projectlombok/lombok.git

ant

lombok是一个ant项目,使用ivy管理依赖,lombok作者有一个ivyplusplus项目增强了ivy,在lombok项目会使用到。
ivy使用的主仓库依然是maven仓库。
安装ant并配置环境变量则可以开始处理lombok项目了。

eclipse

lombok项目推荐使用eclipse开发与调试,intellij可以方便开发但是调试起来很不方便。

转换项目并导入

在lombok项目根目录下面执行:

ant eclipse

可以将项目转换成eclipse可以直接导入的项目。
注:同理ant intellij可以转换成idea可以直接导入的项目。
这个命令会下载依赖、标记必要的目录为source目录、进行基本的项目配置等等等等。这些流程都是配置在ant配置里面的。
注:如果下载依赖太慢,你可以将buildScripts/ivysettings.xml里面的中央仓库地址改成国内镜像地址(顺便一提,同级的ivy.xml配置了项目依赖的包,配置了依赖),如下:

<ivysettings>
    <resolvers>
        <chain name="projectRepos">
            <filesystem name="projectLocalRepo">
                <ivy pattern="${ivy.settings.dir}/ivy-repo/[organization]-[module]-[revision].xml" />
            </filesystem>
            <ibiblio name="eclipse-staging-repo" m2compatible="true" root="https://repo.eclipse.org/content/repositories/eclipse-staging" />
            <ibiblio name="maven-repo2" m2compatible="true" root="https://maven.aliyun.com/repository/central" />
        </chain>
    </resolvers>
    <settings defaultResolver="projectRepos" validate="false" />
    <caches defaultCacheDir="${ivy.basedir}/ivyCache" />
</ivysettings>

不过理论上不会带来多大速度提升,你可以看到有些依赖是需要从eclipse仓库和lombok官网(比如ivyplusplus.jar,和几个javac的jar,和几个runtime的jar)这些都是国外的,所以只改中央仓库是不能解决全部问题的。说实话我从来没有像搞这个项目一样讨厌墙。
注:ivyplusplus可以在github拉下来自己编译jar(依赖apache ivy),javac可以在tools.jar可以找到(如果jdk9以上,则在java.compiler模块)。不过基本上没什么用。建议还是老老实实拉依赖。
注:如果你觉得太慢了,建议发邮件问我要依赖。
注:建议ant或者ant help查看lombok的一些其他操作。会给你的开发带来帮助。

此时,你的项目已经准备好导入到eclipse了。

file > import > project from folder or archive

注:如果你是导入到idea,则需要设置jdk: file > project structure,同时可能还需要手动导入相关的依赖库比如javac、ecj和eclipse的库(在项目lib下面可以找到)。但是你无法直接debug调试你的代码,你可以直接开发然后通过ant打包执行单元测试。也许作者会在后续版本会解决,但是现在还未解决。

此时,你已经准备好开发了。

开发

这里以一个简单的@ToJsonString注解为例子。这个注解的功能会在当前生成一个toJsonString方法,调用fastjson的toJsonString方法,将当前对象this序列化为json字符串。

ivy.xml引入fastjson(当然,也可以不引入,它不会影响打包和你的调试):

      <dependency org="com.alibaba" name="fastjson" rev="2.0.4"  conf="test->master"/>

注: ant deps 命令可以重新下载依赖。其实你只需要将fastjson库加入到你classpath中就可以解决test message fastjson无法找到的问题。

创建ToJsonString注解:

package lombok.extern.json;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.SOURCE;

/**
 * Annotation for generating a JSON string from a class.
 * create a toJsonString method in the class.
 */
@Target({TYPE})
@Retention(SOURCE)
public @interface ToJsonString {
}

添加使用标识:

public class ConfigurationKeys {
    // 此处省略一大堆
    public static final ConfigurationKey<FlagUsageType> TO_JSON_STRING_FLAG_USAGE = new ConfigurationKey<FlagUsageType>("lombok.extern.json.toJsonString.flagUsage", "Emit a warning or error if @ToJsonString is used.") {};
}

实现JavacAnnotationHandler:

package lombok.javac.handlers;

import com.sun.tools.javac.code.Flags;
import com.sun.tools.javac.tree.JCTree.*;
import com.sun.tools.javac.util.List;
import lombok.ConfigurationKeys;
import lombok.core.AnnotationValues;
import lombok.extern.json.ToJsonString;
import lombok.javac.JavacAnnotationHandler;
import lombok.javac.JavacNode;
import lombok.javac.JavacTreeMaker;
import lombok.spi.Provides;

import static lombok.core.handlers.HandlerUtil.handleFlagUsage;
import static lombok.javac.handlers.JavacHandlerUtil.*;

/**
 * Handles the {@code ToJsonString} annotation for javac. 生成一个toJsonString方法,使用Fastjson库生成json字符串
 */
@Provides
public class HandleToJsonString extends JavacAnnotationHandler<ToJsonString> {
    @Override public void handle(AnnotationValues<ToJsonString> annotation, JCAnnotation ast, JavacNode annotationNode) {
        handleFlagUsage(annotationNode, ConfigurationKeys.TO_JSON_STRING_FLAG_USAGE, "@ToJsonString");
        
        deleteAnnotationIfNeccessary(annotationNode, ToJsonString.class); 
        JavacNode javacNode = annotationNode.up();
        // 只能作用在类上面
        if (!isClassOrEnum(javacNode)) {
            annotationNode.addError("@ToJsonString is only supported on a class or enum.");
            return;
        }
        // 幂等性检查
        MemberExistsResult memberExistsResult = methodExists("toJsonString", javacNode, 0);
        if(memberExistsResult==MemberExistsResult.EXISTS_BY_USER) {
            annotationNode.addWarning("@ToJsonString is ignored because a method named toJsonString already exists.");
        }
        // 如果不存在或者存在lombok生成的方法,则生成或者覆盖

        JCMethodDecl methodDecl = createToJsonString(javacNode,annotationNode);
        injectMethod(javacNode, methodDecl);

    }

    static JCMethodDecl createToJsonString(JavacNode typeNode,JavacNode annotationNode) {

        JavacTreeMaker maker = typeNode.getTreeMaker();
        // 注解@Override
//        JCAnnotation overrideAnnotation = maker.Annotation(genJavaLangTypeRef(typeNode, "Override"), List.<JCExpression>nil());;

        // @Override public
//        JCModifiers mods = maker.Modifiers(Flags.PUBLIC, List.of(overrideAnnotation));
        JCModifiers mods = maker.Modifiers(Flags.PUBLIC);
        // String 返回值类型
        JCExpression returnType = genJavaLangTypeRef(typeNode, "String");

        // com.alibaba.fastjson.JSON#toJSONString(java.lang.Object)
        JCExpression body = chainDots(typeNode, "com", "alibaba", "fastjson", "JSON", "toJSONString");
        // this
        JCIdent aThis = maker.Ident(typeNode.toName("this"));

        // com.alibaba.fastjson.JSON#toJSONString(this)
        JCMethodInvocation jcMethodInvocation = maker.Apply(List.<JCExpression>nil(),body, List.<JCExpression>of(aThis));

        // return com.alibaba.fastjson.JSON#toJSONString(this)
        JCReturn aReturn = maker.Return(jcMethodInvocation);
        // 块
        JCBlock block = maker.Block(0, List.<JCStatement>of(aReturn));
        // toJsonString方法
        JCMethodDecl methodDef = maker.MethodDef(mods, typeNode.toName("toJsonString"), returnType,
                List.<JCTypeParameter>nil(), List.<JCVariableDecl>nil(), List.<JCExpression>nil(), block, null);

        createRelevantNonNullAnnotation(typeNode, methodDef);
        return recursiveSetGeneratedBy(methodDef, annotationNode);
    }
}

实现EclipseAnnotationHandler

package lombok.eclipse.handlers;

import lombok.AccessLevel;
import lombok.ConfigurationKeys;
import lombok.core.AnnotationValues;
import lombok.eclipse.Eclipse;
import lombok.eclipse.EclipseAnnotationHandler;
import lombok.eclipse.EclipseNode;
import lombok.extern.json.ToJsonString;
import lombok.spi.Provides;
import org.eclipse.jdt.internal.compiler.ast.*;
import org.eclipse.jdt.internal.compiler.lookup.TypeConstants;

import static lombok.core.handlers.HandlerUtil.handleFlagUsage;
import static lombok.eclipse.handlers.EclipseHandlerUtil.*;

/**
 * Handles the {@code ToJsonString} annotation for eclipse.
 */
@Provides
public class HandleToJsonString extends EclipseAnnotationHandler<ToJsonString> {
    public void handle(AnnotationValues<ToJsonString> annotation, Annotation ast, EclipseNode annotationNode) {
        handleFlagUsage(annotationNode, ConfigurationKeys.TO_JSON_STRING_FLAG_USAGE, "@ToJsonString");

        // 生成一个toJsonString方法,使用Fastjson库生成json字符串
        EclipseNode javacNode = annotationNode.up();
        // 只能作用在类上面
        if (!isClassOrEnum(javacNode)) {
            annotationNode.addError("@ToJsonString is only supported on a class or enum.");
            return;
        }
        // 幂等性检查
        EclipseHandlerUtil.MemberExistsResult memberExistsResult = methodExists("toJsonString", javacNode, 0);
        if(memberExistsResult== EclipseHandlerUtil.MemberExistsResult.EXISTS_BY_USER) {
            annotationNode.addWarning("@ToJsonString is ignored because a method named toJsonString already exists.");
        }
        // 如果不存在或者存在lombok生成的方法,则生成或者覆盖
        MethodDeclaration methodDecl = createToJsonString(javacNode,annotationNode.get());
        injectMethod(javacNode, methodDecl);
    }

    static MethodDeclaration createToJsonString(EclipseNode typeNode, ASTNode source) {
        int sourceStart = source.sourceStart;
        int    sourceEnd = source.sourceEnd;

        // 方法逻辑表达式
        MessageSend toJSONString = new MessageSend();
        toJSONString.sourceStart = sourceStart;
        toJSONString.sourceEnd = sourceEnd;
        toJSONString.receiver = generateQualifiedNameRef(source, "com".toCharArray(),"alibaba".toCharArray(),"fastjson".toCharArray(),"JSON".toCharArray());
        toJSONString.selector = "toJSONString".toCharArray();
        ThisReference thisReference = new ThisReference(sourceStart, sourceEnd);
        toJSONString.arguments = new Expression[]{thisReference};
        toJSONString.selector = "toJSONString".toCharArray();
        // 方法定义
        MethodDeclaration methodDecl = new MethodDeclaration(((CompilationUnitDeclaration) typeNode.top().get()).compilationResult);
        setGeneratedBy(methodDecl, source);
        long p = (long) sourceStart << 32 | sourceEnd;

        // public
        methodDecl.modifiers = toEclipseModifier(AccessLevel.PUBLIC);
        // String 返回类型
        methodDecl.returnType = new QualifiedTypeReference(TypeConstants.JAVA_LANG_STRING,new long[]{p,p,p});
        setGeneratedBy(methodDecl.returnType, source);
        // @Override 注解
//        Annotation overrideAnnotation = makeMarkerAnnotation(TypeConstants.JAVA_LANG_OVERRIDE, source);
//        methodDecl.annotations = new Annotation[] { overrideAnnotation };
        methodDecl.annotations = null;
        methodDecl.arguments = null;
        methodDecl.selector = "toJsonString".toCharArray();
        methodDecl.thrownExceptions = null;
        methodDecl.typeParameters = null;
        methodDecl.bits |= Eclipse.ECLIPSE_DO_NOT_TOUCH_FLAG;
        methodDecl.bodyStart = methodDecl.declarationSourceStart = methodDecl.sourceStart = source.sourceStart;
        methodDecl.bodyEnd = methodDecl.declarationSourceEnd = methodDecl.sourceEnd = source.sourceEnd;

        ReturnStatement returnStatement = new ReturnStatement(toJSONString, sourceStart, sourceEnd);
        setGeneratedBy(returnStatement, source);
        methodDecl.statements = new Statement[] { returnStatement };

        EclipseHandlerUtil.createRelevantNonNullAnnotation(typeNode, methodDecl);
        return methodDecl;
    }

}

注:@Provides为spi依据、javac和ecj是两种不同的编译器,在后文原理会讲到。

调试、打包与使用

调试:

实现DirectoryRunner.TestParams,以支持Javac debug断点调试:

package lombok;

import java.io.File;
import org.junit.runner.RunWith;

@RunWith(DirectoryRunner.class)
public class TestBigbrother extends DirectoryRunner.TestParams {
    @Override
    public DirectoryRunner.Compiler getCompiler() {
// 选择编译器
        return DirectoryRunner.Compiler.DELOMBOK;
    }
    
    @Override
    public boolean printErrors() {
        return true;
    }
    
    @Override
    public File getBeforeDirectory() {
        return new File("test/transform/resource/before");
    }
    
    @Override
    public File getAfterDirectory() {
        return new File("test/transform/resource/after-delombok");
    }
    
    @Override
    public File getMessagesDirectory() {
        return new File("test/transform/resource/messages-delombok");
    }
    
    @Override
    public boolean expectChanges() {
        return true;
    }
    
    @Override public String testNamePrefix() {
        return "javac-";
    }
    @Override public boolean accept(File file) {
// 调试BigbroSimpleTest.java
        return file.getName().contains("BigbroSimpleTest.java");
    }
}

修改TestJavac.java

package lombok;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(Suite.class)
//@SuiteClasses({lombok.bytecode.RunBytecodeTests.class, lombok.transform.TestLombokFilesIdempotent.class, lombok.transform.TestSourceFiles.class, lombok.transform.TestWithDelombok.class})
@SuiteClasses({TestBigbrother.class})
public class TestJavac {}

此时你可以在上文中的HandleToJsonString类里面打断点,然后在TestJavac类Run Debug调试了(当然需要在test/transform/resource/before/BigbroSimpleTest.java存在的情况下)。

同理,ecj同样需实现DirectoryRunner.TestParams:

package lombok;
import java.io.File;
import org.junit.runner.RunWith;

@RunWith(DirectoryRunner.class)
public class TestBigbrotherEclipse extends DirectoryRunner.TestParams {
    @Override
    public DirectoryRunner.Compiler getCompiler() {
        return DirectoryRunner.Compiler.ECJ;
    }
    
    @Override
    public boolean printErrors() {
        return true;
    }
    
    @Override
    public File getBeforeDirectory() {
        return new File("test/transform/resource/before");
    }
    
    @Override
    public File getAfterDirectory() {
        return new File("test/transform/resource/after-ecj");
    }
    
    @Override
    public File getMessagesDirectory() {
        return new File("test/transform/resource/messages-ecj");
    }
    
    @Override
    public boolean expectChanges() {
        return true;
    }
    
    @Override public String testNamePrefix() {
        return "ecj-";
    }
    @Override public boolean accept(File file) {
        return file.getName().contains("BigbroSimpleTest.java");
    }
}

修改TestEclipse.java

package lombok;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(Suite.class)
//@SuiteClasses({lombok.transform.TestWithEcj.class})
@SuiteClasses({TestBigbrotherEclipse.class})
public class TestEclipse {}

注:当然你可以创建自己的TestXXX文件用以专属调试自己的bug。
注:如果fastjson不再你的classpath,单元测试可能会失败,但是这其实并不影响我们debug,并且你可以看到最终生成了一个这样的方法:

    @java.lang.SuppressWarnings("all")
    public java.lang.String toJsonString() {
        return com.alibaba.fastjson.JSON.toJSONString(this);
    }

注:我就是因为在eclipse方便debug在最终在eclipse开发lombok,而可以断点调试的基础是javac和ecj都是开源的。

打包:
修改版本号(在Version.java):

package lombok.core;

public class Version {
    private static final String VERSION = "1.18.25-bigbrotherlee";

输入如下命令则可以直接打包,文件会在dist目录下面:

ant dist

原生有ant maven可以同时打包source和docs的jar,同时会将所有的jar和pom.xml压缩进mavenPublish.tar.bz2,但是不会安装到本地maven仓库。这里我们扩展一下这个命令,在maven.ant.xml文件的maven target下面修改为如下:

    <target name="maven" depends="version, dist, javadoc.build, -setup.build" description="">
        <mkdir dir="build" />
        <mkdir dir="dist" />
        
        <maven.make version-name="${lombok.version}" />
        <tar destfile="dist/mavenPublish.tar.bz2" compression="bzip2">
            <tarfileset dir="dist">
                <include name="lombok-${lombok.version}.jar" />
                <include name="lombok-${lombok.version}-sources.jar" />
                <include name="lombok-${lombok.version}-javadoc.jar" />
            </tarfileset>
            <tarfileset dir="build" includes="pom.xml" />
        </tar>
        
        <exec executable="${exe.mvn}" failifexecutionfails="false" resultproperty="mvn.result">
                <arg value="install:install-file" />
                <arg value="-Dfile=dist/lombok-${lombok.version}.jar" />
                <arg value="-Dsources=dist/lombok-${lombok.version}-sources.jar" />
                <arg value="-Djavadoc=dist/lombok-${lombok.version}-javadoc.jar" />
                <arg value="-DgroupId=org.projectlombok" />
                <arg value="-DartifactId=lombok" />
                <arg value="-Dversion=${lombok.version}" />
                <arg value="-DpomFile=build/pom.xml" />
            </exec>
        
    </target>

这样就可以直接使用ant maven安装到打包到本地maven。
注:扩展后依赖mvn命令。
注:同理,你可以修改为deploy到你的私服。

使用:

eclipse和常规的lomnok安装使用是一样的。运行dist下面的lombok,找到你的IDE(eclipse、sts等等等等),然后安装。在你需要使用lombok的项目pom引入刚刚安装的版本:

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
            <version>1.18.22-bigbrotherlee</version>
        </dependency>

然后就可以使用了:

@Data
@ToJsonString
public class ShopUserInfo {
    private Integer id;
    private String avatar;
    private String nick;
    private String email;
    private String phone;
    private Integer permission;
}
public class TestClass {
    @Test
    public void test() {
        ShopUserInfo info = new  ShopUserInfo();
        info.setEmail("admin@bigbrotherlee.com");
        System.out.println(info.toJsonString());
    }
}

IDEA使用需要扩展对应的插件,插件的作用是不报红,其实程序还是可以正常运行的,idea默认使用javac编译并默认安装了lombok插件。IDEA使用称为PSI(Program Structure Interface,程序结构接口)的技术让IDEA插件可以在IDEA解析文件和创建语法和语义代码模型的时候就行hook,也就是说是给IDEA看的,同理你也可以知道IDEA并直接使用.class编译后的结果来创建语法语义模型。可以在编译时然后打包本地安装插件后,使用就和eclipse一样了。下面扩展一下IDEA的lombok插件:

首先拉代码,导入IDEA:

git@github.com:mplushnikov/lombok-intellij-plugin.git

这是一个gradle管理的项目,IDEA可以直接拉代码导入。我个人推荐使用Gradle,它管理项目确实省力很多,同时由于是配置文件是脚本而不是xml这使得它十分灵活。

修改配置:

settings.gradle

// 原本的bintray仓库在2022年2月关闭了,导致一些依赖无法拉下来,这里可以不填那么多,理论上你只需要将gradle插件相关的仓库配置就行,不过我懒得搞清楚哪个仓库是用来存什么的。
pluginManagement {
  repositories {
    maven {
      url 'https://maven.aliyun.com/repository/central'
    }
    maven {
      url 'https://maven.aliyun.com/repository/public'
    }
    maven {
      url 'https://maven.aliyun.com/repository/google'
    }
    maven {
      url 'https://maven.aliyun.com/repository/gradle-plugin'
    }
    maven {
      url 'https://maven.aliyun.com/repository/spring'
    }
    maven {
      url 'https://maven.aliyun.com/repository/spring-plugin'
    }
    maven {
      url 'https://maven.aliyun.com/repository/grails-core'
    }
    maven {
      url 'https://maven.aliyun.com/repository/apache-snapshots'
    }
    maven {
      url "https://plugins.gradle.org/m2/"
    }
    mavenCentral()
  }
}

rootProject.name = 'lombok-plugin'

gradle.properties

# 建议你填你当前使用的版本
ideaVersion=2021.1.2
# 项目信息
pluginGroup=de.plushnikov.intellij.plugin
pluginName=lombok-plugin
# 你必须版本号比你现有的(默认安装的)Lombok插件要高才能安装升级覆盖掉默认插件。不用担心,如果你卸载掉覆盖版本,那默认版本又会生效
pluginVersion=999.999.999
#
sources=true
#
descriptionFile=parts/pluginDescription.html
changesFile=parts/pluginChanges.html
#
org.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError -Xmx2048m -Dfile.encoding=UTF-8

build.gradle

plugins {
  id "org.jetbrains.intellij" version "0.7.3"
// 这里的语法工具包与前面gradle.properties配置的版本保存一致,
// 一是因为老版本会使用原来的已经下线的仓库导致无法拉下来对应的IDEA社区版
// 二是为了方便你开发的时候不会遇到由于版本导致的问题。
  id "org.jetbrains.grammarkit" version "2021.1.2"
  id "com.github.ManifestClasspath" version "0.1.0-RELEASE"
}

allprojects {
  repositories {
    // 此处省略一大段,你需要将目的,建议你可以直接添加一个本地仓库(你插件安装的本地仓库)然后copy上面sttting.gradle的配置
   // 或者本地仓库
    maven{
      url "file://c://repo"
    }

  }
}
// 此处省略一大堆未修改的配置

dependencies {
// 引入我们前文打包的lombok,其实不引入也没关系,不会影响我们开发调试
  compile 'org.projectlombok:lombok:1.18.25-bigbrotherlee'
  lombok group: 'org.projectlombok', name: 'lombok', version: '1.18.25-bigbrotherlee', classifier: 'sources', ext: 'jar'

  testImplementation("junit:junit:4.13.2")
  testImplementation("org.mockito:mockito-core:3.8.0")
  testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.7.1")
}
// 此处省略一大堆未修改的配置

右侧打开gradle界面,intellij分类,运行task prepareUiTestingSandbox,下载依赖、准备环境。

开发调试:
修改LombokClassNames.java文件

package de.plushnikov.intellij.plugin;

import com.intellij.util.containers.ContainerUtil;
import org.jetbrains.annotations.NonNls;

import java.util.List;

public interface LombokClassNames {
   // 添加ToJsonString注解全限定类名
  @NonNls String TO_JSON_STRING = "lombok.extern.json.ToJsonString";
}

添加ToJsonStringProcessor :

package de.plushnikov.intellij.plugin.processor.clazz;

import com.intellij.psi.*;
import com.intellij.psi.search.GlobalSearchScope;
import de.plushnikov.intellij.plugin.LombokClassNames;
import de.plushnikov.intellij.plugin.problem.ProblemBuilder;
import de.plushnikov.intellij.plugin.psi.LombokLightMethodBuilder;
import de.plushnikov.intellij.plugin.util.PsiClassUtil;
import de.plushnikov.intellij.plugin.util.PsiMethodUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.Collection;
import java.util.Collections;
import java.util.List;

public class ToJsonStringProcessor extends AbstractClassProcessor {

  public static final String TO_JSON_STRING_METHOD_NAME = "toJsonString";

  public ToJsonStringProcessor() {
    super(PsiMethod.class, LombokClassNames.TO_JSON_STRING);
  }

  @Override
  protected boolean possibleToGenerateElementNamed(@Nullable String nameHint, @NotNull PsiClass psiClass,
                                                   @NotNull PsiAnnotation psiAnnotation) {
    return nameHint == null || nameHint.equals(TO_JSON_STRING_METHOD_NAME);
  }

  @Override
  protected boolean validate(@NotNull PsiAnnotation psiAnnotation, @NotNull PsiClass psiClass, @NotNull ProblemBuilder builder) {
    final boolean result = validateAnnotationOnRigthType(psiClass, builder);
    if (result) {
      if (hasToJsonStringMethodDefined(psiClass)) {
        builder.addWarning("Not generated toJsonString(): A method with same name already exists");
      }
    }
    return result;
  }

  private boolean validateAnnotationOnRigthType(@NotNull PsiClass psiClass, @NotNull ProblemBuilder builder) {
    boolean result = true;
    if (psiClass.isAnnotationType() || psiClass.isInterface()) {
      builder.addError("@ToJsonString is only supported on a class or enum type");
      result = false;
    }
    return result;
  }

  private boolean hasToJsonStringMethodDefined(@NotNull PsiClass psiClass) {
    final Collection<PsiMethod> classMethods = PsiClassUtil.collectClassMethodsIntern(psiClass);
    return PsiMethodUtil.hasMethodByName(classMethods, TO_JSON_STRING_METHOD_NAME, 0);
  }

  @Override
  protected void generatePsiElements(@NotNull PsiClass psiClass, @NotNull PsiAnnotation psiAnnotation, @NotNull List<? super PsiElement> target) {
    target.addAll(createToJsonStringMethod4All(psiClass, psiAnnotation));
  }

  @NotNull
  Collection<PsiMethod> createToJsonStringMethod4All(@NotNull PsiClass psiClass, @NotNull PsiAnnotation psiAnnotation) {
    if (hasToJsonStringMethodDefined(psiClass)) {
      return Collections.emptyList();
    }

    final PsiMethod stringMethod = createToJsonStringMethod(psiClass, psiAnnotation);
    return Collections.singletonList(stringMethod);
  }
// 此处要比javac和ecj的编写要轻松很多只需要生成一个方法体的字符串就行。
  @NotNull
  public PsiMethod createToJsonStringMethod(@NotNull PsiClass psiClass,  @NotNull PsiAnnotation psiAnnotation) {
    final PsiManager psiManager = psiClass.getManager();

    final StringBuilder paramString = new StringBuilder();
    paramString.append("com.alibaba.fastjson.JSON.toJSONString(this)");
    final String body = paramString.toString();
    final String blockText = String.format("return %s;", body);

    final LombokLightMethodBuilder methodBuilder = new LombokLightMethodBuilder(psiManager, TO_JSON_STRING_METHOD_NAME)
      .withMethodReturnType(PsiType.getJavaLangString(psiManager, GlobalSearchScope.allScope(psiClass.getProject())))
      .withContainingClass(psiClass)
      .withNavigationElement(psiAnnotation)
      .withModifier(PsiModifier.PUBLIC);
    methodBuilder.withBody(PsiMethodUtil.createCodeBlockFromText(blockText, methodBuilder));
    return methodBuilder;
  }

}

添加LombokToJsonStringHandler :

package de.plushnikov.intellij.plugin.action.lombok;

import com.intellij.psi.*;
import de.plushnikov.intellij.plugin.LombokClassNames;
import org.jetbrains.annotations.NotNull;

public class LombokToJsonStringHandler extends BaseLombokHandler {

  @Override
  protected void processClass(@NotNull PsiClass psiClass) {
    final PsiElementFactory factory = JavaPsiFacade.getElementFactory(psiClass.getProject());
    final PsiClassType stringClassType = factory.createTypeByFQClassName(CommonClassNames.JAVA_LANG_STRING, psiClass.getResolveScope());

    final PsiMethod toStringMethod = findPublicNonStaticMethod(psiClass, "toJsonString", stringClassType);
    if (null != toStringMethod) {
      toStringMethod.delete();
    }
    addAnnotation(psiClass, LombokClassNames.TO_JSON_STRING);
  }

}

添加LombokToJsonStringAction

package de.plushnikov.intellij.plugin.action.lombok;

public class LombokToJsonStringAction extends BaseLombokAction {

  public LombokToJsonStringAction() {
    super(new LombokToJsonStringHandler());
  }

}

修改LombokProcessorManager

package de.plushnikov.intellij.plugin.processor;

import com.intellij.openapi.application.ApplicationManager;
。。。。。。

public final class LombokProcessorManager {
  @NotNull
  public static Collection<Processor> getLombokProcessors() {
    return Arrays.asList(
       ........
        // 添加ToJsonStringProcessor
      ApplicationManager.getApplication().getService(ToJsonStringProcessor.class),

    );
  }
}

添加DelombokToJsonStringAction

package de.plushnikov.intellij.plugin.action.delombok;

import com.intellij.openapi.application.ApplicationManager;
import de.plushnikov.intellij.plugin.processor.clazz.ToJsonStringProcessor;
import org.jetbrains.annotations.NotNull;

public class DelombokToJsonStringAction extends AbstractDelombokAction {
  @Override
  @NotNull
  protected DelombokHandler createHandler() {
    return new DelombokHandler(ApplicationManager.getApplication().getService(ToJsonStringProcessor.class));
  }
}

修改DelombokEverythingAction

package de.plushnikov.intellij.plugin.action.delombok;

import com.intellij.openapi.application.ApplicationManager;
.............

public class DelombokEverythingAction extends AbstractDelombokAction {

  @Override
  protected DelombokHandler createHandler() {
    return new DelombokHandler(true,
      //  .............
      // 添加ToJsonStringProcessor
      ApplicationManager.getApplication().getService(ToJsonStringProcessor.class),

  }

}

修改LombokBundle.properties

# 添加相关msg
action.delombokToJsonString.text=@ToJsonString
action.delombokToJsonString.description=Action to replace lombok @ToJsonString annotation with vanilla java methods

修改IDEA插件配置plugin.xml

<!-- 添加processor -->
<applicationService serviceImplementation="de.plushnikov.intellij.plugin.processor.clazz.ToJsonStringProcessor"/>
<!-- 添加Action -->
<action id="defaultLombokToJsonString" class="de.plushnikov.intellij.plugin.action.lombok.LombokToJsonStringAction"/>
<action id="delombokToJsonString" class="de.plushnikov.intellij.plugin.action.delombok.DelombokToJsonStringAction"/>

由于前文你已经将sandbox已经准备好了。此时你可以在gradle界面上的runIdeForUiTests task上右键Debug,此时会debug运行起刚刚准备环境时下载下来的Idea社区版。此时你可以建一个项目使用我们开发的lombok注解,调试可以右键delombok可以看到生成的代码,或者在Structure界面可以看到你使用我们自定义注解生成的方法。

注:这一步如果你的插件不生效,需要将下载回来的Idea社区版自带的lombok插件删除掉,然后重新debug。

值得注意的是PSI这套接口是用来分析现有语言的。也就是它作用与语言插件之后。

发布插件

调试完成后你需要将build/distributions/目录下面的插件发布到你公司内部的Idea插件仓库,或者直接提供zip从硬盘安装使用。如果build/distributions/目录没有,则在gradle界面执行buildPlugin task打包插件。

原理

前文已经将阐述了Lombok扩展的完整开发。现在深入原理。

Lombok提供编译时增强,在我之前的文章中提到AOP提供运行时增强,重写ClassLoader与提供javaagent可以提供加载时增强,而本文算是补完了最后一块拼图,编译时增强。

我们在maven中引入的lombok依赖只提供了注解,需要实际就行增强还需要对编译器就行hook,这也是我们需要给IDE安装插件的原因。

Javac和ECJ(Eclipse Compiler for Java)
就Java来说,常用的编译器其实就两个,jdk自带的javac和另一个开源的Eclipse Compiler for Java(ECJ),这就是为什么上面需要实现两种AnnotationHandler(当然,如果你确定自己只使用javac,也可以只实现javac一种)。在最终生成的字节码(.class)文件来说是大差不差的,如果不一样就不符合jvm标准了自然无法在jvm运行;对现在的我们来说,它们的主要区别在于通过源码解析生成的抽象语法树(ast,abstract syntax tree)不一样,体现在代码上就是它们的API不一样。前文中提到的PSI不属于编译器的一部分,他是IDEA特有的。

AbstarctProcessor:

javac为我们提供了一组标准的编译时处理API:AbstractProcessor,实现process方法。AbstarctProcessor生效在Javac对我们的源文件(.java)解析生成AST后,在编译成字节码之前,我们不是对源文件进行处理而是对抽象语法树进行处理。过程大致如下:Javac编译器将分析源文件生成AST,交给AbstractProcessor处理,最后将处理过的AST编译成字节码文件(.class文件)。init方法与process方法定义如下:

public void init(ProcessingEnvironment procEnv) ;
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) ;

RoundEnvironment可以获取到对应的Element。但是实际上Lombok对Javac和eclipse的抽象语法树都进行的包装增强,添加一些必要的功能(比如回溯),我们的JavacAnnotationHandler使用的就被增强后的node。生效只需要将实现加入javac的classpath就ok。

对应lombok的实现是LombokProcessor,结构如下:

public class LombokProcessor extends AbstractProcessor {

    private ProcessingEnvironment processingEnv;
    private JavacProcessingEnvironment javacProcessingEnv;
    private JavacFiler javacFiler;
    private JavacTransformer transformer;
    private Trees trees;
。。。。。。。。
}

在初始化的时候会调用init方法,初始化加载所有的JavacAnnotationHandler和JavacASTVisitor,放在transformer属性里面,类结构如下:

public class JavacTransformer {
    private final HandlerLibrary handlers;
    private final Messager messager;
    
    public JavacTransformer(Messager messager, Trees trees) {
        this.messager = messager;
        this.handlers = HandlerLibrary.load(messager, trees);
    }
。。。。。。。。。。。。。。。。。
}

可以看到JavacTransformer在构造的时候通过HandlerLibrary.load将所有的JavacAnnotationHandler和JavacASTVisitor加载并初始化,其关键方法如下(我们只关注Handler):

    /**
     * Creates a new HandlerLibrary that will report any problems or errors to the provided messager,
     * then uses SPI discovery to load all annotation and visitor based handlers so that future calls
     * to the handle methods will defer to these handlers.
     */
    public static HandlerLibrary load(Messager messager, Trees trees) {
        HandlerLibrary library = new HandlerLibrary(messager);
        
        try {
            loadAnnotationHandlers(library, trees);
            loadVisitorHandlers(library, trees);
        } catch (IOException e) {
            System.err.println("Lombok isn't running due to misconfigured SPI files: " + e);
        }
        
        library.calculatePriorities();
        
        return library;
    }
    
    /** 使用SPI发现所有的实现 {@link JavacAnnotationHandler}. */
    @SuppressWarnings({"rawtypes", "unchecked"})
    private static void loadAnnotationHandlers(HandlerLibrary lib, Trees trees) throws IOException {
        //No, that seemingly superfluous reference to JavacAnnotationHandler's classloader is not in fact superfluous!
        for (JavacAnnotationHandler handler : SpiLoadUtil.findServices(JavacAnnotationHandler.class, JavacAnnotationHandler.class.getClassLoader())) {
            handler.setTrees(trees);
            Class<? extends Annotation> annotationClass = handler.getAnnotationHandledByThisHandler();
            AnnotationHandlerContainer<?> container = new AnnotationHandlerContainer(handler, annotationClass);
            String annotationClassName = container.annotationClass.getName().replace("$", ".");
            List<AnnotationHandlerContainer<?>> list = lib.annotationHandlers.get(annotationClassName);
            if (list == null) lib.annotationHandlers.put(annotationClassName, list = new ArrayList<AnnotationHandlerContainer<?>>(1));
            list.add(container);
            lib.typeLibrary.addType(container.annotationClass.getName());
        }
    }
    
    /** 使用SPI发现所有的实现 {@link JavacASTVisitor}. */
    private static void loadVisitorHandlers(HandlerLibrary lib, Trees trees) throws IOException {
        //No, that seemingly superfluous reference to JavacASTVisitor's classloader is not in fact superfluous!
        for (JavacASTVisitor visitor : SpiLoadUtil.findServices(JavacASTVisitor.class, JavacASTVisitor.class.getClassLoader())) {
            visitor.setTrees(trees);
            lib.visitorHandlers.add(new VisitorContainer(visitor));
        }
    }

spi

JavacAnnotationHandler加载时通过SPI机制实现的,通过SpiLoadUtil加载,对于我们的JavacAnnotationHandler他会访问META-INF/services/lombok.javac.JavacAnnotationHandler文件逐行读取实现:

    public static <C> Iterable<C> findServices(final Class<C> target, ClassLoader loader) throws IOException {
        if (loader == null) loader = ClassLoader.getSystemClassLoader();
        Enumeration<URL> resources = loader.getResources("META-INF/services/" + target.getName());
        final Set<String> entries = new LinkedHashSet<String>();
        while (resources.hasMoreElements()) {
            URL url = resources.nextElement();
            readServicesFromUrl(entries, url);
        }
        
        final Iterator<String> names = entries.iterator();
        final ClassLoader fLoader = loader;
        return new Iterable<C> () {
            @Override public Iterator<C> iterator() {
                return new Iterator<C>() {
                    @Override public boolean hasNext() {
                        return names.hasNext();
                    }
                    
                    @Override public C next() {
                        try {
                            return target.cast(Class.forName(names.next(), true, fLoader).getConstructor().newInstance());
                        } catch (Exception e) {
                            Throwable t = e;
                            if (t instanceof InvocationTargetException) t = t.getCause();
                            if (t instanceof RuntimeException) throw (RuntimeException) t;
                            if (t instanceof Error) throw (Error) t;
                            throw new RuntimeException(t);
                        }
                    }
                    
                    @Override public void remove() {
                        throw new UnsupportedOperationException();
                    }
                };
            }
        };
    }

注:Lombok注册AnnotationHandler使用的到了SPI,具体阐述可以参考这篇文章。这里只讲一下Lombok的过程。

META-INF/services/lombok.javac.JavacAnnotationHandler是通过lombok.spi.SpiProcessor生成的,它也是一个AbstractProcessor,关键方法如下(处理@Provides注解标注的源文件):

    private void handleAnnotations(RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(Provides.class);
        for (Element e : elements) handleProvidingElement(e);
    }
    
    private void handleProvidingElement(Element element) {
        .........一大段处理逻辑
    }

至此,lombok对javac编译器的处理过程就阐述完了。

前文讲到还有一个ecj编译器,它的过程和标准的javac是不一样的,下面阐述ecj的过程:

首先ecj是开源的,调用它的编译器是很方便的,直接new一个org.eclipse.jdt.internal.compiler.Compiler实例就可以hook,然而eclipse IDE并未开放相关的API,于是Lombok作者通过往Eclipse配置添加javaagent参数的方式增强(patcher,补丁)eclipse,从而使得我们的lombok生效。关于javaagent可以查看这篇文章。在往eclipse安装lombok后可以在其配置文件看到这样的配置:

-javaagent:pathto\lombok.jar

具体调用链是:lombok.launch.Agent.runLauncher() -> lombok.core.AgentLauncher.runAgents() -> lombok.eclipse.agent.EclipsePatcher.runAgent()

注:其中,Agent类中的premain和agentmain方法最终都会调用runLauncher。EclipsePatcher.runAgent()实际是lombok.core.AgentLauncher.AgentLaunchable接口的方法。

主要逻辑在EclipsePatcher.runAgent(),它对ecj的一些类(这里我们关注org.eclipse.jdt.internal.compiler.parser.Parser类)进行了增强。其中一个增强就是对Parser类的endParse方法进行增强,返回前调用lombok.launch.PatchFixesHider.Transform.transform_swapped()。

sm.addScript(ScriptBuilder.wrapReturnValue()
    .target(new MethodTarget(PARSER_SIG, "endParse", CUD_SIG, "int"))
    .wrapMethod(new Hook("lombok.launch.PatchFixesHider$Transform", "transform_swapped", "void", OBJECT_SIG, OBJECT_SIG))
    .request(StackRequest.THIS, StackRequest.RETURN_VALUE).build());

而Transform.transform_swapped()最终会回到lombok.eclipse.TransformEclipseAST.transform_swapped()。到了这里基本上和javac过程差不多了。TransformEclipseAST结构如下:

public class TransformEclipseAST {
       // SPI加载EclipseAnnotationHandler和EclipseASTVisitor
       static {
        Field f = null;
        HandlerLibrary h = null;
        
        if (System.getProperty("lombok.disable") != null) {
            disableLombok = true;
            astCacheField = null;
            handlers = null;
        } else {
            try {
                h = HandlerLibrary.load();
            } catch (Throwable t) {
                try {
                    error(null, "Problem initializing lombok", t);
                } catch (Throwable t2) {
                    System.err.println("Problem initializing lombok");
                    t.printStackTrace();
                }
                disableLombok = true;
            }
            try {
                f = Permit.getField(CompilationUnitDeclaration.class, "$lombokAST");
            } catch (Throwable t) {
                //I guess we're in an ecj environment; we'll just not cache stuff then.
            }
            
            astCacheField = f;
            handlers = h;
        }
    }
    // 由于没有像javac的AbstarctProcessor,这里作者自定义调用该方法做AST转换
    public static void transform(Parser parser, CompilationUnitDeclaration ast) {
        if (disableLombok) return;
        
        if (Symbols.hasSymbol("lombok.disable")) return;
        if (alreadyTransformed(ast)) return;
        
        
        if (Boolean.TRUE.equals(LombokConfiguration.read(ConfigurationKeys.LOMBOK_DISABLE, EclipseAST.getAbsoluteFileLocation(ast)))) return;
        
        try {
            DebugSnapshotStore.INSTANCE.snapshot(ast, "transform entry");
            long histoToken = lombokTracker == null ? 0L : lombokTracker.start();
            EclipseAST existing = getAST(ast, false);
            existing.setSource(parser.scanner.getSource());
            // 此处最终会调用我们的Handler
            new TransformEclipseAST(existing).go();
            。。。。。。。。。。。。。。。。。。
    }
}

扩充
前文中提到ecj的编译器可以new出来,同理如果你引入了tools包(jdk8),或者jdk.compiler模块(jdk9+),你页可以new com.sun.tools.javac.main.JavaCompiler得到一个编译器。或者使用:

ToolProvider.getSystemJavaCompiler()

获取到编译器,然后提交task(getTask),然后启动编译任务就ok。这为我实现热加载和热替换等等功能提供了一个新的思路。不过我一直在想,这些动态特性完全可以使用groovy等等脚本完成。他是Java本身设计了一套Script API默认也已经支持JavaScript,那在标准API之上补充一个groovy的ScriptEngine或许比在java上添加热特性更加合理。

总结

总的来说。扩展一个lombok注解需要你写对应的注解与handler,如果要方便的在Idea使用自定义的注解还需要扩展IDEA的lombok插件。由于两个项目都有些历史了,而且网上资料不多且多数是不正确的,这导致我走了一些弯路。
本来就只打算写扩展Lombok的。后面越写越抽象,从编译到插件开发,又臭又长,但是没搞明白又不甘心,总感觉哪里缺失了。于是写了好久。
会这个东西也不是什么优势。毕竟大部分程序员都只是写写业务代码困在业务里面,而实现业务的方式有一百种,大多数情况下不会用到本文这样的比较费力的一种。

标签: none

已有 2 条评论

  1. ccl

    李哥你好,十分感谢你的博客“扩展你的lombok”,让我的lombok源码部署有了慢慢有了进展。但我现在有个问题,我跟着你的操作对源码进行修改之后,修改的内容无法被项目接受——新创建的json包以及新创建的类被项目排除在外,已有类添加的内容离开了eclipse则不可见,test内容也是找不到源码之外的代码,我现在直接在文件修改后,用idea可以看见但是仍然不被项目接受,所以猜测是ant的问题,所以希望能从李哥这里得到答案(如果可以的话顺便邮个修改的ant配置信息QAQ万分感谢!)。再次再次感谢李哥以及这篇对于lombok部署源码如此详细的文章!

    1. 我这边没有收到你的邮件,也没有看到你的代码,在这里只能推测是因为你没有使用@Providers注解导致SPI把你的扩展排除在外了。

添加新评论