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.
-
See initialize-spring-boot-migration.yaml for a recipe in YAML syntax
-
See MigrateMuleToBoot.java for a recipe as Spring bean.
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 Finder
s.
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 ProjectResourceWrapper
s 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 Condition
s of recipes and their Action
s 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 Action
s and each Action
modifies the AST through the
ProjectContext
API. These modifications are only represented in memory.
Verify Application In Sync
After applying all Action
s 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.
BuildFile
s 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 JavaSourceSet
s 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 Finder
s exists.
A Finder
implements the ResourceFinder
interface.
public interface ProjectResourceFinder<T> {
T apply(ProjectResourceSet projectResourceSet);
}
These Finder
s 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 Recipe
s, Condition
s and Action
s.
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 Action
s and Condition
s 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
-
Call TestProjectContext static builder method which will inject a mock for ApplicationEventPublisher
-
Add required dependencies to the classpath (assume they’re needed). This will add a pom.xml declaring the provided dependencies.
-
Add a new Java file with given source to
src/main/java/com/acme/app/MyClass.java
-
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)
-
Provide a project, here under
./testcode/example-app/given
. This project will be copied to a clean dirtarget/test-projects/example-app
-
Scan the copied project and apply the given recipe against it
-
Retrieve the path to a resource after migration
-
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)
}
}
-
The
getTestSubDir()
method must be implemented. -
It must return the path to the test project in
src/test/resources
-
Copies test project from
src/test/resources/some-dir/project-dir
totarget/sbm-integration-test/some-dir/project-dir
-
Scans the copied test project, same as calling
scan
in CLI -
Apply the recipe
initialize-spring-boot-migration
, same as callingapply initialize-spring-boot-migration
in CLI. Changes will be reflected in the filesystem after this call. -
Apply another recipe
migrate-x-to-boot
-
Replace a file, here because the Test needs to be migrated manually
-
Load the content a Java file as String
-
Assertions about the context of the Java file
-
Call
mvn clean package spring-boot:build-image
in the test project -
Start the docker image from the last step containing the migrated Spring application
-
Create a RestTemplate
-
Use the RestTemplate to retrive some data from the migrated Spring application running in Docker
-
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