Rozwiązywanie niezgodności binarnych z maven-shade-plugin

Z Jacek Laskowski - Wiki Projektanta Java EE

Wracając do mojej działalności w Apache OpenEJB zabrałem się za czyszczenie mojego backlogu. Lista nie jest pokaźna, ale niezwykle leciwa. Jednym z zadań było rozwiązanie tematu niezgodności binarnej asm używanego w OpenEJB. Innymi słowy, przypadek zależności asm unaocznia problem różnych wersji tej samej biblioteki, która zmieniła publiczny interfejs (API) między wersjami, co skutkuje, że skorzystanie jednocześnie z nich prowadzi do pojawienia się wyjątku:

java.lang.NoSuchMethodError: org.objectweb.asm.ClassReader.accept(Lorg/objectweb/asm/ClassVisitor;I)V

Wynika to z faktu, że między wersjami ASM 1.5.3 i nowszymi istnieje niezgodność publicznego interfejsu, gdzie metody klasy org.objectweb.asm.ClassReader czy interfejsu org.objectweb.asm.ClassVisitor zmieniły swoje sygnatury (!) Na ten temat natrafili również panowie ze SpringSource - ASM version incompatibilities, using Spring @Autowired with Hibernate. Jednym z rozwiązań było przepakowanie klas i interfejsów asm narzędziem jarjar oznaczone numerem 3. W tym artykule przedstawię rozwiązanie numer 4 z użyciem maven-shade-plugin. Wtyczka maven-shade-plugin pozwala na spakowanie wszystkich zależności projektu w pojedynczy plik jar oraz (właśnie ta funkcjonalność mnie najbardziej interesowała) przenieść wskazane klasy z jednego pakietu do wskazanego z jednoczesną zmianą ich wywołań we wszystkich klasach projektów korzystających z nich.

Najlepszym sposobem na przedstawienie problemu będzie sposób praktyczny - przez przykład. Stworzymy projekt z zależnością ASM 1.5.3 i drugi z ASM 3.1. Następnie stworzymy kolejny, trzeci projekt, który będzie zależny od tych dwóch, wcześniej stworzonych. I właśnie ten trzeci projekt będzie projektem demonstracyjnym - dwie niezgodne binarnie wersje tej samej biblioteki będące przechodnimi zależnościami projektów zależnych prowadzą do sytuacji, w której pojawi się wyjątek NoSuchMethodError. Rozwiązanie to wdrożenie wtyczki maven-shade-plugin w projektach przejściowych, które dopiero stanowią zależności projektu demonstracyjnego.

Do tworzenia projektów skorzystamy z Apache Maven. Kompletny projekt dostępny jest jako asm-shaded.zip.

Spis treści

Utworzenie struktury projektowej

Na cele naszego doświadczenia utworzymy kilka projektów mavenowych zebranych w jeden zbiorczy projekt macierzysty, który pozwoli nam na wykonywanie poleceń zbiorczo dla wszystkich podprojektów. Typowa konfiguracja w Maven dla wielu projektów będących projektami współpracującymi.

Utworzenie projektu macierzystego - asm-shaded

Tworzymy wspomniany projekt macierzysty asm-shaded.

mvn archetype:generate -B \
    -DarchetypeGroupId=org.apache.maven.archetypes \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.0 \
    -DgroupId=pl.jaceklaskowski -DartifactId=asm-shaded -Dversion=1.0

W pliku pom.xml zmieniamy deklarację packaging na pom. W ten sposób stanie się on projektem macierzystym kolejnych.

<packaging>pom</packaging>

Kolejne polecenia wykonujemy z poziomu katalogu projektu asm-shaded.

Utworzenie projektu z ASM 1.5.3 - A-asm153

Pora na kilka podprojektów, z których jako pierwszy będzie A-asm153 z zależnością ASM 1.5.3.

mvn archetype:generate -B \
    -DarchetypeGroupId=org.apache.maven.archetypes \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.0 \
    -DgroupId=pl.jaceklaskowski -DartifactId=A-asm153 -Dversion=1.0

W pliku pom.xml deklarujemy zależność asm-1.5.3.

<dependency>
  <groupId>asm</groupId>
  <artifactId>asm</artifactId>
  <version>1.5.3</version>
</dependency>

Tworzymy klasę pl.jaceklaskowski.KlasaA korzystającą z funkcjonalności specyficznej dla ASM 1.5.3 - metody public void accept(ClassVisitor classVisitor, boolean skipDebug).

package pl.jaceklaskowski;

import org.objectweb.asm.*;

import java.io.*;

public class KlasaA {
  public void wykonajMetodeDostepnaJedynieWAsm153() {
    try {
      // void accept(ClassVisitor classVisitor, boolean skipDebug) dostępna w ASM 1.5.3 ale nie ASM 3.1
      new ClassReader(this.getClass().getName()).accept(new PustyClassVisitor(), true);
    } catch (IOException e) {
      // I tak się nie wydarzy
      e.printStackTrace();
    }
  }

  // Już ClassVisitor jest wyróżnikiem, z jaką wersją pracujemy
  private static class PustyClassVisitor implements ClassVisitor {
    public void visit(int i, int i1, String s, String s1, String[] strings, String s2) {
    }

    public void visitInnerClass(String s, String s1, String s2, int i) {
    }

    public void visitField(int i, String s, String s1, Object o, Attribute attribute) {
    }

    public CodeVisitor visitMethod(int i, String s, String s1, String[] strings, Attribute attribute) {
      return null;
    }

    public void visitAttribute(Attribute attribute) {
    }

    public void visitEnd() {
    }
  }
}

Utworzenie projektu z ASM 3.1 - B-asm31

Następnym podprojektem jest projekt B-asm31 z zależnością ASM 3.1.

mvn archetype:generate -B \
    -DarchetypeGroupId=org.apache.maven.archetypes \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.0 \
    -DgroupId=pl.jaceklaskowski -DartifactId=B-asm31 -Dversion=1.0

Definiujemy zależność asm-3.1 w pom.xml:

<dependency>
  <groupId>asm</groupId>
  <artifactId>asm</artifactId>
  <version>3.1</version>
</dependency>

Tworzymy klasę pl.jaceklaskowski.KlasaB korzystającą z funkcjonalności specyficznej dla ASM 3.1 - metody public void accept(ClassVisitor classVisitor, int flags).

package pl.jaceklaskowski;

import org.objectweb.asm.*;

import java.io.*;

public class KlasaB {
  public void wykonajMetodeDostepnaJedynieWAsm31() {
    try {
      // void accept(ClassVisitor classVisitor, boolean skipDebug) dostępna w ASM 3.1, ale brak jej w ASM 1.5.3
      new ClassReader(this.getClass().getName()).accept(new PustyClassVisitor(), ClassReader.SKIP_DEBUG);
    } catch (IOException e) {
      // I tak się nie wydarzy
      e.printStackTrace();
    }
  }
  
  // ClassVisitor jest bardziej rozbudowany w nowszych wersjach niż 1.5.3
  private static class PustyClassVisitor implements ClassVisitor {
    public void visit(int i, int i1, String s, String s1, String s2, String[] strings) {
    }
    
    public void visitSource(String s, String s1) {
    }
    
    public void visitOuterClass(String s, String s1, String s2) {
    }
    
    public AnnotationVisitor visitAnnotation(String s, boolean b) {
      return null;
    }
    
    public void visitAttribute(Attribute attribute) {
    }
    
    public void visitInnerClass(String s, String s1, String s2, int i) {
    }
    
    public FieldVisitor visitField(int i, String s, String s1, String s2, Object o) {
      return null;
    }
    
    public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {
      return null;
    }
    
    public void visitEnd() {
    }
  }
}

Utworzenie projektu głównego - C

Ostatnim podprojektem jest projekt główny C.

mvn archetype:generate -B \
    -DarchetypeGroupId=org.apache.maven.archetypes \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.0 \
    -DgroupId=pl.jaceklaskowski -DartifactId=C -Dversion=1.0

W jego pliku pom.xml definiujemy zależności A-asm153 oraz B-asm31.

<dependency>
 <groupId>pl.jaceklaskowski</groupId>
 <artifactId>A-asm153</artifactId>
 <version>1.0</version>
</dependency>
<dependency>
 <groupId>pl.jaceklaskowski</groupId>
 <artifactId>B-asm31</artifactId>
 <version>1.0</version>
</dependency>

Ostatnim krokiem w zestawianiu środowiska demonstracyjnego jest stworzenie klasy-testu pl.jaceklaskowski.AsmTest, która korzysta z metod udostępnianych przez projekty zależne.

package pl.jaceklaskowski;

public class AsmTest {
  public void testAsmBinaryIncompatibility() {
    new KlasaA().wykonajMetodeDostepnaJedynieWAsm153();
    new KlasaB().wykonajMetodeDostepnaJedynieWAsm31();
  }
}

Pierwsze uruchomienie mvn test - java.lang.NoSuchMethodError

Uruchomienie polecenia mvn test uruchomi klasę-test pl.jaceklaskowski.AsmTest, która zgodnie z oczekiwaniami zakończy się błędem java.lang.NoSuchMethodError.

$ cat C/target/surefire-reports/pl.jaceklaskowski.AsmTest.txt
-------------------------------------------------------------------------------
Test set: pl.jaceklaskowski.AsmTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.031 sec <<< FAILURE!
pl.jaceklaskowski.AsmTest.testAsmBinaryIncompatibility()  Time elapsed: 0.031 sec  <<< FAILURE!
java.lang.NoSuchMethodError: org.objectweb.asm.ClassReader.accept(Lorg/objectweb/asm/ClassVisitor;I)V
        at pl.jaceklaskowski.KlasaB.wykonajMetodeDostepnaJedynieWAsm31(KlasaB.java:12)
        at pl.jaceklaskowski.AsmTest.testAsmBinaryIncompatibility(AsmTest.java:6)

Wdrożenie maven-shade-plugin

Wdrożenie wtyczki maven-shade-plugin rozpoczynamy od utworzenia dwóch, nowych projektów, których jedynym celem jest zmiana pakietów klas ASM - jeden dla wersji 1.5.3, a drugi dla wersji 3.1 (patrz Relocating Classes w dokumentacji maven-shade-plugin). Nowe projekty stają się projektami przejściowymi z maven-shade-plugin, które deklarujemy jako zależności naszego projektu C zamiast dotychczasowych A-asm135 oraz B-asm31. Technicznie moglibyśmy wykonać "przesłonięcie" pakietu wyłącznie w jednym projekcie zależnym, a jedynie dla porządku robimy to w obu. Chodzi o to, aby nazwy pakietów dla klas ASM, które zmieniły swój publiczny interfejs, np. org.objectweb.asm.ClassReader, były różne dla różnych wersji ASM.

Utworzenie projektu przejściowego A-asm153-shaded

Najpierw tworzymy projekt A-asm153-shaded.

mvn archetype:generate -B \
    -DarchetypeGroupId=org.apache.maven.archetypes \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.0 \
    -DgroupId=pl.jaceklaskowski -DartifactId=A-asm153-shaded -Dversion=1.0

a następnie deklarujemy zależność A-asm153 w pom.xml:

<dependency>
    <groupId>pl.jaceklaskowski</groupId>
    <artifactId>A-asm153</artifactId>
    <version>1.0</version>
</dependency>

Ostatnia zmiana w projekcie to dodanie konfiguracji wtyczki maven-shade-plugin w pom.xml:

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <version>1.2.1</version>
      <executions>
        <execution>
          <phase>package</phase>
          <goals>
            <goal>shade</goal>
          </goals>
          <configuration>
            <relocations>
              <relocation>
                <pattern>org.objectweb.asm</pattern>
                <shadedPattern>shaded.a.org.objectweb.asm</shadedPattern>
              </relocation>
            </relocations>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Sekcja relocation konfiguruje przesłonięcie pakietów.

Utworzenie projektu przejściowego B-asm31-shaded

Tworzymy projekt B-asm31-shaded.

mvn archetype:generate -B \
    -DarchetypeGroupId=org.apache.maven.archetypes \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.0 \
    -DgroupId=pl.jaceklaskowski -DartifactId=B-asm31-shaded -Dversion=1.0

a następnie deklarujemy zależność B-asm31 w pom.xml:

<dependency>
    <groupId>pl.jaceklaskowski</groupId>
    <artifactId>B-asm31</artifactId>
    <version>1.0</version>
</dependency>

Kończymy dodając konfigurację wtyczki maven-shade-plugin do pom.xml:

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <version>1.2.1</version>
      <executions>
        <execution>
          <phase>package</phase>
          <goals>
            <goal>shade</goal>
          </goals>
          <configuration>
            <relocations>
              <relocation>
                <pattern>org.objectweb.asm</pattern>
                <shadedPattern>shaded.b.org.objectweb.asm</shadedPattern>
              </relocation>
            </relocations>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Warto zauważyć, że docelowy pakiet klas z org.objectweb.asm w tym projekcie jest inny (shaded.b.org.objectweb.asm) niż ten w projekcie A-asm153-shaded (shaded.a.org.objectweb.asm). To jest kluczowa konfiguracja, która przenosi niezgodne binarnie klasy do różnych pakietów.

Zmiany w projekcie C - dodanie zależności przejściowych

W pom.xml projektu C podmieniamy zależności A-asm153 oraz B-asm31 na odpowiadające im zależności przejściowe, odpowiednio, A-asm153-shaded oraz B-asm31-shaded.

<dependency>
  <groupId>pl.jaceklaskowski</groupId>
  <artifactId>A-asm153-shaded</artifactId>
  <version>1.0</version>
</dependency>
<dependency>
  <groupId>pl.jaceklaskowski</groupId>
  <artifactId>B-asm31-shaded</artifactId>
  <version>1.0</version>
</dependency>

Uruchomienie końcowe z mvn package

Wszystko gotowe na powtórne wykonanie mvn test. Ale co jest?! Wykonanie mvn test kończy się znowu BUILD FAILURE z java.lang.NoSuchMethodError: org.objectweb.asm.ClassReader.accept(Lorg/objectweb/asm/ClassVisitor;I)V?!

Rozwiązaniem jest wykonanie polecenia mvn package, gdyż wynika to z konfiguracji wtyczki maven-shade-plugin w pomach projektów pośrednich, która jest związana z wykonaniem mavenowego etapu package (patrz <phase>package</phase> w pom.xml w projektach A-asm153-shaded i B-asm31-shaded).

Uruchomienie mvn package upewnia nas, że wszystko jest faktycznie w należytym porządku.

$ mvn package
...
-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running pl.jaceklaskowski.AsmTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.047 sec
Running pl.jaceklaskowski.AppTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.031 sec

Results :

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

[INFO] [jar:jar]
[INFO] Building jar: C:\projs\sandbox\asm-shaded\C\target\C-1.0.jar
[INFO]
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Reactor Summary:
[INFO] ------------------------------------------------------------------------
[INFO] asm-shaded ............................................ SUCCESS [1.765s]
[INFO] A-asm153 .............................................. SUCCESS [1.844s]
[INFO] B-asm31 ............................................... SUCCESS [0.360s]
[INFO] A-asm153-shaded ....................................... SUCCESS [0.828s]
[INFO] B-asm31-shaded ........................................ SUCCESS [0.500s]
[INFO] C ..................................................... SUCCESS [0.406s]
[INFO] ------------------------------------------------------------------------
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] ------------------------------------------------------------------------
Osobiste