1. Einleitung

Ziel dieses Tutorials ist es, Entwicklern mit wenig oder gar keiner Erfahrung in der Entwicklung von Webanwendungen einen ersten Einblick in die Entwicklung mit Spring zu verschaffen.

Hierfür wird auf Basis von Spring Boot ein Projekt aufgesetzt, das im Laufe einiger Schritte zu einem Funktionsfähigen (wenn auch rudimentären) Blog entwickelt wird.

1.1. Technologie

Folgende Werkzeuge, Frameworks und sonstige Technologien sind dazu eingeplant

Table 1. verwendete Technologien
Verwendung Alternativen

Java 11

Maven (mit Maven Wrapper)

Build-Tool

Gradle

Spring

Framework für Dependency-Injection, hier in Form von Spring Boot

Hier alternativlos!

H2

(Embedded)-Datenbank

Derby, SQLite (z.B. mit Xerial)

Thymeleaf

Template-Sprache

JSP, Velocity, Freemarker

2. Erzeugen des Projekts

Das Erzeugen eines Projekts nehmen wir euch an dieser Stelle ab. Wer nachlesen möchte, wie man ein einfaches Spring Boot Projekt mit Thymeleaf aufsetzen kann, findet hier weitere Informationen. Außerdem gibt es den SPRING INITIALIZR, mit dem sich vorkonfigurierte Projekte erstellen lassen.

Für uns reicht es, dass vorbereitete Repo zu klonen:

git clone https://tdpe.techfak.uni-bielefeld.de/git/gpse-ss-2019.spring_boot_example.git

Wie das hier generierte Projekt im Detail aussieht, sehen wir uns im nächsten Schritt an.

2.1. Konfiguration

Das Projekt enthält einige nützliche vorkonfigurierte Plugins und Konfigurationen.

2.1.1. Tests

Das Projekt enthält Konfigurationen für getrennte Durchläufe von Unit- und Integration-Tests. Erstere lassen sich mit test starten, letztere mit integration-test.

2.1.2. Testabdeckung

Für die Testabdeckung ist Jacoco konfiguriert, dieses misst die Abdeckung durch Unit- und Integration-Tests.

2.1.3. Checkstyle & PMD

Checkstyle und PMD sind passend vorkonfiguriert.

Wer Anpassungen vornehmen möchte, findet die Konfigurationen unter codecheck/.

2.1.4. .editorconfig

Dem Projekt liegt eine .editorconfig bei, diese sorgt für konstistente Grundeinstellungen der IDE. Dies betrifft z.B. Encoding (UTF-8), Line-Endings (LF), Einrückung und weiteres.

2.1.5. .gitignore

Die .gitignore ist für Java und die üblichen IDEs vorkonfiguriert.

2.2. Bauen und Testen

Das Projekt lässt sich bereits bauen und auch testen.

Mit

./mvnw clean verify

führt man dabei den gesamten Build aus, welcher z.B. das Überprüfen des Code-Stils, Tests und das Packen der jar enthält.

Führen wir dies aus, sollte der Build korrekt durchlaufen.

Statt direkt Maven nutzen wir den Maven-Wrapper. Diesen ruft man im Project-Verzeichnis unter Linux mit ./mvnw statt mvn auf und unter Windows mit mvnw.cmd. Der Maven-Wrapper kümmert sich um die korrekte Maven-Version und Installation für dieses Projekt.

2.3. Starten

Wenn wir das Projekt nur starten wollen, ohne den gesamten Build durchzuführen, bietet Spring ein zusätzliches Maven-Goal:

./mvnw spring-boot:run

Die Ausgabe auf der Konsole sollte, nach den üblichen Meldungen die Anzeigen das Maven das Projekt baut, in etwa so aussehen (dieses Beispiel ist etwas gekürzt):

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.3.RELEASE)

2019-03-07 02:23:48.315  INFO 36287 --- [  restartedMain] gpse.example.ExampleApplication          : Starting ExampleApplication on YBMacBook.local with PID 36287 (/Users/yannick-broeker/shk/1919/gpse-ss-2019.spring_boot_example/target/classes started by yannick-broeker in /Users/yannick-broeker/shk/1919/gpse-ss-2019.spring_boot_example)
2019-03-07 02:23:48.319  INFO 36287 --- [  restartedMain] gpse.example.ExampleApplication          : No active profile set, falling back to default profiles: default
2019-03-07 02:23:48.500  INFO 36287 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2019-03-07 02:23:48.501  INFO 36287 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2019-03-07 02:23:54.357  INFO 36287 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2019-03-07 02:23:54.595  INFO 36287 --- [  restartedMain] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-03-07 02:23:54.598  INFO 36287 --- [  restartedMain] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.16]
2019-03-07 02:23:54.715  INFO 36287 --- [  restartedMain] o.a.catalina.core.AprLifecycleListener   : The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path: [/Users/yannick-broeker/Library/Java/Extensions:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java:.]
2019-03-07 02:23:55.544  INFO 36287 --- [  restartedMain] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-03-07 02:23:55.545  INFO 36287 --- [  restartedMain] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 7043 ms
2019-03-07 02:23:56.877  INFO 36287 --- [  restartedMain] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-03-07 02:23:58.158  INFO 36287 --- [  restartedMain] o.s.b.d.a.OptionalLiveReloadServer       : LiveReload server is running on port 35729
2019-03-07 02:23:58.312  INFO 36287 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path '' (1)
2019-03-07 02:23:58.322  INFO 36287 --- [  restartedMain] gpse.example.ExampleApplication          : Started ExampleApplication in 10.597 seconds (JVM running for 11.473)
1 Hier sieht man das die Anwendung automatisch einen Tomcat gestartet und auf http://localhost:8080 erreichbar ist.

Ruft man die Webapplikation nun im Browser auf, sollte sich in etwa folgendes Bild zeigen:

WebApp

3. Code verstehen

3.1. StartUp

ExampleApplication.java
@SpringBootApplication (1)
public class ExampleApplication {

    public static void main(final String... args) {
        SpringApplication.run(ExampleApplication.class, args); (2)
    }
}
1 Gibt an, dass dieses Projekt eine Spring-Boot-Application ist. Dies aktiviert u.a. Auto-Configuration (Jede im Projekt gefundene Konfiguration wird angewandt) und Component-Scan (Alle im Projekt gefundenen Services werden als Beans bereitgestellt)
2 Zusätzlich wird in der main die Klasse an Spring zum Starten übergeben, ähnlich, wie z.B. mit JavaFX

3.2. Controller

WelcomeController.java
@Controller (1)
public class WelcomeController {

    @Value("${application.message:Hello World}") (3)
    private String message = "Hello World";

    @RequestMapping("/") (2)
    public ModelAndView welcome() {
        final ModelAndView modelAndView = new ModelAndView("welcome");
        modelAndView.addObject("time", LocalDateTime.now());
        modelAndView.addObject("message", this.message);

        return modelAndView;
    }

}
1 Diese Klasse ist durch die Annotation ein Controller. Controller in einer Spring WebMVC-Anwendung nehmen HTTP-Anfragen entgegen, erzeugen ein geeignetes Model und benennen die zu verwendende View. Sie bieten den Startpunkt, von dem aus Aktionen des Benutzers verarbeitet werden
2 Das @RequestMapping("/") deutet an, dass diese Methode aufgerufen werden soll wenn eine HTTP-Anfrage auf den Pfad "/" stattfindet. Die Methode gibt ein ModelAndView zurück, die für die Antwort an den Browser dargestellt werden soll. ModelAndView enthält den Namen einer View und die dazugehörigen Daten. Der Name der View, hier welcome, wird aufgelöst zu einem Html-Template, src/main/resources/templates/welcome.html. Das Model besteht dabei aus mehreren Objekten, die in der View über ihren Namen referenzierbar sind
3 Mit dieser Annotation wird Spring angewiesen, diese Variable mit einem Wert aus der Datei application.properties vorzubelegen, der unter dem Schlüssel application.message zu finden ist. Durch das hinzufügen von ":Hello World" in der Annotation wird "Hello World" als Standardbelegung angenommen, falls der gesuchte Schlüssel in den application.properties nicht gefunden wurde. Würde die Standardbelegung im @Value weggelassen, gäbe es statt dessen beim Anwendungsstart eine Exception wenn die entsprechende Property fehlt

3.3. Konfigurationen

application.properties
application.message:Hallo Welt

Diese Zeile liefert die Belegung für das per @Value befüllte Feld von WelcomeController.

3.4. View

Im Kern handelt es sich bei der Datei um eine HTML-Datei, mit einigen Erweiterungen. Dementsprechend hat sie die übliche Struktur eines HTML Dokuments mit einem HTML5 Doctype. Ein Vorteil von Thymeleaf ist an dieser Stelle, dass diese Datei auch ohne das Auflösen von Variablen oder Tags im Browser anzeigbar ist.

Dieses Template zeigt vor allem, wie man auf Variablen zugreift, die entweder im Controller ins Model eingefügt worden sind, oder innerhalb der Template definiert wurden:

welcome.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Hello World!</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<p th:text="${message} + '!' "></p> (1)
<p th:text="'Current time: ' + ${time}"></p> (2)
</body>
</html>
1 Alle mit th beginnenden Attribute werden während des Renderns von Thymeleaf ausgewertet. Die Dollar-Notation dient dabei dem Zugreifen auf Variablen. Mit ${message} greift man z.B. auf den im Model hinterlegten String zu.
2 Handelt es sich bei der Variablen nicht um einen String wie bei time, wird sie zur Ausgabe mittels ihrer toString()-Methode formatiert.

3.5. Zusammenfassung

  • Die Anwendung verwendet generell die Standardeinstellungen die von Spring Boot vorgeschlagen werden

  • Bei Anfragen auf dem Pfad / wird die Datei welcome.html mit einem Datum und einer Nachricht versorgt und gerendert

  • Konfigurationsdaten liegen in application.properties

  • Als Templatesprache wird Thymeleaf verwendet. Die Beispielseite zeigt, wie man einfachen Text ausgibt und Daten vom Java-Teil eurer Anwendung (das sogenannte Backend) in den HTML-Teil eurer Anwendung (das sogenannte Frontend) transferiert.

4. Domänenmodell erstellen

4.1. Domäne

Entwickelt werden soll ein einfacher Blog, welcher Autoren das Anlegen von neuen Artikeln und Lesern das Kommentieren von diesen erlauben soll.

Anhand der kurzen Beschreibung kann man auch schon direkt einzelne konzeptuelle Klassen erkennen:

  • Nutzer

  • Artikel

  • Kommentare

Nutzer haben erstmal nur Namen:

  • Nutzername

  • Vorname

  • Nachname

Artikel sind jeweils von einem Nutzer erstellt und haben einen Text, einen Titel und einen Veröffentlichungs-Zeitpunkt. Außerdem gehören zu ihnen jeweils Kommentare.

  • Nutzer - als Assoziation

  • Kommentar - auch als Assoziation

  • Überschrift

  • Text

  • Zeitpunkt

Und Kommentare gehören jeweils zu einem Artikel und besitzen einen Text und einen Erstell-Zeitpunkt.

  • Text

  • Zeitpunkt

diagram classes

4.2. Klassen

Getter sind jeweils der Übersichtlichkeit halber weggelassen, müssen aber zusätzlich generiert werden. Weitere Methoden enthalten die Klassen noch nicht.

Die grad entwickelten Klassen können nun implementiert werden. Die Klassen dazu legen wir im Package domain an:

User.java
public class User {

    private String username;

    private String firstname;

    private String lastname;

    public User(final String username, final String firstname, final String lastname) {
        this.username = username;
        this.firstname = firstname;
        this.lastname = lastname;
    }
}
Article.java
public class Article {
    private User author;

    private String title;

    private String text;

    private LocalDateTime publishedAt;

    private List<Comment> comments = new ArrayList<>(); (1)

    public Article(final User author, final String title, final String text) {
        this.title = title;
        this.text = text;
        this.author = author;
        this.publishedAt = LocalDateTime.now(); (2)
    }
}
1 Die Liste initialisieren wir direkt - so verhindern wir NullPointer. Außerdem übergeben wir diese nicht dem Konstruktor - ein neuer Artikel hat noch keine Kommentare.
2 Den Veröffentlichungs-Zeitpunkt setzten wir direkt im Konstruktor, anstatt ihn zu übergeben.
Comment.java
public class Comment {

    private String text;

    private LocalDateTime writtenAt;

    public Comment(final String text) {
        this.text = text;
        this.writtenAt = LocalDateTime.now(); (1)
    }
}
1 Auch hier setzen wir den Erstell-Zeitpunkt direkt im Konstruktor, anstatt ihn zu übergeben.

Uns fehlt noch die Möglichkeit, Artikeln neue Kommentare hinzuzufügen. Dafür fügen wir eine weitere Methode hinzu:

Article.java
    public void addComment(final String commentText) {
        this.comments.add(new Comment(commentText));
    }

4.3. Testen

Um unser Domain-Model auszuprobieren, schreiben wir ein paar kurze Unit-Tests:

UserTest.java
import gpse.example.domain.User;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class UserTest {

    @Test
    void userCreation() {
        User user = new User("Uncle Bob", "Bob", "Martin");

        assertThat(user.getUsername()).isEqualTo("Uncle Bob");
        assertThat(user.getFirstname()).isEqualTo("Bob");
        assertThat(user.getLastname()).isEqualTo("Martin");
    }

}
ArticleTest.java
import java.time.LocalDateTime;

import gpse.example.domain.Article;
import gpse.example.domain.User;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ArticleTest {

    @Test
    void getTitle() {
        LocalDateTime testStarted = LocalDateTime.now();

        User user = new User("testuser", "Test", "User");
        Article article = new Article(user, "A short title", "Some text...");


        assertThat(article.getTitle()).isEqualTo("A short title");
        assertThat(article.getText()).isEqualTo("Some text...");
        assertThat(article.getPublishedAt())
                .isAfterOrEqualTo(testStarted)
                .isBeforeOrEqualTo(LocalDateTime.now());
        assertThat(article.getComments()).isEmpty();
    }

    @Test
    void addComment() {
        LocalDateTime testStarted = LocalDateTime.now();

        User user = new User("testuser", "Test", "User");
        Article article = new Article(user, "A short title", "Some text...");

        assertThat(article.getComments()).isEmpty();

        article.addComment("A first Comment");

        assertThat(article.getComments()).hasSize(1);
        assertThat(article.getComments().get(0).getText()).isEqualTo("A first Comment");
        assertThat(article.getComments().get(0).getWrittenAt())
                .isAfterOrEqualTo(testStarted)
                .isBeforeOrEqualTo(LocalDateTime.now());

    }
}

Die beiden Test können wir mir ./mvnw clean test ausfürhren lassen.

Die Ausgabe sollte dann einen erfolgreichen Durchlauf anzeigen:

Results :

Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

5. Artikel anzeigen

Bisher macht die Anwendung noch nichts - es wäre aber schön, schon mal zu sehen, wie sich Artikel anzeigen lassen.

5.1. Controller

Um auf HTTP-Requests zu reagieren brauchen wir einen weiteren Controller. Dieser soll den Blog anzeigen - wir nennen ihn also BlogController. Die Klasse dazu erstellen wir ein einem weiteren Package: web.

Dieser soll bei Anfragen auf die Hauptseite reagieren und eine Seite mit Artikeln anzeigen:

BlogController.java
@Controller (1)
public class BlogController {

    @RequestMapping("/") (2)
    public ModelAndView showBlog() {
        final ModelAndView modelAndView = new ModelAndView("blog"); (3)

        final User user = new User("Uncle Bob", "Bob", "Martin");

        final List<Article> articles = new ArrayList<>(); (4)
        articles.add(new Article(user, "A magnificent Article", "Lorem ipsum dolor"));
        articles.add(new Article(user, "Another Article", "sit amet, consetetur sadipscing elitr"));

        modelAndView.addObject("articles", articles); (5)

        return modelAndView;
    }
}
1 Damit die Klasse als Controller erkannt wird, muss sie mit @Controller annotiert werden
2 Außerdem muss das RequestMapping angegeben werden - die BlogPosts sollen beim Aufrufen von / angezeigt werden
3 Es wird wieder ein ModelAndView erstellt - als View übergeben wir blog, welche wir gleich noch erstellen müssen
4 Es sollen mehrere Artikel angezeigt werden, wir erstellen also eine Liste und fügen dieser mehrere neue Artikel hinzu
5 Die Liste von Artikeln übergeben wir dann noch dem ModelAndView

5.2. View

Die View erstellen wir als HTML-Datei (blog.html) im template-Ordner.

blog.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Unser Blog!</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<h1>BlogPosts</h1>
<div th:each="article:${articles}"> (1)
    <h2 th:text="${article.title}"></h2> (2)
    <p th:text="${article.text}"></p>
</div>
</body>
</html>
1 th:each - wieder ein Thymeleaf-Attribut - funktioniert ähnlich wie ein foreach-Loop. Mit der Dollar-Notation greift man wieder auf eine bestehende Variable zu, hier articles aus dem Model, und mit article deklariert man eine neue Variable, welche der Reihe nach alle Werte aus articles enthält. Das th:each-enthaltende HTML-Element wird dabei inklusive Inhalt für jeden Wert erneut ausgegeben
2 Mit th:text geben wir wieder den Text des Elements an. Wir greifen dazu auf den eben deklarierten article zu und fragen von diesem den title ab. Im Hintergrund wird dabei der Getter getTitle() aufgerufen

5.3. Testen

Den eben erstellten BlogController können wir jetzt noch mit Tests abdecken. Dabei können wir zwei Arten von Tests erstellen:

  • Unit-Tests: einfache Unit-Tests, die ihn src/test zu finden sind

  • Integration-Tests: Tests, die die Integration der Anwendung, also das Zusammenspiel aller Teile und des Frameworks, testen. Diese sind in src/it zu finden

5.3.1. Unit-Test

Der Unit-Test testet erstmal nur, ob der Controller auch die passende View zurück gibt und das Model Artikel enthält.

BlogControllerTest.java
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.ModelAndView;

import static org.assertj.core.api.Assertions.assertThat;

class BlogControllerTest {

    @Test
    void showBlog() {
        BlogController blogController = new BlogController();

        ModelAndView modelAndView = blogController.showBlog();

        assertThat(modelAndView.getViewName()).isEqualTo("blog");(1)
        assertThat(modelAndView.getModel()).containsKey("articles");
    }

}
1 Hierbei müssen wir den Namen der View überprüfen, nicht direkt die View

5.3.2. Integration-Test

Der Integration-Test testet, ob die Applikation immer noch startbar ist und der Controller auch bei wirklichen Anfragen passend reagiert.

Integration-Tests liegen nicht in src/test, sondern in src/it und enden nicht wie üblich auf Test, sondern auf IT. Dies ist nötig, damit bei der Ausführung der Tests zwischen beiden Arten unterschieden werden kann.

BlogControllerIT.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.CoreMatchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(SpringExtension.class) (1)
@SpringBootTest (2)
@AutoConfigureMockMvc (3)
class BlogControllerIT {

    @Autowired
    private MockMvc mvc;  (4)

    @Test
    void showBlog() throws Exception {
        this.mvc.perform(get("/"))  (5)
                .andExpect(status().isOk())  (6)
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) (7)
                .andExpect(content().string(containsString("BlogPosts"))) (8)
                .andExpect(content().string(containsString("A magnificent Article")))  (9)
                .andExpect(content().string(containsString("Another Article")));
    }
}
1 Wir müssen dem Test-Framework bekannt machen, dass dies ein Spring-Integration-Test ist
2 Außerdem müssen wir Spring sagen, dass dies ein SpringBootTest ist, damit die Anwendung intern gestartet wird
3 Testen wollen wir die MVC-Componente von Spring - dies müssen wir angeben, damit Spring die passenden Klassen bereit stellt
4 MockMVC ist eine spezielle Klasse, mit der man die MVC-Componente (= Model View Controller testen kann). Mit @Autowired fordern wir ein Objekt dieser Klasse bei Spring an - weiteres dazu kommt später noch
5 Mit der MockMVC-Instanz können wir jetzt Requests ausführen. In diesem Fall ein GET auf /, das gleiche, was ein Browser beim Aufrufen der Seite machen würde.
6 Wir erwarten, dass der Status (=das Ergebnis in Form eines Codes) der Anfrage OK ist
7 außerdem soll das Ergebnis vom Typ HTML sein
8 die Seite sollte die passende Überschrift enthalten
9 und die beiden existierenden Posts sollten zu finden sein

5.3.3. Ausführen der Tests

Wenn wir nur die Unit-Tests ausführen wollen, können wir dies wieder mit ./mvnw clean test machen.

In der Ausgabe sollte der erstellte Test als erfolgreich durchgelaufen auftauchen

Running BlogControllerTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.187 sec - in BlogControllerTest

Die Integration-Tests können wir mit ./mvnw clean verify ausführen, dabei werden sowohl Unit als auch Integration-Tests durchgeführt.

Bei der Ausführung sehen wir, dass die Tests fehlschlagen.

Der Grund dafür findet sich im StackTrace:

Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'welcomeController' method
public org.springframework.web.servlet.ModelAndView WelcomeController.welcome()
to {[/]}: There is already 'blogController' bean method

In dem Projekt existiert noch der ursprüngliche WelcomeController, welcher auch ein RequestMapping für / bereitstellt. Zwei gleiche Mappings sind nicht möglich - ihr müsst deshalb das Mapping für den WelcomeController angepasst werden, z.B. auf @RequestMapping("/welcome").

Danach laufen auch die Integration-Tests erfolgreich durch:

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

5.4. Starten

Zusätzlich zu den Tests können wir uns jetzt auch die Ausgabe im Browser angucken.

Dazu können wir die Applikation starten:

./mvnw spring-boot:run

und die Seite dann mit http://localhost:8080 aufrufen.

Blog

6. Services

Die Artikel des Blogs sollten natürlich nicht einfach so im Controller der Anwendung erstellt werden. Stattdessen sollte dieser die Artikel aus dem Model der Anwendung laden, sodass man z.B. später eine Datenbank anbinden kann.

6.1. Service-Interface

Der Controller braucht also einen Service, der die Artikel bereitstellt.

Dazu definieren wir als erstes ein passendes Interface:

BlogService.java
public interface BlogService {
    List<Article> getArticles();
}

Der Service bekommt erstmal nur eine einzelne Methode - zum Laden aller Artikel.

Services werden im domain-Package erstellt.

6.2. Controller anpassen

Diesen benutzen wir dann im Controller, anstatt dort die Artikel neu zu erstellen:

BlogController.java
@Controller
public class BlogController {

    private BlogService blogService;

    @Autowired (1)
    public BlogController(BlogService blogService) {
        this.blogService=blogService;
    }

    @RequestMapping("/")
    public ModelAndView showBlog() {
        ModelAndView modelAndView = new ModelAndView("blog");

        List<Article> articles = blogService.getArticles(); (2)
        modelAndView.addObject("articles", articles);

        return modelAndView;
    }
}
1 Hier benutzen wir wieder @Autowired - diese Annotation weißt Spring an, ein Objekt der Klasse BlogService zu übergeben (injizieren), wenn die Klasse BlogController von Spring instanziert wird.
2 Den injizierten BlogService benutzen wir dann, um eine Liste der Artikel zu erhalten.

6.3. Unit-Test anpassen

Durch die Änderung des Konstruktors von BlogController kompiliert der Tests nicht mehr, da wir in diesem den Default-Konstruktor benutzen.

Wir müssen also auch im Test eine Instanz von BlogService übergeben.

Dazu nutzen wir Mocking - dabei erstellen wir keine echte Implementierung des Interfaces, sondern lassen durch eine Library eine Implementierung erstellen, deren Verhalten wir dann im Nachhinein steuern können.

BlogControllerTest.java
import gpse.example.domain.BlogService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.web.servlet.ModelAndView;

import static org.assertj.core.api.Assertions.assertThat;

class BlogControllerTest {


    @Mock (1)
    private BlogService blogService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.initMocks(this);(2)
    }

    @Test
    void showBlog() {
        BlogController blogController = new BlogController(blogService);(3)

        ModelAndView modelAndView = blogController.showBlog();

        assertThat(modelAndView.getViewName()).isEqualTo("blog");
        assertThat(modelAndView.getModel()).containsKey("articles");
    }

}
1 Wir legen eine Instanzvariable des benötigen Interfaces an. Dieses annotieren wir mit @Mock, wodurch die Library angewiesen wird, einen Mock dieses Interfaces bereitzustellen
2 Vor jedem Test weisen wir die Library dann an, alle mit @Mock annotierten Felder zu inititalisieren.
3 Im Test benutzen wir dann den initialisieren Mock.

6.4. Service implementieren

Wenn man auch die Integration-Tests ausführt oder das gesamte Programm startet, gibt es wieder eine Fehlermeldung:

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'gpse.example.domain.BlogService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

Der Grund dafür ist: im BlogController erwarten wir eine Implementation des BlogService, bisher gibt es aber nur das Interface.

