Meer met Maven – Dependencies and Semantic Versioning

In elke editie zal Robert Scholte een probleem voorleggen en deze oplossen met behulp van Apache Maven om meer inzicht te geven in Maven zelf en de vele beschikbare plugins.

Semantic versioning is een specificatie die beschrijft hoe je versienummers opbouwt en wat elk element van de versie betekent. De samenvatting, te vinden op semver.org, ziet er als volgt uit:

Semantic Versioning 2.0.0 – Summary

Given a version number MAJOR.MINOR.PATCH, increment the:

  1. MAJOR version when you make incompatible API changes,
  2. MINOR version when you add functionality in a backwards-compatible manner, and
  3. PATCH version when you make backwards-compatible bug fixes.

Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.

Deze definitie zal iedereen wel bekend in de oren klinken. En zoals het beschreven is, klinkt het ook allemaal erg logisch. Maar als je beseft hoe dependency resolution en version conflict resolution voor Maven werkt, dan zal je merken dat met de waarde van de versies onderling niet vergeleken wordt en dat er in deze context weinig tot geen waarde aan gehecht wordt.

Per combinatie van groupId en artifactId zal Maven slechts één dependency selecteren. Immers: als er naar een specifieke class gezocht wordt op het classpath, worden de jars en directories op volgorde afgelopen tot het desbetreffende bestand voor het eerst gevonden wordt. Het heeft dus geen zin om meerdere versies van dezelfde library op het pad te hebben staan.

De versie die Maven selecteert, is niet per definitie de nieuwste in de dependency-tree. Maven heeft een andere strategie, waarbij het magische woord “AFSTAND” is. In het geval van indirecte (transitive) dependencies wordt de eerste dichtstbijzijnde versie gekozen. Dependencies die je rechtstreeks in je pom opneemt, hebben de korte afstand en worden dus altijd gekozen. Zelfs als er indirect naar een nieuwere versie gerefereerd wordt.

Semantic versioning werkt dus eigenlijk alleen goed voor eindproducten, maar daarbij zullen incompatible API changes niet zo snel van invloed zijn. Voor tussenproducten moet je in het geval van een incompatible API change zowel de package van de sources aanpassen én een nieuwe artifactId kiezen, zodat Maven beide dependencies op het classpath kan plaatsen. Alleen zo kunnen incompatible jars naast elkaar leven zonder conflicten. Eén van de weinige projecten die ik dit correct heb zien doen, is commons-lang.

Als een project eruit ziet conform deze diagram, zal Maven voor ‘lib’ de 1.0 versie selecteren. Als lib:2.0 niet backwards compatible is, dan zal je dit project hoogstwaarschijnlijk niet kunnen uitvoeren met deze opzet. Idealiter gebruikt versie 2.0 als artifactId lib2 en heeft het een andere packagestructuur. Anders zal je via depA de package structuur moeten manipuleren via relocations met de maven-shade-plugin. Door lib:1.1 toe te voegen aan de dependencyManagement van myproject zorg je ervoor dat deze versie van lib gebruikt wordt in dit Maven project.

Er zijn twee enforcer rules die kunnen helpen bij het selecteren van de juiste versie per dependency. Met de dependencyConvergence rule kun je afdwingen dat in de dependency-tree altijd dezelfde versie van een dependency gebruikt wordt. Dit is de meest ideale oplossing, maar ook het meest lastige om voor elkaar te krijgen. Zeker als je niet de controle hebt over alle dependencies. Daarnaast het je de requireUpperBoundDeps rule, welke controleert dat per dependency de nieuwste geselecteerd is. Met deze plugins kun je runtime heel wat problemen voorkomen.