In diesem letzten Teil der Tutorial-Serie zeige ich dir, wie wir die Anwendung, die wir im Laufe dieses Tutorials – zunächst ohne Framework – nach der hexagonalen Architektur entwickelt und dann auf Quarkus umgestellt haben, auf das Spring-Boot-Framework migrieren.
Rückblick
Wir haben diese Artikelserie mit den Grundlagen der hexagonalen Architektur begonnen.
Danach haben wir eine Java-Anwendung mit der hexagonalen Architektur entwickelt – zunächst ohne ein Application Framework. Anschließend haben wir die Anwendung um einen JPA-Adapter erweitert, um die Daten in einer Datenbank zu persistieren anstatt sie lediglich im Arbeitsspeicher zu halten. Im vorangegangenen Teil der Serie haben wir dann die hexagonale Anwendung in Quarkus als Application Framework eingebettet.
Alle Erweiterungen der Anwendung – sowohl die Anbindung eines neuen Adpaters als auch die Migration zu Quarkus – konnten wir durchführen, ohne auch nur eine Zeile Code im Anwendungskern ändern zu müssen.
Die folgende Grafik zeigt die Architektur unserer Anwendung. Der zuvor erwähnte Anwendungskern wird durch das mit „Application” bezeichnete Hexagon dargestellt:
Den aktuellen Stand der Demo-Anwendung findest du im „with-quarkus”-Branch dieses GitHub-Repositories.
Worum geht es in diesem Teil?
In diesem fünften und letzten Teil der Serie werden wir unsere Anwendung von Quarkus zu Spring Boot migrieren – und auch das wieder, ohne Code im Anwendungskern ändern zu müssen.
Wir werden dabei wieder schrittweise vorgehen wie bei der Migration zu Quarkus. Doch da Spring sowohl bei den primären als auch bei den sekundären Controllern andere Technologien einsetzt (Spring Web MVC anstelle von Jakarta RESTful Web Services im Frontend und Spring Data JPA anstelle von Panache im Backend), wird die Anwendung zwischen den Schritten nicht kompilierbar und lauffähig sein, sondern erst nach Abschluss der Migration.
Wir werden die Anwendung in folgenden Schritten zu Spring Boot migrieren:
- Zuerst werden wir die Quarkus-Dependencies in den pom.xml-Dateien durch Spring-Boot-Dependencies ersetzen.
- Dann werden wir die primären REST-Adapter anpassen, indem wir die JAX-RS-Annotationen durch Spring-Web-MVC-Annotationen ersetzen.
- Danach werden wir die sekundären Persistenzadapter migrieren, indem wir die Panache-Repositories durch Spring-Data-JPA-Repositories ersetzen. Dabei werden wir auch die Annotationen austauschen, die kontrollieren, welche Adapter (In-Memory oder JPA) erzeugt werden.
- Dann werden wir die Quarkus-Anwendungskonfiguration im adapter-Modul durch eine Spring-Boot-Konfiguration ersetzen, woraufhin das adapter-Modul wieder kompilierbar sein wird.
- Zuletzt werden wir eine Spring-Boot-Starter-Klasse hinzufügen und die End-to-End-Tests im bootstrap-Modul anpassen.
Den Stand des Codes nach Abschluss der Migration findest du im „with-spring-boot”-Branch des GitHub-Repositories. Alle Änderungen gegenüber der Quarkus-Version findest du in diesem Pull Request.
Beginnen wir mit Schritt 1...
Schritt 1: Dependencies austauschen
Ausgehend vom Projektstand im „with-quarkus”-Branch werden wir in den pom.xml-Dateien des Projekts die Quarkus-Bill-of-Materials durch den Spring-Boot-Starter-Parent und alle Quarkus-Dependences durch Spring-Boot-Dependencies ersetzen.
Anpassung der Eltern-pom.xml
Um die Versionen aller Spring-Boot-Bibliotheken verfügbar zu haben, fügen wir als erstes in die pom.xml des Projektverzeichnisses den Spring-Boot-Starter-Parent ein, z. B. zwischen <modelVersion>
und <groupId>
:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
</parent>
Code-Sprache: HTML, XML (xml)
Alle Quarkus-spezifischen Einträge können wir dann entfernen:
- den
<quarkus.platform.version>
-Eintrag aus dem<properties>
-Block, - die Version der assertj-core-Bibliothek, da diese bereits im Spring-Boot-Parent definiert ist,
- die Quarkus-BOM-Dependency aus dem Dependency-Management
- und das Quarkus-Maven-Plugin.
Hier findest du die Änderungen als GitHub-Commit und hier die geänderte pom.xml.
Anpassung der adapter/pom.xml
In der adapter/pom.xml ersetzen wir alle Quarkus-Extensions durch:
- Spring-Boot-Libraries,
- den MySQL-Treiber
- und Testcontainers.
(Die letzten beiden Libraries wurden zuvor über Quarkus-Extensions als transitive Dependencies ins Projekt importiert.)
Außerdem müssen wir einen H2-Datenbanktreiber hinzufügen. Der Grund dafür ist, dass die Spring-Boot-Anwendung durch das Einbinden von spring-boot-starter-data-jpa auch im In-Memory-Modus eine gültige Datenbankkonfiguration erwartet. Quarkus konnten wir im In-Memory-Modus mit einer Dummy-MySQL-URL abspeisen, was lediglich zu einer Warnung geführt hat. Spring hingegen bricht den Startvorgang bei einer ungültigen Datenbank-URL ab. Die Anbindung einer – letztendlich nicht genutzten – H2-In-Memory-Datenbank ist ein Workaround, der nicht schön ist, aber für unsere Demo-Anwendung ausreichend.
Nach den Änderungen sieht der <dependencies>
-Block der adapter/pom.xml wie folgt aus:
<dependencies>
<!-- Internal -->
<dependency>
<groupId>eu.happycoders.shop</groupId>
<artifactId>application</artifactId>
<version>${project.version}</version>
</dependency>
<!-- External -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Test scope -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<!-- To use the "attached test JAR" from the "model" module -->
<dependency>
<groupId>eu.happycoders.shop</groupId>
<artifactId>model</artifactId>
<version>${project.version}</version>
<classifier>tests</classifier>
<type>test-jar</type>
<scope>test</scope>
</dependency>
</dependencies>
Code-Sprache: HTML, XML (xml)
Hier findest du die Änderungen als GitHub-Commit und hier die angepasste adapter/pom.xml.
Anpassung der bootstrap/pom.xml
In der bootstrap/pom.xml müssen wir die Test-Dependencies auf die Quarkus-Extensions von JUnit und Mockito durch spring-boot-starter-test ersetzen und Testcontainers hinzufügen – dieses war zuvor transitiv über die Quarkus-Extensions importiert.
Hier siehst du die Test-Dependencies nach der Migration:
<!-- Test scope -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
Code-Sprache: HTML, XML (xml)
Außerdem müssen wir das quarkus-maven-plugin durch das spring-boot-maven-plugin ersetzen. Der <build>
-Block sieht damit wie folgt aus:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Code-Sprache: HTML, XML (xml)
Hier findest du die Änderungen als GitHub-Commit und hier die bootstrap/pom.xml nach den Änderungen.
Beachte, dass die adapter- und bootstrap-Module der Anwendung zu diesem Zeitpunkt nicht mehr kompiliert werden können, die model- und application-Module hingegen schon, da diese keinerlei technische Details enthalten.
Entsprechend sollte an dieser Stelle ein Aufruf von mvn spotless:apply clean verify
zu folgender Ausgabe führen:
[INFO] parent ............................................. SUCCESS [ 0.377 s]
[INFO] model .............................................. SUCCESS [ 2.908 s]
[INFO] application ........................................ SUCCESS [ 2.392 s]
[INFO] adapter ............................................ FAILURE [ 1.221 s]
[INFO] bootstrap .......................................... SKIPPED
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
Wir werden in den folgenden Abschnitten nach und nach den Code im adapter-Modul und dann im bootstrap-Modul anpassen, um das Projekt wieder kompilierbar zu machen.
Schritt 2: REST-Adapter von JAX-RS nach Spring Web MVC migrieren
Im Adapter-Modul beginnen wir mit den Anpassungen der REST-Adapter. Die Migration von Jakarta RESTful Web Services nach Spring Web MVC bedeutet in erster Linie, dass wir
- Annotationen und
- Enum-Konstanten von HTTP-Statuscodes
durch ihre jeweiligen Spring-Pendants ersetzen müssen.
Außerdem müssen wir den Jakarta-EE-Exception-Handling-Mechanismus nachbauen, da es etwas Vergleichbares meines Wissens nach in Spring nicht gibt. Korrigiere mich bitte über die Kommentarfunktion, wenn ich falsch liege!
Zuletzt müssen wir in den REST-Assured-Integrationstests den HTTP-Port wieder setzen. Das hatten wir im Zuge der Migration zu Quarkus ausgebaut, da Quarkus das automatisch macht.
JAX-RS-Annotationen durch Spring-Annotationen ersetzen
Beginnen wir mit den Annotationen...
GetCartController
Fangen wir mit dem einfachsten Controller an, dem GetCartController
. Hier siehst du den JAX-RS-Controller einschließlich der relevanten Imports:
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class GetCartController {
. . .
@GET
@Path("/{customerId}")
public CartWebModel getCart(@PathParam("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
Cart cart = getCartUseCase.getCart(customerId);
return CartWebModel.fromDomainModel(cart);
}
}
Code-Sprache: Java (java)
Gehen wir die Annotationen einmal von oben nach unten durch. Die folgende Tabelle listet die JAX-RS-Annotationen auf, erklärt deren Bedeutung und zeigt das jeweilige Spring-Pendant:
JAX-RS-Annotation(en) | Beschreibung | Spring-Pendant |
---|---|---|
@Path("/carts") | Markiert die Klasse als REST-Controller und definiert den Basis-Pfad | @RestController |
@Produces(APPLICATION_JSON) | Definiert das Antwortformat; war in der Anwendung ohne Application Framework notwendig. Hätte in der Quarkus-Version entfernt werden können, da Quarkus JSON als Default setzt. | Entfällt, da auch Spring JSON als Default setzt. |
@GET | Markiert die Methode als GET-Controller und definiert den Pfad. | @GetMapping("/{customerId}") |
@PathParam("customerId") | Extrahiert das Pfadelement {customerId} in den annotierten Methodenparameter. | @PathVariable("customerId") |
Wenn wir die in der Tabelle aufgelisteten Annotationen ersetzen, wird aus dem oben gezeigten Jakarta-RESTful-Webservices-Controller der folgende Spring-Web-MVC-Controller (GetCartController im GitHub-Repository, hier nur die Änderungen):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/carts")
public class GetCartController {
. . .
@GetMapping("/{customerId}")
public CartWebModel getCart(@PathVariable("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
Cart cart = getCartUseCase.getCart(customerId);
return CartWebModel.fromDomainModel(cart);
}
}
Code-Sprache: Java (java)
Wie du siehst, ist der Unterschied zwischen Spring und Jakarta EE minimal.
EmptyCartController
Beim EmptyCartController
können wir genauso vorgehen, müssen allerdings eine Besonderheit bei den HTTP-Statuscodes beachten – mehr dazu weiter unten.
Hier zunächst der Jakarta-RESTful-Webservices-Code (die Imports drucke ich dieses Mal nicht mit ab):
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class EmptyCartController {
. . .
@DELETE
@Path("/{customerId}")
public void deleteCart(@PathParam("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
emptyCartUseCase.emptyCart(customerId);
}
}
Code-Sprache: Java (java)
Wir treffen hier auf eine weitere JAX-RS-Annotation, @DELETE
:
JAX-RS-Annotation(en) | Beschreibung | Spring-Pendant |
---|---|---|
@DELETE | Markiert die Methode als DELETE-Controller und definiert den Pfad. | @DeleteMapping("/{customerId}") |
Die JAX-RS-@DELETE
-Annotation führt dazu, dass der Controller einen HTTP-Statuscode 204 „No Content” zurückliefert. Springs @DeleteMapping
-Annotation hingegen gibt den HTTP-Statuscode 200 „OK” zurück. Das würde die Integrationstests für diese Methode fehlschlagen lassen.
Um das ursprüngliche Verhalten beizubehalten und auch Spring einen Statuscode 204 zurückgeben zu lassen, muss die Methode eine entsprechende ResponseEntity
zurückliefern. Diese können wir über ResponseEntity.noContent().build()
erzeugen.
Hier der nach Spring migrierte Controller (EmptyCartController im GitHub-Repository, hier nur die Änderungen):
@RestController
@RequestMapping("/carts")
public class EmptyCartController {
. . .
@DeleteMapping("/{customerId}")
public ResponseEntity<Void> deleteCart(
@PathVariable("customerId") String customerIdString) {
CustomerId customerId = parseCustomerId(customerIdString);
emptyCartUseCase.emptyCart(customerId);
return ResponseEntity.noContent().build();
}
}
Code-Sprache: Java (java)
Enum-Konstanten der HTTP-Statuscodes austauschen
Bei den zwei anderen REST-Controllern, AddToCartController
und FindProductsController
müssen wir außerdem die Enum-Konstanten für die HTTP-Statuscodes austauschen.
AddToCartController
Schauen wir zunächst auf den AddToCartController
– hier der alte Code:
. . .
import jakarta.ws.rs.core.Response;
@Path("/carts")
@Produces(MediaType.APPLICATION_JSON)
public class AddToCartController {
. . .
@POST
@Path("/{customerId}/line-items")
public CartWebModel addLineItem(
@PathParam("customerId") String customerIdString,
@QueryParam("productId") String productIdString,
@QueryParam("quantity") int quantity) {
CustomerId customerId = parseCustomerId(customerIdString);
ProductId productId = parseProductId(productIdString);
try {
Cart cart = addToCartUseCase.addToCart(customerId, productId, quantity);
return CartWebModel.fromDomainModel(cart);
} catch (ProductNotFoundException e) {
throw clientErrorException(
Response.Status.BAD_REQUEST, "The requested product does not exist");
} catch (NotEnoughItemsInStockException e) {
throw clientErrorException(
Response.Status.BAD_REQUEST,
"Only %d items in stock".formatted(e.itemsInStock()));
}
}
}
Code-Sprache: Java (java)
Wir sehen zunächst zwei weitere Annotationen im Einsatz:
JAX-RS-Annotation(en) | Beschreibung | Spring-Pendant |
---|---|---|
@POST | Markiert die Methode als POST-Controller und definiert den Pfad. | @PostMapping("/.../...") |
@QueryParam("...") | Bindet einen Query- bzw. Request-Parameter (zwei Unterschiedliche Bezeichnungen für die mit einem Fragezeichen an die URL angehängten Parameter) mit dem angegebenen Namen an die annotierte Variable. | @RequestParam("...") |
Die JAX-RS-Enum-Klasse jakarta.ws.rs.core.Response.Status
definiert Konstanten für alle möglichen HTTP-Statuscodes. In Spring sind die Statuscodes in der Klasse org.springframework.http.HttpStatus
definiert.
Das folgende Listing zeigt den vollständig nach Spring migrierten Controller (AddToCartController im GitHub-Repository, hier die Änderungen):
import org.springframework.http.HttpStatus;
. . .
@RestController
@RequestMapping("/carts")
public class AddToCartController {
. . .
@PostMapping("/{customerId}/line-items")
public CartWebModel addLineItem(
@PathVariable("customerId") String customerIdString,
@RequestParam("productId") String productIdString,
@RequestParam("quantity") int quantity) {
CustomerId customerId = parseCustomerId(customerIdString);
ProductId productId = parseProductId(productIdString);
try {
Cart cart = addToCartUseCase.addToCart(customerId, productId, quantity);
return CartWebModel.fromDomainModel(cart);
} catch (ProductNotFoundException e) {
throw clientErrorException(
HttpStatus.BAD_REQUEST, "The requested product does not exist");
} catch (NotEnoughItemsInStockException e) {
throw clientErrorException(
HttpStatus.BAD_REQUEST,
"Only %d items in stock".formatted(e.itemsInStock()));
}
}
}
Code-Sprache: Java (java)
Beachte, dass die Aufrufe der clientErrorException(...)
-Methode aktuell noch nicht kompilieren, da wir diese noch nicht auf den Spring-HttpStatus
migriert haben.
FindProductsController
Zuletzt gibt es auch in FindProductsController
eine Besonderheit zu beachten, nämlich beim Query- bzw. Request-Parameter. Hier zunächst der alte Code:
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
public class FindProductsController {
. . .
@GET
public List<ProductInListWebModel> findProducts(@QueryParam("query") String query) {
if (query == null) {
throw clientErrorException(Response.Status.BAD_REQUEST, "Missing 'query'");
}
List<Product> products;
try {
products = findProductsUseCase.findByNameOrDescription(query);
} catch (IllegalArgumentException e) {
throw clientErrorException(Response.Status.BAD_REQUEST, "Invalid 'query'");
}
return products.stream().map(ProductInListWebModel::fromDomainModel).toList();
}
}
Code-Sprache: Java (java)
Und hier der nach Spring migrierte Code (Klasse FindProductsController, hier die Änderungen):
@RestController
@RequestMapping("/products")
public class FindProductsController {
. . .
@GetMapping
public List<ProductInListWebModel> findProducts(
@RequestParam(value = "query", required = false) String query) {
if (query == null) {
throw clientErrorException(HttpStatus.BAD_REQUEST, "Missing 'query'");
}
List<Product> products;
try {
products = findProductsUseCase.findByNameOrDescription(query);
} catch (IllegalArgumentException e) {
throw clientErrorException(HttpStatus.BAD_REQUEST, "Invalid 'query'");
}
return products.stream().map(ProductInListWebModel::fromDomainModel).toList();
}
}
Code-Sprache: Java (java)
Die ersetzten Annotationen haben wir im Grunde bereits bei den drei vorherigen Controllern besprochen. Allerdings mussten wir hier bei der @RequestParam
-Annotation den Parameter required = false
hinzufügen. Andernfalls würde ein fehlender query
-Parameter zu einer Fehlermeldung des Frameworks führen anstatt zu der Fehlermeldung, die wir zu Beginn der Methode selbst implementiert haben.
Auch hier kompiliert der Aufruf der clientErrorException(...)
-Methode aktuell noch nicht.
CustomerIdParser und ProductIdParser
In den Parser-Klassen geben wir je nach Art des Fehlers bestimmte HTTP-Statuscodes zurück. Wir müssen also auch in diesen Klassen die entsprechenden Response.Status
-Konstanten durch HttpStatus
-Konstanten ersetzen.
Hier die geänderten Klassen:
public final class CustomerIdParser {
private CustomerIdParser() {}
public static CustomerId parseCustomerId(String string) {
try {
return new CustomerId(Integer.parseInt(string));
} catch (IllegalArgumentException e) {
throw clientErrorException(HttpStatus.BAD_REQUEST, "Invalid 'customerId'");
}
}
}
Code-Sprache: Java (java)
Und:
public final class ProductIdParser {
private ProductIdParser() {}
public static ProductId parseProductId(String string) {
if (string == null) {
throw clientErrorException(HttpStatus.BAD_REQUEST, "Missing 'productId'");
}
try {
return new ProductId(string);
} catch (IllegalArgumentException e) {
throw clientErrorException(HttpStatus.BAD_REQUEST, "Invalid 'productId'");
}
}
}
Code-Sprache: Java (java)
Die Änderungen findest du in hier und hier und die Ergebnisse in den angepassten Klassen CustomerIdParser und ProductIdParser.
Exception-Handler implementieren
In der Jakarta-RESTful-Webservices-Implementierung konnten wir bei Eingabefehlern eine jakarta.ws.rs.ClientErrorException werfen. Dieser Exception konnten wir ein Response
-Objekt übergeben, welches den HTTP-Statuscode und einen JSON-Response-Body mit einer Fehlermeldung enthält.
Soweit ich weiß, bietet das Spring-Framework keine entsprechende Exception. Korrigiere mich bitte über die Kommentarfunktion, wenn ich falsch liege!
Daher müssen wir uns selbst eine solche Exception mitsamt zugehörigem Exception-Handler bauen. Die Exception (ClientErrorException im GitHub-Repository) bekommt einen Konstruktor, dem wir eine ResponseEntity
übergeben, die im Feld response
gespeichert wird:
public class ClientErrorException extends RuntimeException {
@Getter private final ResponseEntity<ErrorEntity> response;
public ClientErrorException(ResponseEntity<ErrorEntity> response) {
super(getMessage(response));
this.response = response;
}
private static String getMessage(ResponseEntity<ErrorEntity> response) {
ErrorEntity body = response.getBody();
return body != null ? body.errorMessage() : null;
}
}
Code-Sprache: Java (java)
Der Exception-Handler (Klasse ClientErrorHandler) ist ebenfalls schnell implementiert. Er bekommt eine @RestControllerAdvice
-Annotation sowie eine @ExceptionHandler
-annotierte Methode, die die in der ClientErrorException
hinterlegte ResponseEntity
zurückliefert:
@RestControllerAdvice
public class ClientErrorHandler {
@ExceptionHandler(ClientErrorException.class)
public ResponseEntity<ErrorEntity> handleProductNotFoundException(
ClientErrorException ex) {
return ex.getResponse();
}
}
Code-Sprache: Java (java)
Dann sind nur noch kleine Anpassungen an der ControllerCommons
-Klasse nötig, um statt der jakarta.ws.rs.ClientErrorException
unsere eigene ClientErrorException
zu werfen. Hier die vollständige ControllerCommons-Klasse:
public final class ControllerCommons {
private ControllerCommons() {}
public static ClientErrorException clientErrorException(
HttpStatus status, String message) {
return new ClientErrorException(errorResponse(status, message));
}
public static ResponseEntity<ErrorEntity> errorResponse(
HttpStatus status, String message) {
ErrorEntity errorEntity = new ErrorEntity(status.value(), message);
return ResponseEntity.status(status.value()).body(errorEntity);
}
}
Code-Sprache: Java (java)
Das Spring-Framework sorgt nun dafür, dass eine ClientErrorException
vom ClientErrorHandler
abgefangen wird und der REST-Controller den in der Exception gespeicherten Fehlercode und die Fehlernachricht zurückliefert.
Nach der Anpassung der ControllerCommons
-Klasse sollten die Compiler-Fehler in den Controllern und den Parsern verschwunden sein.
Integrationstests von Quarkus nach Spring migrieren
In den Integrationstests müssen wir folgende Änderungen vornehmen:
- Annotationen austauschen,
- in den REST-Assured-Tests den Server-Port setzen,
- Jakartas
Response.Status
durch SpringsHttpStatus
ersetzen.
Ich zeige die Änderungen beispielhaft am ProductsControllerTest
. Dies ist der alte Stand:
import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST;
. . .
@QuarkusTest
class ProductsControllerTest {
private static final Product TEST_PRODUCT_1 = createTestProduct(euros(19, 99));
private static final Product TEST_PRODUCT_2 = createTestProduct(euros(25, 99));
@InjectMock FindProductsUseCase findProductsUseCase;
. . .
@Test
void givenANullQuery_findProducts_returnsError() {
Response response = given().get("/products").then().extract().response();
assertThatResponseIsError(response, BAD_REQUEST, "Missing 'query'");
}
. . .
}
Code-Sprache: Java (java)
Annotationen austauschen
Wir tauschen folgende Annotationen aus:
JAX-RS-Annotation(en) | Beschreibung | Spring-Pendant |
---|---|---|
@QuarkusTest | Startet die Anwendung vor dem Integrationstest. | @ActiveProfiles("test") webEnvironment = RANDOM_PORT) |
@InjectMock | Mockt eine Bean und injiziert den Mock überall dort, wo die Bean verwendet wird. | @MockBean |
Server-Port setzen
Dann informieren wir REST-Assured über den Port der Anwendung (mit Quarkus geschah das automatisch). Durch folgende Zeile injiziert Spring den Server-Port in die Variable port
:
@LocalServerPort private Integer port;
Code-Sprache: Java (java)
Dann müssen wir nach jedem Aufruf von given()
den Aufruf port(port)
einfügen. Also z. B.:
given()
.get("/products")
.then().extract().response();
Code-Sprache: Java (java)
wird zu:
given()
.port(port) // ⟵ set port
.get("/products")
.then().extract().response();
Code-Sprache: Java (java)
Response.Status durch HttpStatus ersetzen
Zuletzt ersetzen wir den statischen Import jakarta.ws.rs.core.Response.Status.BAD_REQUEST
durch org.springframework.http.HttpStatus.BAD_REQUEST
. Da der Name der Enum-Konstante, BAD_REQUEST
, in beiden Frameworks gleich ist, müssen wir hier nichts weiter tun.
Die geänderte Testklasse sieht dann wie folgt aus:
import static org.springframework.http.HttpStatus.BAD_REQUEST;
. . .
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ProductsControllerTest {
private static final Product TEST_PRODUCT_1 = createTestProduct(euros(19, 99));
private static final Product TEST_PRODUCT_2 = createTestProduct(euros(25, 99));
@LocalServerPort private Integer port;
@MockBean FindProductsUseCase findProductsUseCase;
. . .
@Test
void givenANullQuery_findProducts_returnsError() {
Response response = given().port(port).get("/products").then().extract().response();
assertThatResponseIsError(response, BAD_REQUEST, "Missing 'query'");
}
. . .
}
Code-Sprache: Java (java)
Der Aufruf von assertThatResponseIsError(...)
kompiliert aktuell noch nicht, da die Methode noch nicht auf den Spring-HttpStatus
umgestellt ist. Das machen wir als nächstes.
Wir ersetzen in der Klasse HttpTestCommons
den Typ der Variablen expectedStatus
, Response.Status
durch HttpStatus
und expectedStatus.getStatusCode()
durch expectedStatus.value()
. Die geänderte Klasse sieht dann wie folgt aus:
public final class HttpTestCommons {
private HttpTestCommons() {}
public static void assertThatResponseIsError(
Response response,
HttpStatus expectedStatus,
String expectedErrorMessage) {
assertThat(response.getStatusCode()).isEqualTo(expectedStatus.value());
JsonPath json = response.jsonPath();
assertThat(json.getInt("httpStatus")).isEqualTo(expectedStatus.value());
assertThat(json.getString("errorMessage")).isEqualTo(expectedErrorMessage);
}
}
Code-Sprache: Java (java)
Den CartsControllerTest
passen wir analog dazu an. Und in CartsControllerAssertions
und ProductsControllerAssertions
ersetzen wir den statischen Import jakarta.ws.rs.core.Response.Status.OK
durch org.springframework.http.HttpStatus.OK
und den Ausdruck OK.getStatusCode()
durch OK.value()
.
Du findest alle Änderungen an den Tests ab dieser Position im Git-Commit und die geänderten Klassen in diesem Verzeichnis im GitHub-Repository.
Damit haben wir die primären REST-Adapter von Quarkus nach Spring migriert. Im nächsten Schritt wenden wir uns den sekundären Persistenzadaptern zu.
Schritt 3: JPA-Adapter von Panache nach Spring Data JPA migrieren
Um die sekundären Persistenzadapter nach Spring zu migrieren, gehen wir wie folgt vor:
- Wir tauschen die Annotationen aus, die steuern, ob die In-Memory-Adapter oder die JPA-Adapter verwendet werden.
- Wir ersetzen die Bean-erzeugenden Annotationen.
- Wir ersetzen die Panache-Repositories durch Spring-Data-JPA-Repositories.
- Da Springs
findById(...)
-MethodenOptional
s zurückliefern, müssen wir ein paar kleine Anpassungen an den Mappern durchführen. - Abschließend passen wir die Integrationstests an.
Annotationen austauschen
In der Quarkus-Version haben wir die folgenden Annotationen verwendet, um zu steuern, welche Persistenzadapter verwendet werden:
An den In-Memory-Adaptern InMemoryCartRepository
und InMemoryProductRepository
:
@LookupIfProperty(name = "persistence", stringValue = "inmemory",
lookupIfMissing = true)
Code-Sprache: Java (java)
An den JPA-Adaptern JpaCartRepository
und JpaProductRepository
:
@LookupIfProperty(name = "persistence", stringValue = "mysql")
Code-Sprache: Java (java)
Für die Spring-Version ersetzen wir die Annotationen wie folgt:
In-Memory-Adapter:
@ConditionalOnProperty(name = "persistence", havingValue = "inmemory",
matchIfMissing = true)
Code-Sprache: Java (java)
JPA-Adapter:
@ConditionalOnProperty(name = "persistence", havingValue = "mysql")
Code-Sprache: Java (java)
Außerdem ersetzen wir in denselben vier Klassen die Bean-erzeugende Annotation @ApplicationScoped
durch @Repository
.
Hier findest du die nach Spring migrierten Klassen InMemoryCartRepository und InMemoryProductRepository. An den JPA-Adaptern werden wir im nächsten Abschnitt noch weitere Änderungen vornehmen.
Panache-Repositories durch Spring-Data-JPA-Repositories ersetzen
In der Quarkus-Variante haben wir Panache verwendet, um JPA-Entities komfortabel aus der Datenbank zu laden. Das Spring-Pendant dazu lautet „Spring Data JPA”.
Das folgende Listing zeigt die beiden Panache-Repositories:
@ApplicationScoped
public class JpaCartPanacheRepository
implements PanacheRepositoryBase<CartJpaEntity, Integer> {}
@ApplicationScoped
public class JpaProductPanacheRepository
implements PanacheRepositoryBase<ProductJpaEntity, String> {}
Code-Sprache: Java (java)
Und hier siehst du die entsprechenden Spring-Data-JPA-Repositories (beachte auch die geänderten Klassennamen):
@Repository
public interface JpaCartSpringDataRepository
extends JpaRepository<CartJpaEntity, Integer> {}
@Repository
public interface JpaProductSpringDataRepository
extends JpaRepository<ProductJpaEntity, String> {
@Query("SELECT p FROM ProductJpaEntity p "
+ "WHERE p.name like ?1 or p.description like ?1")
List<ProductJpaEntity> findByNameOrDescriptionLike(String pattern);
}
Code-Sprache: Java (java)
Es gibt zwei bedeutende Unterschiede:
- Die Panache-Repositories sind Klassen, die Spring-Data-JPA-Repositories sind Interfaces.
- Im Panache-Repository haben wir die Suche über die
PanacheRepositoryBase.find(...)
-Methode realisiert, indem wir dieser eine HQL-WHERE-Query übergeben haben. Im Spring-Data-JPA-Repository können wir dafür eine separate Interface-Methode deklarieren und diese mit einer JPQL-Query-Annotation versehen. Hierauf komme ich weiter unten noch einmal zurück, wenn ich die Änderungen an den Adapter-Klassen zeige.
Nun müssen wir noch unsere Adapter JpaCartRepository
und JpaProductRepository
anpassen und statt der Panache-Repositories die Spring-Data-JPA-Repositories verwenden.
Beginnen wir mit dem einfacheren Adapter, JpaCartRepository
. Bisher sieht er so aus:
@LookupIfProperty(name = "persistence", stringValue = "mysql")
@ApplicationScoped
public class JpaCartRepository implements CartRepository {
private final JpaCartPanacheRepository panacheRepository;
public JpaCartRepository(JpaCartPanacheRepository panacheRepository) {
this.panacheRepository = panacheRepository;
}
@Override
@Transactional
public void save(Cart cart) {
panacheRepository.getEntityManager().merge(CartMapper.toJpaEntity(cart));
}
@Override
@Transactional
public Optional<Cart> findByCustomerId(CustomerId customerId) {
CartJpaEntity cartJpaEntity = panacheRepository.findById(customerId.value());
return CartMapper.toModelEntityOptional(cartJpaEntity);
}
@Override
@Transactional
public void deleteByCustomerId(CustomerId customerId) {
panacheRepository.deleteById(customerId.value());
}
}
Code-Sprache: Java (java)
Wir ersetzen die zwei Klassen-Annotationen @LookupIfProperty
und @ApplicationScoped
wie im Abschnitt „Annotationen austauschen” oben beschrieben.
Wir ersetzen das Feld JpaCartPanacheRepository panacheRepository
durch JpaCartSpringDataRepository springDataRepository
und passen den Konstruktor entsprechend an.
Den Aufruf panacheRepository.getEntityManager().merge(...)
ersetzen wir durch springDataRepository.save(...)
.
Während die panacheRepository.findById(...)
-Methode ein CartJpaEntity
zurücklieferte, gibt springDataRepository.findById(...)
ein Optional<CartJpaEntity>
zurück. Entsprechend passen wir den Aufruf des Mappers an.
Hier siehst du die Spring-Version der Adapter-Klasse:
@ConditionalOnProperty(name = "persistence", havingValue = "mysql")
@Repository
public class JpaCartRepository implements CartRepository {
private final JpaCartSpringDataRepository springDataRepository;
public JpaCartRepository(JpaCartSpringDataRepository springDataRepository) {
this.springDataRepository = springDataRepository;
}
@Override
@Transactional
public void save(Cart cart) {
springDataRepository.save(CartMapper.toJpaEntity(cart));
}
@Override
@Transactional
public Optional<Cart> findByCustomerId(CustomerId customerId) {
Optional<CartJpaEntity> cartJpaEntity =
springDataRepository.findById(customerId.value());
return cartJpaEntity.map(CartMapper::toModelEntity);
}
@Override
@Transactional
public void deleteByCustomerId(CustomerId customerId) {
springDataRepository.deleteById(customerId.value());
}
}
Code-Sprache: Java (java)
Die Methodenreferenz CartMapper::toModelEntity
führt aktuell noch zu einem Compilerfehler, da die entsprechende Methode noch nicht existiert. Im CartMapper
haben wir aktuell eine Methode toModelEntityOptional(...)
, die aus einem CartJpaEntity
ein Optional<Cart>
macht:
static Optional<Cart> toModelEntityOptional(CartJpaEntity cartJpaEntity) {
if (cartJpaEntity == null) {
return Optional.empty();
}
CustomerId customerId = new CustomerId(cartJpaEntity.getCustomerId());
Cart cart = new Cart(customerId);
for (CartLineItemJpaEntity lineItemJpaEntity : cartJpaEntity.getLineItems()) {
cart.putProductIgnoringNotEnoughItemsInStock(
ProductMapper.toModelEntity(lineItemJpaEntity.getProduct()),
lineItemJpaEntity.getQuantity());
}
return Optional.of(cart);
}
Code-Sprache: Java (java)
Das Spring-Data-JPA-Repository liefert uns bereits ein Optional<CartJpaEntity>
, welches wir mit der map(...)
-Methode auf ein Optional<Cart>
mappen können. Dafür ersetzen wir die toModelEntityOptional(...)
-Methode durch folgende toModelEntity(...)
-Methode:
static Cart toModelEntity(CartJpaEntity cartJpaEntity) {
CustomerId customerId = new CustomerId(cartJpaEntity.getCustomerId());
Cart cart = new Cart(customerId);
for (CartLineItemJpaEntity lineItemJpaEntity : cartJpaEntity.getLineItems()) {
cart.putProductIgnoringNotEnoughItemsInStock(
ProductMapper.toModelEntity(lineItemJpaEntity.getProduct()),
lineItemJpaEntity.getQuantity());
}
return cart;
}
Code-Sprache: Java (java)
JpaCartRepository
sollte nun keine Compiler-Fehler mehr anzeigen.
Schauen wir als nächstes auf die Klasse JpaProductRepository
. Hier die Quarkus-Version:
@LookupIfProperty(name = "persistence", stringValue = "mysql")
@ApplicationScoped
public class JpaProductRepository implements ProductRepository {
private final JpaProductPanacheRepository panacheRepository;
public JpaProductRepository(JpaProductPanacheRepository panacheRepository) {
this.panacheRepository = panacheRepository;
}
@PostConstruct
void createDemoProducts() {
DemoProducts.DEMO_PRODUCTS.forEach(this::save);
}
@Override
@Transactional
public void save(Product product) {
panacheRepository.getEntityManager().merge(ProductMapper.toJpaEntity(product));
}
@Override
@Transactional
public Optional<Product> findById(ProductId productId) {
ProductJpaEntity jpaEntity = panacheRepository.findById(productId.value());
return ProductMapper.toModelEntityOptional(jpaEntity);
}
@Override
@Transactional
public List<Product> findByNameOrDescription(String queryString) {
List<ProductJpaEntity> entities =
panacheRepository
.find("name like ?1 or description like ?1", "%" + queryString + "%")
.list();
return ProductMapper.toModelEntities(entities);
}
}
Code-Sprache: Java (java)
Wir machen zunächst einmal die gleichen Anpassungen wie beim JpaCartAdapter
.
In der findByNameOrDescription(...)
-Methode ersetzen wir außerdem den Aufruf der find(...)
-Methode:
panacheRepository
.find("name like ?1 or description like ?1", "%" + queryString + "%")
.list();
Code-Sprache: Java (java)
durch einen Aufruf der im Spring-Data-JPA-Interface deklarierten findByNameOrDescriptionLike(...)
-Methode.
springDataRepository.findByNameOrDescriptionLike("%" + queryString + "%");
Code-Sprache: Java (java)
Bei der Panache-Variante übergeben wir an dieser Stelle eine HQL-(Hibernate Query Language)-WHERE-Query; bei der Spring-Variante haben wir hingegen die findByNameOrDescriptionLike(...)
-Methode im JpaProductSpringDataRepository
-Interface mit einer JPQL-Query annotiert:
@Query("SELECT p FROM ProductJpaEntity p "
+ "WHERE p.name like ?1 or p.description like ?1")
List<ProductJpaEntity> findByNameOrDescriptionLike(String pattern);
Code-Sprache: Java (java)
Hier siehst du die nach Spring migrierte Klasse:
@ConditionalOnProperty(name = "persistence", havingValue = "mysql")
@Repository
public class JpaProductRepository implements ProductRepository {
private final JpaProductSpringDataRepository springDataRepository;
public JpaProductRepository(JpaProductSpringDataRepository springDataRepository) {
this.springDataRepository = springDataRepository;
}
@PostConstruct
void createDemoProducts() {
DemoProducts.DEMO_PRODUCTS.forEach(this::save);
}
@Override
@Transactional
public void save(Product product) {
springDataRepository.save(ProductMapper.toJpaEntity(product));
}
@Override
@Transactional
public Optional<Product> findById(ProductId productId) {
Optional<ProductJpaEntity> jpaEntity =
springDataRepository.findById(productId.value());
return jpaEntity.map(ProductMapper::toModelEntity);
}
@Override
@Transactional
public List<Product> findByNameOrDescription(String queryString) {
List<ProductJpaEntity> entities =
springDataRepository.findByNameOrDescriptionLike("%" + queryString + "%");
return ProductMapper.toModelEntities(entities);
}
}
Code-Sprache: Java (java)
Du findest alle Änderungen (einschließlich derer an den Mappern, die ich hier nicht mit abgedruckt habe) in diesem Git-Commit und die Ergebnisse in den Klassen JpaCartRepository, JpaCartSpringDataRepository, CartMapper, JpaProductRepository, JpaProductSpringDataRepository und ProductMapper.
Anpassung der Integrationstests
In den Integrationstests für die Perstistenzadapter müssen wir:
- die Dependency Injection anpassen,
- die Konfiguration der Testprofile von Quarkus an Spring anpassen.
Dependency Injection anpassen
Ich zeige dir die notwendigen Anpassungen an der Dependency Injection an der Klasse AbstractProductRepositoryTest
– hier ist der aktuelle Stand:
public abstract class AbstractProductRepositoryTest {
@Inject Instance<ProductRepository> productRepositoryInstance;
private ProductRepository productRepository;
@BeforeEach
void initRepository() {
productRepository = productRepositoryInstance.get();
}
. . .
}
Code-Sprache: Java (java)
Bei Quarkus mussten wir das Produkt-Repository, was entweder ein In-Memory- oder ein JPA-Repository sein kann, als Instance<ProductRepository>
injizieren und dann in der @BeforeEach
-Methode über Instance.get()
auslesen.
In Spring geht das einfacher. Wir können die konfigurierbare Bean, wie jede andere Bean, ganz ohne Umwege injizieren – und eine @BeforeEach
-Methode brauchen wir nicht:
public abstract class AbstractProductRepositoryTest {
@Autowired ProductRepository productRepository;
. . .
}
Code-Sprache: Java (java)
Die Klasse AbstractCartRepositoryTest
wird analog angepasst.
Quarkus Testprofile nach Spring migrieren
In der Quarkus-Variante haben wir eine Testprofil-Klasse TestProfileWithMySQL
definiert, die die Konfiguration persistence=mysql setzt:
public class TestProfileWithMySQL implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("persistence", "mysql");
}
}
Code-Sprache: Java (java)
Diese Klasse können wir löschen. In Spring benötigen wir solch eine Klasse nicht. Testprofile in Spring benötigen einen Namen und eine Properties-Datei. Wir legen im Verzeichnis test/resources des application-Moduls die Datei application-test-with-mysql.properties an mit folgendem Inhalt:
spring.datasource.url=jdbc:tc:mysql:8.0:///shop
spring.jpa.hibernate.ddl-auto=update
persistence=mysql
Code-Sprache: Klartext (plaintext)
In der letzten Zeile definieren wir den aus Quarkus bekannten Konfigurationseintrag persistence=mysql. Die zwei Zeilen zu Beginn legen fest, dass wir als Datenbank Testcontainers („tc”) verwenden und dass Hibernate die Tabellen automatisch anlegen soll. Beides hat Quarkus automatisch gemacht.
Ich hatte zuvor schon erwähnt, dass Spring wegen des vorhandenen spring-data-jpa-Moduls auch im In-Memory-Modus eine Datenbank benötigt, da es sonst nicht startet. Dafür legen wir noch eine Datei application-test.properties an mit folgendem Inhalt:
spring.datasource.url=jdbc:h2:mem:testdb
Code-Sprache: Klartext (plaintext)
Im In-Memory-Modus soll also eine H2-Datenbank verwendet werden. Dies ist der einfachste Workaround, der mir für unsere Demo-Anwendung eingefallen ist.
Um nun für die jeweiligen Tests die entsprechenden Profile zu aktivieren, müssen wir noch ein paar Annotationen in den folgenden Testklassen ändern. So sehen die konkreten Persistenztests in der Quarkus-Variante aus:
@QuarkusTest
class InMemoryCartRepositoryTest extends AbstractCartRepositoryTest {}
@QuarkusTest
class InMemoryProductRepositoryTest extends AbstractProductRepositoryTest {}
@QuarkusTest
@TestProfile(TestProfileWithMySQL.class)
class JpaCartRepositoryTest extends AbstractCartRepositoryTest {}
@QuarkusTest
@TestProfile(TestProfileWithMySQL.class)
class JpaProductRepositoryTest extends AbstractProductRepositoryTest {}
Code-Sprache: Java (java)
Wir ersetzen die Annotationen wie folgt:
@QuarkusTest
ersetzen wir durch@SpringBootTest
.@TestProfile(TestProfileWithMySQL.class)
ersetzen wir durch@ActiveProfiles("test-with-mysql")
.- Und den In-Memory-Tests müssen wir noch die Annotation
@ActiveProfiles("test")
hinzufügen, damit sie im test-with-mysql-Profil nicht erzeugt werden. Bei Quarkus wurden sie automatisch nicht erzeugt, wenn ein Profil aktiviert ist.
Nach den Änderungen sehen die Testklassen wie folgt aus:
@SpringBootTest
@ActiveProfiles("test")
class InMemoryCartRepositoryTest extends AbstractCartRepositoryTest {}
@SpringBootTest
@ActiveProfiles("test")
class InMemoryProductRepositoryTest extends AbstractProductRepositoryTest {}
@SpringBootTest
@ActiveProfiles("test-with-mysql")
class JpaCartRepositoryTest extends AbstractCartRepositoryTest {}
@SpringBootTest
@ActiveProfiles("test-with-mysql")
class JpaProductRepositoryTest extends AbstractProductRepositoryTest {}
Code-Sprache: Java (java)
Du findest alle Änderungen an den Tests ab dieser Position im Git-Commit und die geänderten Testklassen in diesem Verzeichnis im GitHub-Repository.
Schritt 4: Anwendungskonfiguration von Quarkus nach Spring Boot migrieren
In diesem Schritt ersetzen wir die Quarkus-Anwendungskonfiguration im Adapter-Modul durch eine Spring-Boot-Konfiguration. Am Ende dieses vergleichweise kurzen Schritts werden wir das Adapter-Modul kompilieren und die Tests ausführen können.
Die Quarkus-Anwendungskonfiguration liegt in der Klasse QuarkusAppConfig
, die aktuell wie folgt aussieht:
class QuarkusAppConfig {
@Inject Instance<CartRepository> cartRepository;
@Inject Instance<ProductRepository> productRepository;
@Produces
@ApplicationScoped
GetCartUseCase getCartUseCase() {
return new GetCartService(cartRepository.get());
}
@Produces
@ApplicationScoped
EmptyCartUseCase emptyCartUseCase() {
return new EmptyCartService(cartRepository.get());
}
@Produces
@ApplicationScoped
FindProductsUseCase findProductsUseCase() {
return new FindProductsService(productRepository.get());
}
@Produces
@ApplicationScoped
AddToCartUseCase addToCartUseCase() {
return new AddToCartService(cartRepository.get(), productRepository.get());
}
}
Code-Sprache: Java (java)
Zur Erinnerung:
Wir benötigen diese Klasse, um die im application-Modul liegenden Service-Klassen zu instanziieren. Denn das application-Modul weiß nichts von Quarkus (es ist ein technisches Detail, das dort nicht hingegört), und somit können und wollen wir dort auch keine @ApplicationScoped
-Annotationen einsetzen.
Das gleiche gilt für Spring. Das application-Modul weiß nichts von Spring, und entsprechend können wir dort auch keine @Service
-Annotation verwenden.
Was müssen wir nun konkret ändern?
- Wir bennenen die Klasse
QuarkusAppConfig
um inSpringAppConfig
. - Wir annotieren die Klasse mit
@SpringBootApplication
. - Wir ersetzen die
@Inject
-Annotationen durch@Autowired
. - Wir entfernen die
Instance<...>
-Wrapper umCartRepository
undProductRepository
(so wie wir es auch in den Tests der Persistenzadapter gemacht haben). - Wir ersetzen das Annotation-Paar
@Produces
@ApplicationScoped
durch@Bean
.
Danach sieht die Klasse wie folgt aus:
@SpringBootApplication
public class SpringAppConfig {
@Autowired CartRepository cartRepository;
@Autowired ProductRepository productRepository;
@Bean
GetCartUseCase getCartUseCase() {
return new GetCartService(cartRepository);
}
@Bean
EmptyCartUseCase emptyCartUseCase() {
return new EmptyCartService(cartRepository);
}
@Bean
FindProductsUseCase findProductsUseCase() {
return new FindProductsService(productRepository);
}
@Bean
AddToCartUseCase addToCartUseCase() {
return new AddToCartService(cartRepository, productRepository);
}
}
Code-Sprache: Java (java)
Du findest die Änderungen in diesem Git-Commit und das Ergebnis in der Klasse SpringAppConfig.
Damit haben wir das Adapter-Modul fertig migriert. Ein Aufruf von mvn spotless:apply clean verify
sollte nun (trotz einiger Compiler-Fehler im bootstrap-Modul) zeigen, dass sich das Adapter-Modul erfolgreich kompilieren lässt und dass alle Tests erfolgreich durchlaufen:
[INFO] parent ............................................. SUCCESS [ 0.388 s]
[INFO] model .............................................. SUCCESS [ 3.178 s]
[INFO] application ........................................ SUCCESS [ 2.586 s]
[INFO] adapter ............................................ SUCCESS [ 35.616 s]
[INFO] bootstrap .......................................... FAILURE [ 0.901 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
Damit haben wir einen Großteil der Migration von Quarkus zu Spring Boot erledigt. Im nächsten Kapitel wenden wir uns dem letzten Modul, dem bootstrap-Modul, zu.
Schritt 5: Konfiguration, Starter-Klasse und End-to-End-Tests
Im bootstrap-Modul müssen wir zunächst die Quarkus-Konfiguration in eine Spring-Konfiguration übersetzen.
In Quarkus liegen die Konfigurationsparameter für alle Profile in der Datei application.properties
im src/main/resources-Verzeichnis des bootstrap-Moduls. Den Konfigurationsparametern ist der Profilname „prod” bzw. „mysql” mit einem Prozentzeichen vorangestellt:
%prod.quarkus.datasource.jdbc.url=dummy
%prod.persistence=inmemory
%mysql.quarkus.datasource.jdbc.url=jdbc:mysql://localhost:3306/shop
%mysql.quarkus.datasource.username=root
%mysql.quarkus.datasource.password=test
%mysql.quarkus.hibernate-orm.database.generation=update
%mysql.persistence=mysql
Code-Sprache: Properties (properties)
Im Default-Produktionsprofil „prod” wollen wir die In-Memory-Adapter aktivieren, und im Profil „mysql” wollen wir die JPA-Adapter aktivieren und diese mit einer lokalen MySQL-Datenbank verbinden.
Für Spring splitten wir die Konfiguration in zwei Dateien auf: application.properties für den Default-Modus und application-mysql.properties für den MySQL-Modus.
application.properties enthält die ersten zwei Zeilen mit folgenden Anpassungen:
- der Profil-Präfix ist entfernt,
- der Key
quarkus.datasource.jdbc.url
ist durchspring.datasource.url
ersetzt, - die Dummy-Datenquelle, die, wie oben bereits erwähnt, Spring zum Absturz bringen würde, ist durch eine H2-Datenquelle ersetzt.
spring.datasource.url=jdbc:h2:mem:testdb
persistence=inmemory
Code-Sprache: Properties (properties)
Die application-mysql.properties enthält die unteren fünf Zeilen der Quarkus-application.properties, entsprechend angepasst auf Spring:
spring.datasource.url=jdbc:mysql://localhost:3306/shop
spring.datasource.username=root
spring.datasource.password=test
spring.jpa.hibernate.ddl-auto=update
persistence=mysql
Code-Sprache: Properties (properties)
Spring-Starter-Klasse
Zuletzt brauchen wir für Spring noch eine Starter-Klasse, die bei Quarkus nicht erforderlich war. Wir legen dazu eine Klasse Launcher
im eu.happycoders.shop.bootstrap-Paket des bootstrap-Moduls an, mit folgendem Inhalt:
public class Launcher {
public static void main(String[] args) {
SpringApplication.run(SpringAppConfig.class, args);
}
}
Code-Sprache: Java (java)
End-to-End-Tests anpassen
Bevor wir unsere Anwendung starten, passen wir noch die zwei End-to-End-Tests an. Wir müssen hier die gleichen Anpassungen vornehmen wie im adapter-Modul. Ich zeige dir die Änderungen beispielhaft am FindProductsTest
– hier der bisherige Stand:
@QuarkusTest
@TestProfile(TestProfileWithMySQL.class)
class FindProductsTest {
@Test
void givenTestProductsAndAQuery_findProducts_returnsMatchingProducts() {
String query = "monitor";
Response response =
given()
.queryParam("query", query)
.get("/products")
.then()
.extract()
.response();
assertThatResponseIsProductList(
response, List.of(COMPUTER_MONITOR, MONITOR_DESK_MOUNT));
}
}
Code-Sprache: Java (java)
Wir passen den Test wie folgt an:
@QuarkusTest
ersetzen wir durch@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
.@TestProfile(TestProfileWithMySQL.class)
ersetzen wir durch@ActiveProfiles("test-with-mysql")
.- Um den Port herauszufinden, auf dem die Spring-Anwendung läuft, fügen wir folgende Zeile ein:
@LocalServerPort private Integer port;
- Und schließlich setzen wir den Port, indem wir
given()
um.port(port)
erweitern.
Die geänderte Testklasse sieht dann wie folgt aus:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test-with-mysql")
class FindProductsTest {
@LocalServerPort private Integer port;
@Test
void givenTestProductsAndAQuery_findProducts_returnsMatchingProducts() {
String query = "monitor";
Response response =
given()
.port(port)
.queryParam("query", query)
.get("/products")
.then()
.extract()
.response();
assertThatResponseIsProductList(
response, List.of(COMPUTER_MONITOR, MONITOR_DESK_MOUNT));
}
}
Code-Sprache: Java (java)
Die Testklasse CartTest
passen wir analog an.
Du findest alle Änderungen dieses Schritts in diesem Git-Commit. Die Ergebnisse der Änderungen findest du in den Klassen Launcher, FindProductsTest und CartTest sowie den Konfigurationsdateien application.properties und application-mysql.properties.
Ein Aufruf von mvn spotless:apply clean verify
sollte das Projekt jetzt vollständig kompilieren und alle Tests erfolgreich durchlaufen lassen:
[INFO] parent ............................................. SUCCESS [ 0.371 s]
[INFO] model .............................................. SUCCESS [ 3.027 s]
[INFO] application ........................................ SUCCESS [ 2.492 s]
[INFO] adapter ............................................ SUCCESS [ 33.636 s]
[INFO] bootstrap .......................................... SUCCESS [ 33.090 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
Starten der Anwendung
Nun ist es an der Zeit, die migrierte Anwendung zu starten.
Start der Anwendung aus der IDE heraus
Wir können die Anwendung direkt aus der IDE starten – in IntelliJ z. B. durch die Tastenkombination Ctrl+Shift+F10 in der geöffneten Launcher
-Klasse oder durch Klick auf das grüne Dreieck neben dem Klassennamen oder neben der main-Methode:
Sobald die Anwendung gestartet ist, kannst du die REST-Endpoints aufrufen wie im Abschnitt „Start der Anwendung” im zweiten Teil der Serie beschrieben.
In IntelliJ kannst du einfach die Datei sample-requests.http im doc-Verzeichnis öffnen und die verschiedenen GET-, POST- und DELETE-Kommandos anklicken.
Start der Anwendung im MySQL-Modus
Um die Anwendung im MySQL-Modus zu starten, brauchst du zunächst eine lokale MySQL-Datenbank. Diese startest du am einfachsten wie folgt über Docker (unter Windows musst du den Backslash am Ende der ersten Zeile entfernen und alles in eine Zeile schreiben):
docker run --name hexagon-mysql -d -p3306:3306 \
-e MYSQL_DATABASE=shop -e MYSQL_ROOT_PASSWORD=test mysql:8.1
Code-Sprache: Klartext (plaintext)
Sobald die Datenbank hochgefahren ist, musst du die Launcher
-Klasse mit der VM-Option -Dspring.profiles.active=mysql
Im folgenden Dialog drückst du dann Alt+V, um die Eingabezeile für die VM-Optionen anzuzeigen und trägst dort -Dspring.profiles.active=mysql
ein:
Falls deine Datenbank z. B. auf einem anderen Port läuft, kannst du diesen in der application-mysql.properties ändern. Du kannst die Konfiguration auch durch VM-Optionen oder Umgebungsvariablen überschreiben, z. B. die Datenbank-URL durch die VM-Option -Dspring.datasource.url=...
SPRING_DATASOURCE_URL=...
.
Fazit
In diesem fünften und letzten Teil der Tutorial-Serie über die hexagonale Architektur haben wir eine nach dieser Architektur entwickelte Demo-Anwendung von Quarkus nach Spring Boot migriert.
Dabei mussten wir nicht eine Zeile Code im Anwendungskern ändern und haben damit einen der großen Vorteile der hexagonalen Architektur demonstriert: Technische Details – und dazu zählt auch das Application Framework – lassen sich austauschen, ohne den Kern der Anwendung, also den fachlichen Code, anpassen zu müssen.
Was ist besser – Spring oder Quarkus?
Womit ließ sich die hexagonale Architektur einfacher implementieren? Mit Spring oder Quarkus? Letztendlich gibt es keinen großen Unterschied – vom Programmiermodell her funktionieren beide Frameworks gleich.
Wir haben ein paar Annotationen ausgetauscht, die Panache-Repositories durch Spring-Boot-Data-Repositories ersetzt und die Anwendungs- und Test-Konfigurationen angepasst.
Bei Quarkus sehe ich Vorteile in der vollautomatischen Konfiguration von Testcontainers und von REST-Assured, der vorhandenen ClientErrorException – und natürlich im Developer-Mode, der Änderungen ohne Kompilieren und Neustart ermöglicht. Bei Spring mussten wir in den Persistenzadaptern nicht den Umweg über die Instance<...>
-Dependency-Injection machen.
Letztendlich ist die Wahl des Framework eine Frage der Erfahrung und des persönlichen Geschmacks.
Damit sind wir am Ende dieser Tutorial-Serie angekommen. Wenn dir die Serie gefallen hat, würde ich mich über eine kurze Bewertung auf ProvenExpert freuen.
Möchtest du auf dem Laufenden bleiben und informiert werden, wenn neue Artikel auf HappyCoders.eu veröffentlicht werden? Dann klicke hier, um dich für den kostenlosen HappyCoders-Newsletter anzumelden.
Planst du die hexagonale Architektur in Zukunft einzusetzen? Wenn ja, für welche Art von Anwendung? Mit oder ohne Framework? Mit Quarkus oder Spring? Lass es mich gerne über die Kommentar-Funktion wissen!