Wir erstellen dazu die Klasse BlogServiceImpl (Impl für Implementierung). Diese liefert in getArticles erstmal eine einfache Liste von Artikeln:

BlogServiceImpl.java
@Service (1)
class BlogServiceImpl implements BlogService {

    @Override
    public List<Article> getArticles() {
        final User user = new User("Uncle Bob", "Bob", "Martin");

        final List<Article> articles = new ArrayList<>();
        articles.add(new Article(user, "A magnificent Article", "Lorem ipsum dolor"));
        articles.add(new Article(user, "Another Article", "sit amet, consetetur sadipscing elitr"));

        return articles;
    }
}
1 Die Klasse ist annotiert mit @Service - damit erkennt Spring sie als Service und kann sie anderen Klassen bereitstellen, z.B. dem BlogController

Wir können die Anwendung jetzt starten und sehen auf http://localhost:8080 die beiden im Service erstellten Artikel.

Blog

7. Datenbank

Die Daten sollten natürlich nicht fest im Service erstellt werden. Stattdessen wäre es sinnvoll, wenn sie aus einer Datenbank geladen werden.

Dazu bietet Spring eine einfache Integration an, die einen Großteil der Arbeit beim Anbinden von Datenbanken abnimmt.

7.1. Dependencies

Hinzufügen müssen wir erstmal nur folgende Dependencies. Diese müssen dazu einfach in der <dependencies>-Section der pom.xml eingefügt werden:

pom.xml
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId> (1)
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId> (2)
            <version>1.4.199</version>
        </dependency>
1 Dies enthält die nötigen APIs zur Verwendung von Spring-Data-JPA und JPA allgemein
2 H2 ist eine einfache Embedded-Datenbank, die ohne weitere Einrichtung funktioniert.

7.2. Datenmodell anpassen

Damit die Klassen in einer Datenbank speicherbar sind, müssen diese mit entsprechenden JPA-Annotationen annotiert werden.

JPA ist die Java Persistance Api

Achtet darauf die Annotationen aus javax.persistence zu importieren und nicht aus anderen Paketen.

Article.java
@Entity (1)
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long id; (2)

    @ManyToOne (3)
    private User author;

    @Column (4)
    private String title;

    @Lob
    @Column (5)
    private String text;

    @Column (6)
    private LocalDateTime publishedAt;

    @OneToMany (7)
    private List<Comment> comments = new ArrayList<>();

    protected Article() { (8)

    }
1 Die Klasse ist eine Entität -sie entspricht einer Tabelle in der Datenbank
2 Jeder Entität braucht eine ID, einen eindeutigen Identifier. Ein Artikel hat im Datenmodell bisher keinen einzigartigen Wert, wir führen deshalb eine "künstliche" ID ein. Dieses ist einfach ein Long, welcher von der Datenbank beim Speichern generiert werden soll
3 author muss als Assoziation gekennzeichnet werden. ManyToOne gibt dabei an, dass viele Artikel zu einem User gehören
4 title ist ein einfacher Wert und entspricht deshalb in der Datenbank einer Spalte, wird deshalb als Column gekennzeichnet
5 text ist auch eine Column, zusätzlich aber auch ein Lob, welches beliebig große Texte erlaubt (Column ist Standardmäßig 255 Zeichen lang, lässt sich aber auf einen höheren Wert festlegen)
6 publishedAt ist wieder ein einfacher Wert, deshalb wieder Column
7 comments ist wieder eine Assoziation, ein Artikel kann dabei mehrere Kommentare enthalten, weshalb es OneToMany ist
8 Jede Entität braucht einen parameterlosen Konstruktor. Dieser kann protected sein, damit man ihn nicht versehentlich benutzt

Getter für die neu hinzukommenden Felder nicht vergessen!

Comment.java
@Entity(1)
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long id; (2)

    @Lob
    @Column (3)
    private String text;

    @Column (4)
    private LocalDateTime writtenAt;

    protected Comment() { (5)

    }
1 Die Klasse ist wieder eine Entität
2 Sie braucht wieder eine ID
3 text soll wieder ein beliebig großer Text sein
4 writtenAt ist wieder ein einfacher Wert
5 Die Entität braucht wieder einen Default-Konstruktor
User.java
@Entity (1)
public class User {

    @Id
    @Column
    private String username; (2)

    @Column
    private String firstname; (3)

    @Column
    private String lastname; (3)

    protected User() { (4)

    }
1 Die Klasse ist wieder eine Entität
2 Nutzer haben einen eindeutigen Nutzernamen - dieser kann als ID für die Datenbank genutzt werden
3 Vor und Nachname sind wieder einfache Werte
4 Die Entität braucht wieder einen Default-Konstruktor

7.3. Repository

Die Interaktion mit der Datenbank findet über ein Repository statt.

Für dieses muss nur ein Interface angelegt werden, Spring legt dann im Hintergrund eine entsprechende Implementation an und stellt diese bereit.

Wir können dazu einfach das von Spring bereitgestellte Interface CrudRepository erweitern:

ArticleRepository.java
import org.springframework.data.repository.CrudRepository;

public interface ArticleRepository extends CrudRepository<Article, Long> {
}

Repository stellt dabei Methoden zum Erstellen (Create), Lesen (Read), Updaten (Update) und Löschen (Delete) von Datensätzen bereit. Angeben müssen wir nur den Typ der Entität (Article) und der Id (Long).

Repositorys werden im domain-Package erstellt.

7.4. Aktivieren

Damit die Datenbank benutzt werden kann, müssen wir diese noch aktivieren:

ExampleApplication.java
@SpringBootApplication
@EnableJpaRepositories (1)
@EnableTransactionManagement (2)
public class ExampleApplication {
1 Hiermit wird das Erkennen von Repositorys aktiviert
2 Hiermit wird die Unterstützung für Transaktionen aktiviert. Methoden können damit mit @Transactional annotiert werden, sodass alle darin stattfindenden Datenbankzugriffe in einer Transaktion stattfinden

7.5. Repository nutzen

Das Repository können wir jetzt in BlogServiceImpl nutzen, um die Artikel aus der Datenbank zu laden:

BlogServiceImpl.java
@Service
public class BlogServiceImpl implements BlogService {

    private final ArticleRepository articleRepository;

    @Autowired (1)
    public BlogServiceImpl(final ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    @Override
    public List<Article> getArticles() {
        final List<Article> articles = new ArrayList<>();

        articleRepository.findAll().forEach(articles::add); (2)

        return articles;
    }
}
1 Der Konstuktor hat als Argument ein ArticleRepository und Spring wird mit Autowired angewiesen, ein ensprechendes Objekt zu übergeben
2 Aus dem ArticleRepository werden dann die Artikel geladen, der Liste hinzugefügt und diese dann zurückgegeben

7.6. Beispieldaten

Damit beim Testen auch Daten vorhanden sind, wäre es schön wenn die Anwendung beim Start die Datenbank erstmal mit ein paar Beispiel-Artikeln befüllt wird. Dazu wird eine Datei data.sql im Verzeichnis src/main/resources erstellt:

data.sql
INSERT INTO user (username, firstname, lastname) VALUES ('Uncle_Bob', 'Bob', 'Martin');

INSERT INTO article (id, published_at, author_username, title, text) VALUES (1,'2015-12-01 08:00:00.0', 'Uncle_Bob' , 'A magnificent Article', 'Lorem ipsum dolor');
INSERT INTO article (id, published_at, author_username, title, text) VALUES (2, '2015-12-02 08:00:00.0', 'Uncle_Bob', 'Another Article', 'sit amet, consetetur sadipscing elitr');

7.7. Starten

Wir können die Anwendung wieder ausführen (./mvnw spring-boot:run)

Blog

8. Sicherheit und Login

Damit neue Artikel veröffentlicht werden können, müssen sich Nutzer natürlich auch einloggen können.

8.1. Spring Security

Spring liefert bereits ein recht umfassendes Framework um Login, Sessionverwaltung, und so weiter umzusetzen: Spring Security.

Wie bereits bei der Datenbankanbindung muss man nur in der pom.xml die passende Abhängigkeit angeben - in diesem Fall spring-boot-starter-security:

pom.xml
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

8.2. Nutzer anpassen

Damit Spring die entsprechende Klasse als User erkennt, muss diese das Interface UserDetails implementieren. Dieses schreibt u.a. ein Passwort sowie eine Liste von Rollen (z.B. User, Admin) vor.

User.java
@Entity
public class User implements UserDetails { (1)

    private static final long serialVersionUID = 0L;

