~/home of geeks

Transitive Dependencies und verwaiste Dependencies nachhalten mit Maven

· 1068 Wörter · 6 Minute(n) Lesedauer

Maven

Schon kleine Projekte haben dank Open Source und Maven eine Menge Abhängigkeiten. Neben nicht mehr genutzten verwaisten Dependencies gibt es noch eine Klasse von Abhängigkeiten, die transitiven Abhängigkeiten (transitive dependencies), die besonders zu beachten sind.

Transitive Dependencies #

Transitive Dependencies, sind Dependencies, die in unserem Code referenziert (also von uns un unserem Code benutzt) werden, aber nicht explizit in der POM drinstehen, weil sie als Dependency von einer anderen Dependency eingeladen werden.

Ein Beispiel: Wir haben eine Abhängigkeit zu org.springframework:spring-context in unserer POM. Diese Abhängigkeit hat wiederum eine Abhängigkeit zu org.springframework:spring-beans. Wenn wir nun in unserem Code eine Klasse aus dem Paket spring-beans verwenden, wie die Annotation @Autowired, haben wir eine transitive Abhängigkeit zu org.springframework:spring-beans. Beim Entwickeln fällt uns das nicht sonderlich auf, denn spring-beans ist im Klassenpfad und kann von uns ganz normal verwendet werden.

Beispiel Transitive Dependency auf spring-beans
Beispiel Transitive Dependency auf spring-beans

Warum kann das ein Problem sein?

Probleme gibt es meist mit transitiven Dependencies, wenn die nicht-transitive Dependency wegfällt. Dann fehlt auch die von uns verwendete transitive Dependency. Auch kann sich die Version der transitiven Dependency ändern, ohne das wir diese bestimmt haben, wenn sich die Version der nicht-transitiven Dependency ändert. Wir können also, ohne die transitive Dependency in unsere POM aufzunehmen, nicht sicherstellen, dass wir die Version kriegen, die wir benutzen wollen.

Bei großen Frameworks, wie Spring, ist es sicherlich kein Problem, wenn eigene Teilmodule voneinander abhängig sind. Dies ändert sich jedoch, sobald man Dependencies hat, die ausserhalb des Frameworks liegen und auch von anderen eingesetzten Frameworks verwendet werden.

Das maven-dependency-plugin hilft uns, solche transitiven Dependencies zu identifizieren. Mit dem goal analyze-only können wir uns eine Liste der transitiven Dependencies ausgeben lassen. Sie werden hier Used undeclared dependencies genannt:


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>analyze-dependencies</id>
            <goals>
                <goal>analyze-only</goal>
            </goals>
            <configuration>
                <ignoredUsedUndeclaredDependencies>
                    <!-- 
                        Inhaltlich ändern sich die Validation-Annotationen selten,
                        es gibt aber immer wieder minor Versionsanpassungen,
                        da sie durch die Implementierung von Hibernate-Validator
                        in unserem Projekt bestimmt wird.
                        Wir lassen diese transitive Dependency bestehen. 
                     -->
                    <dependency>javax.validation:validation-api</dependency>
                </ignoredUsedUndeclaredDependencies>
                <failOnWarning>true</failOnWarning>
            </configuration>
        </execution>
    </executions>
</plugin>

Über ignoredUsedUndeclaredDependencies können wir Dependencies angeben, die wir als transitive Dependencies ignorieren wollen. In diesem Fall ist es die javax.validation:validation-api, die wir als transitive Dependency von Hibernate bekommen.

Die Ausgabe des maven-dependency-plugin sieht dann so aus:

[INFO] --- maven-dependency-plugin:3.5.0:analyze-only (analyze-dep) @ myModule ---
[WARNING] Used undeclared dependencies found:
[WARNING]    org.apache.lucene:lucene-core:jar:5.5.4:compile
[WARNING]    org.mockito:mockito-core:jar:3.10.0:test
[WARNING]    org.hibernate:hibernate-search-engine:jar:5.11.10.Final:compile
[WARNING]    javax.validation:validation-api:jar:1.1.0.Final:compile
[WARNING]    javax.persistence:javax.persistence-api:jar:2.2:compile
[WARNING]    commons-collections:commons-collections:jar:3.2.2:compile
[WARNING]    org.springframework:spring-beans:jar:5.3.18:compile
[WARNING]    org.apache.logging.log4j:log4j-api:jar:2.17.1:compile

Für jede der aufgelisteten Dependencies lohnt sich ein Blick in den Code, wo und wie diese verwendet wird.

Beispielsweise wurde mir einmal die Dependency org.apache.httpcomponents.core5:httpcore5 angezeigt. Ein Blick in die Library zeigte, dass die Klassen im Paket org.apache.hc.core5.http liegen. Eine Suche im Code nach org.apache.hc.core5.http ergab, dass es einen einzigen Import in einer Klasse gab:

import org.apache.hc.core5.http.ContentType;
// ...
externalContext.setResponseContentType(ContentType.APPLICATION_OCTET_STREAM.getMimeType());

Die Dependency war einmal als transitive Dependency im Code verfügbar und ein Entwickler benutzte eine Konstante aus dem Paket. Seitdem war die Library als Dependency fest verankert. Ich entfernte die Referenz und ersetzte sie durch einen konstanten String für die Mime-Type und konnte die ganze Library als Dependency entfernen und somit das Zielprodukt verschlanken.

Verwaiste Dependencies #

Verwaiste Dependencies sind Dependencies, die in der POM stehen, aber nicht verwendet werden. Das Maven-Plugin nennt diese ‘Unused declared dependencies’.

