Pull to refresh
665.14
Сбер
Больше чем банк

Lombok, sources.jar и удобный дебаг

Reading time15 min
Views6.5K
В нашей команде мы очень любим Lombok. Он позволяет писать меньше кода и меньше его рефакторить, что идеально подходит для ленивых разработчиков. Но если помимо артефакта проекта вы публикуете так же и исходники с документацией, то можете столкнуться с проблемой — код исходников не будет совпадать с байткодом. О том, как мы решали эту проблему и с какими трудностями столкнулись в процессе, я и расскажу в этом посте.



Кстати, если вы пишете на Java и по какой-то причине всё ещё не используете Lombok в своём проекте, то я рекомендую познакомиться со статьями на Хабре (раз и два). Уверен, вам понравится!

Проблема


Проект, над которым мы работаем, состоит из нескольких модулей. Часть из них (назовём их условно backend) при выпуске релиза пакуется в архив (поставку), загружается в репозиторий и впоследствии деплоится на серверы приложений. Другая часть — т.н. клиентский модуль — публикуется в репозиторий в виде набора артефактов, содержащего в т.ч. sources.jar и javadoc.jar. Lombok мы используем во всех частях, а собирается всё это Maven'ом.

Некоторое время назад один из потребителей нашего сервиса обратился с проблемой — он пытался дебажить наш модуль, но не мог этого сделать, т.к. в sources.jar отсутствовали методы (и даже классы), в которых он хотел бы поставить breakpoint. Мы в нашей команде считаем, что попытка самостоятельно выявить и решить проблему, вместо того, чтобы бездумно заводить дефект — поступок достойного мужа, который нужно поощрять! :-) Поэтому было принято решение привести sources.jar в соответствие с байткодом.

Пример


Давайте представим себе, что у нас есть простое приложение, состоящее из двух классов:

SomePojo.java
package com.github.monosoul.lombok.sourcesjar;

import lombok.Builder;
import lombok.Value;

@Value
@Builder(toBuilder = true)
class SomePojo {

    /**
     * Some string field
     */
    String someStringField;
    /**
     * Another string field
     */
    String anotherStringField;
}


Main.java
package com.github.monosoul.lombok.sourcesjar;

import lombok.val;

public final class Main {

    public static void main(String[] args) {
        if (args.length != 2) {
            throw new IllegalArgumentException("Wrong arguments!");
        }

        val pojo = SomePojo.builder()
                           .someStringField(args[0])
                           .anotherStringField(args[1])
                           .build();

        System.out.println(pojo);
    }
}


И собирается наше приложение с помощью Maven'а:

pom.xml
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <artifactId>lombok-sourcesjar</artifactId>
  <groupId>com.github.monosoul</groupId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>1.18.2</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.1.1</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>com.github.monosoul.lombok.sourcesjar.Main</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-source-plugin</artifactId>
        <version>3.0.1</version>
        <executions>
          <execution>
            <id>attach-sources</id>
            <goals>
              <goal>jar</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

</project>


Если скомпилировать этот проект (mvn compile), а затем сдекомпилировать получившийся байткод, то класс SomePojo будет выглядеть так:

SomePojo.class
package com.github.monosoul.lombok.sourcesjar;

final class SomePojo {
    private final String someStringField;
    private final String anotherStringField;

    SomePojo(String someStringField, String anotherStringField) {
        this.someStringField = someStringField;
        this.anotherStringField = anotherStringField;
    }

    public static SomePojo.SomePojoBuilder builder() {
        return new SomePojo.SomePojoBuilder();
    }

    public SomePojo.SomePojoBuilder toBuilder() {
        return (new SomePojo.SomePojoBuilder()).someStringField(this.someStringField).anotherStringField(this.anotherStringField);
    }

    public String getSomeStringField() {
        return this.someStringField;
    }

    public String getAnotherStringField() {
        return this.anotherStringField;
    }