    @Id
    @Column
    private String username;

    @Column
    private String firstname;

    @Column
    private String lastname;

    @Column
    private String password; (2)

    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles; (3)

    protected User() {

    }

    public User(final String username, final String firstname, final String lastname, final String password) { (4)
        this.username = username;
        this.firstname = firstname;
        this.lastname = lastname;
        this.password = password;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return AuthorityUtils.createAuthorityList(roles.toArray(new String[0])); (5)
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() { (6)
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    public String getFirstname() {
        return firstname;
    }

    public String getLastname() {
        return lastname;
    }


}
1 Die Klasse muss UserDetails implementieren
2 User brauchen ein Passwort
3 Außerdem brauchen sie eine Liste von Rollen, diese können einfach als String (im Format ROLE_USER) gespeichert werden. Die Liste enthält nur einfache Datentypen, man kann sie deshalb mit ElementCollection annotieren, damit sie passend in der Datenbank gespeichert werden. Zusätzlich geben wir als Fetch-Type EAGER an, damit die Nutzerrollen beim Laden eines Nutzers immer geladen werden
4 Den Konstruktor passen wir an, damit ihm auch das Passwort übergeben wird
5 Das Interface schreibt vor, dass getAuthorities eine Liste von GrantedAuthority zurückgibt - diese können wir einfach aus der Liste der Rollen erzeugen lassen
6 Außerdem gibt es einige weitere den Zustand des Nutzers betreffende Methode - diese können aber erstmal ignoriert werden und geben einfach true zurück

Da wir den Nutzer geändert haben, müssen wir die Beispieldaten im data.sql entsprechend anpassen:

data.sql
INSERT INTO user (username, firstname, lastname, password) VALUES ('Uncle_Bob', 'Bob', 'Martin','{bcrypt}$2a$10$WoG5Z4YN9Z37EWyNCkltyeFr6PtrSXSLMeFWOeDUwcanht5CIJgPa'); (1)
INSERT INTO user_roles (user_username,roles) VALUES ('Uncle_Bob', 'ROLE_USER'); (2)

INSERT INTO article (id, published_at, author_username, title, text) VALUES (1,'2015-12-01 08:00:00.0', 'Uncle_Bob' , 'A magnificent Article', 'Lorem ipsum dolor');
INSERT INTO article (id, published_at, author_username, title, text) VALUES (2, '2015-12-02 08:00:00.0', 'Uncle_Bob', 'Another Article', 'sit amet, consetetur sadipscing elitr');
1 Der Nutzer bekommt zusätzlich ein Passwort übergeben. Um zu verhindern, dass Passwörter im Klartext in der Datenbank stehen, werden diese gehasht - in diesem Fall mit BCrypt. In diesem Fall ist das Passwort zu diesem Hash password, hashen kann man diese z.B. mit https://www.devglan.com/online-tools/bcrypt-hash-generator
2 Außerdem müssen wir dem Nutzer noch Rollen zuweisen, dieser Nutzer bekommt die Rolle ROLE_USER

Außerdem wird in den Tests noch der alte Konstruktor von User benutzt, dort muss der Aufruf entsprechend angepasst werden - z.B., indem man obigen Hash als Passwort übergibt.

8.3. Service

Die Nutzer müssen natürlich noch aus der Datenbank geladen werden können.

Wie bei den Artikeln brauchen wir dazu ein Repository und eine Service.

UserRepository.java
import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository<User, String> {
}
UserService.java
import org.springframework.security.core.userdetails.UserDetailsService;

public interface UserService extends UserDetailsService { (1)

}
1 UserDetailsService ist ein Interface von Spring, welches vom Framework zum Laden der Nutzer benutzt wird, diese erweitert unseren UserService
UserServiceImpl.java
@Service
class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    @Autowired
    public UserServiceImpl(final UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
        return userRepository.findById(username) (1)
        .orElseThrow(() -> new UsernameNotFoundException("User name " + username + " not found.")); (2)
    }
}
1 In der Implementierung laden wir den User aus der Datenbank, zurück bekommen wir ein Optional
2 Aus diesem lassen wir den User zurückgeben, wenn keiner vorhanden ist, soll eine UsernameNotFoundException geworfen werden

8.4. Konfiguration

Spring Security muss noch konfiguriert werden - sodass z.B. die Nutzer aus der Datenbank geladen werden und diese sich einloggen können.

Dazu wird eine SecurityConfig angelegt:

SecurityConfig.java
@Configuration
@EnableWebSecurity (1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        //@formatter:off
        http
            .authorizeRequests()
                .antMatchers("/").permitAll() (2)
                .anyRequest().authenticated()
                .and()
            .formLogin()  (3)
                .permitAll()
                .and()
            .logout()  (4)
                .permitAll();
        //@formatter:on
    }

    @Autowired
    public void configureGlobal(final UserDetailsService userDetailsService,  (5)
                                final PasswordEncoder passwordEncoder,
                                final AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
        .passwordEncoder(passwordEncoder);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {  (6)
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

}
1 Die Klasse wird als Konfiguration gekennzeichnet und WebSecurity wird aktiviert
2 Für jede Seite lässt sich angeben, wer Zugriff auf diese hat. Auf den Pfad "/" sollte in diesem Fall jeder Zugriff haben (permitAll)
3 Außerdem soll es eine Seite mit einem Login geben (die unter dem Pfad /login erreichbar ist), auf die alle zugreifen können
4 und entsprechende auch einen Logout
5 hier wird konfiguriert, welcher UserDetailsService und welcher PasswordEncoder genutzt werden. Da die Methode mit Autowired annotiert ist, sucht Spring entsprechende Objekte (z.B. den mit Service annotierten UserDetailsService)
6 als PasswordEncoder wird ein Delegating-Password-Encoder verwendet, welcher zur Abwärtskombatibilität intern verschiedene Algorithmen nutzen kann, für neue Passwörter wird aber der jeweils beste genutzt (aktuell BCrypt). Da die Methode mit Bean annotiert ist, steht Spring das zurückgegebene Objekt zur Verfügung, wie auch mit Service annotierte Klassen

8.5. Starten

Wir können die Anwendung wieder mit ./mvnw spring-boot:run starten.

Die Hauptseite sollte wieder die beiden Artikel zeigen.

Außerdem können wir http://localhost:8080/login aufrufen:

Login

Verwenden wir falsche Daten, erscheint eine Fehlermeldung:

Login

Verwenden wir die in dieDatenbank eingetragenen Daten (Uncle_Bob,password), werden wir auf die Hauptseite weitergeleitet.

Eingeloggt

Der Login scheint also zu funktionieren - auch wenn er bisher noch keine weitere Funktion hat.

9. Artikel anlegen

Angemeldete Nutzer sollen natürlich auch Artikel schreiben können.

Was wir dazu brauchen, sind im Wesentlichen eine Eingabemaske (View), ein Controller, der die Eingaben verarbeitet, und einen Service, der die neuen Artikel speichert.

9.1. Service

Zuerst passen wir den Service entsprechend an:

BlogService.java
public interface BlogService {
    List<Article> getArticles();

    Article addArticle(User user, String title, String text); (1)
}
1 hinzugefügt wird eine Methode, die einen Artikel für den Nutzer mit gegebenem Text und Titel erstellt

Diese Methode implementieren wir dann entsprechend:

BlogServiceImpl.java
    @Override
    public Article addArticle(final User user, final String title, final String text) {
        final Article article = new Article(user, title, text); (1)

        return articleRepository.save(article); (2)
    }
1 der neue Artikel wird erzeugt
2 diesen speichern wir im Repository. Dabei bekommen wir einen Artikel zurück, welchen wir dann auch zurückgeben

9.2. Eingabemaske für neue Artikel

9.2.1. Controller

Im Controller brauchen wir als erstes eine Methode, die eine View zurück gibt, in der man den neuen Artikel eingeben kann.

BlogController.java
    @GetMapping("/articles/add")(1)
    public ModelAndView addArticle() {
        final ModelAndView modelAndView = new ModelAndView("article/add"); (2)

        modelAndView.addObject("createArticleCmd", new CreateArticleCmd()); (3)

        return modelAndView;
    }
1 Die View soll dabei bei Aufrufen von /articles/add zurückgegeben werden
2 Die View heißt add und liegt im Ordner article
3 Dem Model fügen wir ein neues CreateArticleCmd hinzu

9.2.2. Command-Objekt

CreateArticleCmd ist dabei ein Command-Objekt, welches dazu dient, die eingegeben Daten an den Controller weiter zu geben. Es enthält nur die für einen neuen Artikel nötigen Felder und Getter und Setter für diese:

CreateArticleCmd.java
public class CreateArticleCmd {

    private String title;

    private String text;

    public String getTitle() {
        return title;
    }

    public void setTitle(final String title) {
        this.title = title;
    }

    public String getText() {
        return text;
    }

    public void setText(final String text) {
        this.text = text;
    }
}

9.2.3. View

Das Command-Objekt nutzen wir dann in der View. Für diese legen wir in src/main/resources/templates einen neuen Ordner article und in diesem die Datei add.html an:

add.html
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<body>
<form th:action="@{/articles/add}"
      th:object="${createArticleCmd}"
      method="post"> (1)
    <label for="title">Titel:</label>
    <input type="text" id="title" th:field="*{title}"/> (2)
    <label for="text">Text:</label>
    <textarea id="text" th:field="*{text}"></textarea>
    <input type="submit" value="Speichern"/>
</form>

</body>

</html>
1 Die Seite enthält eine einfache form
  • als th:action ist @{/articles/add} angegeben - th:action löst thymeleaf zu action auf und wertet dabei die angegebene URL aus. Dies ist die URL, zu der die ausgefüllte Form geschickt wird

  • mit th:object verweisen wir auf das Model-Objekt, welches mit dieser Form gefüllt werden soll. In diesem Fall das dem Model hinzugefügte createArticleCmd

  • als Methode geben wir post an, damit der Browser beim abschicken des Formlars einen POST statt GET-Request durchführt

2 Die Form enthält zwei input-Felder. Diese referenzieren mit th:field und {} jeweils das zugehörige Feld aus dem hinterlegtem Model - {title} z.B. createArticleCmd.title

URLS verweisen jeweils auf ein Mapping, nicht auf die template-Dateien.

In der blog.html legen wir außerdem noch einen Link auf die neue Seite an:

blog.html
<a th:href="@{/articles/add}">Neuer Artikel</a>

9.3. Artikel anlegen

Damit die aus der form gesendeten Daten auch zum Anlegen eines neuen Artikels führt, muss im Controller noch eine entsprechende Methode angelegt werden:

BlogController.java
    @PostMapping("/articles/add")(1)
    public ModelAndView addArticle(@AuthenticationPrincipal final User user, (2)
                                   final CreateArticleCmd createArticleCmd) { (3)
        blogService.addArticle(user, createArticleCmd.getTitle(), createArticleCmd.getText()); (4)

        return new ModelAndView("redirect:/"); (5)
    }
1 Statt einem GetMapping benutzen wir ein PostMapping, damit auf POST-Request statt GET-Requests reagiert wird
2 Übergeben bekommt die Methode ein Objekt unserer Nutzer-Klasse. Dieses ist mit AuthenticationPrincipal annotiert, sodass Spring dort den aktuell angemeldeten Nutzer übergibt
3 Außerdem wird ein CreateArticleCmd übergeben, welches auch in der Form referenziert wurde
4 In der Methode benutzen wir die addArticle-Methode des BlogService, um einen neuen Artikel zu erstellen
5 Zurück geben wir ein neues ModelAndView-Objekt, welchem wir als View-namen redirect:/ übergeben. Durch das redirect: führt dies dazu, dass der Browser auf die /-Seite unserer Anwednung weiterleitet

9.4. Starten

Wir können die Anwendung wieder mit ./mvnw spring-boot:run starten.

Wenn man auch den neuer Artikel-Link klickt, wird man zum Login weitergeleitet.

Nach dem Login kommt man zu der Eingabemaske:

Eingabemaske

Gibt man dort etwas ein und bestätigt, erscheint auf der Hauptseite ein neuer Artikel.

neuer Artikel

10. Datenbank persistieren

Die Datenbank soll natürlich nicht bei jedem Start neu geladen werden, sondern mehrere Neustarts überdauern.

Außerdem wird die Datenbank aktuell aus einem SQL-Skript mit initialen Daten befüllt. Dafür muss man z.B. den genauen Aufbau der Datenbank kennen. Schöner wäre es, wenn man dies über Java-Code lösen könnte.

10.1. Service anpassen

Aktuell kann der UserService nur Nutzer laden, aber keine anlegen. Wir müssen also eine Methode zum Speichern der Nutzer hinzufügen:

UserService.java
import org.springframework.security.core.userdetails.UserDetailsService;

public interface UserService extends UserDetailsService {

    User createUser(String username, String password, String firstname, String lastname, String... roles); (1)

}
1 Dem Interface fügen wir dazu eine einfache Methode zum erstellen der Nutzer hinzu

Außerdem braucht der Nutzer noch eine Möglichkeit, diesem Rollen hinzuzufügen:

User.java
    public void addRole(String role) {
        if (roles == null) {
            this.roles = new ArrayList<>();(1)
        }

        this.roles.add(role);(2)
    }
1 Sollte der Nutzer noch keine Rollen haben, erstellen wir diese
2 Den Rollen fügen wir dann die neue Rolle hinzu
UserServiceImpl.java
    @Override
    public User createUser(final String username, final String password, final String firstname, final String lastname, final String... roles) {
        final User user = new User(username, firstname, lastname, password);(1)
        for (final String role : roles) {
            user.addRole(role);(2)
        }

        final User saved = userRepository.save(user); (3)
        return saved;
    }
1 Wir erstellen den neuen Nutzer mit den übergebenen Daten
2 Wir fügen alle Rollen hinzu
3 Und speichern den Nutzer und geben ihn zurück

10.2. Initialisierung der Datenbank

Die Initialisierung der Datenbank können wir jetzt in eine extra Klasse verschieben, und die data.sql löschen.

Dazu legen wir eine neue Klasse InitializeDatabase an. Diese ist eine InitializingBean, deren afterPropertiesSet beim Start der Anwendung ausgeführt wird.

InitializeDatabase.java
import gpse.example.domain.*;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class InitializeDatabase implements InitializingBean {

    private final UserService userService;

    private final BlogService blogService;

    @Autowired
    public InitializeDatabase(final UserService userService, final BlogService blogService) {
        this.userService = userService;
        this.blogService = blogService;
    }


    @Override
    public void afterPropertiesSet() {
        try {
            userService.loadUserByUsername("Uncle_Bob");(1)
        } catch (UsernameNotFoundException ex) {
            final User user = userService.createUser("Uncle_Bob",(2)
            "{bcrypt}$2a$10$WoG5Z4YN9Z37EWyNCkltyeFr6PtrSXSLMeFWOeDUwcanht5CIJgPa",
            "Bob", "Martin", "ROLE_USER");

            blogService.addArticle(user, "A magnificent Article", "Lorem ipsum dolor");
            blogService.addArticle(user, "Another Article", "sit amet, consetetur sadipscing elitr");(3)
        }
    }
}
1 Zuerst versuchen wir, den Nutzer zu laden. Existiert dieser, muss die Datenbank nicht weiter gefüllt werde
2 Existiert er nicht, legen wir den Nutzer neu an
3 Außerdem legen wir erneut die beiden Artikel an

Die data.sql muss gelöscht werden!

10.3. Speichern der Datenbank

Damit die Datenbank auch gespeichert wird, müssen wir einfach einen Pfad für diese angeben:

application.properties
(1)
spring.datasource.url=jdbc:h2:file:./data/blog

(2)
spring.jpa.hibernate.ddl-auto=update
1 Als Pfad nutzen wir einfach das Unterverzeichnis data, in diesem wird dann die Datenbank blog erstellt. Der Pfad gibt dabei an, dass es eine jdbc-Datenbank vom Typ h2 ist, die als Datei vorliegt
2 Zusätzlich setzen wir noch die ddl-auto-Property auf update. Dadurch wir die Datenbank beim Starten der Anwendung an das aktuelle Schema angepasst. Normalerweise steht dies auf create-drop, damit würde die Anwendung be jedem Starten neu erstellt und beim Beenden gelöscht (drop) werden

10.4. Starten

Wir können die Anwendung wieder starten.

Fügen wir Artikel hinzu, beenden die Anwendung und starten sie neu, sind die alten Artikel immer noch vorhanden

11. Form Validation

Momentan ist es möglich, das Formular zum Erstellen eines neuen Blogeintrags leer abzusenden. In diesem Fall wird auch tatsächlich ein leerer Eintrag erstellt und taucht danach auf der Startseite auf.

Schöner wäre es natürlich, wenn der Autor darauf hingewiesen würde, dass alle Formularfelder ausgefüllt sein müssen und ein unvollständig ausgefülltes Formular niemals in der Datenbank landen kann.

Hierfür wird Bean Validation verwendet. Dabei handelt es sich um einen Java EE-Standard, der z.B. vom Hibernate-Projekt implementiert wird.

11.1. Controller & Command-Object

11.1.1. Command-Object

Innerhalb von CreateArticleCmd kann man mit Annotationen angeben, welche Bedingungen die Felder erfüllen müssen. Dazu lassen sich z.B. die Klassen aus dem Package javax.validation.constraints nutzen:

CreateArticleCmd.java
    @NotBlank(1)
            String title;
    @NotBlank
    String text;
1 NotBlank gibt dabei z.B. an, dass der String nicht leer sein darf

11.1.2. Controller

Außerdem müssen wir den Controller noch anpassen. Dieser muss angeben, dass das Command-Objekt validiert werden soll, und muss auf das Ergebnis dieser entsprechend reagieren:

BlogController.java
    @PostMapping("/articles/add")
    public ModelAndView addArticle(@AuthenticationPrincipal final User user,
                                   @Valid final CreateArticleCmd createArticleCmd,(1)
                                   BindingResult bindingResult) {(2)

        if (bindingResult.hasErrors()) {(3)
            final ModelAndView modelAndView = new ModelAndView("article/add");(4)
            modelAndView.addObject("createArticleCmd", createArticleCmd);(5)
            return modelAndView;
        }

        blogService.addArticle(user, createArticleCmd.getTitle(), createArticleCmd.getText());

        return new ModelAndView("redirect:/");
    }
1 Das Command-Object muss mit @Valid annotiert werden
2 Das Ergebnis wird in einem BindingResult angelegt. Dieses muss als Parameter direkt auf das Command-Object folgen
3 Vor dem Anlegen eines Artikels können wir dann prüfen, ob dieses Fehler enthält
4 Tut es dies, geben wir die View zum Hinzufügen eines Artikels an
5 Und geben als Model das ausgefüllte Command-Object, welches die Fehler enthält, mit

11.2. View

In der View für das zu validierende Formular müssen nun noch ggf. anfallende Fehlermeldungen angezeigt werden.

add.html
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<body>
<form th:action="@{/articles/add}"
      th:object="${createArticleCmd}"
      method="post">
    <label for="title">Titel:</label>
    <input type="text" id="title" th:field="*{title}"/>

    <span th:if="${#fields.hasErrors('title')}" th:errors="*{title}">(1)
        Darf nicht leer sein!(2)
    </span>

    <br/>

    <label for="text">Text:</label>
    <textarea id="text" th:field="*{text}"></textarea>
    <span th:if="${#fields.hasErrors('text')}" th:errors="*{text}">Darf nicht leer sein!</span>


    <input type="submit" value="Speichern"/>
</form>

</body>

</html>
1 Wir prüfen, ob Fehler vorliegen. Wenn ja, geben wir mit th:errors alle Fehlermeldungen aus. Dabei geben wir jeweils das entsprechende Feld an
2 Dies dient dabei nur als Platzhalter, und wird später durch th:errors mit den echten Fehlermeldungen ersetzt

11.3. Starten

Wir können die Anwendung wieder starten.

Tätigt man nun fehlerhafte Eingaben, erhält man Fehlermeldungen und es wird kein neuer BlogPost erstellt.

neuer Artikel

12. Einzelne Artikel anzeigen

Bisher werden immer alle Artikel des Blogs auf einmal angezeigt. Um später aber Kommentare zu einzelnen Artikeln anzeigen zu können, sollten auch einzelne Artikel angezeigt werden können.

Damit das möglich ist, muss:

  • Der BlogService entsprechend anpassen, sodass einzelne Artikel abgefragt werden können.

  • Der Controller muss auf entsprechende Seitenaufrufe reagieren können

  • Die SecurityCofig muss diese Aufrufe zulassen

  • Eine passende View muss hinzugefügt werden

12.1. BlogService anpassen

Dem Interface fügen wir eine Methode zum Abfragen eines einzelnen Artikels anhand seiner ID hinzu:

BlogService.java
public interface BlogService {
    List<Article> getArticles();

    Article addArticle(User user, String title, String text);

    Article getArticle(String id);
}

Diese Methode implementieren wir dann entsprechend:

BlogServiceImpl.java
    @Override
    public Article getArticle(final String id) {
        final Long articleId = Long.valueOf(id);(1)

        return articleRepository.findById(articleId)(2)
            .orElseThrow(() -> new IllegalArgumentException("No Article with id " + articleId + " found!"));(3)
    }
1 Article haben als ID einen Long, den übergebenen String müssen wir also parsen
2 Mit der ID fragen wir dann den Artikel ab
3 Wird einer gefunden, geben wir diesen zurück, ansonsten werfen wir eine Exception

12.2. Controller anpassen

BlogController.java
    @RequestMapping("/articles/{id:\\d+}")(1)
    public ModelAndView showArticle(@PathVariable("id") final String id) {(2)
        final ModelAndView modelAndView = new ModelAndView("article/article");(3)

        final Article article = blogService.getArticle(id);(4)
        modelAndView.addObject("article", article);

        return modelAndView;
    }
1 Der Pfad, auf den die Methode reagieren soll, sieht auf den ersten Blick erst mal komisch auf. Der Teil innerhalb der geschweiften Klammern nennt sich Path-Variable und ist im Wesentlichen genau das - ein variabler Teil innerhalb des Pfades. Dieser bekommt einen Namen, id, und in diesem Fall zusätzlich einen regulären Audruck, der valide Belegungen angibt, in diesem Fall \d+, also mindestens eine Ziffer. Die Methode reagiert damit auf Pfade wie z.B. /articles/0 und /articles/123, aber nicht auf /articles/a oder /articles/0a
2 Die PathVariable können wir dann als Methoden-Parameter angeben. Dazu muss diese mit @PathVariable annotiert sein, der passende Name muss angegeben sein und sie muss einen passenden Typ haben
3 Als View geben wir "article/article" an, dieses referenziert damit auf src/main/resources/templates/article/article.html, welche wir gleich noch anlegen
4 Dann laden wir über den Service den entsprechenden Artikel und übergeben diesen dem Model

12.3. Security-Config anpassen

Bisher dürfen nicht-angemeldete Nutzer nur auf / zugreifen, der Zugriff auf Artikel sollte aber natürlich auch möglich sein.

Dazu müssen wir den Pfad der Security-Config hinzufügen:

SecurityConfig.java
    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        //@formatter:off
        http
            .authorizeRequests()
                .antMatchers("/", "/articles/{id:\\d+}/**")(1)
                    .permitAll() (2)
                .anyRequest().authenticated()
                .and()
            .formLogin()  (3)
                .permitAll()
                .and()
            .logout()  (4)
                .permitAll();
        //@formatter:on
    }
1 Wir geben dazu einfach den Pfad, auf den der Controller reagiert, als weiteren Pfad an, auf den alle zugreifen dürften

12.4. View erstellen

Als View für einzelne Artikel erstellen wir die article.html in src/main/resource/templates/article:

article.html
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<body>

<h2 th:text="${article.title}"></h2> (1)
<p th:text="${article.text}"></p>

<span th:text="${#temporals.format(article.getPublishedAt())}"></span> (2)

</body>

</html>
1 Wir lassen die Title und Text des Artikels wie auch auf der Hauptseite ausgeben
2 Außerdem zeigen wir den Zeitpunkt des Artikels an. Dazu nutzen wir das #temporals-Objekt, welches von Thymeleaf zum Formatieren von Daten angeboten wird. Darauf rufen wir die format-Methode auf, die ein Datum lesbar formatiert, und übergeben den Veröffentlichungszeitpunkt des Artikels

Was noch fehlt, ist ein Link zu den einzelnen Artikeln. Diesen fügen wir in blog.html hinzu:

blog.html
<div th:each="article:${articles}">
    <a th:href="@{articles/{article_id}(article_id=${article.id})}"> (1)
        <h2 th:text="${article.title}"></h2>
    </a>

    <p th:text="${article.text}"></p>
</div>
1 Die Überschrift umschließen wir dazu mit einem Anker, welcher als Ziel die Seite eines einzelnen Artikels hat. Für die URL nutzen wir die Thymeleaf-URL-Syntax. Für diese geben wir den Link mit Platzhaltern an (articles/{article_id}) und legen danach fest, wie diese gefüllt werden sollen (article_id=${article.id})

12.5. Starten

Wenn wir starten, sehen wir auf der Hauptseite Links zu den einzelnen Artikeln.

Öffnen wir damit einen, sehen wir den einzelnen Artikel zusammen mit dem Zeitpunkt.

neuer Artikel

13. Kommentare hinzufügen

Zu den einzelnen Artikel sollen sich jetzt noch Kommentare hinzufügen lassen.

Dazu brauchen wir:

  • Entsprechende Methoden im Service und eine kleine Anpassung an der Artikel-Klasse

  • Ein Command-Objekt zum anlegen der Kommentare

  • Passende Methoden im Controller

  • Eine Liste der Kommentare auf der Artikel-Seite

  • Und ein entsprechendes Formular auf der Artikel-Seite

13.1. Service anpassen

Dem Interface fügen wir eine entsprechende Methode zum Hinzufügen eines Kommentars hinzu:

BlogService.java
    Article addComment(String id, String comment);

Diese Methode implementieren wir dann entsprechend:

BlogServiceImpl.java
    @Override
    public Article addComment(final String articleId, final String comment) {
        final Article article = getArticle(articleId);(1)
        article.addComment(comment);(2)
        return articleRepository.save(article);(3)
    }
1 Wir laden den entsprechenden Artikel aus dem Repository
2 Fügen diesem den Kommentar hinzu
3 Und speichern ihn dann und geben ihn zurück

13.2. Artikel anpassen

Damit Kommentare sich so speichern lassen, müssen wir eine kleine Änderung an der Artikel-Klasse vornehmen:

Article.java
    @OneToMany(cascade = CascadeType.ALL) (1)
    private List<Comment> comments = new ArrayList<>();
1 Für die Beziehung geben wir zusätzlich cascade = CascadeType.ALL an. Damit werden beim Speichern eines Artikels auch alle diesem hinzugefügten Kommentare gespeichert, ohne das wir dies explizit selbst machen müssen

13.3. Command-Object

Als Command-Object legen wir AddCommentCmd mit einem einzigen Attribut text an:

AddCommentCmd.java
package gpse.example.web;

import javax.validation.constraints.NotBlank;

public class AddCommentCmd {

    @NotBlank
    private String text;

    public AddCommentCmd() {
    }

    public String getText() {
        return text;
    }

    public void setText(final String text) {
        this.text = text;
    }
}

13.4. Controller anpassen

Im Controller müssen wir eine kleine Anpassung vornehmen sowie eine Methode zum Hinzufügen der Kommentare anlegen.

Zuerst müssen wir beim Anzeigen der Kommentare auch das Command-Object der View hinzufügen:

BlogController.java
    @RequestMapping("/articles/{id:\\d+}")
    public ModelAndView showArticle(@PathVariable("id") String id) {
        final ModelAndView modelAndView = new ModelAndView("article/article");

        final Article article = blogService.getArticle(id);
        modelAndView.addObject("article", article);
        modelAndView.addObject("addCommentCmd", new AddCommentCmd());(1)

        return modelAndView;
    }
1 Wir legen dazu einfach ein neues Command-Object an und legen dieses in das Model

Außerdem legen wir eine neue Methode addComment an:

BlogController.java
    @PostMapping("/articles/{id:\\d+}/comments")(1)
    public ModelAndView addComment(@PathVariable("id") String id,
                                   @Valid final AddCommentCmd addCommentCmd,
                                   BindingResult bindingResult) {(2)

        if (!bindingResult.hasErrors()) {(3)
            final Article saved = blogService.addComment(id, addCommentCmd.getText());(4)
        }

        return new ModelAndView("redirect:/articles/" + id);(5)
    }
1 Diese soll nur auf POST-Request an den Unter-Pfad comments eines einzelnen Artikels reagieren
2 Als Parameter brauchen wir wieder die ID des Artikels und das validierte Command-Object
3 Wir prüfen, ob dieses keine fehlerhaften Felder enthält
4 Ist es in Ordnung, fügen wir den Kommentar hinzu, ansonsten machen wir nichts weiter
5 Danach leiten wir wieder auf die normale Artikel-Seite weiter

13.5. View anpassen

In der View müssen wir jetzt noch eine Liste alle Kommentare und ein Fomular zum Hinzufügen neuer Kommentare hinzufügen:

article.html
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<body>

<h2 th:text="${article.title}"></h2>
<p th:text="${article.text}"></p>

<span th:text="${#temporals.format(article.getPublishedAt())}"></span>


<ul>
    <li th:each="comment:${article.comments}" th:text="${comment.getText()}"></li>(1)
</ul>

<form th:method="POST"
      th:action="@{/articles/{article_id}/comments(article_id=${article.id})}"
      th:object="${addCommentCmd}">(2)

    <label for="text">Kommentar-Text:</label>(3)
    <textarea id="text" th:field="*{text}"></textarea>

    <input type="submit" value="Speichern"/>
</form>

</body>

</html>
1 Wir iterieren über alle Kommentare eines Artikels und lassen jeweils deren Text anzeigen
2 Die Form hat als action das Mapping des Controllers eingetragen. Außerdem referenzieren wir wieder das Command-Object
3 Die Form enthält dann nur ein einfaches Textfeld zur Eingabe des Kommentars

13.6. Starten

Wenn wie die Anwendung starten, und einen einzelnen Artikel (z.B. http://localhost:8080/articles/1) anzeigen lassen, können wir diesem Kommentare hinzufügen.

neuer Artikel

http://spring.io/guides

Offizielle Spring Tutorials

http://getbootstrap.com/css/

Übersicht über die CSS-Klassen in Bootstrap

https://github.com/spring-projects/spring-boot/tree/master/spring-boot-project/spring-boot-starters

Liste der spring-boot-starter Artefakte

http://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods

Spring-Data-JPA Query-Methods

http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api

Best-Practices für Rest-APIs

Bezieht sich hauptsächlich auf REST-APIs, die meisten Punkt lassen sich aber auch für reine HTML-Seiten umsetzen.

15. Weitere Hinweise

15.1. Grundlage für eigenes Projekt

In eurem Team-Repo findet ihr bereits ein Initiales Projekt.

Für dieses solltet ihr in der pom.xml den Namen und die Beschreibung anpassen.

15.2. Auto-Reload

Bei Änderungen an der Anwendung können diese automatisch neu geladen werden, ohne das neu gestartet werden muss.

Dazu muss in IntelliJ die entsprechende Option aktiviert sein, damit Änderung direkt kompiliert werden:

Einstellungen → Build, Execution, Deployment → Compiler: "Build Project automatically"

Außerdem muss das Projekt über das Terminal mit Maven gestartet werden, und nicht direkt über IntelliJ.

15.3. Autovervollständigung in templates

Für viele model-Attribute bietet IntelliJ IDEA innerhalb der Templates bereits Autovervollständigung. Für alle anderen, z.B. Listen von Objekten, kann man angeben, welcher Typ es ist. Für die View aus Schritt 4 sieht dies folgendermaßen aus:

<!--/*@thymesVar id="blogPosts" type="java.util.List<gpse.example.domain.BlogPost>;"*/-->
<tr th:each="post:${blogPosts}">
    ...
</tr>

IntelliJ IDEA "weiß" damit, das blogPosts vom Typ java.util.List<gpse.example.domain.BlogPost> ist, und kann dann sowohl für die Liste, als auch für Objekte aus der Liste (hier post) Autovervollständigung bieten.

15.4. PUT, PATCH & DELETE in HTML

Die http-Methoden PUT,PATCH,DELETE lassen sich in normalem HTML nicht verwenden, jedoch bietet Spring Boot Möglichkeiten, diese zusätzlich zu GET und POST zu nutzen.

Gibt man die form-Methode in Templates nicht mit method, sondern mit th:method an, wandelt Spring die Methoden in für den Browser passende um, in der Anwendung lassen sie sich aber als PUT,PATCH & DELETE verwenden.

15.5. Anbieten einer REST-API in Spring

In diesem Tutorial liefern die Methoden im Controller Objekte vom Typ ModelAndView zurück, die mithilfe eines Templates als HTML Seite gerendert werden um sie ansehen zu können.

Ihr könnt aber auch sehr einfach einen Controller erstellen der über JSON-Objekte kommuniziert, indem ihr den entsprechenden Controller mit @RestController annotiert.

Dies kann zum Beispiel hilfreich sein, wenn ihr dynamisch per JavaScript auf Daten eurer Anwendung zugreifen möchtet.