Beliebte Suchanfragen
|
//

Spring und Vue - Ein Setup für kleine Projekte (Teil 2)

17.1.2025 | 9 Minuten Lesezeit

Im ersten Teil haben wir ein Setup für die Kombination aus Spring Boot und Vue.js vorgestellt. Übrig bleibt die Aufgabe, zwei typsichere Sprachen - TypeScript im Browser, Java im Backend - auch typsicher und komfortabel über eine REST-API kommunizieren zu lassen. Am Beispiel zweier Views für eine Liste von Usern und Details für einen User stellen wir unsere Lösung vor. Wie im ersten Teil legen wir auch hier großen Wert auf Komfort für Entwickler.

Gemeinsame Basis: OpenAPI Specification

Verwendet man auf beiden Seiten einer Schnittstelle die gleiche Programmiersprache, reichen gemeinsame Transportobjekte für die Datendefinition aus. Für Operationen kann man dann noch, je nach Sprache, Funktionsdefinitionen oder Interfaces schreiben. Es fehlt dann nur noch eine Bibliothek für Serialisierung/Deserialisierung in das gewünschte Datenformat für den Transport, zum Beispiel JSON.
Bei verschiedenen Programmiersprachen benötigt man die Objekte und Operationen in beiden Sprachen. Schreibt man sie getrennt, besteht die Gefahr von Inkonsistenzen, abgesehen von der doppelten Arbeit. Mit OpenAPI (ehemals Swagger) existiert ein Standard, mit dem man REST-Schnittstellen sprachunabhängig formal spezifizieren kann.
Über den OpenAPI-Generator lassen sich Client- und Server-Stubs generieren. Für die Server-Seite haben wir das Spring-Plugin verwendet, das sich bequem per Maven-Plugin aufrufen lässt. Der generierte Code verwendet das Delegate-Pattern: Für alle Operationen generiert er Interfaces, die man in einer mit @Controller annotierten Klasse implementiert. So ist generierter Code von der manuell geschriebenen Implementierung getrennt. Eine inkompatible Änderung an der API führt zu Übersetzungsfehlern, sodass man sie im Code nicht übersehen kann.
Die API liegt bei uns in src/main/api/api.yaml, die generierten Java-Quelltexte für den Server in target/generated-sources/openapi/src/main/java. Beim Pfad für die api.yaml sind wir zuerst in eine Falle gelaufen: Beim Maven Aufruf von der Kommandozeile reichte es, ihn ab src/ zu spezifizieren, innerhalb der IDE musste es ${project.basedir}/src sein, ansonsten erhielten wir nur die unspezifische Fehlermeldung, dass die Generierung fehlgeschlagen sei.

1<plugin>
2    <groupId>org.openapitools</groupId>
3    <artifactId>openapi-generator-maven-plugin</artifactId>
4    <version>7.10.0</version>
5    <executions>
6        <execution>
7            <id>Java</id>
8            <goals>
9                <goal>generate</goal>
10            </goals>
11            <configuration>
12                <generatorName>spring</generatorName>
13                <inputSpec>${project.basedir}/src/main/api/api.yaml</inputSpec>
14                <configOptions>
15                    <delegatePattern>true</delegatePattern>
16                    <useJakartaEe>true</useJakartaEe>
17                    <apiPackage>de.codecentric.generated.api</apiPackage>
18                    <modelPackage>de.codecentric.generated.model</modelPackage>
19                    <configPackage>
20                        de.codecentric.generated.configuration
21                    </configPackage>
22                    <basePackage>de.codecentric.generated</basePackage>
23                </configOptions>
24            </configuration>
25        </execution>
26    </executions>
27</plugin>

Neben der Server-Implementierung in Java wird noch eine Client-Implementierung in TypeScript benötigt. In der Liste der OpenAPI-Generatoren haben wir auch einen Generator für TypeScript gefunden, dieser hat allerdings unseren Ansprüchen nicht genügt. Stattdessen haben wir OpenAPI-TS genutzt. Er existiert zwar nicht als Plugin, lässt sich aber problemlos über die Kommandozeile oder das exec-maven-plugin aufrufen. Ziel für die generierte Datei ist src/vueapp/src/generated/api.ts.

1npx openapi-typescript src/main/api/api.yaml --output src/vueapp/src/generated/api.ts

Der Code nutzt openapi-fetch, der Client ist damit - wie der Server - ebenfalls typsicher.
Die API hält sich an die REST-Prinzipien. Für den Pfad /users ist nur GET spezifiziert, das Ergebnis ist eine Liste von User-Objekten. In einem größeren Projekt könnte man hier noch etwas erweitern: Paging, kleinere Objekte für die Listendarstellung, Query-Parameter für eine Einschränkung der Ergebnismenge, etc.
Einen einzelnen User adressiert man über /users/{id}. Dort sind die Operationen, GET und PUT spezifiziert. DELETE haben wir uns gespart. Um die Datei api.yaml nicht grenzenlos anwachsen zu lassen, haben wir die Definition aller Objekte in getrennte Dateien ausgelagert und referenzieren sie nur. Ein User-Objekt mit den drei Required Properties id, firstName und lastName ist damit sehr übersichtlich.

Implementierung im Server

Wie schon oben erwähnt, muss der Controller für Objekte vom Typ User das Delegate-Interface implementieren. Für die drei in der OpenAPI spezifizierten HTTP-Verben (GET auf /users, GET/PUT auf /users/{id}) existiert im Interface jeweils eine Methode. Die Pfad-Variable {id} wird dabei zu einem Methoden-Parameter. In einem „richtigen“ Projekt würden die User vermutlich in einer Datenbank gespeichert, für die Demo haben wir uns auf eine HashMap beschränkt, die im Konstruktor mit ein paar Beispieldaten gefüllt wird.

1@Controller
2public class UserControllerImplementation implements UsersApiDelegate {
3
4    private final HashMap<String, User> usersById = new HashMap<>(Stream.of(
5            new User("1", "Max", "Musterman"),
6            new User("2", "Lisa", "Müller"),
7            new User("3", "John", "Doe"),
8            new User("4", "Jane", "Smith"),
9            new User("5", "Tom", "Brown"),
10            new User("6", "Emma", "Johnson"),
11            new User("7", "Oliver", "Williams"),
12            new User("8", "Sophia", "Davis"),
13            new User("9", "Liam", "Jones"),
14            new User("10", "Ava", "Garcia"),
15            new User("11", "Noah", "Martinez"),
16            new User("12", "Isabella", "Hernandez")
17    ).collect(Collectors.toMap(User::getId, Function.identity())));
18
19    @Override
20    public synchronized ResponseEntity<List<User>> usersGet() {
21        List<User> users = new ArrayList<User>(usersById.values());
22        return ResponseEntity.ok(users);
23    }
24
25    @Override
26    public synchronized ResponseEntity<User> usersIdGet(String id) {
27        User user = usersById.get(id);
28        if (user == null) {
29            throw new NotFoundException("User not found");
30        } else {
31            return ResponseEntity.ok(user);
32        }
33    }
34
35    @Override
36    public synchronized ResponseEntity<User> usersIdPut(String id, User user) {
37        usersById.put(id, user);
38        return ResponseEntity.ok(user);
39    }
40
41}

Die Rückgabe der 404-Antwort für einen nicht gefunden User hat etwas Probleme bereitet: In OpenAPI lassen sich Rückgabeobjekte pro HTTP Response-Code spezifizieren, hier User für einen gefundenen (200) und Error für einen nicht gefundenen (404). Im generierten Java-Code ist der Rückgabetyp jedoch ResponseEntity<User>. Besser wäre ResponseEntity<User|Error>, was Java jedoch so nicht unterstützt. Eine Lösung ist ResponseEntity.notFound() zu nutzen. Das führt zu einem 404 Response-Code, aber mit einem leeren Body. Wir haben eine andere Variante gewählt: Eine eigene Exception, die mit @ResponseStatus(HttpStatus.NOT_FOUND) annotiert ist. Damit wird sowohl der korrekte Response-Code als auch ein Body zurückgegeben. In der IDE mit Stacktrace, in der Produktionsversion ohne Stacktrace. Wer möchte, kann dies durchaus weiter anpassen, siehe Spring Exceptions.

