1. Verborgene Annahmen, die jar nicht mehr verzeiht
Wenn Sie bereits ein Jar erstellt haben und gelernt haben, ihm Profil, Port und externe Konfiguration zu übergeben, beginnt eine andere Art von Problemen: Der Code ist derselbe, aber das Verhalten zwischen IDE und java-jar weicht ab. In diesem Moment möchte man zwei Schlüsse ziehen. Erster – „Spring ist magisch und kapriziös“. Zweiter – „Mein Laptop ist verflucht“. Aber in Wirklichkeit ist meist ein dritter Beteiligter schuld: eine verborgene Annahme, die in der IDE unbemerkt wahr war und im gepackten Lauf plötzlich nicht mehr wahr ist.
Mit „versteckter Annahme“ meine ich Dinge wie „das aktuelle Arbeitsverzeichnis ist das Projektwurzelverzeichnis“, „das Profil local ist immer aktiviert“, „Ressourcen sind als normale Dateien in src/main/resources verfügbar“. Die IDE spielt Ihnen manchmal zu: sie speichert Startargumente, setzt das Arbeitsverzeichnis, mischt Umgebungsvariablen ein. Ein Jar kennt nur das Artefakt und die Parameter, die Sie ihm explizit geben.
Verschiedene Startmodi haben wir bereits früher besprochen. Hier ist der nächste Schritt wichtig: zu verstehen, warum der gepackte Lauf vom IDE-Lauf abweicht und wie man das ohne Schamanismus diagnostiziert.
Der gepackte Lauf ändert nicht die Geschäftslogik von catalog-service, aber er ändert die Umgebung, in der der Dienst startet. Daher interessiert uns im Folgenden nicht die wiederholte Taxonomie der Startmodi, sondern konkrete Quellen der Abweichung: die Form des Classpath, der Zugriff auf Ressourcen, das Arbeitsverzeichnis und die tatsächlich übergebenen Profile mit Overrides.
Lassen Sie uns dies nach technischen Gründen aufschlüsseln, nicht nach Emotionen. Die Emotionen kommen später, beim Kaffee.
2. Exploded vs. jar: Warum ändert sich die Dateirealität?
In der IDE und bei bootRun startet die Anwendung normalerweise in der sogenannten entpackten Form (exploded form). Das bedeutet, dass Klassen und Ressourcen als separate Dateien im Dateisystem existieren: IDE/Gradle baut den Classpath aus Ordnern wie build/classes/java/main und build/resources/main zusammen und bindet Abhängigkeiten als separate Jars aus dem Gradle-Cache ein.
Bei java-jar ist das Bild anders: Sie haben eine Datei – catalog-service-...jar. Darin liegen Ihre Klassen und Ressourcen, und die Abhängigkeiten sind in denselben Jar (als verschachtelte Jars) verpackt. Der Boot-Launcher hebt dies so an, dass es für die JVM wie ein normaler Classpath aussieht, aber die „Dateirealität“ stimmt nicht mehr mit der Projektbaumstruktur überein.
Stellen Sie es sich am besten als zwei verschiedene Verpackungen derselben „Ware“ vor:
Exploded (IDE / bootRun) Packaged (java -jar)
---------------------------------- ----------------------------------
project/ catalog-service.jar
├─ build/
│ ├─ classes/java/main/... ├─ BOOT-INF/classes/...
│ └─ resources/main/... ├─ BOOT-INF/lib/*.jar
└─ (und Abhängigkeiten separat) └─ (Launcher innen)
Daraus ergeben sich fast alle Symptome. Wichtig ist, zwei Realitäten zu trennen: das, was innerhalb des Artefakts reist, wird als Classpath-Ressource gelesen; das, was außerhalb liegt, wird über das Dateisystem gesucht und hängt vom Arbeitsverzeichnis ab.
3. src/main/resources und Lesen über Path
Der häufigste und schmerzhafteste Fehler ist der Zugriff auf Ressourcen, als ob die Anwendung immer als Projekt existiert. Etwa wie jemand, der von einer Wohnung in einen Koffer umzieht, aber weiterhin den Kühlschrank „hinter der zweiten Tür rechts“ sucht. In einem Jar gibt es kein Verzeichnis src/main/resources. Es gab es in der Entwicklung. Im Artefakt ist es zu einer Classpath-Ressource geworden.
Sehen Sie sich diesen Code an:
import java.nio.file.Files;
import java.nio.file.Path;
// Schlechte Idee: relativer Pfad + Erwartung, dass es src/main/resources zur Laufzeit gibt
Path path = Path.of("src/main/resources/catalog-data.yaml");
// In der IDE kann das funktionieren (Arbeitsverzeichnis = Projektwurzel), im Jar fast sicher nicht
String text = Files.readString(path);
In der IDE kann das „zufällig“ funktionieren, weil das Arbeitsverzeichnis oft der Projektwurzel entspricht und die Datei tatsächlich auf der Festplatte liegt. Bei java-jar wird daraus ein Problem: Ein Jar ist kein Ordner src/, und innerhalb des Jars liegt die Datei nicht unter diesem Pfad. Sie erhalten etwas wie NoSuchFileException, und das ist ärgerlich, weil „die Datei doch definitiv da ist“.
Wenn Sie eine Ressource lesen müssen, die innerhalb der Anwendung verpackt ist, benötigen Sie den Ansatz „über den Classpath“. In der Spring-Welt bedeutet das normalerweise Resource / ClassPathResource (oder ResourceLoader), in reinem Java ClassLoader.getResourceAsStream(...).
4. Classpath-Ressourcen und Resource
Um die Vorlesung nicht in einen „Kurs über Ressourcen“ zu verwandeln, reicht ein gesundes mentales Modell: Wenn eine Datei innerhalb Ihrer Anwendung reisen soll, müssen Sie sie als Classpath-Ressource betrachten, nicht als „Datei unter einem Pfad“.
Der einfachste Weg in Spring ist ClassPathResource:
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.ClassPathResource;
// Ressource genau im Classpath suchen (so in IDE und im Jar verfügbar)
var resource = new ClassPathResource("catalog-data.yaml");
try (var in = resource.getInputStream()) {
// Bytes der Ressource aus dem Classpath lesen, nicht aus dem Projekt-Dateisystem
String text = new String(in.readAllBytes(), StandardCharsets.UTF_8);
// Diagnoseausgabe: sicherstellen, dass überhaupt etwas gelesen wurde
System.out.println(text.substring(0, Math.min(60, text.length()))); // erste Zeichen
}
Ja, das wirkt etwas „framework-lastiger“ als Files.readString(Path.of(...)). Dafür funktioniert es sowohl im entpackten als auch im gepackten Modus gleich, weil Spring versteht, wie man eine Ressource aus dem Classpath holt, selbst wenn sie in einem Jar liegt.
In unserem catalog-service müssen Sie catalog-data.yaml gar nicht manuell lesen (wir haben Boot bereits beigebracht, die Konfiguration über spring.config.import zu importieren). Aber zur Diagnose ist es nützlich, überprüfen zu können: „Ist die Ressource überhaupt in das Jar gelangt?“. Und hier ist es praktisch, einen kleinen Diagnose-Log in den bereits existierenden StartupSummaryRunner einzubauen.
Beispiel (Ausschnitt, 8–9 Zeilen Logik, um nicht aufzuschwellen):
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
@Component
public class StartupSummaryRunner {
private static final Logger log = LoggerFactory.getLogger(StartupSummaryRunner.class);
public void logClasspathHints() {
// Kurze Prüfung: Existiert die Ressource im Classpath im aktuellen Startmodus?
boolean exists = new ClassPathResource("catalog-data.yaml").exists();
// Falls false im Jar – dann ist die Datei nicht im Artefakt oder liegt unter einem anderen Namen/Pfad
log.info("classpath:catalog-data.yaml exists = {}", exists);
}
}
Der Clou hier ist nicht, die Prüfungen für immer ins Projekt zu packen. Der Clou liegt in der Denkweise: In der gepackten Welt ist eine Ressource entweder im Classpath oder nicht vorhanden. „Pfade in src/“ leben dort nicht.
Und ja, Sie können das Jar auch von außen ohne Java-Code „befühlen“:
jar tf build/libs/catalog-service-0.0.1-SNAPSHOT.jar | grep catalog-data.yaml
Wenn grep nichts findet – dann ist die Ressource wirklich nicht im Artefakt. Das ist keine Philosophie mehr, sondern rechtsmedizinische Untersuchung.
5. Externe Dateien und das Arbeitsverzeichnis
Das Arbeitsverzeichnis haben wir bereits angerissen, als wir externe Konfigurationen besprochen haben. Hier ist es kein neues Thema, sondern eine der ersten Diagnosehypothesen: Wenn eine Datei außerhalb des Jars liegen soll, wird der relative Pfad relativ zum aktuellen Prozessverzeichnis aufgelöst.
Warum ist das wichtig? Weil jeder relative Pfad wie ./config/ oder files/catalog-extra.yaml relativ zum aktuellen Verzeichnis aufgelöst wird, nicht „relativ zum Jar“.
Ein gutes Beispiel aus unserem Thema – die externe Konfiguration:
# Wichtig: ./config/ wird relativ zum aktuellen Verzeichnis (Arbeitsverzeichnis) gesucht,
# nicht "neben dem Jar" und nicht "im Repository-Root"
java -jar build/libs/catalog-service-0.0.1-SNAPSHOT.jar \
--spring.config.additional-location=optional:file:./config/
Das funktioniert nur, wenn ./config/ relativ zu dem Ort existiert, von dem aus Sie den Befehl ausführen. Wenn Sie in build/libs gehen und von dort starten, bedeutet ./config/ build/libs/config/, nicht <Repo-Root>/config/.
Um solche Fälle zu diagnostizieren, ist es manchmal hilfreich, user.dir explizit auszugeben – das ist eine Java-Systemeigenschaft, die normalerweise dem Arbeitsverzeichnis entspricht:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ProcessHints {
private static final Logger log = LoggerFactory.getLogger(ProcessHints.class);
public static void logWorkingDirectory() {
// user.dir zeigt fast immer das aktuelle Prozessverzeichnis (Arbeitsverzeichnis)
log.info("user.dir = {}", System.getProperty("user.dir"));
}
}
Wenn Sie in den Logs sehen, dass user.dir nicht das ist, was Sie erwartet haben, fluchen Sie nicht auf Boot. Sie müssen entweder aus dem richtigen Verzeichnis starten oder einen absoluten Pfad für die externen Konfigurationsorte übergeben (das ist langweilig, funktioniert aber überall).
6. Profile und Startparameter als Diagnosehypothese
Bei Profilen und Startparametern ist die Geschichte dieselbe: Wir können sie bereits übergeben, aber in der Diagnose ist es wichtig zu bedenken, dass ein Jar oft nicht abstürzt, sondern einfach in einem anderen Modus startet. Und das ist noch schlimmer als ein sofortiger Fehler: Die Anwendung lebt scheinbar, verhält sich aber nicht wie erwartet.
Das Problem ist hier tückisch: In der IDE haben Sie etwas wie „Active profiles: local“ und „Umgebungsvariablen: APP_CATALOG_TITLE=...“ eingerichtet, aber bei java-jar haben Sie das nicht übergeben. Infolgedessen startet der Dienst, aber mit einem anderen Profil, einem anderen Port, einem anderen Actuator-Exposure, anderen app.catalog.*.
Daher ist es im catalog-service schon lange eine gute Idee, einen StartupSummaryRunner zu haben, der zumindest grundlegende Dinge loggt: aktive Profile, Port, einige wichtige Einstellungen. Ich würde in dieser Vorlesung noch eine kleine Gewohnheit hinzufügen: Loggen Sie immer die aktiven Profile, wenn Sie Unterschiede zwischen den Laufmodi debuggen.
Mini-Ausschnitt (Logik in 6–7 Zeilen):
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.env.Environment;
public class ProfileHints {
private static final Logger log = LoggerFactory.getLogger(ProfileHints.class);
public static void logProfiles(Environment env) {
// Eine leere Profilliste ist auch ein Signal: Jar ist "standardmäßig" gestartet
String joined = String.join(",", env.getActiveProfiles());
// So ist es einfacher, IDE-Lauf und Jar-Lauf in den Logs zu vergleichen
log.info("activeProfiles = [{}]", joined);
}
}
Ja, die Actuator-Endpunkte env und configprops helfen auch, und wir haben sie bereits studiert. Aber zum Actuator muss man erst gelangen. Profile und user.dir sind zwei Lämpchen, die es sinnvoll ist, gleich einzuschalten, wenn „das Jar sich anders verhält“.
Und banal, aber häufig: Sie starten das falsche Jar.
Sie ändern Code, starten dann java-jar ... und wundern sich, dass die Änderungen nicht wirken. Und dann stellt sich heraus, dass Sie das Jar nicht neu gebaut haben. Oder Sie haben es neu gebaut, starten aber eine andere Datei. Oder im Ordner liegen zwei Jars und Sie haben das „ähnliche“ ausgewählt.
Klassisches Symptom: „In der IDE wird die neue Log-Nachricht angezeigt, im Jar die alte“. Das ist fast immer ein veraltetes Artefakt.
Ein kleiner Anti-Stress-Ritual sieht so aus:
# Artefakt neu bauen, um „Ich starte einen alten Build“ auszuschließen
./gradlew clean bootJar
# Prüfen, dass das Jar wirklich aktualisiert wurde und dort liegt, wo Sie es erwarten
ls -la build/libs
clean rettet hier oft vor der Situation „irgendwas war gecached und ich bin mir nicht mehr sicher, was ich starte“. Ja, Gradle ist schnell und intelligent. Aber wenn Sie Abweichungen zwischen den Startmodi debuggen, ist es besser, nicht nur intelligent, sondern auch ruhig zu sein.
Wenn Sie es ganz bombensicher haben wollen, können Sie sich auf einen einzelnen Artefaktnamen einigen (das haben wir bereits in der Vorlesung über bootJar gezeigt). Dann gibt es in der Dokumentation und in den Startbefehlen keine „zufälligen Namen“ mit Versionen, und Sie starten seltener „das Falsche“.
7. Diagnose der Unterschiede zwischen IDE und Jar
Wenn ein Jar sich anders verhält, besteht die größte Gefahr darin, chaotisch „den Code zu reparieren“, ohne Beweise zu haben. Am Ende reparieren Sie vielleicht etwas, das nicht kaputt ist, und zerstören, was funktioniert hat. Es ist besser, wie ein Ingenieur vorzugehen: Fakten sammeln, Hypothesen eingrenzen, eine bestätigen.
Ich stelle mir die Diagnose gerne als eine kurze Route vor, bei der jeder Schritt die Unsicherheit verringert. Hier ist ein kompaktes Flussdiagramm, das genau für die Fälle „IDE vs. Jar“ gut funktioniert:
flowchart TD
A["Symptom: In IDE funktioniert es, im Jar nicht/anders"] --> B["Neu bauen: ./gradlew clean bootJar"]
B --> C["Sicherstellen, dass Sie dasselbe Jar starten (Pfad/Name/Datum)"]
C --> D["Jar mit explizitem Befehl und Parametern starten"]
D --> E["Startup-Logs ansehen: Profiles, Port, user.dir"]
E --> F["Ressourcen im Jar prüfen: jar tf ... | grep ..."]
F --> G["Externe Konfiguration prüfen: spring.config.additional-location + Arbeitsverzeichnis"]
G --> H["Falls nötig: Actuator env/configprops (in local/dev)"]
H --> I["Erst dann: Code/Konfiguration ändern"]
Hier ist nicht das Mermaid-Diagramm selbst wichtig, sondern das Prinzip: Sie stellen zuerst sicher, dass Sie das richtige Artefakt starten, dann erfassen Sie die Startparameter, dann prüfen Sie die sichtbaren Fakten (Profile, Verzeichnis, Ressourcen), und erst dann ziehen Sie den Schluss „wo der Fehler ist“.
Ich betone extra: In den meisten Fällen wird das Problem irgendwo beim Schritt „Arbeitsverzeichnis“ oder „Ressourcen sind nicht ins Jar gelangt“ gelöst. Und das ist normal. Das bedeutet, dass Ihre Anwendung „echt“ geworden ist und nicht mehr von der Großzügigkeit der IDE abhängt.
8. Typische Fehler beim Starten eines Jars
Fehler Nr. 1: Lesen interner Ressourcen als Dateien aus src/main/resources.
Das ist die häufigste Falle, weil im Projekt „alles wie Dateien aussieht“. Aber nach der Verpackung werden Ressourcen zu Einträgen innerhalb des Jars und müssen als Classpath-Ressource gelesen werden. Wenn Sie denken „aber es ist doch dieselbe Datei“, denken Sie daran: Ein Jar ist kein Ordner, und der Pfad src/main/resources/... muss zur Laufzeit nicht existieren.
Fehler Nr. 2: Relative Pfade zu externen Konfigurationen ohne Kontrolle des Arbeitsverzeichnisses.
Ein Befehl mit --spring.config.additional-location=optional:file:./config/ sieht harmlos aus, solange Sie das Jar nicht aus einem anderen Ordner starten. Dann beginnt ./config/ einen anderen Ort zu bedeuten, und die Anwendung „sieht plötzlich“ die externen Overrides nicht. Das wird behoben entweder durch Disziplin beim Start aus einem einzigen Verzeichnis, durch absolute Pfade oder durch explizite Dokumentation.
Fehler Nr. 3: Erwartung, dass das Profil local von selbst aktiviert wird.
Die IDE ist kein Gedankenleser. Wenn Sie das Profil nicht an java-jar übergeben (über --spring.profiles.active=... oder SPRING_PROFILES_ACTIVE=...), startet die Anwendung standardmäßig. Und Sie erhalten einen anderen Logging-Level, ein anderes Actuator-Exposure und generell einen anderen Laufzeitcharakter. Das ist kein Bug, das ist Ehrlichkeit.
Fehler Nr. 4: Starten eines alten Artefakts und Vergleichen mit neuem Code.
Das ist der Fall, wenn Sie eigentlich alles richtig gemacht haben, aber eine „Kleinigkeit“ vergessen haben: das Jar neu zu bauen oder den Pfad zu aktualisieren. Es hilft sehr, die Angewohnheit zu entwickeln, die Diagnose mit ./gradlew clean bootJar und einer Überprüfung des Inhalts von build/libs zu beginnen.
Fehler Nr. 5: Versuch, den Code zu reparieren, bevor Fakten gesammelt wurden.
Wenn etwas „sich anders verhält“, zucken die Hände danach, das halbe Projekt umzuschreiben. Aber meist liegt das Problem nicht in der Geschäftslogik oder in Spring, sondern darin, dass der Jar-Lauf eine andere Form der Anwendung ist. Es ist viel günstiger, user.dir, aktive Profile und das Vorhandensein von Ressourcen im Jar zu überprüfen, als den Code blindlings umzustrukturieren.
GO TO FULL VERSION