    public boolean equals(Object o) {
        if (o == this) {
            return true;
        } else if (!(o instanceof SomePojo)) {
            return false;
        } else {
            SomePojo other = (SomePojo)o;
            Object this$someStringField = this.getSomeStringField();
            Object other$someStringField = other.getSomeStringField();
            if (this$someStringField == null) {
                if (other$someStringField != null) {
                    return false;
                }
            } else if (!this$someStringField.equals(other$someStringField)) {
                return false;
            }

            Object this$anotherStringField = this.getAnotherStringField();
            Object other$anotherStringField = other.getAnotherStringField();
            if (this$anotherStringField == null) {
                if (other$anotherStringField != null) {
                    return false;
                }
            } else if (!this$anotherStringField.equals(other$anotherStringField)) {
                return false;
            }

            return true;
        }
    }

    public int hashCode() {
        int PRIME = true;
        int result = 1;
        Object $someStringField = this.getSomeStringField();
        int result = result * 59 + ($someStringField == null ? 43 : $someStringField.hashCode());
        Object $anotherStringField = this.getAnotherStringField();
        result = result * 59 + ($anotherStringField == null ? 43 : $anotherStringField.hashCode());
        return result;
    }

    public String toString() {
        return "SomePojo(someStringField=" + this.getSomeStringField() + ", anotherStringField=" + this.getAnotherStringField() + ")";
    }

    public static class SomePojoBuilder {
        private String someStringField;
        private String anotherStringField;

        SomePojoBuilder() {
        }

        public SomePojo.SomePojoBuilder someStringField(String someStringField) {
            this.someStringField = someStringField;
            return this;
        }

        public SomePojo.SomePojoBuilder anotherStringField(String anotherStringField) {
            this.anotherStringField = anotherStringField;
            return this;
        }

        public SomePojo build() {
            return new SomePojo(this.someStringField, this.anotherStringField);
        }

        public String toString() {
            return "SomePojo.SomePojoBuilder(someStringField=" + this.someStringField + ", anotherStringField=" + this.anotherStringField + ")";
        }
    }
}


Довольно сильно отличается от того, что попадёт в наш sources.jar, не правда ли? ;) Как видите, если бы вы подключили исходники для дебага SomePojo и захотели поставить breakpoint, например, в конструкторе, то вы бы столкнулись с проблемой — breakpoint ставить некуда, а класса SomePojoBuilder там вообще нет.

Что с этим делать?


Как это часто бывает — у этой проблемы есть несколько способов решения. Давайте рассмотрим каждый из них.

Не использовать Lombok


Когда мы столкнулись с этой проблемой впервые — речь шла о модуле, в котором была всего пара классов, использующих Lombok. Отказываться от него, конечно, не хотелось, поэтому я сразу подумал о том, чтоб делать delombok. Поисследовав этот вопрос, я нашёл несколько странных решений с использованием Lombok-плагина для Maven'а — lombok-maven-plugin. В одном из них предлагали, например, держать исходники, в которых используется Lombok, в отдельной директории, для которой будет выполняться delombok, и сгенерированные исходники будут попадать в generated-sources, откуда уже будет компилироваться и попадать в sources.jar. Вариант это, наверное, рабочий но в этом случае в IDE не будет работать подсветка синтаксиса в оригинальных исходниках, т.к. каталог с ними не будет считаться директорией с исходниками. Такой вариант меня не устраивал, и, поскольку цена отказа от Lombok'а в этом модуле была невелика, было принято решение не тратить на это время, отключить Lombok и просто сгенерировать нужные методы через IDE.

В целом, мне кажется, что такой вариант имеет право на жизнь, но только если классов, использующих Lombok действительно мало и они редко меняются.


Delombok плагин + сборка sources.jar с помощью Ant


Спустя некоторое время пришлось вернуться к этой проблеме снова, когда речь шла уже о модуле, в котором Lombok использовался гораздо более интенсивно. Вернувшись снова к изучению этой проблемы, я наткнулся на вопрос на stackoverflow, где предлагалось выполнять для исходников delombok, а затем с помощью задачи в Ant генерировать sources.jar.
Тут нужно сделать отступление о том, почему генерировать sources.jar нужно именно с помощью Ant'а, а не с помощью Source-плагина (maven-source-plugin). Дело в том, что для этого плагина нельзя сконфигурировать директорию с исходниками. Он всегда будет использовать содержимое свойства sourceDirectory проекта.

