Tech Blog

Un'analisi dettagliata del modulo Core di Spring

Un viaggio nel cuore del framework per comprendere e ottimizzare l'uso di Spring Context

Edoardo Patti
Technical Lead
Andrea D'Angeli
Junior Backend Developer
Flavio Cassarà
Junior Backend Developer
9 minuti di lettura
backend, java e spring-context-6.2.0-SNAPSHOT
Questo articolo è disponibile anche in English 🇬🇧

Introduzione

In questo articolo forniremo, dapprima, una visione di alto livello di Spring Framework, spiegandone motivazioni storiche e architetture. Andremo poi ad analizzare nel dettaglio il funzionamento di quello che è il suo modulo core noto come spring context, concluderemo indicando alcune tecniche per poter interagire con quest’ultimo modificandone il comportamento a runtime.

Sommario

Spring Framework

Spring rappresenta una famiglia di progetti utili a creare applicazioni Java Enteprise, per questo si dice essere un framework modulare, dove ogni modulo può essere utilizzato per rispondere a determinate esigenze consentendo agli sviluppatori di concentrarsi principalmente sulla logica di business delegando al framework quelli che sono aspetti tipicamente più architetturali. Ad oggi esso rappresenta uno strumento essenziale per lo sviluppo di applicazioni Java specialmente in ambito web e si distingue per il suo potente meccanismo di gestione delle dipendenze. Il framework nasce nel 2003, sotto la guida di Rod Johnson, come risposta alla complessità delle specifiche Java Enterprise. Sebbene questo possa far pensare che essi siano diretti competitor, la realtà è che questi due mondi sono complementari, l’obiettivo di Spring non è, infatti, essere compliant con le specifiche Java Enterprise quanto integrarsi con alcune di esse e utilizzarle come supporto per il framework stesso. I principi fondanti di Spring sono senz’altro la possibilità di lasciare agli sviluppatori la scelta ad ogni livello, porre particolare attenzione alla retro compatibilità nonché al design delle API cercando di renderle il più intuitive possibile, e mantenere uno standard elevato in ambito della qualità del codice. Su quest’ultimo punto va sottolineato come il framework abbia sempre rivolto particolare attenzione a questo aspetto rendendolo uno tra i software maggiormente documentato e autodescrittivo.

Spring Context

Il modulo core di Spring è rappresentanto da spring-context e la sua funzione principale è quella di creare applicazioni basate sul pattern IoC (Inversion of Control) implementato tramite DI (Dependency Injection). Con questo pattern il controllo delle dipendenze e delle loro implementazioni non è più a carico delle istanze di oggetti che le utilizzano ma viene delegato ad un componente terzo, messo a disposizione dal framework, noto come container. Questo trasferimento di responsabilità consente agli oggetti di business di non dover dipendere da una specifica implementazione né di doverne scegliere una a runtime favorendo di fatto lo sviluppo di applicazioni nel totale rispetto dei principi SOLID. Al centro di questo meccanismo, come già detto, troviamo un componente noto come container al cui interno vengono istanziati e gestiti per il loro intero ciclo di vita i cosidetti "bean". Ma cosa sono realmente i bean e come avviene la loro creazione?

Se per la prima domanda è sufficiente dire che i bean possono sia essere comuni POJO che classi con specifiche annotazioni, per dare una risposta dettagliata alla seconda domanda andremo ad analizzare il cuore di Spring Framework per scoprirne il processo tramite il quale i bean vengono istanziati e gestiti. Ci concentreremo sui dettagli della classe AnnotationConfigApplicationContext che rappresenta il pilastro tramite il quale è possibile configurare un'applicazione Spring utilizzando le annotazioni. Tra le interfacce principali che orchestrano il flusso rientrano senz’altro il BeanDefinitionRegistry, responsabile della creazione e gestione delle definizioni dei bean, la BeanFactory, responsabile della creazione e gestione dei bean e l’ApplicationContext che coordina in maniera puntuale le API fornite dalle interfacce precedenti nonché delle loro implementazioni.

