1. Introduction

Spring Boot Migrator (SBM) aims to help developers migrating applications to Spring Boot, upgrade existing Boot applications or migrate an application to use Spring Boot features.
These migrations are defined as recipes consisting of one or more actions to be applied to the target application.

2. Getting started

2.1. Requirements

  • Maven

  • JDK 11+

  • IntelliJ (other IDEs should work too)

  • Docker (for tests)

2.2. Build and Run

Note
M2_HOME env must be set to run integration tests!
Docker must be installed to run (some) integration tests! To build SBM ignoring integration tests you can run mvn clean install -DskipITs
# clone project
git clone0 git@github.com:spring-projects-experimental/spring-boot-migrator.git

# build project
cd spring-boot-migrator
mvn clean install

# Ignore all tests
mvn clean install -DskipTests

# Ignore all tests marked with @Tag("integration")
mvn clean install -DskipITs

# Run the jar
java -jar applications/spring-shell/target/spring-boot-migrator.jar

3. Concepts

SBM offers commands to scan application source code and run recipes against it. Recipes bundle actions to apply source code migrations or find information in the codebase.

An Action is a single step in a recipe and can be reused by other recipes. Every Recipe has at least one Action and each Recipe and every action has a condition which defines if the recipe and/or it’s actions are applicable.

The condition has access to all resources to evaluate to true or false. If a recipe’s condition evaluates to false the recipe is not displayed. Actions with a condition evaluating to true are applicable and will be executed with the recipe. When all actions of a recipe evaluate to false the recipe itself is not applicable.

3.1. Recipes

Recipes can be declared in yaml syntax or provided as Java bean definitions.

3.2. Actions

Action is the starting point when developing a recipe. Every Action has access to all resources and their ASTs through the ProjectContext.

class MyAction extends AbstractAction {
    void apply(ProjectContext context) {
        // analyse and modify AST
    }
}

3.3. ProjectContext

After scanning and parsing the source code of a given application a ProjectContext gets created. The ProjectContext acts as facade to the abstract syntax tree (AST) of the project and provides an API to access project resources.

ProjectJavaSources pjs = context.getProjectJavaSources();
ApplicationModules am = context.getApplicationModules()

The ProjectContext represents the current application state in memory and provides access to all resources. The API provides methods to retrieve and modify Java source, application modules and build files. All other resource types can be retrieved using finders.

3.4. Finders

Finders are useful to access and filter resources.

public interface ProjectResourceFinder<T> {
    T apply(ProjectResourceSet projectResourceSet);
}

Finder have access to the ProjectResourceSet (see [ProjectResourceSet]) to filter/find resources and return the result. The result is of the same type as the generic type T.

The ProjectContext provides a search(..) method to apply Finder.

List<..> resources = projectContext.search(new PathMatchingProjectResourceFinder("/**/some/path/*.file"));

Finders also provide access to specialized resources, resources that are not directly accessible through the ProjectContext Api. Specialized resources like SpringBootApplicationProperties, PersistenceXml, WebXml, …​, can be retrieved by their Finders.

void apply(ProjectContext context) {
    SpringBootApplicationProperties p = context.search(new SpringBootApplicationPropertiesFinder());
    p.getProperty("cloud-profile", "some.property.key")
}

Read the Specialized Resources section to learn how you can provide new Specialized Resources.

3.5. Multi module projects

3.6. Application Lifecycle

3.6.1. Scan Application

The user provides a root directory to scan a project. After scanning the given directory a set of preconditions is checked to verify that the scanned project can be successfully parsed. See Check Preconditions to learn how you can provide additional precondition checks.

Parsing

When all preconditions are met the project resources are parsed and the abstract syntax tree (AST) gets created in memory.

Specialized Resources

After parsing the project resources a set of ProjectResourceWrappers is called and generic resources can be replaced with more specialized resources providing a specialized API for these resources. Think of replacing a generic XML file representing persistence.xml (JPA deployment descriptor) with a specialized resource representation offering an API to act on a specialized "JPA deployment descriptor". See Specialized Resources to learn how yo can provide specialized resources.

Creating the ProjectContext

After replacing specialized resources the ProjectContext gets created.

Display Applicable Recipes

With the ProjectContext in place SBM checks all Conditions of recipes and their Actions to find applicable recipes. The resulting list of applicable recipes is then shown to the user to select and apply recipes against the scanned application.

3.6.2. Apply Recipe

The user applies a recipe from the list of applicable recipes.

Applying Recipe Actions

SBM provides the ProjectContext to the list of applicable Actions and each Action modifies the AST through the ProjectContext API. These modifications are only represented in memory.

Verify Application In Sync

After applying all Actions of the Recipe the changes need to be written back to filesystem. When sbm.gitSupportEnabled is true SBM verifies that nothing changed in the scanned project while the recipe was applied. If the git hash changed or non-indexed resources are found the changes are rolled back, the project must be synced, re-scanned and the recipe needs to be re-applied.

Writing Back Changes

When git support is disabled or the project is in sync the in-memory representation is written back to the file system.

Commit Changes

When git support is enabled SBM commits the changes applied by running the recipe and the next recipe can be applied.

3.7. Modules

Since 0.9.0 SBM starts to support multi module applications.

3.8. Spring Boot Resources

3.8.1. Spring RestController Bean

Classes annotated with Spring @RestController annotation can be found using the FindRestControllerBeans finder.

List<RestControllerBean> restController = projectContext.search(new FindRestControllerBeans());

List<RestMethod> restMethods = restController.get(0).getRestMethods();

restMethods.get(0).

4. How To

4.1. Check Preconditions

Preconditions that must be met to allow a successful migration can be checked before scanning an application. This allows a user to be informed about unmet preconditions that would prevent a successful migration before the sometimes lengthy parsing is done.

To add a new precondition check you need to provide a component extending abstract PreconditionCheck class and return the result of the precondition check as PreconditionCheckResult.

@Component
public class MyPreconditionCheck extends PreconditionCheck {
    @Override
    public PreconditionCheckResult verify(Path projectRoot, List<Resource> projectResources) {
        // verify precondition is met...
    }
}

This class must be placed under org.springframework.sbm to be picked up by component scan and gets executed after scanning the project but before parsing resources into an AST. If one of the preconditions fails (ResultState.WARN or ResultState.FAILED), the scan is interrupted and a message is shown to the user.

4.2. Implement Actions

First step on your journey to implement a new recipe will be the implementation of an Action. Every Action that needs access to the ProjectContext to modify resources should extend AbstractAction.

class MyAction extends AbstractAction {
    void apply(ProjectContext context) {
        // analyse and modify AST
    }
}

Use TestProjectContext to test your Action.

4.2.1. Display Progress

A long running action can leave the user without any information about the progress of the migration.

The Action interface defines methods to display progress information to the user. There are two types of rendered information, process and log messages.

Process

startStep(String) starts a new sub routine.

stopStep() stops the last sub routine

Log Messages

logEvent(String) logs a message inside a routine

When an action starts, a first process (the action) is automatically started. It begins with the action and ends with success or error. If no other progress change is reported during the execution of the action, a loader is rendered during the

.    My Action
..   My Action
[..] My Action
    .    Sub Routine
    ..   Sub Routine
    [ok] Sub Routine
[ok] My Action

4.3. Creating Recipes

Recipes bundle a set of Actions for a migration. They can be declared programmatically as Spring beans or declarative using yaml syntax. The main difference is that yaml recipes can be provided to SBM without code changes on startup allowing to add new, declarative recipes without recompilation while the bean definition is arguably easier to use.

4.3.1. Declarative recipes