Итак, в случае с нашим примером, pom.xml станет выглядеть так:

pom.xml
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <artifactId>lombok-sourcesjar</artifactId>
  <groupId>com.github.monosoul</groupId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <lombok.version>1.18.2</lombok.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>${lombok.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.1.1</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>com.github.monosoul.lombok.sourcesjar.Main</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>

      <plugin>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok-maven-plugin</artifactId>
        <version>${lombok.version}.0</version>
        <executions>
          <execution>
            <phase>generate-sources</phase>
            <goals>
              <goal>delombok</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <sourceDirectory>src/main/java</sourceDirectory>
          <outputDirectory>${project.build.directory}/delombok</outputDirectory>
          <addOutputDirectory>false</addOutputDirectory>
          <encoding>UTF-8</encoding>
          <formatPreferences>
            <generateDelombokComment>skip</generateDelombokComment>
          </formatPreferences>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-resources-plugin</artifactId>
        <version>3.1.0</version>
        <executions>
          <execution>
            <id>copy-to-lombok-build</id>
            <phase>process-resources</phase>
            <goals>
              <goal>copy-resources</goal>
            </goals>
            <configuration>
              <resources>
                <resource>
                  <directory>${project.basedir}/src/main/resources</directory>
                </resource>
              </resources>
              <outputDirectory>${project.build.directory}/delombok</outputDirectory>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-antrun-plugin</artifactId>
        <version>1.8</version>
        <executions>
          <execution>
            <id>generate-delomboked-sources-jar</id>
            <phase>package</phase>
            <goals>
              <goal>run</goal>
            </goals>
            <configuration>
              <target>
                <jar destfile="${project.build.directory}/${project.build.finalName}-sources.jar"
                   basedir="${project.build.directory}/delombok"/>
              </target>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-install-plugin</artifactId>
        <version>2.5.2</version>
        <executions>
          <execution>
            <id>install-source-jar</id>
            <goals>
              <goal>install-file</goal>
            </goals>
            <phase>install</phase>
            <configuration>
              <file>${project.build.directory}/${project.build.finalName}-sources.jar</file>
              <classifier>sources</classifier>
              <generatePom>true</generatePom>
              <pomFile>${project.basedir}/pom.xml</pomFile>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-deploy-plugin</artifactId>
        <version>3.0.0-M1</version>
        <executions>
          <execution>
            <id>deploy-source-jar</id>
            <goals>
              <goal>deploy-file</goal>
            </goals>
            <phase>deploy</phase>
            <configuration>
              <file>${project.build.directory}/${project.build.finalName}-sources.jar</file>
              <classifier>sources</classifier>
              <generatePom>true</generatePom>
              <pomFile>${project.basedir}/pom.xml</pomFile>
              <repositoryId>someRepoId</repositoryId>
              <url>some://repo.url</url>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

</project>


Как видите, конфигурация очень сильно разрослась, и в ней есть далеко не только lombok-maven-plugin и maven-antrun-plugin. Почему так произошло? Дело в том, что поскольку sources.jar мы теперь собираем Ant'ом, то Maven ничего об этом артефакте не знает. И нам нужно явно указать ему, как этот артефакт устанавливать, как его деплоить и как паковать в него ресурсы.

Кроме того, я обнаружил, что при выполнении delombok'а по умолчанию Lombok добавляет в шапку сгенерированных файлов комментарий. При этом формат генерирумеых файлов управляется не с помощью опций в файле lombok.config, а с помощью опций плагина. Список этих опций оказалось непросто найти. Можно было, конечно, вызвать jar-ник Lombok'а с ключами delombok и --help, но я слишком ленивый для этого ж программист, поэтому я нашёл их в исходниках на гитхабе.

Но ни объём конфигурации, ни её особенности не идут ни в какое сравнение с главным недостатком этого способа. Он не решает проблему. Байткод компилируется из одних исходников, а в sources.jar попадают другие. И несмотря на то, что delombok выполняется тем же самым Lombok'ом, между байткодом и сгенерированными исходниками всё равно будут отличия, т.е. для дебага они по прежнему непригодны. Мягко говоря, я расстроился, когда понял это.


Delombok плагин + профиль в maven


Так что же делать? У меня был sources.jar с «правильными» исходниками, но они всё равно отличались от байткода. В принципе проблему могла бы решить компиляция из исходников, сгенерированных в результате delombok'а. Но проблема в том, что maven-compiler-plugin'у нельзя указать путь до исходников. Он всегда использует исходники, указанные в sourceDirectory проекта, как и maven-source-plugin. Можно было бы указать там каталог, в который генерируются delomboked исходники, но в этом случае при импорте проекта в IDE, каталог с реальными исходниками не будет считаться таковым и для файлов в нём не будет работать подсветка синтаксиса и другие фичи. Такой вариант меня тоже не устраивал.

Можно использовать профили! Создать профиль, который бы использовался только при сборке проекта и в котором бы подменялось значение sourceDirectory! Но есть нюанс. Тег sourceDirectory можно объявить только внутри тега build в корне проекта.

К счастью, для этой проблемы есть обходной путь. Можно объявить свойство, которое будет подставляться в тег sourceDirectory, а в профиле менять значение этого свойства!

В этом случае конфигурация проекта будет выглядеть так:

pom.xml
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <artifactId>lombok-sourcesjar</artifactId>
  <groupId>com.github.monosoul</groupId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>

    <lombok.version>1.18.2</lombok.version>

    <origSourceDir>${project.basedir}/src/main/java</origSourceDir>
    <sourceDir>${origSourceDir}</sourceDir>
    <delombokedSourceDir>${project.build.directory}/delombok</delombokedSourceDir>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>${lombok.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <profiles>
    <profile>
      <id>build</id>
      <properties>
        <sourceDir>${delombokedSourceDir}</sourceDir>
      </properties>
    </profile>
  </profiles>

  <build>
    <sourceDirectory>${sourceDir}</sourceDirectory>
    <plugins>
      <plugin>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok-maven-plugin</artifactId>
        <version>${lombok.version}.0</version>
        <executions>
          <execution>
            <phase>generate-sources</phase>
            <goals>
              <goal>delombok</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <sourceDirectory>${origSourceDir}</sourceDirectory>
          <outputDirectory>${delombokedSourceDir}</outputDirectory>
          <addOutputDirectory>false</addOutputDirectory>
          <encoding>UTF-8</encoding>
          <formatPreferences>
            <generateDelombokComment>skip</generateDelombokComment>
            <javaLangAsFQN>skip</javaLangAsFQN>
          </formatPreferences>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.1.1</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>com.github.monosoul.lombok.sourcesjar.Main</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-source-plugin</artifactId>
        <version>3.0.1</version>
        <executions>
          <execution>
            <id>attach-sources</id>
            <goals>
              <goal>jar</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

</project>


Работает это следующим образом. В свойство origSourceDir мы подставляем путь до каталога с оригинальными исходниками, а в свойство sourceDir по умолчанию подставляем значение из origSourceDir. В свойстве delombokedSourceDir мы указываем путь до исходников, сгенерированных delombok'ом. Таким образом, при импорте проекта в IDE используется каталог из origSourceDir, а при сборке проекта, если указать профиль build, будет использован каталог delombokedSourceDir.

В результате мы получим байткод, скомпилированный из тех же исходников, которые попадут в sources.jar, т.е. дебаг наконец-то будет работать. При этом нам не нужно конфигурировать установку и деплой артефакта с исходниками, поскольку мы используем для генерации артефакта плагин maven-source-plugin. Правда, магия с переменными может запутать незнакомого с нюансами Maven'а человека.

А ещё можно в lombok.config добавить опцию lombok.addJavaxGeneratedAnnotation = true, тогда в сгенерированных исходниках над сгенерированным кодом будет стоять аннотация @javax.annotation.Generated("lombok"), что поможет избежать вопросов типа «Почему ваш код выглядит так странно?!». :)


Использовать Gradle


Думаю, если вы уже знакомы с Gradle, то не стоит объяснять все его преимущества перед Maven. Если же вы ещё не знакомы с ним, то на хабре для этого есть целый Хаб. Отличный повод заглянуть в него! :)
Вообще, когда я подумал об использовании Gradle, то я ожидал, что сделать в нём то, что мне нужно, будет гораздо проще, поскольку я знал, что уж в нём-то я без проблем смогу указать из чего собирать sources.jar и что компилировать в байткод. Проблема поджидала меня там, где я ожидал меньше всего — для Gradle нет работающего delombok плагина.