Estendendo la classe GenericApplicationContext, AnnotationConfigApplicationContext non solo implementa BeanDefinitionRegistry con il metodo fondamentale registerBeanDefinition(String beanName, BeanDefinition beanDefinition) ma possiede anche una variabile d’istanza di tipo DefaultListableBeanFactory. Va sottolineato che GenericApplicationContext estende a sua volta AbstractApplicationContext che dichiara il metodo createEnvironment() che ritorna un'istanza di StandardEnvironment basato su properties di sistema System.getProperties() e di ambiente System.getEnv() dove le prime hanno la precedenza sulle seconde. Quest'ultimo estende, infine ApplicationContext che implementa ApplicationEventPublisher ed è quindi in grado di generare eventi tramite il metodo publishEvent(Object event)

UML class diagram
UML class diagram

BeanDefinitionRegistry

Il BeanDefinitionRegistry è un'interfaccia che dichiara alcuni metodi per lavorare con le BeanDefinition tra cui quelli per registrarne di nuove, recuperarle e rimuoverle. Come già detto questi metodi sono implementati all'interno di GenericApplicationContext tramite i metodi forniti da DefaultListableBeanFactory che implementa a sua volta questa interfaccia.

Una BeanDefinition descrive una classe dichiarata come bean, tramite alcune informazioni tra cui:

  • beanClass che rappresenta il nome comprensivo di package della classe
  • scope singleton di default
  • initMethodName
  • destroyMethodName
  • qualifiers se vengono indicati tramite annotation
  • source che rappresenta il percorso al file .class
  • metadata che rappresenta un oggetto di tipo SimpleAnnotationMetadata con al suo interno, ad esempio, i metadati dei metodi dichiarati, ed eventuale super classe

AnnotatedBeanDefinitionReader e ClassPathBeanDefinitionScanner

Possiamo istanziare un AnnotationConfigApplicationContext in due modi distinti. Il primo approccio consiste nel passare una lista di classi di componenti direttamente al costruttore. Per componente si intende una qualunque specializzazione dell’annotazione @Component (@Service, @Configuration, @Controller etc.)

public AnnotationConfigApplicationContext(Class<?>... componentClasses) {
  this();
  register(componentClasses);
  refresh();
}

Il secondo approccio prevede il passaggio di una lista di basePackages al costruttore. In questo modo verrà effettuata una scansione di tutte le classi dei packages e dei loro sub-packages.

public AnnotationConfigApplicationContext(String... basePackages) {
  this();
  scan(basePackages);
  refresh();
}

In entrambi i casi come prima istruzione viene invocato il costruttore senza parametri dove vengono istanziati un AnnotatedBeanDefinitionReader e uno ClassPathBeanDefinitionScanner. Questi due oggetti per essere istanziati necessitano di un BeanDefinitionRegistry che in questo caso sarà l’istanza stessa di AnnotationConfigApplicationContext.

public AnnotationConfigApplicationContext() {
  ...
  this.reader = new AnnotatedBeanDefinitionReader(this);
  ...
  this.scanner = new ClassPathBeanDefinitionScanner(this);
}

In una semplice applicazione spring con la sola dipendenza spring-context nel momento in cui viene istanziato il reader vengono aggiunte delle BeanDefinition di default al BeanDefinitioRegistry attraverso il metodo registerAnnotationConfigProcessors() della classe AnnotationConfigUtils richiamato nel costruttore del reader stesso. Tali definizioni rappresentano le seguenti classi:

  • ConfigurationClassPostProcessor usata per processare le classi annotate con @Configuration. Registrata di default quando viene abilitata la configurazione basata su annotazioni.
  • DefaultEventListenerFactory usata per supportare l'annotazione @EventListener
  • EventListenerMethodProcessor usata per registrare i metodi annotati con @EventListener come istanze di ApplicationListener
  • AutowiredAnnotationBeanPostProcessor usata per supportare l'autowired tramite costruttore, metodi e variabili