Declarative recipes must be placed under src/main`resources/recipes.
See initialize-spring-boot-migration.yaml for a recipe in YAML syntax

4.3.2. Recipe beans

A recipe can be defined as Spring bean using @Bean annotated method, see MigrateMuleToBoot.java for a recipe declared as Spring bean.

Note
Remember to provide a description to all Actions to display the description to the user when the Action is applied.

4.4. Using OpenRewrite Recipes

4.4.1. Programmatically

ProjectContext context = ...
String annotationPattern = "@java.lang.Deprecated";
org.openrewrite.java.RemoveAnnotation rewriteRecipe = new RemoveAnnotation(annotationPattern);
context.getProjectJavaSources().apply(rewriteRecipe);

4.4.2. In SBM YAML

Using Declarative OpenRewrite YAML

Use org.springframework.sbm.engine.recipe.OpenRewriteDeclarativeRecipeAdapter to use embedded OpenRewrite YAML syntax in SBM YAML to run a declarative OpenRewrite recipe as SBM action.

- name: test-recipe
  description: "Remove @Deprecated annotations"
  condition:
    type: org.springframework.sbm.common.migration.conditions.TrueCondition
  actions:
    - type: org.springframework.sbm.engine.recipe.OpenRewriteDeclarativeRecipeAdapter
      description: "Use a OpenRewrite recipe to remove @Deprecated annotations"
      openRewriteRecipe: |-
        type: specs.openrewrite.org/v1beta/recipe
        name: org.openrewrite.java.RemoveAnnotation
        displayName: "Remove @Deprecated annotation"
        description: "Remove @Deprecated annotation"
        recipeList:
          - org.openrewrite.java.RemoveAnnotation:
              annotationPattern: "@java.lang.Deprecated"
By Name

Use org.springframework.sbm.engine.recipe.OpenRewriteNamedRecipeAdapter to run an OpenRewrite recipe by name as SBM action. Here an OpenRewrite recipe with name org.springframework.sbm.dummy.RemoveDeprecatedAnnotation must exist on the classpath and will be executed as action in test-recipe.

- name: test-recipe
  description: "Remove @Deprecated annotations"
  condition:
    type: org.springframework.sbm.common.migration.conditions.TrueCondition
  actions:
    - type: org.springframework.sbm.engine.recipe.OpenRewriteNamedRecipeAdapter
      description: "Call a OpenRewrite recipe to remove @Deprecated annotations"
      openRewriteRecipeName: org.springframework.sbm.dummy.RemoveDeprecatedAnnotation

4.4.3. In SBM Java code

Using Declarative OpenRewrite YAML
@Configuration
public class SomeRecipe {

    @Bean
    Recipe someRecipe(RewriteRecipeLoader rewriteRecipeLoader, RewriteMigrationResultMerger resultMerger) {
        return Recipe.builder()
                ...
                .action(
                        OpenRewriteDeclarativeRecipeAdapter.builder()
                                .rewriteRecipeLoader(rewriteRecipeLoader)
                                .resultMerger(resultMerger)
                                .openRewriteRecipe(
                                    "type: specs.openrewrite.org/v1beta/recipe\n" +
                                    "name: org.openrewrite.java.RemoveAnnotation\n" +
                                    "displayName: \"Remove @Deprecated annotation\"\n" +
                                    "description: \"Remove @Deprecated annotation\"\n" +
                                    "recipeList:\n" +
                                    "  - org.openrewrite.java.RemoveAnnotation:\n" +
                                    "      annotationPattern: \"@java.lang.Deprecated\"\n"
                                )
                                .build())
                ...
                .build();
    }
}
By Name
@Configuration
public class SomeRecipe {

    @Bean
    Recipe someRecipe(RewriteRecipeLoader rewriteRecipeLoader, RewriteMigrationResultMerger resultMerger) {
        return Recipe.builder()
                ...
                .action(
                        OpenRewriteNamedRecipeAdapter.builder()
                                .rewriteRecipeLoader(rewriteRecipeLoader)
                                .resultMerger(resultMerger)
                                .openRewriteRecipeName(
                                    "org.springframework.sbm.dummy.RemoveDeprecatedAnnotation"
                                )
                                .build())
                ...
                .build();
    }
}

4.5. Create a Condition

Every Action and every Recipe must have a Condition which defines if the Recipe or Action is applicable. Therefore the Condition interface must be implemented which defines a evaluate(ProjectContext) method which must return true if the Recipe or Action is applicable and false otherwise. A condition should only read from the ProjectContext and never modify the AST.

public class MyCondition implements Condition {
    @Override
    public boolean evaluate(ProjectContext context) {
        // analyze ProjectContext to evaluate condition
    }
}

4.6. Single and Multi module projects

Projects can come as single module or multi-module project. Working with single module projects is significantly easier because only one BuildFile exists. With multi-module projects the application modules play a central role and there must be means to select a module.

4.6.1. Application Modules

A common multi-module project has a root modules which consists of one or more child modules which again can consist of multiple child modules and so on.

  • The root module

  • The application module(s), bundle all modules of an application and define the composition of deployable artifact buulding a runnable application, e.g. a war module

  • The component module(s), define reusable components which are not deployable in isolation

public void apply(ProjectContext context) {
    Modules modules = context.getModules();
    Module module = modules.getModule("path/of/module");
    boolean isMultiModule = modules.isMultiModuleProject();
    Module root = modules.getRootModule(); // type="pom" or type="ear"
    modules.getApplicationModules(); // type="war", containing "main" method
    modules.getComponentModules(); // type="jar" without main method

    List<Module> parentModules = module.getParentModules();
    List<Module> subModules = module.getSubModules();
}
Finding the Module containing a resource
Note
Only "Maven" main and test are used as source sets and any settings in the pom.xml or otherwise are ignored.
ProjectResource pr = ...
Optional<Module> module = context.getModules.findModuleContaining(pr.getAbsolutePath());

4.7. BuildFile and Dependencies

The buildfiles of the scanned project are represented by BuildFile. The BuildFile API offers methods to read and modify the buildfile. BuildFiles can be retrieved through the ProjectContext.

    // Retrieve the root build file
    BuildFile rootBuildFile = projectContext.getBuildFile();

4.7.1. Adding a Dependency

public void apply(ProjectContext context) {
    // ...get buildFile for module
    BuildFile buildFile = context...
    Dependency dependency = Dependency.builder()
                                .groupId("...")
                                .artifactId("...")
                                .version("...")
                                .scope("test")
                                .build();
    buildFile.addDependency(dependency);
}

4.7.2. Removing a Dependency

4.7.3. Adding Dependency Exclusion

4.8. Migrating Java Code

4.8.1. Access all JavaSources

4.8.2. Adding annotations

4.8.3. Removing annotations

4.8.4. Modifying annotations

4.8.5. Migrating Methods

4.8.6. Add a new JavaSource

A new JavaSource must be added to a JavaSourceSet of a given ApplicationModule. The default JavaSourceSets are 'main' (src/main/java) and 'test' (src/test/java).

Example: Adding a new Java class to the 'main' source set of an ApplicationModule

public void apply(ProjectContext context) {

    ApplicationModule targetModule = ... // retrieve target module

    String javaCode =
        "package com.example.foo;\n" +
        "public class Bar {}";

    Path projectRootDirectory = context.getProjectRootDirectory();

    targetModule.getMainJavaSourceSet().addJavaSource(projectRootDirectory, sourceFolder, src, packageName);
}

4.9. OpenRewrite Recipe and Visitor

4.10. Use Freemarker templates

Add this snippet to your Action to use freemarker

public class MyAction extends AbstractAction {

    @Autowired
    @JsonIgnore
    @Setter
    private Configuration configuration;

    // ...
}

and place your template under src/main/resources/templates

Example: using Freemarker template in Action

Map<String, String> params = new HashMap<>();
params.put("groupId", "com.example.change");
params.put("artifactId", projectName);
params.put("version", "0.1.0-SNAPSHOT");

StringWriter writer = new StringWriter();
try {
    Template template = configuration.getTemplate("minimal-pom-xml.ftl");
    template.process(params, writer);
} catch (TemplateException | IOException e) {
    throw new RuntimeException(e);
}
String src = writer.toString();

4.11. Migrate Multi Module Projects

4.11.1. Access a Module’s JavaSources

4.12. Specialized Resources

4.12.1. Create a Finder to access other resources

The ProjectContext only offers direct access to Java and BuildFile resources. To access other resources the concept of Finders exists. A Finder implements the ResourceFinder interface.

public interface ProjectResourceFinder<T> {
    T apply(ProjectResourceSet projectResourceSet);
}

These Finders can than be provided to the search(…​) method of ProjectContext. The ProjectContext will provide the ProjectResourceSet to the Finder and the Finder can then filter/search

4.12.2. Manipulate Spring Boot properties

4.12.3. Create a specialized Resource

5. Testing

Different classes exist to help writing tests for Recipes, Conditions and Actions.

5.1. Windows & Linux

Linebreaks, encoding and path separator do (most probably will) differ depending on the OS the tests and the application is executed on.

To make the tests and application run on Windows, Linux and OSX you must take precautions in some cases.

5.1.1. Ignoring Line separator in assertions

5.2. TestProjectContext

When testing Actions and Conditions you’ll need a initialized ProjectContext as test fixture. To avoid reading projects for testing from the filesystem the TestProjectContext can be used.

The TestProjectContext offers ways to create all kinds of ProjectContext instances initialized from resorces given as strings as if they were read from filesystem. Have a look at the API documentation for details.

  • Creates different pom.xml. A blank default, with given dependencies defined, from a given String or as Mockito mock

  • Provides a method to register additional ResourceWrapper for specialized resources

String sourceCode = "package com.acme.app;\npublic class MyClass {}";

ProjectContext projectContext = TestProjectContext.buildProjectContext() (1)
        .withBuildFileHavingDependencies("org.junit.jupiter:junit-jupiter-api:5.7.0") (2)
        .withJavaSources(sourceCode) (3)
        .build(); (4)

// Use ProjectContext to test Action or Condition
  1. Call TestProjectContext static builder method which will inject a mock for ApplicationEventPublisher

  2. Add required dependencies to the classpath (assume they’re needed). This will add a pom.xml declaring the provided dependencies.

  3. Add a new Java file with given source to src/main/java/com/acme/app/MyClass.java

  4. build and return the initialized ProjectContext.

5.3. ProjectContextFileSystemTestSupport

If you want to create a ProjectContext from filesystem, ProjectContextFileSystemTestSupport is your friend.
It assumes a directory testcode in the same module as the test resides in. The initial project is read from a given directory inside testcode, let’s say testcode/given/my-project. The resources are then copied to target/test-projects/my-project and the ProjectContext is created from this directory.

@Test
void someTest() {
    ProjectContext projectContext = ProjectContextFileSystemTestSupport
                                            .createProjectContextFromDir("boot-23-app");
    // modify ProjectContext
}

Copies all resources from ./testcode/given/boot-23-app to ./target/test-projects/boot-23-app and creates a ProjectContext from it.

5.4. RecipeIntegrationTestSupport

If you want to integration test a recipe against a project read from filesystem, RecipeIntegrationTestSupport can help you.

String applicationDir = "example-app"; (1)

RecipeIntegrationTestSupport.initializeProject(applicationDir)
        .andApplyRecipe("boot-2.4-2.5-datasource-initializer"); (2)

Path javaClass = RecipeIntegrationTestSupport.getResultDir(applicationDir).resolve("src/main/java/com/example/SomeJavaClass.java"); (3)

assertThat(javaClass).hasContent("..."); (4)
  1. Provide a project, here under ./testcode/example-app/given. This project will be copied to a clean dir target/test-projects/example-app

  2. Scan the copied project and apply the given recipe against it

  3. Retrieve the path to a resource after migration

  4. Verify the resource has expected content

5.5. IntegrationTestBaseClass

For end-to-end tests using the shell commands IntegrationTestBaseClass will be helpful.
These are the most complete tests but also the slowest.

Note
Currently these tests reside in spring-shell module.
public class MyFullBlownIntegrationTest extends IntegrationTestBaseClass {
    @Override
    protected String getTestSubDir() { (1)
        return "some-dir/project-dir"; (2)
    }

    @Test
    @Tag("integration")
    void migrateSomethingToSpringBoot() {
        initializeTestProject(); (3)
        scanProject(); (4)
        applyRecipe("initialize-spring-boot-migration"); (5)
        applyRecipe("migrate-x-to-boot"); (6)
        // simulate manual step
        replaceFile( (7)
                getTestDir().resolve("src/test/java/com/example/jee/app/PersonServiceTest.java"),
                getTestDir().resolve("manual-step/BootifiedPersonServiceTest.java")
        );
        String localBusinessInterface = loadJavaFile("com.example.jee.app.ejb.local", "ABusinessInterface"); (8)
        // verify @Local was removed
        assertThat(localBusinessInterface).doesNotContain("@Local"); (9)
        executeMavenGoals(getTestDir(), "clean", "package", "spring-boot:build-image"); (10)
        int port = startDockerContainer("jee-app:8.0.5-SNAPSHOT", 8080); (11)
        TestRestTemplate testRestTemplate = new TestRestTemplate(); (12)
        // Test Servlet
        String response = testRestTemplate.getForObject("http://localhost:" + port + "/HelloWorld", String.class); (13)
        assertThat(response).isEqualTo("Hello World!"); (14)
    }
}
  1. The getTestSubDir() method must be implemented.

  2. It must return the path to the test project in src/test/resources

  3. Copies test project from src/test/resources/some-dir/project-dir to target/sbm-integration-test/some-dir/project-dir

  4. Scans the copied test project, same as calling scan in CLI

  5. Apply the recipe initialize-spring-boot-migration, same as calling apply initialize-spring-boot-migration in CLI. Changes will be reflected in the filesystem after this call.

  6. Apply another recipe migrate-x-to-boot

  7. Replace a file, here because the Test needs to be migrated manually

  8. Load the content a Java file as String

  9. Assertions about the context of the Java file

  10. Call mvn clean package spring-boot:build-image in the test project

  11. Start the docker image from the last step containing the migrated Spring application

  12. Create a RestTemplate

  13. Use the RestTemplate to retrive some data from the migrated Spring application running in Docker

  14. Verify the response

6. Contributing

You want to contribute to the Spring Boot Migrator project?

Great! All contributions are welcome.

If you have not previously done so, please sign the Contributor License Agreement. You will be reminded automatically when you submit the pull request.

Please also see CONTRIBUTING.adoc for further information.

All files need to have a license header. Please use this copyright text:

Copyright 2022 the original author or authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

     https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

You can configure license headers under IntelliJ IDEA/Copyright/Copyright Profile. See https://www.jetbrains.com/help/idea/copyright.html#configure-copyright-profiles