Implementierung im Client

Um den Umgang mit mehreren Routen zu automatisieren, nutzen nicht wir den vue-router sondern den unplugin-vue-router. Dieser ist ein Wrapper um den normalen vue-router mit zusätzlichen Features, wie z.B. die automatische Registrierung von Routen, Typed Routen und Data Loaders. Details dazu stehen in der Dokumentation des Plugins und im zugehörigen GitHub Repository. Hier die Kurzfassung: Zunächst laden wir das Plugin in der vite.config.ts. Dann verschieben wir die alten Views (HomeView und AboutView) in das pages Verzeichnis und benennen sie entsprechend um in index.ts und about.ts. Beim nächsten Aufruf von npm run dev wird dann eine typed-router.d.ts Datei erzeugt. In unserer alten Router Definition können wir nun die manuelle Routen Definition gegen die automatisch generierten Routen austauschen.

1const router = createRouter({
2    history: createWebHistory(import.meta.env.BASE_URL),
3    routes
4})
5
6if (import.meta.hot) {
7    handleHotUpdate(router)
8}
9
10export default router

Um openapi-fetch besser nutzen zu können, erstellen wir ein neues Composable, in dem wir einen Fetch Client instanziieren. Dabei können wir einfacher die URL unseres Backends angeben, in unserem Beispiel reicht dazu auch ein relativer Pfad: /api.

1export default () => {
2    // declare fetcher for paths
3    return createClient<paths>({
4        baseUrl: '/api',
5    })
6}

Mit dem gerade erstellten Fetch Client können wir jetzt ein weiteres Composable erstellen, das alle Operationen rund um Benutzer zusammenfasst. Dadurch muss nur an einer Stelle der Fetch Client direkt aufgerufen werden und an allen anderen Stellen kann eine einfachere Methode aufgerufen werden, was auch ein mögliches Refactoring einfacher machen würde.
In der fetchUser Methode rufen wir die url /api/users/{id} auf, da wir aber einen BasePath im Client angegeben haben, müssen wir hier nur den letzten Teil /users/{id} angeben. Die ID wird dann als zweiter Parameter übergeben.

1const fetchUser = async (id: string) => {
2    return client.GET('/users/{id}', {
3        params: {
4            path: {id}
5        }
6    })
7}

Die putUser Methode ist ähnlich aufgebaut, erhält aber zusätzlich zur ID auch noch ein User Objekt, das dann ebenfalls im Request mitgeschickt wird.

1const putUser = async (id: string, user: UserRequest) => {
2    return client.PUT('/users/{id}', {
3        body: user,
4        params: {
5            path: {
6                id
7            }
8        }
9    })
10}

Diese beiden Methoden werden dann zusammengefasst in einem Objekt, welches von der Composable Methode zurückgegeben wird.

1export const useUsers = () => {
2    const client = useApiClient()
3
4    const fetchUser = async (id: string) => {...}
5
6    const putUser = async (id: string, user: UserRequest) => {...}
7
8    return { fetchUser, putUser }
9}

Vue Components und Dataloaders