Register

Come visto in precedenza nel primo approccio, quando passiamo una lista di componentClasses, verrà invocato il metodo register(componentClasses) della classe AnnotatedBeanDefinitionReader.

@Override
public void register(Class<?>... componentClasses) {
	...
	this.reader.register(componentClasses);
	...
}

All’interno di questo flusso avverrà la registrazione delle BeanDefinition. Il processo inizia con l'iterazione attraverso la lista di classi fornite come input, ognuna delle quali è un potenziale candidato per diventare un bean.

public void register(Class<?>... componentClasses) {
		for (Class<?> componentClass : componentClasses) {
         registerBean(componentClass);
    }
}

Per iniziare il processo di trasformazione, all’interno del metodo doRegisterBean(), viene istanziato un AnnotatedGenericBeanDefinition. vengono configurati parametri chiave per l'AnnotatedGenericBeanDefinition, come il suo scope e il nome, se non forniti esplicitamente. Lo scope definisce il ciclo di vita e la visibilità di un bean gestito dal container Spring. Ci sono diversi tipi di scope, ognuno dei quali determina come viene istanziato e mantenuto il bean all'interno dell'applicazione. Ecco alcuni degli scope più comuni:

  • Singleton: È lo scope predefinito. Spring crea una singola istanza del bean per l'applicazione e la mantiene in memoria. Tutte le richieste per quel bean restituiranno sempre la stessa istanza.
  • Prototype: Spring crea una nuova istanza del bean ogni volta che viene richiesto, quindi ogni richiesta restituirà un'istanza separata del bean.
  • Request: Il bean esiste solo all'interno del ciclo di vita di una singola richiesta HTTP. Viene creato un nuovo bean per ogni richiesta HTTP e distrutto alla fine della richiesta.
  • Session: Il bean esiste all'interno del ciclo di vita di una singola sessione utente. Viene creato un nuovo bean per ogni sessione utente e distrutto alla fine della sessione.

Dopo aver impostato lo scope, attraverso il metodo processCommonDefinitionAnnotations(), vengono interpretate e applicate annotazioni comuni come Primary, Fallback, Role, Lazy, Description e DependsOn.

private <T> void doRegisterBean(Class<T> beanClass, ...) {

    AnnotatedGenericBeanDefinition abd = new AnnotatedGenericBeanDefinition(beanClass);
    ...
    abd.setScope(scopeMetadata.getScopeName());
    String beanName = (name != null ? name : this.beanNameGenerator.generateBeanName(abd, this.registry));
    AnnotationConfigUtils.processCommonDefinitionAnnotations(abd);
	...
    BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(abd, beanName);
    BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, this.registry);
}

Una volta completata la fase di preparazione, viene istanziato un BeanDefinitionHolder. Infine, la definizione viene ufficialmente registrata tramite il metodo registerBeanDefinition().

Scan

Quando verrà istanziato AnnotationConfigApplicationContext passando al costruttore un array di stringhe che rappresentano i base packages, la situazione sarà analoga.

@Override
public void scan(String... basePackages) {
    ...
    this.scanner.scan(basePackages);
    ...
}

Quando viene istanziato l’ApplicationContext verrà invocato il metodo doScan(basePackages) della classe ClassPathBeanDefinitionScanner, in cui scannerizziamo tutti gli ipotetici beanDefinition all'interno di ogni basePackage. Al suo interno verrà invocato il metodo findCandidateComponents(basePackage), il quale a partire da un array di stringhe rappresentanti i basePackages, costruisce inizialmente un array di Resource ognuna delle quali punterà alle classi presenti nei packages, e a partira dal quale verrà crato un array di BeanDefinition. Per ognuna di esse settiamo lo scope e il beanName. In seguito come già visto nel flusso del register(), invochiamo il metodo processCommonDefinitionAnnotations() della classe AnnotationConfigUtils dove verranno intepretate e applicate ala BeanDefinition le annotazioni comuni sopra citate.

Successivamente ci sara un checkCandidate per ogni candidato che controllerà, tra l'altro, che il registry non contenga già quella definizione. Una volta controllata la definizione verrà registrata all'interno della beanFactory.

protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
    ...
    Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<>();
    for (String basePackage : basePackages) {
        Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
        for (BeanDefinition candidate : candidates) {
            ...
            candidate.setScope(scopeMetadata.getScopeName());
            String beanName = this.beanNameGenerator.generateBeanName(candidate, this.registry);
            ...
            if (checkCandidate(beanName, candidate)) {
                BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
                beanDefinitions.add(definitionHolder);
                registerBeanDefinition(definitionHolder, this.registry);
            }
        }
    }
    return beanDefinitions;
}

Refresh

Una volta registrate tutte le definizioni tramite uno dei due flussi sopra descritti, atterriamo sul metodo refresh(). All'interno di questo metodo avverrà la creazione effettiva dei bean.

Questo processo è caratterizzato da diversi passaggi. Inizialmente viene recuperata la beanFactory tramite il metodo obtainFreshBeanFactory(), una volta ottenuta entriamo nel metodo invokeBeanFactoryPostProcessors(beanFactory) che chiamerà il suo omonimo in PostProcessorRegistrationDelegate.

@Override
public void refresh() throws BeansException, IllegalStateException {
    ...
    try {
        ...
        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
        ...
        try {
            ...
            invokeBeanFactoryPostProcessors(beanFactory);
            registerBeanPostProcessors(beanFactory);
            ...
            finishBeanFactoryInitialization(beanFactory);
            finishRefresh();
            ...
        }
    }
}

BeanFactoryPostProcessor

Nel metodo PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors() verranno istanziati i BeanFactoryPostProcessor, che sono bean particolari tramite i quali è possibile modifcare le BeanDefinition precedentemente registrate. Ad esempio, possono essere impiegati per svariati compiti critici. Uno dei ruoli principali è la configurazione di proprietà globali, la risoluzione delle dipendenze o l’aggiunta di definizioni al contesto in modo dinamico.

È importante notare che i beanFactoryPostProcessor implementano l'interfaccia funzionale BeanFactoryPostProcessor, definendo così un contratto ben preciso per la manipolazione della configurazione del contesto dell'applicazione. È possibile stabilire priorità tra diversi beanFactoryPostProcessor, ad esempio utilizzando l'interfaccia PriorityOrdered, per gestire l'ordine delle configurazioni. Ciò garantisce una gestione accurata delle dipendenze e delle proprietà nell'ambiente dell'applicazione.

public class FactoryPostProcessor implements BeanFactoryPostProcessor, PriorityOrdered {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        System.out.println(Arrays.toString(beanFactory.getBeanDefinitionNames()));
    }

    @Override
    public int getOrder() {return 0; }
}

BeanPostProcessor

Dopo aver istanziato e invocato i beanFactoryPostProcessor, Spring si occuperà di istanziare anche i BeanPostProcessor, che implementano l’interfaccia BeanPostProcessor e che a differenza dei beanFactoryPostProcessor agiscono a livello di ogni singolo bean dopo che esso è stato istanziato. Ciò avviene all’interno del metodo registerBeanPostProcessors().

È possibile dichiarare static i beanPostProcessor e i beanFactoryPostProcessor, in modo da forzare Spring a inizializzarli prima di ogni altro bean. Per comprendere meglio, consideriamo un esempio di dichiarazione di un BeanPostProcessor:

public class Processor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        System.out.println(bean.getClass().getName()+" Before callback");
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        System.out.println(bean.getClass().getName()+" After callback");
        return bean;
    }
}

In questo esempio, consideriamo la definizione di un BeanPostProcessor che regola il comportamento desiderato dei bean dopo la fase di inizializzazione, ma prima o dopo l'esecuzione delle callbacks di inizializzazione (afterPropertySet() e init-method). Questo permette di dinamicizzare e modificare l'istanza finale del bean, consentendo una personalizzazione dettagliata delle sue proprietà e comportamenti. È importante sottolineare che l'applicazione di tale BeanPostProcessor avviene solo successivamente alla creazione del bean.

Infine, la fase di istanziazione degli altri bean, che costituiscono le componenti principali dell'applicazione (annotati con @Component, @Service, @Repository, ecc.), avviene sempre all'interno del metodo refresh, più precisamente nel metodo finishBeanFactoryInitialization()

Il processo inizia con l'invocazione del metodo doGetBean() della beanFactory di AnnotationConfigApplicationContext

protected <T> T doGetBean(
        String name, @Nullable Class<T> requiredType, @Nullable Object[] args, boolean typeCheckOnly)
        throws BeansException {

    String beanName = transformedBeanName(name);
    ...
    RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
    ...
    return createBean(beanName, mbd, args);
  }
  ...
}

All'interno del metodo createBean() si trova il cuore del processo. Nel metodo, definito nella classe AbstractAutowireCapableBeanFactory, viene richiamato il metodo doCreateBean(). In questa fase, viene effettuato un controllo preliminare per verificare se il bean da istanziare è un singleton e se è già presente nella cache dei bean singleton. Se non è presente nella cache, il metodo createBeanInstance() della classe AbstractAutowireCapableBeanFactory viene chiamato per gestire la creazione del bean. Qui, avviene il processo di istanziazione effettiva utilizzando il metodo instantiateUsingFactoryMethod() della classe ConstructorResolver.

Se utilizziamo l'iniezione delle dipendenze tramite costruttore, è proprio in questo punto che vengono importate nell'istanza wrapper ritornata. D'altra parte, utilizzando l'annotazione @Autowired per l'iniezione delle dipendenze, il processo è leggermente diverso. Le dipendenze verranno inizializzate nel metodo populateBean() della classe AbstractAutowireCapableBeanFactory, più precisamente all'interno del metodo postprocessProperty(). Quest'ultimo metodo, a sua volta, invoca doResolveDependency() per risolvere le dipendenze richieste.

Una volta terminato il populateBean() verrà chiamato initializeBean() della classe AbstractAutowireCapableBeanFactory che prende il BeanName e l'istanza del bean creata precedentemente. Questo sarà il metodo che si occuperà di applicare i beanPostProcessor in modo da dinamicizzare e modificare l’istanza finale del bean.

Dopo aver invocato l’initializeBean(), si attivano due fasi che definiscono il comportamento del bean appena inizializzato:

  1. applyBeanPostProcessorsBeforeInitialization: Questo metodo applica i BeanPostProcessor agendo in base alla logica definita nel metodo postProcessBeforeInitialization(). Qui, i BeanPostProcessor hanno l'opportunità di manipolare e personalizzare il bean prima che vengano eseguite le cosiddette callbacks di inizializzazione quali afterPropertySet() ed eventuali init-method custom.
@Nullable
protected Object applyBeanPostProcessorsBeforeInstantiation(Class<?> beanClass, String beanName) {
    for (InstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().instantiationAware) {
       Object result = bp.postProcessBeforeInstantiation(beanClass, beanName);
       if (result != null) {
          return result;
       }
    }
    return null;
}
  1. applyBeanPostProcessorsAfterInitialization: In questa fase, i BeanPostProcessor operano in base alla logica contenuta nel metodo postProcessAfterInitialization(). Qui, i BeanPostProcessor intervengono a seguito delle callbacks di cui sopra.
@Override
public Object applyBeanPostProcessorsAfterInitialization(Object existingBean, String beanName)
       throws BeansException {

    Object result = existingBean;
    for (BeanPostProcessor processor : getBeanPostProcessors()) {
       Object current = processor.postProcessAfterInitialization(result, beanName);
       if (current == null) {
          return result;
       }
       result = current;
    }
    return result;
}

Infine, una volta che tutti i bean sono stati inizializzati con le loro dipendenze, e i BeanPostProcessor sono stati applicati, si conclude il flusso di aggiornamento con il metodo finishRefresh(). Questo metodo esegue una serie di operazioni essenziali:

  1. Inizia eseguendo l'operazione di pulizia delle cache e l'inizializzazione del LifecycleProcessor che implementa i metodi onRefresh() e onClose() gestendo le due fasi.
  2. Successivamente, notifica il LifecycleProcessor dell'avvenuto aggiornamento dell’applicationContext.
  3. Infine, pubblica un ContextRefreshedEvent, segnalando che il contesto è ora completamente inizializzato e pronto per l'uso.

In sintesi, finishRefresh() è il punto finale che assicura che il contesto sia pronto e operativo dopo l'aggiornamento.

ApplicationListener

Dopo aver discusso dell'importanza dei BeanFactoryPostProcessor e dei BeanPostProcessor nell'inizializzazione e nella manipolazione dei bean all'interno dell'applicazione è importante esplorare anche il ruolo degli ApplicationListener<E extends ApplicationEvent> i quali forniscono un meccanismo per la gestione degli eventi permettendo di definire azioni specifiche da eseguire in risposta a eventi, come l'avvio, l'arresto, il refresh o la chiusura dell'applicazione.

Di seguito un elenco delle possibili specializzazioni di ApplicationEvent:

  • ApplicationContextEvent: ogni evento del contesto dell'applicazione.
  • ContextClosedEvent: chiusura dell'applicazione.
  • ContextRefreshedEvent: aggiornamento del contesto dell'applicazione.
  • ContextStartedEvent: avvio dell'applicazione.
  • ContextStoppedEvent: interruzione dell'applicazione.
  • PayloadApplicationEvent: invio di payload personalizzati con gli eventi.
public class CustomApplicationListener implements ApplicationListener<ApplicationContextEvent> {

    @Override
    public void onApplicationEvent(ApplicationContextEvent event) {
        System.out.println(event.getClass() + " Event Class");
    }
}

Dunque, l'utilizzo di ApplicationListener aggiunge un ulteriore livello di personalizzazione e controllo sul comportamento dell'applicazione in risposta agli eventi che si verificano durante la sua esecuzione.

Conclusioni

In conclusione, abbiamo esplorato in dettaglio il processo interno di creazione dei bean all'interno del framework Spring. Da quando istanziamo un AnnotationConfigApplicationContext fino al completamento del metodo finishRefresh(), abbiamo analizzato ogni fase critica del ciclo di vita dei bean e della bean factory e come interagire a runtime con questi due componenti essenziali rispettivamente tramite i BeanPostProcessor ed i BeanFactoryPostProcessor.

Abbiamo visto come Spring gestisce la registrazione dei bean attraverso la lettura delle annotazioni, la scansione delle classi e dei packages, e infine come completa il processo di inizializzazione garantendo che il contesto sia completamente pronto per l'uso.

Questo approfondimento ci ha fornito una panoramica completa del funzionamento interno di Spring e ci ha permesso di comprendere meglio come questo framework potente e flessibile implementa il pattern IoC tramite la gestione delle dipendenze e la creazione dei bean nelle applicazioni Java.

scritto da
Edoardo Patti
Technical Lead
In SMC ricopre il ruolo di Technical Lead
Andrea D'Angeli
Junior Backend Developer
In SMC ricopre il ruolo di Junior Backend Developer
Flavio Cassarà
Junior Backend Developer
In SMC ricopre il ruolo di Junior Backend Developer. È laureato in informatica presso l'Università di Palermo

Potrebbero interessarti anche…