Есть этот плагин, но похоже, что в нём нельзя указать опции для форматирования delomboked-исходников, что мне не подходило.

Есть ещё один плагин, он генерирует текстовый файл из переданных ему опций, а потом передаёт его в качестве аргумента lombok.jar. Мне не удалось заставить его складывать сгенерированные исходники в нужный каталог, похоже это связано с тем, что путь в текстовом файле с аргументами не берётся в кавычки и не экранируется должным образом. Возможно позже я сделаю пулл реквест автору плагина с предложением исправления.

В итоге я решил пойти другим путём и просто описал задачу с вызовом Ant для выполнения delombok'а, в Lombok'е как раз есть Ant task для этого, и выглядит это вполне неплохо:

build.gradle.kts
group = "com.github.monosoul"
version = "1.0.0"

plugins {
    java
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

dependencies {
    val lombokDependency = "org.projectlombok:lombok:1.18.2"

    annotationProcessor(lombokDependency)
    compileOnly(lombokDependency)
}

repositories {
    mavenCentral()
}

tasks {
    "jar"(Jar::class) {
        manifest {
            attributes(
                    Pair("Main-Class", "com.github.monosoul.lombok.sourcesjar.Main")
            )
        }
    }

    val delombok by creating {
        group = "lombok"

        val delombokTarget by extra { File(buildDir, "delomboked") }
        
        doLast({
            ant.withGroovyBuilder {
                "taskdef"(
                        "name" to "delombok",
                        "classname" to "lombok.delombok.ant.Tasks\$Delombok",
                        "classpath" to sourceSets.main.get().compileClasspath.asPath)
                "mkdir"("dir" to delombokTarget)
                "delombok"(
                        "verbose" to false,
                        "encoding" to "UTF-8",
                        "to" to delombokTarget,
                        "from" to sourceSets.main.get().java.srcDirs.first().absolutePath
                ) {
                    "format"("value" to "generateDelombokComment:skip")
                    "format"("value" to "generated:generate")
                    "format"("value" to "javaLangAsFQN:skip")
                }
            }
        })
    }

    register<Jar>("sourcesJar") {
        dependsOn(delombok)

        val delombokTarget: File by delombok.extra
        from(delombokTarget)
        archiveClassifier.set("sources")
    }

    withType(JavaCompile::class) {
        dependsOn(delombok)

        val delombokTarget: File by delombok.extra
        source = fileTree(delombokTarget)
    }
}


По результату этот вариант эквивалентен предыдущему.


Выводы


Довольно тривиальная, по сути, задача в итоге оказалась чередой попыток найти обходные пути вокруг странных решений авторов Maven. Как по мне — скрипт Gradle, на фоне получившихся конфигов Maven'а, выглядит гораздо более очевидно и логично. Впрочем, может быть мне просто не удалось найти решение лучше? Расскажите в комментариях, решали ли вы похожую задачу, и если решали, то каким образом.

Спасибо за чтение!

Исходники
Tags:
Hubs:
Total votes 21: ↑15 and ↓6+9
Comments7

Information

Website
www.sber.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия