Wir sind momentan daran einen Piloten für den zukünftigen, standardisierten AV-Webservice umzusetzen. Das wird sowas ähnliches wie der ÖREB-DATA-Extract. D.h. es gibt einen XML-Auszug und einen PDF-Auszug, der sich direkt und vollständig aus der XML-Datei ableiten lässt. Der AV-Webservice liefert Information aus der amtlichen Vermessung pro Grundstück (und nicht ganze Gemeinden). Wie beim ÖREB-Kataster lassen wir den Service durch eine externe Firma entwickeln. Von uns kommt «nur» noch die Bibliothek, die für die Umwandlung der XML-Datei in die PDF-Datei verantwortlich ist. Das machen wir state-of-the-art mit XSLT und XSL-FO.

Wir haben abgemacht, dass zuerst der Webservice mit XML-only-Output entwickelt wird und ich anschliessend die PDF-Umwandlung mache. Der hauptsächliche Grund dafür ist, dass ich mir so viele verschiedene XML-Beispiele einfach generieren lassen kann. Die Anwendung wird mit Spring Boot entwickelt. Was das Ganze interessant macht, ist unsere unterschiedlich Ansicht, wie man die Spring Boot Anwendung publiziert. Ich bin der Meinung, dass man die Fat Jar Datei publiziert. In dieser Jar-Datei ist die gesamte Anwendung verpackt: Business-Logik, Spring Boot Framework und Applikationsserver (z.B. Apache Tomcat). Der Lieferant publiziert aber lieber nur die reine (von ihm programmierte) Business-Logik in einer Thin Jar Datei. Im Gegensatz zur circa 50 MB grossen Fat Jar Datei, ist diese nur 132 KB gross. Im Kern mag ich diesen Thin Jar Ansatz enorm und finde ihn super sexy: Wenn man an die grossen und umfassenden JavaEE-/JakartaEE-Anwendungsserver denkt, ist dieser Ansatz ziemlich genial. Weil diese Anwendungsserver als Runtime bereits sehr viel bieten (Datenbank-Kommunikation, verschiedene Frontend-Möglichkeiten, …​), bleibt am Schluss wirklich nur ein kleiner Artefakt übrig, den man publizieren und auch deployen muss. Das ist sogar in Zeiten von Docker noch sehr aktuell. Kleinere, sich veränderte Artefakte, bedeuten schnellere Deployments etc. Nun ist es aber m.E. in Spring Boot so, dass man genau diesen Ansatz nicht verfolgt hat, als man Spring Boot «erfunden» hat. Man wollte eine sich selbst genügende Jar-Datei mit allen Abhängigkeiten inkl. Applikationsserver.

Mein Problem war nun, dass ich aus dieser Thin Jar Datei irgendwie eine lauffähige Spring Boot Anwendung machen musste, damit ich mit ihr meine XML-Beispiele für die PDF-Konversion herstellen konnte. Dieses Unterfangen stellte sich als sehr einfach mit JBang heraus. Ich muss nur eine einzige Klasse erstellen und die Thin Jar als Abhängigkeit definieren und schon funktionierte es:

///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 21
//REPOS central
//REPOS guru=https://jars.interlis.guru/snapshots
//REPOS umleditor=https://jars.umleditor.org/
//REPOS interlis=https://jars.interlis.ch/
//DEPS ch.ehi.avwebservice:av-web-service:1.0.0-SNAPSHOT

import org.springframework.boot.SpringApplication;

public class avws {
    public static void main(String[] args) throws Exception {
        String mainClass = System.getProperty(
            "mainClass",
            "ch.ehi.av.webservice.Application"
        );

        SpringApplication.run(Class.forName(mainClass), args);
    }
}

Das funktioniert, weil die pom.xml-Datei des AV-Webservices vollständig alle Dependencies auflistet (inkl. Spring Boot) und JBang für das Dependency Management Maven verwendet.

Die Spring Boot Anwendung lässt sich wie folgt starten:

jbang run avws.java --server.port=8080 \
  --spring.datasource.url=jdbc:postgresql://localhost:54321/edit \
  --spring.datasource.username=ddluser \
  --spring.datasource.password=ddluser \
  --spring.datasource.driver-class-name=org.postgresql.Driver \
  --logging.level.ch.ehi.av.webservice=DEBUG \
  --logging.level.org.springframework=INFO \
  --logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG \
  --avws.dbschema=stage \
  --avws.tmpdir=/tmp \
  --avws.cadastreAuthorityUrl=https://agi.so.ch \
  --avws.webAppUrl="https://geo.so.ch/map/?oereb_egrid=" \
  --avws.subUnitOfLandRegisterDesignation=GB-Gemeinde \
  --avws.planForMainPage="https://geodienste.ch/db/av_situationsplan_0/deu?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&LAYERS=daten&STYLES=&SRS=EPSG%3A2056&CRS=EPSG%3A2056&TILED=false&MAP_RESOLUTION=100&DPI=96&OPACITIES=255&t=675&WIDTH=1920&HEIGHT=710&BBOX=2607051.2375,1228517.0374999999,2608067.2375,1228892.7458333333" \
  --avws.planForLandDescription="https://geodienste.ch/db/av_situationsplan_0/deu?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&LAYERS=daten&STYLES=&SRS=EPSG%3A2056&CRS=EPSG%3A2056&TILED=false&MAP_RESOLUTION=100&DPI=96&OPACITIES=255&t=675&WIDTH=1920&HEIGHT=710&BBOX=2607051.2375,1228517.0374999999,2608067.2375,1228892.7458333333" \
  --avws.planForProjectedObjects="https://geodienste.ch/db/av_situationsplan_0/deu?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&LAYERS=daten&STYLES=&SRS=EPSG%3A2056&CRS=EPSG%3A2056&TILED=false&MAP_RESOLUTION=100&DPI=96&OPACITIES=255&t=675&WIDTH=1920&HEIGHT=710&BBOX=2607051.2375,1228517.0374999999,2608067.2375,1228892.7458333333"

Letzten Endes läuft das Teil bei uns in einem Dockercontainer und da wollte ich nicht zwingend noch JBang als Abhängigkeit drin haben. D.h. ich muss aus der Thin Jar Datei eine lauffähige Fat Jar Spring Boot Anwendung machen. JBang kennt einen export-Befehl mit verschiedenen Optionen, z.B. fatjar. Folgender Befehl macht aus meiner JBang-Anwendung mit 20 Zeilen eine fixfertige Spring Boot App:

jbang export fatjar --output avws-all.jar avws.java

Einen kleinen Nachteil hat diese Fat Jar Datei. Intern ist sie nicht «gelayered». Spring Boot macht beim Herstellen der Fat Jar verschiedene Layer damit beim Herstellen des Dockerimages besser gecached werden kann und nicht immer die ganzen circa 50MB neu kopiert etc. werden müssen (siehe oben).

Nachdem ich meine pdf4av-Library erstmalig publiziert habe, konnte sie in den AV-Webservice als Abhängigkeit integriert werden. Im build.gradle des AV-Webservices ist die Abhängigkeit mit der Version 0.0.1+ definiert. Das führt leider zum Problem, dass JBang diese beim Exportieren nicht findet. Diese Plus-Syntax scheint etwas von Gradle zu sein, das Maven nicht versteht. Aber - Alhamdulillah - hat JBang auch für das eine Lösung: Man kann Dependencies überschreiben:

jbang export fatjar --repos "https://jars.interlis.guru/" --deps "ch.so.agi:pdf4av:0.0.1-SNAPSHOT" --output avws-all.jar avws.java

JBang kennt nicht nur einen fatjar-Export, sondern kann auch ein Gradle Build-Skript erstellen:

jbang export gradle --group ch.so.agi --artifact av-web-service --version 0.0.1-SNAPSHOT --repos https://jars.interlis.guru --deps ch.so.agi:pdf4av:0.0.1-SNAPSHOT avws.java

