Major migrations made easy with OpenRewrite
OpenRewrite enables large-scale distributed source code refactoring for framework migrations, vulnerability patches, and API migrations with an early focus on the Java language.
https://docs.openrewrite.org
To demonstrate OpenRewrite, this blogpost will walk through upgrading a Spring Boot 1.5 application to 2.5+. Along the way we will pick up JUnit 5, and migrate from Java 8 to 17, with minimal manual intervention.
We’ll start with the Spring PetClinic Sample Application, back as it was almost five years ago in 2017!
You can follow along by copy-pasting the commands and viewing the changes made in each step. You’ll need git, patch and SDKMAN!, to easily switch between Java versions. Be sure to commit your changes after each command to get a clean diff of changes at each step.
Checkout Spring PetClinic 1.5.x
Start by checking out the relevant branch of Spring PetClinic locally.
git clone https://github.com/spring-projects/spring-petclinic.git;
cd spring-petclinic;
git switch -c 2.5.x 1.5.x;
SDKMAN!
SDKMAN! is a tool for managing parallel versions of multiple Software Development Kits on most Unix based systems. It provides a convenient Command Line Interface (CLI) and API for installing, switching, removing and listing Candidates.
https://sdkman.io
Through SDKMAN! we’ll install and switch between the Java versions used in this blogpost. Start by installing recent versions of Java 8 and 17, and activate Java 8.
# curl -s "https://get.sdkman.io" | bash
sdk install java 8.0.322-tem
sdk install java 17.0.2-tem
sdk use java 8.0.322-tem
Keep the terminal window open to stay on Java 8.
Or add a .sdkmanrc
file containing java=8.0.322-tem
inside spring-petclinic/
,
to switch automatically each time you enter the project folder.
Apache Maven wrapper
Next up we’ll switch out the almost 7 years old Takari Maven Wrapper 3.3.3 for the most recent Apache Maven Wrapper version 3.8.5. This ensures there’s no incompatibilities running more recent plugins and Java versions.
./mvnw wrapper:wrapper -Dmaven=3.8.5
Verify
At any step in the migration we can verify our setup produces a working build by invoking the Maven verify goal.
./mvnw verify
We need neither clean nor install when using well behaved plugins.
|
This should produce a working build when run with Java 8. Runs with Java 17 fail for now; we’ll get to those later.
Upgrade to JUnit 5
To gain confidence with OpenRewrite, we’ll first try to migrate our application from JUnit 4 to 5. JUnit 5 was released in September 2017, yet it’s all too common to still see JUnit 4 on existing projects.
Take a moment to think about what would be needed for a JUnit 4 to 5 migration;
we will need to add new dependencies, remove old dependencies, update imports, occasionally swap argument orders in assertions, before getting into more complicated patterns such as replacing @Rule
usage.
It should become apparent that while some steps could be achieved with a search-and-replace operation, others are not as clear cut.
Let’s get OpenRewrite set up to apply our first automated migration:
./mvnw org.openrewrite.maven:rewrite-maven-plugin:4.22.1:init \ (1)
-Ddependencies=org.openrewrite.recipe:rewrite-testing-frameworks:1.20.1 \ (2)
-DactiveRecipes=org.openrewrite.java.testing.junit5.JUnit5BestPractices (3)
git diff (4)
1 | We run the
recently added InitMojo to add the rewrite-maven-plugin to the pom.xml . |
2 | We add a dependency on rewrite-testing-frameworks to the plugin. |
3 | We activate the JUnit5BestPractices recipe. |
4 | The diff should show the plugin added to the project. |
diff --git a/pom.xml b/pom.xml
index 6fdc4d1..755b35a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -206,6 +206,23 @@
</dependency>
</dependencies>
</plugin>
+ <plugin>
+ <groupId>org.openrewrite.maven</groupId>
+ <artifactId>rewrite-maven-plugin</artifactId>
+ <version>4.22.1</version>
+ <configuration>
+ <activeRecipes>
+ <recipe>org.openrewrite.java.testing.junit5.JUnit5BestPractices</recipe>
+ </activeRecipes>
+ </configuration>
+ <dependencies>
+ <dependency>
+ <groupId>org.openrewrite.recipe</groupId>
+ <artifactId>rewrite-testing-frameworks</artifactId>
+ <version>1.20.1</version>
+ </dependency>
+ </dependencies>
+ </plugin>
</plugins>
</build>
<reporting>
If you take a closer look at the added
JUnit5BestPractices recipe,
you’ll find it’s composed of a recipeList
, with references defined further down.
Note how
the recipeList
under JUnit4to5Migration
matches some of the migration steps we identified above.
This is a common pattern with OpenRewrite recipes; complex migrations are composed of more fine grained recipes, each of which applies a small step in the full migration.
Run JUnit 5 migration
With the plugin configured, next comes running OpenRewrite.
Notice how we can use the shorthand rewrite
to invoke Maven plugin goals once the plugin is added to the project pom.xml
.
./mvnw rewrite:run
This command should complete in about a minute, and migrate all tests to JUnit 5, as well as updating the pom.xml
.
While most tests in the Spring PetClinic are fairly straightforward, the changes in PetTypeFormatterTests
highlight a few strengths of OpenRewrite.
Notice how it updates not just the imports and @Test
annotations, but also method visibility, applies MockitoExtension
and adopts assertThrows
.
diff --git a/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java b/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java
index f332257..1d5e072 100644
--- a/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java
@@ -1,19 +1,20 @@
package org.springframework.samples.petclinic.owner;
-import static org.junit.Assert.assertEquals;
-
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Mockito;
-import org.mockito.runners.MockitoJUnitRunner;
+import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.samples.petclinic.owner.PetRepository;
import org.springframework.samples.petclinic.owner.PetType;
import org.springframework.samples.petclinic.owner.PetTypeFormatter;
@@ -23,7 +24,7 @@ import org.springframework.samples.petclinic.owner.PetTypeFormatter;
*
* @author Colin But
*/
-@RunWith(MockitoJUnitRunner.class)
+@ExtendWith(MockitoExtension.class)
public class PetTypeFormatterTests {
@Mock
@@ -31,13 +32,13 @@ public class PetTypeFormatterTests {
private PetTypeFormatter petTypeFormatter;
- @Before
+ @BeforeEach
public void setup() {
this.petTypeFormatter = new PetTypeFormatter(pets);
}
@Test
- public void testPrint() {
+ void testPrint() {
PetType petType = new PetType();
petType.setName("Hamster");
String petTypeName = this.petTypeFormatter.print(petType, Locale.ENGLISH);
@@ -45,16 +46,18 @@ public class PetTypeFormatterTests {
}
@Test
- public void shouldParse() throws ParseException {
+ void shouldParse() throws ParseException {
Mockito.when(this.pets.findPetTypes()).thenReturn(makePetTypes());
PetType petType = petTypeFormatter.parse("Bird", Locale.ENGLISH);
assertEquals("Bird", petType.getName());
}
- @Test(expected = ParseException.class)
- public void shouldThrowParseException() throws ParseException {
- Mockito.when(this.pets.findPetTypes()).thenReturn(makePetTypes());
- petTypeFormatter.parse("Fish", Locale.ENGLISH);
+ @Test
+ void shouldThrowParseException() throws ParseException {
+ assertThrows(ParseException.class, () -> {
+ Mockito.when(this.pets.findPetTypes()).thenReturn(makePetTypes());
+ petTypeFormatter.parse("Fish", Locale.ENGLISH);
+ });
}
/**
Now unfortunately this migration does not yet lead to a working build, due to a Spring Boot incompatibility. JUnit 5 support was added in Spring Boot 2.2, while JUnit 5’s Vintage Engine was dropped in Spring Boot 2.4.
So let’s revert this partial JUnit 5 migration for now, as it will be picked up (correctly) as part of the Spring Boot migration.
git reset --hard
Upgrade to Spring Boot 2.x
To upgrade Spring Boot applications we need a different dependency and recipe.
We invoke the rewrite plugin configure
goal for the plugin to update it’s own configuration. Neat!
./mvnw rewrite:configure \
-Ddependencies=org.openrewrite.recipe:rewrite-spring:4.19.2 \
-DactiveRecipes=org.openrewrite.java.spring.boot2.SpringBoot1To2Migration
The configure goal only works if the plugin is already present; if not replace rewrite:configure with org.openrewrite.maven:rewrite-maven-plugin:4.22.1:init .
|
We can again see how the SpringBoot1To2Migration is composed of finer grained recipes. If you look closely these migration takes us first to 2.0.x, then 2.1.x, 2.2.x, all the way through to 2.5.x at present, with 2.6.x still in development. As indicated above a JUnit 5 migration will be executed as part of the Spring Boot 2.4.x migration.
We again run OpenRewrite to upgrade our application.
./mvnw rewrite:run
This results in a large changeset, with notable changes to src/main/resources/application.properties
and pom.xml
, and relatively small changes in src/main/java
.
The same test changes that we saw before with the isolated migration to JUnit 5 are again present.
Verify & fix tests
The big change now is that we can once again run our build, with a small caveat.
./mvnw verify
Two tests fail after the migration to Spring Boot 2.5.x.
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR] ValidatorTests.shouldNotValidateWhenFirstNameEmpty:42
expected: "may not be empty"
but was : "must not be empty"
[ERROR] VetControllerTests.testShowResourcesVetList:67 Content type
expected: <application/json;charset=UTF-8>
but was: <application/json>
[INFO]
[ERROR] Tests run: 41, Failures: 2, Errors: 0, Skipped: 1
The ValidatorTests.shouldNotValidateWhenFirstNameEmpty
failure is caused by a change in the validation implementation.
The VetControllerTests.testShowResourcesVetList
failure is caused by a change in the returned media type.
Both tests are easily fixed with small patch command.
patch -p1 << EOF
diff --git a/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java b/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java
index b623330..b5294f4 100644
--- a/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/model/ValidatorTests.java
@@ -39,7 +39,7 @@
assertThat(constraintViolations.size()).isEqualTo(1);
ConstraintViolation<Person> violation = constraintViolations.iterator().next();
assertThat(violation.getPropertyPath().toString()).isEqualTo("firstName");
- assertThat(violation.getMessage()).isEqualTo("may not be empty");
+ assertThat(violation.getMessage()).isEqualTo("must not be empty");
}
}
diff --git a/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java b/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java
index 5fd6598..ccb5d78 100644
--- a/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java
+++ b/src/test/java/org/springframework/samples/petclinic/vet/VetControllerTests.java
@@ -64,7 +64,7 @@
void testShowResourcesVetList() throws Exception {
ResultActions actions = mockMvc.perform(get("/vets.json").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
- actions.andExpect(content().contentType("application/json;charset=UTF-8"))
+ actions.andExpect(content().contentTypeCompatibleWith("application/json;charset=UTF-8"))
.andExpect(jsonPath("$.vetList[0].id").value(1));
}
EOF
When we now run the tests again, all tests will pass. That means our migration to Spring Boot 2.5 and JUnit 5 worked!
Upgrade to Java 17
Next we want to adopt the Java 17 runtime.
Replace Cobertura with JaCoCo
To prepare for our Java 17 migration, we first need fix the outdated Cobertura code coverage plugin, which we can best replace with the more modern JaCoCo. We cheat just a little bit by cherry picking that change out of the main branch.
git cherry-pick 60105d5d9a8b64d29927b98cd06d6d811fd4bb52
Java8toJava11 recipe
To upgrade our application itself we need yet another dependency and recipe. We will run the Java8toJava11 recipe, which comprises a number of fixes to be compatible with Java 11 runtime. There’s no need for a recipe yet to explicitly upgrade to Java 17.
./mvnw rewrite:configure \
-Ddependencies=org.openrewrite.recipe:rewrite-migrate-java:1.4.2 \
-DactiveRecipes=org.openrewrite.java.migrate.Java8toJava11
./mvnw rewrite:run
On our project the Java8toJava11
recipe produces the following relevant changes.
diff --git a/pom.xml b/pom.xml
index b058c6c..49d7e34 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
<properties>
<!-- Generic properties -->
- <java.version>1.8</java.version>
+ <java.version>11</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@@ -25,9 +25,9 @@
<webjars-bootstrap.version>3.3.6</webjars-bootstrap.version>
<webjars-jquery-ui.version>1.11.4</webjars-jquery-ui.version>
<webjars-jquery.version>2.2.4</webjars-jquery.version>
- <wro4j.version>1.8.0</wro4j.version>
+ <wro4j.version>1.10.1</wro4j.version>
- <jacoco.version>0.8.1</jacoco.version>
+ <jacoco.version>0.8.7</jacoco.version>
</properties>
@@ -122,6 +122,12 @@
<artifactId>jquery-ui</artifactId>
<version>${webjars-jquery-ui.version}</version>
</dependency>
+ <dependency>
+ <groupId>com.sun.xml.bind</groupId>
+ <artifactId>jaxb-impl</artifactId>
+ <version>2.3.2</version>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
@@ -245,6 +251,14 @@
</dependency>
</dependencies>
</plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jdeprscan-plugin</artifactId>
+ <version>3.0.0-alpha-1</version>
+ <configuration>
+ <release>11</release>
+ </configuration>
+ </plugin>
</plugins>
</build>
Notice how the java.version
Maven property is changed to 11
, which sets maven.compiler.source
and maven.compiler.target
. This unlocks Java 11 language features such as var
.
Java 11 removed the Java EE modules, which is why we need jaxb-impl
when running on Java 11+.
The
maven-jdeprscan-plugin surfaces any incompatibilities when running the jdeprscan
goal on Java 11+.
Along the way Wro4j and JaCoCo are updated to their latest versions as well.
When we run our tests on Java 17 we can see we are now compatible with the Java 17 runtime.
sdk use java 17.0.2-tem
./mvnw verify
Our source level is still at 11 though, so we can not yet use any Java 17 language feautures.
Patch java.version
To update the source level we override the java.version
property
defined in the Spring Boot parent pom.xml
.
That sets both maven.compiler.source
and maven.compiler.target
to use Java 17 as well, unlocking new language features such as text blocks and records.
patch -p1 << EOF
diff --git a/pom.xml b/pom.xml
index 9701237..818e20d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
<properties>
<!-- Generic properties -->
- <java.version>11</java.version>
+ <java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
EOF
Now we are able to start using Java 17 language features in our updated Spring PetClinic application.
Cleanup
When we take a closer look through the application source code, we notice there’s quite a few unnecessary imports. Luckily OpenRewrite also contains a large number of clean up recipes that we can use. The RemoveUnusedImports recipe in particular comes in handy here.
Be aware that OpenRewrite presently requires access to Java compiler internals to function.
On JDK 17+ you can opt-in to access these internals via
--add-exports in a number of ways.
Adding --add-exports statements to .mvn/jvm.config is by far the easiest way to set these parameters.
|
Reconfigure the plugin to activate and run this new recipe; This recipe does not need any additional dependencies.
./mvnw rewrite:configure \
-DactiveRecipes=org.openrewrite.java.RemoveUnusedImports
./mvnw rewrite:run
That concludes the Java recipes we will run against the Spring PetClinic for now. But there’s a host of other recipes to explore still; look at the Java collection of recipes to see which might be applicable to your projects.
Also know OpenRewrite is not limited to Java; you can also explore recipes for related technology such as Maven, Gradle, XML, YAML, JSON, GitHub Actions, Kubernetes and more.
Remove plugin
Once we’re done migrating our application, we can have the OpenRewrite Maven plugin remove itself from our pom.xml
.
./mvnw rewrite:remove
That concludes our first look at OpenRewrite. Be sure to reach out if you encounter any issues; I’ve found the folks behind OpenRewrite are very responsive with any issues.
Versions used
-
rewrite-maven-plugin:4.22.1
-
rewrite-migrate-java:1.4.2
-
rewrite-spring:4.19.2
-
rewrite-testing-frameworks:1.20.1
-
Java 8.0.322 Temurin
-
Java 17.0.2 Temurin
-
Apache Maven 3.8.5
-
Git 2.25.1
-
SDKMAN! 5.14.2