Als Nächstes legen wir eine neue Vue Component für die User Detailseite unter pages/users/[id].vue an. In dieser definieren wir zwei Script Tags, einen normalen und einen für Setup. Im normalen erstellen und exportieren wir einen Dataloader mit defineBasicLoader; als Namen übergeben wir den Pfad der Seite. Als zweites übergeben wir eine Methode, in der wir die fetchUser Methode aus unserem soeben erstellen Composable aufrufen. Dann schauen wir, ob der Request fehlgeschlagen ist, ist das der Fall und wir haben einen 404 Statuscode zurückbekommen, geben wir ein NavigationResult zurück. Das führt dazu, dass automatisch die Übersichtsseite geladen wird, wenn versucht wird, einen nicht existenten Benutzer anzuzeigen.
Würden wir nun eine URL für einen nicht existenten Benutzer aufrufen, würden wir sehen, dass diese Weiterleitung nicht funktioniert. Das liegt daran, dass die openapi-fetch Library erwartet, dass auch bei einem Response mit 4xx Statuscode ein Body vorhanden ist. Nur dann wird eine Fehlermeldung erstellt und zurückgegeben. Spätestens hier sollte klar werden, warum wir uns beim Spring Fehlerhandling so viel Mühe gemacht haben.

1export const useUserData = defineBasicLoader('/users/[id]', async (to) => {
2    return fetchUser(to.params.id).then(res => {
3        console.log(res)
4        if (res.error) {
5            console.log(res)
6            if (res.response.status === 404) {
7                return new NavigationResult("/users")
8            }
9            throw new Error(res.error.message)
10        } else {
11            return res.data!
12        }
13    })
14})

Dieser Dataloader wird dann, und dafür muss er exportiert werden, von unplugin-vue-router automatisch für diese Seite registriert und aufgerufen, sobald die Seite geladen wird oder sich etwas an der URL geändert hat.
Im Setup-Teil kann die Dataloader-Funktion dann aufgerufen werden. Man erhält dann einen Ref, der entweder die geladenen Daten oder undefined enthält, bis die Daten geladen wurden. Die Daten können dann im Template-Teil der Component genutzt werden.
Um die Benutzerdaten im Backend zu aktualisieren, legen wir eine submit Methode an, in der wir die putUser Methode aus dem User-Composable aufrufen. Hier übergeben wir das Datenobjekt, das wir vom Dataloader bekommen haben und das wir über ein Formular mit Inputs aktualisieren können.

1const submit = () => {
2  putUser(data.value.id, data.value).then((res) => {
3    reload()
4  })
5}

Denkbare Projektstruktur für größere Projekte

Der in diesem Artikel vorgestellte Projektaufbau funktioniert wunderbar für kleine Projekte. Viele Nice-To-Have Funktionalitäten wie Hot-Code-Reload funktionieren für Vue und Spring und am Ende purzelt aus dem Build Prozess eine jar Datei hinaus, die direkt ausführbar ist und sowohl das Backend als auch das Frontend beinhaltet.
In der Demo haben wir viele Dinge weggelassen, die in einem realen Projekt jedoch notwendig sind:

  • HTTPS / SSL Konfiguration
  • Security (zumindest für den Pfad /api)
  • Datenbank statt HashMap für die Persistenz

Für diese Themen liefert das Internet reichlich Dokumentation und Beispiele, daher haben wir sie hier ignoriert.
Der Ansatz, alles in einem gemeinsamen Projekt (und damit Dateibaum) zusammenzufassen, skaliert nicht mehr, wenn die Komplexität (Anzahl Seiten) steigt. Dann ergibt es Sinn, den Code auf mehrere Repositories aufzuteilen. Jeweils eigene Repositories für Backend, Frontend und API Definition. Aus der API Definition baut dann eine CI/CD Pipeline automatisch ein Java/Maven Package welches das Spring Grundgerüst enthält und ein npm Package, das die Typen für den Fetch Client beinhaltet. Die können dann einfach in den beiden anderen Teilprojekte eingebunden werden und über Prozesse wie den Renovate-Bot aktuell gehalten werden. Backend und Frontend können dann auch automatisch gebaut werden und z.B. in einem Kubernetes-Cluster deployt werden. Man könnte sie aber auch manuell deployen und dann hinter einem Reverse-Proxy haben. In beiden Fällen muss dann aber CORS korrekt konfiguriert sein, wenn das Backend auf einer anderen Domain (auch einer anderen Subdomain) läuft.

Das gesamte Projekt könnt ihr in unserem GitHub Repository finden.

|

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.