Wenn wir ein Gradle-Projekt haben, haben wir noch mehr Möglichkeiten einzugreifen und auch die Möglichtkeit eine layered Fat Jar Datei zu erstellen. Das von JBang erstellte build.gradle-File ist jedoch nicht ganz perfekt, z.B. fehlt das Spring Boot Plugin:

plugins {
    id 'java'
    id 'application'
    id 'org.springframework.boot' version '3.5.9'
}

repositories {
    mavenCentral()
    maven {
        url = uri('https://jars.interlis.guru/')
    }
    maven {
        url = uri('https://jars.umleditor.org/')
    }
    maven {
        url = uri('https://jars.interlis.ch/')
    }
    mavenLocal()
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation platform('org.springframework.boot:spring-boot-dependencies:3.5.9')
    implementation('ch.ehi.avwebservice:av-web-service:1.0.0-SNAPSHOT') {
        exclude group: 'ch.so.agi', module: 'pdf4av'
    }
    implementation 'ch.so.agi:pdf4av:0.0.1-SNAPSHOT'
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

application {
    mainClass = 'avws'
    applicationDefaultJvmArgs = []
}

compileJava {
    options.compilerArgs += ['-g', '-parameters']
}

tasks.named('bootJar') {
    mainClass = 'avws'
}

tasks.named('jar') {
    enabled = false
}

Weil ein einfacher Client nur mehr oder weniger einen guten Prompt entfernt ist, habe ich mit Kimi-2.6 und OpenCode (via Infomaniak) ein kleines Frontend gemäss Ideen der Grundstückinformations-Arbeitsgruppe machen lassen. Damit ich diese JavaScript-Anwendung nicht noch separat deployen muss, dachte ich mir: Warum kopiere ich diese nicht in die Spring Boot Applikation als statische Ressource?

tasks.register('buildFrontend', Exec) {
    workingDir = file('frontend')
    commandLine 'npm', 'run', 'build'
}

tasks.register('copyFrontend', Copy) {
    dependsOn 'buildFrontend'

    from 'frontend/dist'
    into layout.buildDirectory.dir('resources/main/static/app')
}

tasks.named('processResources') {
    dependsOn 'copyFrontend'
}

Damit sich Spring Boot und das Frontend nicht in die Quere kommen, müssen wir a) das Frontend in einen Pfad kopieren, wo wir garantiert keinen gleichnamigen Kontroller haben und b) dies auch noch im Code definieren:

import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

public class avws {
    public static void main(String[] args) throws Exception {
        String mainClass = System.getProperty(
                "mainClass",
                "ch.ehi.av.webservice.Application"
        );

        SpringApplication.run(
                new Class<?>[] {
                        Class.forName(mainClass),
                        SpaConfig.class
                },
                args
        );
    }

    @Configuration
    static class SpaConfig {

        @Controller
        static class SpaForwardController {

            @GetMapping({
                    "/app",
                    "/app/"
            })
            public String appRoot() {
                return "forward:/app/index.html";
            }

            @GetMapping({
                    "/app/{path:^(?!assets$|config$|favicon\\.ico$|vite\\.svg$)[^\\.]*$}",
                    "/app/{path:^(?!assets$|config$)[^\\.]*$}/**"
            })
            public String appRoute() {
                return "forward:/app/index.html";
            }
        }
    }
}

Zusätzlich muss man (leider) dem Frontend noch mitteilen, dass es hinter einem Pfad installiert wurde, indem man in vite.config.ts die Base-Url definiert:

import { defineConfig } from 'vite';

export default defineConfig({
  base: "/app/",
  define: {
    'import.meta.env.VITE_MOCK_XML_BASE_PATH': JSON.stringify('/mock-data'),
  },
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['src/**/*.test.ts'],
    setupFiles: ['src/test/setup.ts'],
  },
});

Voilà: Ich kann mir mit ./gradlew build eine «all inklusive» Jar-Datei erstellen inkl. AV-Webservice und Frontend. Gestartet wird mit java -jar avws.jar <viele Optionen>:

Frontend
Frontend

Links: