1. Anzeichen einer aufgeblähten AppConfig
Solange das Projekt klein ist, scheint eine einzige Konfigurationsklasse eine großartige Idee zu sein: öffne die Datei – sieh, wie alles zusammengestellt ist, schließe sie – lebe weiter. Aber AppConfig hat eine unangenehme Superkraft: Sie wächst schneller, als du sagen kannst: „Ich füge nur einen kleinen Bean hinzu“. Nach properties, profiles, resources, locale-Regeln und MessageSource beginnen darin Lösungen mit völlig unterschiedlichen Änderungsgründen zu leben, und genau das macht die Konfiguration unbequem.
Stellen Sie sich den typischen Weg des Lehrprojekts ContextFlow vor. Zuerst haben wir das Komponentenscanning hinzugefügt, dann ein paar infrastrukturelle @Bean, dann Einstellungen aus properties, dann Resource-Vorlagen, jetzt Nachrichten. Am Ende wird die Konfiguration wie eine Küchenschublade, in die irgendwann sowohl Gabeln als auch Batterien und ein Schraubenzieher und ein seltsamer Schlüssel gelegt werden, „von dem man nicht weiß, wofür er ist, aber wegwerfen ist schade“.
Das Problem einer giant config class zeigt sich normalerweise nicht darin, dass es „viele Zeilen“ sind (obwohl das auch der Fall ist), sondern darin, dass Dinge aus verschiedenen Welten nebeneinander stehen: z. B. ein Bean für MessageSource, ein Bean für ReportOutputManager, ein Bean für die Auswahl von Locale, ein Bean für irgendetwas anderes – und das alles ist mit @ComponentScan vermischt. Sie öffnen die Datei, um eine kleine Einstellung zu finden, und plötzlich lesen Sie die Hälfte eines Romans.
So sieht die „Symptomatik“ aus (ohne Drama, rein nach Entwicklergefühl):
| Symptom | Was im Kopf passiert | Warum es im Leben schlecht ist |
|---|---|---|
| In einer Klasse: scanning, locale und Berichte | „Ich kam nach Salz, landete aber auf einer Mieterversammlung“ | Schwer zu lesen, schwer zu erklären, schwer zu warten |
| Config wird oft von verschiedenen Personen bearbeitet | „Warum habe ich in Git einen Konflikt bei @Bean-Methoden?“ | Merge-Konflikte und ständiges „Oh, ich habe versehentlich deine Einstellung kaputt gemacht“ |
| Viele @Bean-Methoden mit ähnlichen Namen | „Dieses formatter() – das oder das andere?“ | Leichte Verwirrung wird zu schweren Wiring-Bugs |
| Im Config taucht Logik auf: „wenn das, sonst das“ | „Warum lebt eine Geschäftsentscheidung im Config?“ | Die Konfiguration wird zu einer zweiten Anwendungsschicht |
Und hier brauchen wir das nächste Werkzeug: lernen, die Konfiguration in Stücke zu teilen, so dass das Projekt lesbarer wird, nicht „komplex um der Komplexität willen“.
2. Konfigurationsmodul
Wenn ich „Konfigurationsmodul“ sage, meine ich kein Multi-Module-Gradle (das ist eine andere Geschichte und definitiv nicht heute). Hier ist ein Modul einfach eine kleine @Configuration-Klasse, die für einen verständlichen Teil des Wires zuständig ist. Und die Schlüsselidee ist sehr ausgereift: Wir teilen die Konfiguration nicht nach Anzahl der Beans, sondern nach Änderungsgründen.
Das heißt, ein gutes Konfigurationsmodul ist eine Klasse, über die Sie einen menschlichen Satz sagen können: „Es ist für die Lokalisierung zuständig“ oder „Es ist für den Aufbau der Core-Ebene und den Komponentenscan zuständig“ oder „Es ist für die Berichtsinfrastruktur zuständig“. Wenn Sie versuchen, das Modul als „nun… hier sind verschiedene allgemeine Dinge“ zu beschreiben, haben Sie gerade den zukünftigen MiscConfig erfunden, der in einer Woche zur Müllhalde wird.
Für ContextFlow ist die Aufteilungslogik normalerweise natürlich:
- ein Modul ist zuständig für @ComponentScan und das „Hochfahren“ der Anwendung als Satz von Komponenten;
- ein Modul ist zuständig für MessageSource und Message-Bundles;
- ein Modul ist zuständig für die Regel zur Auswahl von Locale (z. B. preferred locale des Clients oder default);
- ein Modul ist zuständig für Berichte und deren Output-Einstellungen (falls erforderlich).
Beachten Sie: Das Modul ist im Wesentlichen ein „Stück des Composition Root“, nur nicht alles auf einmal, sondern sauber aufgeteilt. Der Spring-Container mag diese Disziplin: Es ist ihm egal, ob Sie eine oder zehn Konfigurationen haben, aber Ihnen ist es nicht egal.
Eine kleine Idee, die es wert ist, im Kopf behalten zu werden: @Configuration ist kein „weiteres Component“, sondern eine Baukarte. Wenn die Karte zu groß wird und zu viele Schichten hat, ist es sinnvoll, sie zu teilen. Nicht weil „es üblich ist“, sondern weil Sie wieder die Struktur sehen wollen, nicht das Chaos.
Damit das gelingt, braucht das Modul eine einfache Tugend: Es soll Wiring machen, nicht „das Leben der Anwendung leben“. Wenn innerhalb einer @Bean-Methode komplexe Logik auftritt, bauen Sie versehentlich eine zweite Geschäftsschicht in der Konfiguration. Dazu sind Konfigurationen nicht da.
3. @Import zum Zusammenbau von Modulen
Wenn die Konfiguration in Module aufgeteilt ist, taucht eine neue Frage auf: „Okay, wie setzt man das alles wieder zu einem Einstiegspunkt zusammen?“ Und hier tritt @Import auf den Plan. Es ist eine Annotation, die Spring sagt: „Nimm diese Klassen und nimm sie in die Kontextkonfiguration auf.“ Ganz einfach ausgedrückt ist @Import die „Verklebung“ von Konfigurationsmodulen zu einem Satz.
Es ist wichtig, @Import nicht als „magisches Ding“ zu verstehen, sondern als eine sehr direkte Aktion. Die Idee dahinter ist: Sie könnten die Klassen von Hand im Kontext registrieren, etwa so:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ManualRegisterDemo {
public static void main(String[] args) {
// Beispiel 'von Hand': Kontext erstellen ohne Startkonfiguration.
try (var ctx = new AnnotationConfigApplicationContext()) {
// Konfigurationsklassen explizit registrieren – als ob wir @Import verwenden würden.
ctx.register(CoreConfig.class, MessageConfig.class);
// Nach der Registrierung muss der Container 'gestartet' werden: Konfigurationen verarbeiten und Beans erstellen.
ctx.refresh();
} // try-with-resources stellt das korrekte Schließen des Kontexts sicher.
}
}
@Import macht dasselbe, nur deklarativ, auf der Konfigurationsebene. Wir werden die Vorlesung nicht in die „Interna von refresh()“ verwandeln, aber es ist wichtig zu verstehen: Der Import arbeitet in der Phase der Konfigurationsregistrierung, also noch bevor Spring mit der Erstellung tatsächlicher Objekte beginnt.
Hier ist die bekannteste Form einer Root-Konfiguration:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false) // Config-'Karte': kein CGLIB-Proxy für @Bean-Methoden erforderlich.
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // Module explizit in einem Kontext zusammenführen.
public class AppConfig {
// Im Idealfall gibt es hier kein @Bean: nur 'Inhaltsverzeichnis' und Modulkomposition.
}
Jetzt wird AppConfig nicht mehr zu einem Lagerhaus für @Bean-Methoden, sondern zu einer kurzen Karte: Es ist sichtbar, aus welchen Teilen der Kontext zusammengesetzt ist.
Um eine solche Konfiguration richtig zu „lesen“, ist es hilfreich, ein einfaches Schema im Kopf zu haben:
flowchart TD
%% Schema hilft, das Wiring von oben nach unten zu lesen: root -> Module -> Beans/Komponenten.
AppConfig["AppConfig (root)"] --> CoreConfig["CoreConfig: Komponentenscan"]
AppConfig --> MessageConfig["MessageConfig: MessageSource"]
AppConfig --> LocalizationConfig["LocalizationConfig: Locale-Regel"]
CoreConfig --> Komponenten["@Component/@Service/@Repository beans"]
MessageConfig --> MsgBean["Bean: messageSource"]
LocalizationConfig --> LocaleBeans["Beans: defaultLocale, localeSelector"]
@Import hat zwei praktische Vorteile, die Sie fast sofort spüren werden.
Erstens – Explizitheit. Sie öffnen AppConfig und sehen die Anwendungsstruktur, anstatt sie mental aus 30 Methoden rekonstruieren zu müssen.
Zweitens – Kontrolle. Der Import hängt nicht davon ab, wo die Klasse liegt oder ob sie in @ComponentScan fällt. Sie entscheiden selbst, was in den Build einfließt.
Und noch eine Nuance, die es vorab zu klären gilt: Die Reihenfolge der Klassen in @Import({...}) ist keine „Reihenfolge der Bean-Erstellung“ im Sinne von „zuerst dies, dann das“. Der Container erstellt Beans basierend auf Abhängigkeiten, Lifecycle und eigenen Regeln. Daher geht es bei @Import um die Zusammensetzung der Konfiguration, nicht um ein „Ausführungsskript“.
4. Refactoring von AppConfig in Module
Jetzt machen wir den praktischsten Schritt: Wir zeigen, wie genau AppConfig in Module geschnitten werden kann, damit das Leben danach einfacher wird. Die Idee ist nicht, sofort eine „ideale Architektur“ zu erstellen, sondern eine transparente Zusammenstellung: Eine Klasse zeigt die Karte, die Module enthalten die konkreten Lösungen.
Beginnen wir mit CoreConfig. Seine Rolle ist es, Spring mitzuteilen, wo Ihre @Service/@Component-Klassen zu finden sind. Und hier gibt es einen wichtigen Punkt: Wenn Sie anfangen, das gesamte com.example.contextflow zu scannen, laufen Sie Gefahr, versehentlich auch die Configs selbst durch das Scanning zu erfassen, und @Import wird dann entweder überflüssig oder es kommt zu Duplikaten und Überschreibungen. Daher ist eine vernünftige Teaching-Default hier, nur die Pakete zu scannen, in denen tatsächlich Komponenten leben.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false) // Config ohne @Bean: Sie legt die Regeln für das Komponentenscanning fest.
@ComponentScan(basePackages = {
// Nur die Anwendungspakete scannen, damit keine Configs versehentlich erfasst werden.
"com.example.contextflow.application",
"com.example.contextflow.infrastructure"
})
public class CoreConfig {
// Eine leere Klasse ist in Ordnung: Hier sind die Annotationen und ihre Parameter wichtig.
}
application.i18n ist dieselbe Anwendungsschicht, daher gelangt OrderTextService problemlos hierher durch den Scan. Das Paket config ist bewusst nicht im Scan enthalten: Es wird explizit über @Import zusammengebaut.
Jetzt das Nachrichtenmodul. Es ist dafür verantwortlich, dass im Kontext ein Bean mit dem Namen messageSource erscheint. Und ja, der Name ist wichtig – das haben wir besprochen: ApplicationContext sucht genau diesen als Standard-Punkt für die Message-Suche.
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
@Configuration(proxyBeanMethods = false)
public class MessageConfig {
@Bean
MessageSource messageSource() {
// Standardmäßig sucht Spring nach einem Bean mit dem Namen "messageSource" für die Lokalisierung von Nachrichten.
var source = new ResourceBundleMessageSource();
// "messages" bedeutet, dass Dateien wie messages.properties / messages_ru.properties gesucht werden.
source.setBasename("messages");
return source;
}
}
Jetzt das Lokalisierungsmodul. Seine Aufgabe ist nicht, Texte zu speichern, sondern die Regel zur Auswahl des Locale zu definieren und der Anwendung ein oder zwei kleine Beans zur Verfügung zu stellen, die dabei helfen. Zum Beispiel LocaleSelector, der das bevorzugte Locale des Clients auswählt, und falls es keins gibt, das Default-Locale verwendet.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Locale;
@Configuration(proxyBeanMethods = false)
public class LocalizationConfig {
@Bean
Locale defaultLocale(@Value("${contextflow.locale.default}") String tag) {
// Wert aus properties (z.B. "ru-RU", "en-US") holen und in Locale umwandeln.
return Locale.forLanguageTag(tag);
}
@Bean
LocaleSelector localeSelector(Locale defaultLocale) {
// Regel zur Locale-Auswahl zusammenbauen: Wenn der Client kein preferred locale hat, wird default verwendet.
return new LocaleSelector(defaultLocale);
}
}
Und schließlich die Root-AppConfig, die dies alles in einem Einstiegspunkt zusammenfasst:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration(proxyBeanMethods = false) // Root config: In der Regel ohne @Bean und ohne Logik.
@Import({CoreConfig.class, MessageConfig.class, LocalizationConfig.class}) // Explizite 'Baukarte' des Kontexts.
public class AppConfig {
}
Spüren Sie den Unterschied: Jetzt ist AppConfig ein „Inhaltsverzeichnis“, nicht „Band zwei mit Anhängen“.
Und der Start der Anwendung bleibt genauso einfach wie zuvor, nur dass Sie jetzt eine sauberere Konfigurationsstruktur haben:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Starten des Kontexts mit der Root-AppConfig, die bereits alle Module importiert.
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Eingabe-Bean des Szenarios holen und Anwendung starten.
ctx.getBean(ScenarioRunner.class).run();
} // Beim Beenden den Kontext korrekt schließen (Ressourcen, Lifecycle).
}
}
Selbst wenn Sie nicht auswendig wissen, was in MessageConfig steckt, verstehen Sie bereits, wo Sie suchen müssen: Die Struktur liest sich von oben nach unten. Das ist der seltene Fall, in dem das „Aufteilen in Dateien“ das System wirklich einfacher gemacht hat, nicht zersplittert.
5. @Import und @ComponentScan zusammen
Wenn Sie zum erste Mal modulare Konfiguration ausprobieren, entsteht oft das Gefühl, dass @Import und @ComponentScan Konkurrenten seien: entweder „alles importieren“ oder „alles scannen“. In der Praxis arbeiten sie eher als zwei Werkzeuge für verschiedene Aufgabentypen, und Ihr Ziel ist es, dass sie sich nicht gegenseitig stören.
@ComponentScan ist praktisch für Klassen, die „gewöhnliche Anwendungskomponenten“ sind: Services, Repositories (in unserem Sinne In-Memory-Speicher), Benachrichtigungssender, Audit usw. Diese Klassen „leben“ in unserem Code, es gibt viele von ihnen, und wir möchten wirklich, dass der Container sie automatisch findet.
@Import ist nützlich für die Konfiguration als „Baukarte“. Konfigurationen möchten wir fast immer explizit einbinden, da ihre Anwesenheit die Kontextstruktur verändert. Wenn eine Config „versehentlich eingebunden wird, weil sie in den Scan gerät“, erhalten Sie den Effekt „wo kommt das her?“. Genau deshalb haben wir in CoreConfig oben das Scanning auf application / infrastructure beschränkt und die Configs außerhalb des Scans gelassen.
Ein weiterer wichtiger Punkt ist die Disziplin der Bean-Namen. In einer modularen Konfiguration ist es sehr einfach, versehentlich zwei @Bean-Methoden mit demselben Namen in verschiedenen Modulen zu haben, z.B. zwei verschiedene messageSource() oder zwei defaultLocale(). Was dann passiert, hängt von den Containereinstellungen und dem spezifischen Kontext ab: Manchmal überschreibt eine Definition die andere, manchmal stürzt der Kontext beim Start ab. Beides sind böse Überraschungen. Daher ersetzt Modularität nicht die Sorgfalt: Wenn ein Modul für Nachrichten zuständig ist, dann sollte genau dort der einzige messageSource() sein und nirgendwo sonst.
Eine weitere subtile Nuance: Konfigurationsklassen sind ebenfalls Beans. Wenn Sie versehentlich dieselbe Config zweimal in den Kontext bringen (durch Scanning und durch @Import), riskieren Sie entweder eine „Doppeldefinition“ oder „warum erscheinen meine Beans plötzlich zweimal?“. Daher lautet eine einfache Regel für Anfänger: Entweder wir importieren Configs explizit oder wir binden sie bewusst in das Scanning ein, aber wir tun nicht beides gleichzeitig.
Wenn man das im Kopf behält, ergibt sich ein sehr ruhiges Modell:
- Anwendungskomponenten kommen durch Scanning;
- Infrastruktur- und „genau konfigurierte“ Dinge kommen über @Bean in Modulen;
- die oberste Zusammenstellungsebene wird kurz über @Import beschrieben.
6. Typische Fehler bei modularen Konfigurationen
Wenn Sie zum ersten Mal anfangen, die Konfiguration zu teilen, möchten Sie es oft „maximal schön“ machen. Und manchmal führt das dazu, dass die Konfiguration komplexer wird als die Anwendung selbst. Im Folgenden einige Fehler, die meistens am Anfang auftreten und die bei Anfängern das Gefühl entstehen lassen, @Import sei „unnötige Bürokratie“. In Wirklichkeit liegt das Problem normalerweise darin, wie wir die Module geschnitten und wie wir sie zusammengebaut haben.
Fehler Nr. 1: Konfiguration nach Anzahl der @Bean-Methoden aufteilen, nicht nach Sinn.
Wenn Sie eine Datei „für zwei Beans“ erstellen, nur weil „es so weniger ist“, erhalten Sie schnell 15 Mikroconfigs, die man sich nicht merken kann. Ein Modul sollte die Frage „warum existiert es“ beantworten und einen klaren Änderungsgrund haben. Dann können es mehr Dateien sein, aber das Lesen wird leichter.
Fehler Nr. 2: @ComponentScan("com.example.contextflow") beibehalten und gleichzeitig Configs aus demselben Paket importieren.
Diese Variante sieht harmlos aus, bis es zu Duplikaten oder Überschreibungen von Beans kommt. Wenn Sie möchten, dass die Konfiguration explizit ist, sollte das Scanning die Anwendungskomponenten abdecken, aber keine Configs „automatisch“ einziehen. In einem Lehrprojekt ist das besonders wichtig: Wir lernen, das Wiring zu sehen, nicht zu erraten, woher es kommt.
Fehler Nr. 3: AppConfig in einen „Giganten“ verwandeln, indem man Methoden einfach auf Klassen verteilt.
Manchmal befreit man AppConfig von @Bean-Methoden, schreibt aber stattdessen riesige @Import({...})-Listen ohne Struktur und benennt die Module undeutlich. Dadurch wird die „Karte“ genauso unleserlich wie die alte monolithische Config. AppConfig sollte kurz und aussagekräftig sein, und die Modulnamen sollten die Verantwortung widerspiegeln.
Fehler Nr. 4: Geschäftslogik in Configs verstecken.
Die Konfiguration ist der Ort, an dem wir Objekte erstellen und verknüpfen, nicht der Ort, an dem wir Geschäftsentscheidungen treffen. Wenn Sie innerhalb einer @Bean-Methode komplexe Regeln berechnen (insbesondere solche, die an das Domänenmodell gebunden sind), werden Sie nach ein paar Tagen nicht mehr verstehen können, wo die „echte“ Anwendungslogik ist und wo die „Zusammenbaulogik“.
Fehler Nr. 5: Bean-Name-Konflikte zwischen Modulen.
In einer modularen Konfiguration ist der @Bean-Name standardmäßig der Methodenname. Zwei Methoden defaultLocale() in verschiedenen Modulen sehen aus wie „na und, sie sind doch in verschiedenen Klassen“, aber für den Container sind das zwei Kandidaten für denselben Bean-Namen. Es ist besser, die Module von vornherein so zu disziplinieren, dass die wichtigsten Infrastruktur-Beans einen einzigen Definitionspunkt haben.
GO TO FULL VERSION