[INFO] --- maven-dependency-plugin:3.5.0:analyze-only (analyze-dep) @ myModule ---
[WARNING] Unused declared dependencies found:
[WARNING]    org.junit.jupiter:junit-jupiter-engine:jar:5.7.1:test
[WARNING]    org.hibernate:hibernate-entitymanager:jar:5.6.5.Final:compile
[WARNING]    com.sun.xml.bind:jaxb-impl:jar:3.0.1:compile
[WARNING]    org.projectlombok:lombok:jar:1.18.22:provided

Falsche Positive #

Falsche Positive (false positives) sind Dependencies, die von Maven als ungenutzte Dependencies erkannt werden, aber in Wirklichkeit genutzt werden. Bei den falschen Positiven geht es insbesondere darum, wie das Maven-Dependency-Plugin diese Dependencies ermittelt: anhand der Klassen im Projektcode und ihrer Importe. Wird in den Importen einer Klasse auf eine Dependency referenziert, so wird diese als genutzte Dependency identifiziert. Existieren keine Importe in den Klassen, so wird die Dependency als ungenutzt erkannt.

Es gibt aber neben Java-Importen auch “dynamische Wege”, eine Klasse aus einer Dependency zu laden.

Konfigurationsdateien #

Beispielsweise XML-Konfigurationsdokumente, wie in Spring: <bean id="myJdbcDriver" class="com.mysql.jdbc.Driver">

Die Klassen aus com.mysql werden idealerweise nirgendwo im Code direkt referenziert. Es läuft alles gekapselt über java.jdbc Interfaces. Die Verknüpfung findet in der XML-Konfiguration statt und das Maven-Plugin kann keine Spring-XML-Konfigurationen auswerten.

Bei einigen APIs kann man unter den Resourcen eine Text-Datei hinterlegen, in der die Klasse der Implementierung für eine API steht. Diese kann das Maven-Plugin auch nicht auswerten.

Dynamische Instantiierung #

Man kann in Java per Reflection Klassen anhand ihres Klassennamen instantiieren. So hat man früher gerne den JDBC-Treiber instantiiert:

Class.forName("com.mysql.cj.jdbc.Driver").newInstance();
// Interne Registrierung im DriverManager
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/test?user=myuser&password=mypassword");

Das funktioniert analog auch mit Klassen, die über die Suche von Klassen im Klassenpfad erfolgen. Spring bietet so etwas mit dem ClassPathBeanDefinitionScanner an, es gibt aber auch eigene Libraries, die darauf spezialisiert sind, Klassen im Klassenpfad zu finden und instantiieren.

Man kann z. B. alle Klassen im Klassenpfad ermitteln, die das Interface X implementieren und sich dann per Class.forName eine Instanz dieser Klasse erzeugen lassen. Welche Klassen man dann zur Laufzeit hat, hängt davon ab, welche Libraries im Klassenpfad sind.

Sowas kann das Maven-Plugin auch nicht auflösen.

Ausschlussliste #

Hieraus ergibt sich, dass einige Dependencies in die Ignore-List aufgenommen werden müssen. Hierzu zählen insbesondere Datenbanktreiber, Klassen, die nur in Spring-XML referenziert werden, Codegeneratoren und dynamisch geladene Libraries (XML, Logging).


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <version>${version.dependency.plugin}</version>
    <executions>
        <execution>
            <id>analyze-dependencies</id>
            <goals>
                <goal>analyze-only</goal>
            </goals>
            <configuration>
                <ignoredUnusedDeclaredDependencies>
                    <!-- Wird nicht in allen Modulen genutzt, ist aber praktisch,
                        wenn es schon da ist, wenn man es braucht -->
                    <dependency>org.projectlombok:lombok</dependency>
                    <!-- Wird dynamisch per Konfiguration geladen und daher hier nicht erkannt -->
                    <dependency>mysql:mysql-connector-java</dependency>
                    <!-- Wird per Konfiguration geladen -->
                    <dependency>org.primefaces.themes:smoothness</dependency>
                    <!-- Wird irgendwo dynamisch geladen. Ohne läuft nichts. -->
                    <dependency>jakarta.enterprise:jakarta.enterprise.cdi-api</dependency>
                    <!-- Logging wird dynamisch geladen via log4j -->
                    <dependency>org.apache.logging.log4j:log4j-slf4j-impl</dependency>
                </ignoredUnusedDeclaredDependencies>
                <failOnWarning>true</failOnWarning>
            </configuration>
        </execution>
    </executions>
</plugin>

Fazit #

Das Erstellen einer solchen Liste kann sehr mühselig sein, denn wenn eine Klasse vom Maven-Plugin als verwaist vorgeschlagen wird, muss man nach dem Entfernen prüfen, ob das auch wirklich funktioniert hat, üblicherweise mit einem gesamten Build und einer sehr hohen Testabdeckung.

Bei den “Unused declared dependencies” empfiehlt es sich 2-3 Dependencies auszukommentieren und einen vollen Integrationstest auszuführen, um Fehler zu ermitteln.

Dieses Vorgehen ist etwas gefährlich, da es auch Stellen geben kann, wo dann eine Klasse fehlt, die aber nicht durch Tests abgedeckt ist.

Bei “false positives” gibt es üblicherweise irgendwo eine ClassNotFoundException. Es ist manchmal sehr schwer zu ermitteln, aus welcher Dependency die Klasse erwartet wurde. Daher die Iteration in 2-3 Dependencies Schritten. Die entsprechende Dependency kann dann mit Kommentaren in die Ignore-List.