A la lliçó anterior vam escriure public EstacioService(EstacioRepositori estacioRepositori) i Spring va trobar tot sol la implementació correcta. Aquesta capacitat —construir un objecte lliurant-li els seus col·laboradors ja llestos— és el cor del contenidor i la raó per la qual Spring existeix. En aquesta lliçó desmuntarem el mecanisme: què és la inversió de control, quin problema real resol, quins són els tres estils d'injecció i per què només un és defensable, com resol Spring un tipus quan hi ha diverses implementacions candidates, com injectar-les totes de cop en una List o un Map, què cal fer quan una dependència és opcional i com sortir d'una dependència circular sense recórrer a pedaços. Tot això construint la primera peça del sistema de tarifes de la xarxa de Ribalta.
Contingut
- Inversió de control i injecció de dependències
- L'abans i el després: acoblament davant d'injecció
- Els tres tipus d'injecció
- Per què la injecció per constructor és l'única recomanable
- Com resol Spring les dependències
- Diverses implementacions del mateix tipus:
@Primaryi@Qualifier - Injectar totes les implementacions:
ListiMap - Dependències opcionals:
OptionaliObjectProvider - Dependències circulars
- Errors Comuns i Consells
- Exercicis
- Inversió de control i injecció de dependències
Són dos conceptes relacionats però diferents, i convé no barrejar-los.
Inversió de control (IoC) és un principi general: en comptes que el teu codi controli el flux i decideixi quan es crea cada cosa, és un framework el que controla el flux i crida el teu codi quan toca. Quan escrius un CommandLineRunner, tu no l'invoques: ho fa Spring. Quan escrius un @GetMapping, tu no crides el mètode: el crida el DispatcherServlet. Això és inversió de control.
Injecció de dependències (DI) és una manera concreta d'aplicar IoC a la construcció d'objectes: una classe declara quins col·laboradors necessita, però no els crea; algú extern —el contenidor— els hi lliura ja construïts.
flowchart LR
subgraph SENSE["Sense injecció"]
A1["EstacioService"] -->|new| B1["EstacioRepositoriEnMemoria"]
end
subgraph AMB["Amb injecció"]
C["ApplicationContext"] -->|construeix| B2["EstacioRepositoriEnMemoria"]
C -->|construeix i injecta| A2["EstacioService"]
A2 -.->|depèn de la interfície| I["EstacioRepositori"]
end
El contenidor de Spring, l'ApplicationContext, és un injector de dependències: manté un catàleg de beans i sap com satisfer el que cadascun demana.
- L'abans i el després: acoblament davant d'injecció
Vegem-ho amb la peça que construirem en aquesta lliçó: el càlcul de la tarifa d'un lloguer a Ribalta.
Abans: la dependència es crea a dins
package com.ciclourbana.lloguers;
import java.math.BigDecimal;
import java.time.Duration;
public class LloguerService {
// La dependència s'instancia aquí dins
private final TarifaEstandard calculadora = new TarifaEstandard();
public BigDecimal finalitzar(String matricula, Duration durada) {
return calculadora.calcular(durada);
}
}Funciona. I tanmateix té quatre problemes greus:
- Acoblament a una implementació concreta.
LloguerServiceno depèn d'"una manera de calcular tarifes": depèn deTarifaEstandard. Afegir la tarifa d'estudiant obliga a modificar aquesta classe. - Impossible de provar de manera aïllada. No hi ha manera de substituir la calculadora per una de controlada en un test. Qualsevol prova de
LloguerServiceprova també, inevitablement,TarifaEstandard. - Configuració enterrada. Si
TarifaEstandardhagués de llegir el preu per minut de la configuració,LloguerServicehauria de saber com construir-la amb aquests paràmetres. La responsabilitat es contagia. - Cicle de vida descontrolat. Cada
LloguerServicecrea la seva pròpia calculadora. Si hi hagués deu serveis, hi hauria deu calculadores idèntiques ocupant memòria.
Després: la dependència es rep
package com.ciclourbana.lloguers;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
@Service
public class LloguerService {
// Depèn de l'abstracció, no pas d'una implementació
private final CalculadoraTarifa calculadora;
// Spring lliura el col·laborador ja construït
public LloguerService(CalculadoraTarifa calculadora) {
this.calculadora = calculadora;
}
public BigDecimal finalitzar(String matricula, Duration durada) {
return calculadora.calcular(durada);
}
}Els quatre problemes desapareixen:
| Problema | Com es resol |
|---|---|
| Acoblament | LloguerService només coneix la interfície CalculadoraTarifa. |
| Proves | En un test n'hi ha prou amb new LloguerService(durada -> new BigDecimal("2.50")). Sense Spring, sense base de dades, sense res. |
| Configuració | Qui construeix TarifaEstandard és el contenidor, que sap injectar-li les seves propietats. |
| Cicle de vida | Hi ha una instància compartida, gestionada per Spring. |
El segon punt mereix èmfasi: la testabilitat no és un benefici secundari de la injecció de dependències, és pràcticament la seva raó de ser. Quan al mòdul 6 escrivim proves amb Mockito, substituir col·laboradors serà trivial precisament per aquest disseny.
- Els tres tipus d'injecció
Spring admet tres mecanismes. Els veiem amb la mateixa classe per poder comparar-los.
Injecció per constructor (recomanada)
@Service
public class LloguerService {
private final CalculadoraTarifa calculadora;
private final EstacioService estacioService;
// Des de Spring 4.3, @Autowired és innecessari si hi ha UN sol constructor
public LloguerService(CalculadoraTarifa calculadora, EstacioService estacioService) {
this.calculadora = calculadora;
this.estacioService = estacioService;
}
}Injecció per setter
@Service
public class LloguerService {
private CalculadoraTarifa calculadora; // no pot ser final
@Autowired
public void setCalculadora(CalculadoraTarifa calculadora) {
this.calculadora = calculadora;
}
}Injecció per camp
@Service
public class LloguerService {
@Autowired
private CalculadoraTarifa calculadora; // Spring l'assigna per reflexió
}Comparades:
| Criteri | Constructor | Setter | Camp |
|---|---|---|---|
Permet final (immutabilitat) |
Sí | No | No |
| Objecte sempre en estat vàlid | Sí | No (hi ha un instant sense dependència) | No |
| Detecta dependències que falten | A l'arrencada | A l'arrencada | A l'arrencada |
| Detecta dependències circulars | A l'arrencada, amb error clar | Les amaga | Les amaga |
Instanciable amb new en un test |
Sí | Sí, amb setters extra | No (requereix reflexió o Spring) |
Requereix @Autowired |
No (amb un constructor) | Sí | Sí |
| Fa visible l'excés de dependències | Sí: el constructor creix i molesta | Poc | No: tant li fa tenir 15 camps |
| Admet dependències opcionals | Amb @Nullable/ObjectProvider |
Sí, natural | Amb required = false |
| Recomanació oficial de Spring | Sí | Casos opcionals | Desaconsellada |
- Per què la injecció per constructor és l'única recomanable
Cinc arguments, en ordre d'importància:
1. Immutabilitat real. Només el constructor permet declarar els camps final. Un camp final no es pot reassignar per accident ni per una crida concurrent, i el compilador garanteix que està assignat. Amb setters o camps, qualsevol codi pot canviar la dependència en calent.
2. Un objecte no existeix mai a mig construir. Amb injecció per constructor, si l'objecte existeix, les seves dependències estan posades. Amb setter, hi ha una finestra entre new LloguerService() i setCalculadora(...) en què l'objecte és una bomba de NullPointerException.
3. Fallada primerenca i llegible. Si falta un bean, Spring falla a l'arrencada amb un missatge explícit, no pas en producció a les tres de la matinada:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.ciclourbana.lloguers.LloguerService
required a bean of type 'com.ciclourbana.lloguers.CalculadoraTarifa'
that could not be found.
Action:
Consider defining a bean of type 'com.ciclourbana.lloguers.CalculadoraTarifa'
in your configuration.4. Testabilitat sense framework. Aquesta és la diferència pràctica més notable:
// Amb injecció per constructor: una línia, sense Spring
var servei = new LloguerService(durada -> new BigDecimal("1.80"), estacioService);
// Amb injecció per camp: no hi ha manera neta.
// Cal ReflectionTestUtils o aixecar el context sencer
ReflectionTestUtils.setField(servei, "calculadora", calculadoraFalsa);5. La pressió de disseny és sana. Un constructor amb vuit paràmetres fa vergonya d'escriure, i aquesta vergonya és informació: la classe fa massa coses i s'ha de partir. Amb injecció per camp, vuit @Autowired no molesten visualment i la classe creix sense límit. És el pitjor efecte de la injecció per camp, i és cultural, no pas tècnic.
Des de Spring 4.3, si la classe té un únic constructor, @Autowired és opcional. A Spring Boot 3 la pràctica universal és ometre'l. Amb dos constructors o més sí que cal marcar quin s'ha d'utilitzar:
@Service
public class LloguerService {
private final CalculadoraTarifa calculadora;
@Autowired // necessari: hi ha més d'un constructor
public LloguerService(CalculadoraTarifa calculadora) {
this.calculadora = calculadora;
}
// Constructor auxiliar per a proves manuals
public LloguerService() {
this(durada -> BigDecimal.ZERO);
}
}
- Com resol Spring les dependències
L'algorisme, en ordre:
flowchart TD
A["Paràmetre de constructor:<br/>CalculadoraTarifa calculadora"] --> B["Cercar beans assignables<br/>a aquest tipus"]
B --> C{"Quants n'hi ha?"}
C -- "0" --> D["És opcional?"]
D -- No --> E["NoSuchBeanDefinitionException<br/>Fallada d'arrencada"]
D -- "Sí (Optional/@Nullable)" --> F["Injecta null o Optional.empty()"]
C -- "1" --> G["S'injecta aquest bean"]
C -- "2 o més" --> H{"Hi ha @Qualifier<br/>al punt d'injecció?"}
H -- Sí --> I["S'injecta el bean amb aquest nom/qualificador"]
H -- No --> J{"Hi ha un bean @Primary?"}
J -- Sí --> K["S'injecta el @Primary"]
J -- No --> L{"El nom del paràmetre<br/>coincideix amb un nom de bean?"}
L -- Sí --> M["S'injecta per coincidència de nom"]
L -- No --> N["NoUniqueBeanDefinitionException<br/>Fallada d'arrencada"]
Els dos criteris fonamentals són, per aquest ordre: per tipus primer, per nom com a desempat. Molta gent creu que Spring injecta pel nom de la variable; només ho fa com a últim recurs, i dependre'n és fràgil (n'hi ha prou amb reanomenar el paràmetre per trencar-ho).
- Diverses implementacions del mateix tipus:
@Primary i @Qualifier
@Primary i @QualifierArriba el moment de construir el sistema de tarifes de Ribalta. Comencem pel contracte, al nou paquet com.ciclourbana.lloguers:
package com.ciclourbana.lloguers;
import java.math.BigDecimal;
import java.time.Duration;
/**
* Contracte de càlcul de l'import d'un lloguer de la xarxa de Ribalta.
* Les tarifes concretes (estàndard, estudiant, i les que vinguin)
* són implementacions diferents d'aquesta interfície.
*/
public interface CalculadoraTarifa {
/** Import en euros d'un lloguer de la durada indicada. */
BigDecimal calcular(Duration durada);
/** Identificador llegible de la tarifa, per a logs i factures. */
default String nom() {
return getClass().getSimpleName();
}
}I dues implementacions. L'estàndard:
package com.ciclourbana.lloguers;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
/**
* Tarifa general de Ribalta: 0,50 € de desbloqueig + 0,12 €/minut.
* Els imports es fixaran per configuració a la lliçó 02-05.
*/
@Component
@Primary // és la tarifa per defecte de la xarxa
public class TarifaEstandard implements CalculadoraTarifa {
private static final BigDecimal DESBLOQUEIG = new BigDecimal("0.50");
private static final BigDecimal PER_MINUT = new BigDecimal("0.12");
@Override
public BigDecimal calcular(Duration durada) {
BigDecimal minuts = BigDecimal.valueOf(Math.max(1, durada.toMinutes()));
return DESBLOQUEIG
.add(PER_MINUT.multiply(minuts))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String nom() {
return "estandard";
}
}I la d'estudiant:
package com.ciclourbana.lloguers;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
/**
* Tarifa universitària de Ribalta: sense desbloqueig i 0,08 €/minut.
* Els primers 15 minuts són gratuïts (conveni amb la Universitat).
*/
@Component("tarifaEstudiant")
public class TarifaEstudiant implements CalculadoraTarifa {
private static final BigDecimal PER_MINUT = new BigDecimal("0.08");
private static final long MINUTS_GRATIS = 15;
@Override
public BigDecimal calcular(Duration durada) {
long facturables = Math.max(0, durada.toMinutes() - MINUTS_GRATIS);
return PER_MINUT
.multiply(BigDecimal.valueOf(facturables))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String nom() {
return "estudiant";
}
}Ara hi ha dos beans de tipus CalculadoraTarifa. Sense més indicacions, l'arrencada fallaria així:
Parameter 0 of constructor in com.ciclourbana.lloguers.LloguerService
required a single bean, but 2 were found:
- tarifaEstandard: defined in file [.../TarifaEstandard.class]
- tarifaEstudiant: defined in file [.../TarifaEstudiant.class]Tres maneres de desambiguar:
@Primary: designar un guanyador per defecte
Com que hem anotat TarifaEstandard amb @Primary, qualsevol punt d'injecció que demani un CalculadoraTarifa sense més precisió rebrà aquesta. És l'opció correcta quan existeix un cas clarament dominant.
@Service
public class LloguerService {
private final CalculadoraTarifa calculadora; // rep TarifaEstandard
public LloguerService(CalculadoraTarifa calculadora) {
this.calculadora = calculadora;
}
}@Qualifier: demanar-ne una de concreta
@Service
public class LloguerUniversitariService {
private final CalculadoraTarifa calculadora;
public LloguerUniversitariService(
@Qualifier("tarifaEstudiant") CalculadoraTarifa calculadora) {
this.calculadora = calculadora;
}
}@Qualifier guanya sempre a @Primary. El valor és el nom del bean o un qualificador declarat amb @Qualifier sobre la mateixa classe.
Un @Qualifier amb cadenes de text és fràgil: un error d'escriptura només es detecta en arrencar. L'alternativa robusta és un qualificador amb anotació pròpia, que el compilador verifica:
package com.ciclourbana.lloguers;
import org.springframework.beans.factory.annotation.Qualifier;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Universitaria {
}public LloguerUniversitariService(@Universitaria CalculadoraTarifa calculadora) {
this.calculadora = calculadora;
}Ara un error d'escriptura no compila. En projectes grans és clarament preferible.
Comparació
| Mecanisme | On es declara | Quan utilitzar-lo |
|---|---|---|
@Primary |
Sobre el bean | Hi ha una implementació per defecte òbvia i la resta són excepcions. |
@Qualifier("nom") |
Al punt d'injecció | Puntual, quan cal una implementació concreta. |
Qualificador propi (@Universitaria) |
Sobre el bean i al punt d'injecció | Projectes grans: segur davant d'errors d'escriptura, autodocumentat. |
- Injectar totes les implementacions:
List i Map
List i MapA CicloUrbana la tarifa no la tria el desenvolupador: la tria el tipus d'usuari en temps d'execució. Triar amb un if entre dos beans injectats no escala. La solució idiomàtica és demanar a Spring totes les implementacions de cop.
Injecció d'un Map<String, T>
Quan el tipus del paràmetre és Map<String, CalculadoraTarifa>, Spring injecta un mapa amb el nom del bean com a clau i el bean com a valor:
package com.ciclourbana.lloguers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Map;
/**
* Selecciona la tarifa aplicable segons el tipus d'usuari.
* El mapa el construeix Spring: clau = nom del bean.
*/
@Service
public class SelectorTarifa {
private static final Logger log = LoggerFactory.getLogger(SelectorTarifa.class);
private final Map<String, CalculadoraTarifa> tarifesPerNom;
public SelectorTarifa(Map<String, CalculadoraTarifa> tarifesPerNom) {
this.tarifesPerNom = tarifesPerNom;
log.info("Tarifes registrades a la xarxa: {}", tarifesPerNom.keySet());
}
public BigDecimal calcular(String nomTarifa, Duration durada) {
CalculadoraTarifa calculadora = tarifesPerNom.get(nomTarifa);
if (calculadora == null) {
throw new IllegalArgumentException(
"Tarifa desconeguda: " + nomTarifa
+ ". Disponibles: " + tarifesPerNom.keySet());
}
return calculadora.calcular(durada);
}
}A l'arrencada veuràs:
La gran virtut d'aquest patró: afegir una tarifa nova no requereix tocar SelectorTarifa. N'hi ha prou amb crear una classe anotada amb @Component i apareix al mapa. És el principi obert/tancat fet realitat amb dotze línies de codi.
Un detall important: les claus són els noms de bean (tarifaEstandard), no pas els valors de nom() (estandard). Si prefereixes indexar per l'identificador de negoci, construeix-lo tu a partir d'una List:
@Service
public class SelectorTarifa {
private final Map<String, CalculadoraTarifa> tarifesPerNom;
public SelectorTarifa(List<CalculadoraTarifa> tarifes) {
// Indexem per l'identificador de negoci, no pel nom del bean
this.tarifesPerNom = tarifes.stream()
.collect(Collectors.toUnmodifiableMap(
CalculadoraTarifa::nom, Function.identity()));
}
}Amb aquesta variant les claus són estandard i estudiant, que és el que arribarà per l'API al mòdul 3.
Injecció d'una List<T> i @Order
Quan l'ordre importa —una cadena de validacions, per exemple—, Spring respecta @Order en construir la llista: menor valor, abans.
package com.ciclourbana.lloguers;
public interface ValidacioLloguer {
void validar(String matricula, Long estacioId);
}@Component
@Order(1)
public class ValidarBicicletaDisponible implements ValidacioLloguer { /* ... */ }
@Component
@Order(2)
public class ValidarUsuariSenseDeute implements ValidacioLloguer { /* ... */ }
@Component
@Order(3)
public class ValidarLimitLloguersSimultanis implements ValidacioLloguer { /* ... */ }@Service
public class LloguerService {
private final List<ValidacioLloguer> validacions; // ordenada per @Order
public LloguerService(List<ValidacioLloguer> validacions) {
this.validacions = validacions;
}
public void iniciar(String matricula, Long estacioId) {
validacions.forEach(v -> v.validar(matricula, estacioId));
// ... resta del lloguer
}
}Alternatives a @Order: implementar Ordered (permet calcular l'ordre) o utilitzar @Priority de Jakarta. @Order és l'habitual.
Compte amb un matís: @Order afecta l'ordre dins d'una col·lecció injectada, no pas l'ordre de creació dels beans. Per a l'ordre de creació, l'eina és @DependsOn.
- Dependències opcionals:
Optional i ObjectProvider
Optional i ObjectProviderDe vegades un col·laborador pot no existir: un mòdul desactivat, una integració opcional. Hi ha tres maneres d'expressar-ho.
package com.ciclourbana.lloguers;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class LloguerService {
private final CalculadoraTarifa calculadora;
// Opció A: Optional<T>. Mai no és null; queda clar a la signatura.
private final Optional<ServeiNotificacions> notificacions;
// Opció B: @Nullable. Senzill, però el camp pot ser null.
private final @Nullable ServeiFidelitzacio fidelitzacio;
// Opció C: ObjectProvider<T>. La més flexible.
private final ObjectProvider<ServeiAuditoria> auditoria;
public LloguerService(CalculadoraTarifa calculadora,
Optional<ServeiNotificacions> notificacions,
@Nullable ServeiFidelitzacio fidelitzacio,
ObjectProvider<ServeiAuditoria> auditoria) {
this.calculadora = calculadora;
this.notificacions = notificacions;
this.fidelitzacio = fidelitzacio;
this.auditoria = auditoria;
}
public void finalitzar(String matricula) {
// A: s'utilitza si existeix
notificacions.ifPresent(n -> n.avisarFiLloguer(matricula));
// B: comprovació manual
if (fidelitzacio != null) {
fidelitzacio.sumarPunts(matricula);
}
// C: resolució mandrosa, en el moment d'utilitzar-lo
auditoria.ifAvailable(a -> a.registrar("fi-lloguer", matricula));
}
}ObjectProvider mereix un apartat propi perquè és la més potent:
| Mètode | Què fa |
|---|---|
getIfAvailable() |
Retorna el bean o null si no existeix. |
getIfUnique() |
Retorna el bean només si n'hi ha exactament un; null si n'hi ha diversos. |
ifAvailable(Consumer) |
Executa l'acció només si el bean existeix. |
getObject() |
Retorna el bean; llança una excepció si no existeix. Es resol a cada crida. |
stream() |
Tots els beans del tipus, com a flux. |
orderedStream() |
Igual, respectant @Order. |
Dues propietats clau el distingeixen de les altres opcions: la resolució és mandrosa (el bean es busca quan crides el mètode, no pas en construir) i getObject() demana una instància nova cada vegada si el bean és d'àmbit prototype. Aquesta segona propietat és la solució al problema clàssic d'injectar un prototype en un singleton, que veurem a la lliçó 02-03.
- Dependències circulars
Passa quan A necessita B i B necessita A:
@Service
public class LloguerService {
public LloguerService(EstacioService estacioService) { /* ... */ }
}
@Service
public class EstacioService {
public EstacioService(LloguerService lloguerService) { /* ... */ }
}Amb injecció per constructor això és lògicament impossible: per construir A cal B ja construït, i per construir B cal A. Spring ho detecta i falla l'arrencada, que és exactament el que ha de fer:
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| lloguerService defined in file [.../LloguerService.class]
↑ ↓
| estacioService defined in file [.../EstacioService.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default.
Update your application to remove the dependency cycle between beans.A Spring Boot 3 els cicles estan prohibits per defecte. Existeix la propietat spring.main.allow-circular-references=true i existeix @Lazy sobre un dels dos punts d'injecció. No utilitzis cap de les dues com a solució. Totes dues fan desaparèixer el missatge sense arreglar res, i el cicle continua allà, a punt de produir un NullPointerException o un ordre d'inicialització imprevisible.
Un cicle és sempre un símptoma de disseny. Les tres cures reals:
Cura 1: extreure la responsabilitat compartida a un tercer component. Sol ser la millor.
flowchart LR
subgraph MALAMENT["Cicle"]
A["LloguerService"] --> B["EstacioService"]
B --> A
end
subgraph BE["Sense cicle"]
A2["LloguerService"] --> C["DisponibilitatAncoratges"]
B2["EstacioService"] --> C
end
Si LloguerService necessita d'EstacioService només el recompte d'ancoratges lliures, i EstacioService necessita de LloguerService només el nombre de bicicletes en curs, extreu aquest coneixement comú a un DisponibilitatAncoratges del qual depenguin tots dos. El cicle desapareix.
Cura 2: invertir la direcció amb esdeveniments. Si EstacioService només necessita reaccionar a alguna cosa que fa LloguerService, publica un esdeveniment en comptes de cridar el servei:
// A LloguerService: publica, no crida
publicador.publishEvent(new LloguerIniciat(matricula, estacioId));// A EstacioService: escolta, no és cridat
@EventListener
public void enAlliberarseAncoratge(LloguerIniciat esdeveniment) {
// actualitzar el recompte de l'estació
}Ja vam utilitzar @EventListener a la lliçó 01-05 amb els esdeveniments del cicle de vida; aquí és el mateix mecanisme amb esdeveniments propis. L'acoblament passa a ser unidireccional.
Cura 3: revisar qui ha de cridar qui. Sovint el cicle apareix perquè una capa inferior crida una de superior. Si EstacioService crida LloguerService, cal preguntar-se si aquesta lògica no pertany en realitat a un orquestrador per damunt de tots dos.
Errors Comuns i Consells
Utilitzar @Autowired sobre camps privats. Continua sent el primer que apareix en tutorials antics. És la pitjor opció: impedeix final, impedeix construir la classe en un test sense reflexió i amaga el creixement descontrolat de dependències. Si el teu IDE és IntelliJ, veuràs un avís groc: fes-li cas.
Posar @Autowired a l'únic constructor. No és un error, però és soroll: des de Spring 4.3 és innecessari. Elimina'l.
Instanciar un bean amb new. new EstacioService(...) crea un objecte que no és un bean: no rep injeccions, no té cicle de vida gestionat, no li funcionen @Transactional ni @Cacheable. Si necessites un objecte de Spring, demana'l per constructor.
Confiar en la coincidència de noms. Escriure el paràmetre tarifaEstudiant perquè Spring triï aquest bean funciona, però és fràgil: en reanomenar el paràmetre —una cosa que un IDE fa sense avisar— l'arrencada es trenca o, pitjor, s'injecta silenciosament un altre bean. Utilitza @Qualifier explícit.
Injectar ApplicationContext per fer getBean(...). És service locator, l'antipatró que la injecció de dependències va venir a substituir: amaga les dependències reals de la classe i fa les proves impossibles. Es justifica en eines i diagnòstics (com el VerificadorBeans de la lliçó anterior), mai en lògica de negoci.
Resoldre un cicle amb @Lazy. Ho repetim per la seva importància: no és una solució, és una anestèsia. Redissenya.
Consell: si el constructor passa de quatre o cinc paràmetres, atura't. Gairebé sempre significa que la classe té més d'una responsabilitat. Extreu, no augmentis.
Consell: depèn sempre d'interfícies a les fronteres entre capes. EstacioService depèn d'EstacioRepositori, no pas d'EstacioRepositoriEnMemoria. És el que permetrà que el mòdul 4 canviï la implementació sense tocar el servei. Dins d'una mateixa capa, en canvi, crear una interfície amb una sola implementació sol ser cerimònia inútil.
Consell: no anotis classes de domini. Estacio i els futurs Lloguer o Bicicleta són dades, es creen amb new i no participen en la injecció.
Exercicis
Exercici 1: tercera tarifa sense modificar el selector
Afegeix a la xarxa de Ribalta una tarifa TarifaJubilat: sense desbloqueig, 0,05 €/minut, amb els primers 30 minuts gratuïts. Comprova que apareix automàticament al SelectorTarifa construït a partir d'una List<CalculadoraTarifa>, sense modificar ni una línia de SelectorTarifa. Registra a l'arrencada les tarifes disponibles i l'import d'un lloguer de 45 minuts amb cadascuna.
Exercici 2: cadena ordenada de validacions
Implementa tres validacions de lloguer (ValidacioLloguer) amb @Order: la matrícula ha de tenir el format RB-NNNN; l'estació d'origen ha d'existir a la xarxa; l'estació ha de tenir com a mínim una bicicleta (simula-ho amb la capacitat). Injecta-les com a List<ValidacioLloguer> a LloguerService i verifica que s'executen en ordre llançant un lloguer no vàlid.
Exercici 3: trencar un cicle amb esdeveniments
Provoca deliberadament una dependència circular entre LloguerService i EstacioService (injecta cadascun a l'altre), arrenca l'aplicació i anota el missatge d'error. Després trenca el cicle publicant un esdeveniment LloguerIniciat des de LloguerService i escoltant-lo a EstacioService.
Solucions
Solució 1
package com.ciclourbana.lloguers;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
/**
* Tarifa per a més grans de 65 anys empadronats a Ribalta:
* sense desbloqueig, 0,05 €/minut i 30 minuts gratuïts.
*/
@Component
public class TarifaJubilat implements CalculadoraTarifa {
private static final BigDecimal PER_MINUT = new BigDecimal("0.05");
private static final long MINUTS_GRATIS = 30;
@Override
public BigDecimal calcular(Duration durada) {
long facturables = Math.max(0, durada.toMinutes() - MINUTS_GRATIS);
return PER_MINUT
.multiply(BigDecimal.valueOf(facturables))
.setScale(2, RoundingMode.HALF_UP);
}
@Override
public String nom() {
return "jubilat";
}
}El selector, sense tocar (així és com ha de quedar):
package com.ciclourbana.lloguers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class SelectorTarifa {
private static final Logger log = LoggerFactory.getLogger(SelectorTarifa.class);
private final Map<String, CalculadoraTarifa> perNomNegoci;
public SelectorTarifa(List<CalculadoraTarifa> tarifes) {
this.perNomNegoci = tarifes.stream()
.collect(Collectors.toUnmodifiableMap(
CalculadoraTarifa::nom, Function.identity()));
log.info("Tarifes disponibles a Ribalta: {}", perNomNegoci.keySet());
}
public BigDecimal calcular(String tarifa, Duration durada) {
CalculadoraTarifa calculadora = perNomNegoci.get(tarifa);
if (calculadora == null) {
throw new IllegalArgumentException("Tarifa desconeguda: " + tarifa
+ ". Disponibles: " + perNomNegoci.keySet());
}
return calculadora.calcular(durada);
}
public Map<String, CalculadoraTarifa> disponibles() {
return perNomNegoci;
}
}I el comprovador d'arrencada:
package com.ciclourbana.lloguers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@Order(10)
public class DemostracioTarifes implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DemostracioTarifes.class);
private final SelectorTarifa selectorTarifa;
public DemostracioTarifes(SelectorTarifa selectorTarifa) {
this.selectorTarifa = selectorTarifa;
}
@Override
public void run(String... args) {
Duration durada = Duration.ofMinutes(45);
selectorTarifa.disponibles().keySet().forEach(tarifa ->
log.info("Lloguer de 45 min amb tarifa '{}': {} €",
tarifa, selectorTarifa.calcular(tarifa, durada)));
}
}Sortida:
c.c.lloguers.SelectorTarifa : Tarifes disponibles a Ribalta: [estandard, estudiant, jubilat]
c.c.l.DemostracioTarifes : Lloguer de 45 min amb tarifa 'estandard': 5.90 €
c.c.l.DemostracioTarifes : Lloguer de 45 min amb tarifa 'estudiant': 2.40 €
c.c.l.DemostracioTarifes : Lloguer de 45 min amb tarifa 'jubilat': 0.75 €Comentari: el punt de l'exercici és comprovar que SelectorTarifa no ha canviat. Estendre el sistema ha consistit únicament a afegir una classe. Error freqüent: oblidar @Component a la tarifa nova; llavors no apareix a la llista i la fallada és silenciosa, sense missatge d'error. Si alguna cosa no apareix en una col·lecció injectada, el primer que cal mirar és si la classe és realment un bean.
Solució 2
package com.ciclourbana.lloguers;
public interface ValidacioLloguer {
void validar(String matricula, Long estacioId);
}package com.ciclourbana.lloguers;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
@Component
@Order(1)
public class ValidarFormatMatricula implements ValidacioLloguer {
private static final Pattern FORMAT = Pattern.compile("RB-\\d{4}");
@Override
public void validar(String matricula, Long estacioId) {
if (matricula == null || !FORMAT.matcher(matricula).matches()) {
throw new IllegalArgumentException(
"Matrícula no vàlida: '" + matricula + "'. Format esperat: RB-0142");
}
}
}package com.ciclourbana.lloguers;
import com.ciclourbana.estacions.EstacioService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(2)
public class ValidarEstacioExisteix implements ValidacioLloguer {
private final EstacioService estacioService;
public ValidarEstacioExisteix(EstacioService estacioService) {
this.estacioService = estacioService;
}
@Override
public void validar(String matricula, Long estacioId) {
estacioService.cercarPerId(estacioId).orElseThrow(() ->
new IllegalArgumentException(
"L'estació " + estacioId + " no existeix a la xarxa de Ribalta"));
}
}package com.ciclourbana.lloguers;
import com.ciclourbana.estacions.EstacioService;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(3)
public class ValidarEstacioOperativa implements ValidacioLloguer {
private final EstacioService estacioService;
public ValidarEstacioOperativa(EstacioService estacioService) {
this.estacioService = estacioService;
}
@Override
public void validar(String matricula, Long estacioId) {
// Simulació: fins al mòdul 4 no hi ha inventari real de bicicletes
estacioService.cercarPerId(estacioId)
.filter(estacio -> estacio.capacitat() >= 8)
.orElseThrow(() -> new IllegalStateException(
"L'estació " + estacioId + " està fora de servei"));
}
}package com.ciclourbana.lloguers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class LloguerService {
private static final Logger log = LoggerFactory.getLogger(LloguerService.class);
private final CalculadoraTarifa calculadoraPerDefecte; // el @Primary
private final List<ValidacioLloguer> validacions; // ordenades per @Order
public LloguerService(CalculadoraTarifa calculadoraPerDefecte,
List<ValidacioLloguer> validacions) {
this.calculadoraPerDefecte = calculadoraPerDefecte;
this.validacions = validacions;
log.info("LloguerService amb tarifa '{}' i {} validacions: {}",
calculadoraPerDefecte.nom(),
validacions.size(),
validacions.stream().map(v -> v.getClass().getSimpleName()).toList());
}
public void iniciar(String matricula, Long estacioId) {
validacions.forEach(v -> v.validar(matricula, estacioId));
log.info("Lloguer iniciat: bicicleta {} a l'estació {}", matricula, estacioId);
}
}Sortida a l'arrencada i en provar amb una matrícula no vàlida:
c.c.lloguers.LloguerService : LloguerService amb tarifa 'estandard' i 3 validacions:
[ValidarFormatMatricula, ValidarEstacioExisteix, ValidarEstacioOperativa]
...
java.lang.IllegalArgumentException: Matrícula no vàlida: 'RB-14'. Format esperat: RB-0142Comentari: en injectar una List, Spring ordena per @Order de menor a major. Si treus els @Order, l'ordre passa a ser el de descobriment de l'escaneig, que és estable a la pràctica però no està garantit: no en depenguis mai. Consell: quan l'ordre importa de debò, deixa salts entre els valors (10, 20, 30) per poder inserir validacions intermèdies sense renumerar.
Solució 3
Primer, el cicle deliberat:
@Service
public class LloguerService {
public LloguerService(EstacioService estacioService) { /* ... */ }
}
@Service
public class EstacioService {
// Provoca el cicle
public EstacioService(EstacioRepositori repo, LloguerService lloguerService) { /* ... */ }
}L'arrencada falla amb el requadre ┌─────┐ ... └─────┘ mostrat a l'apartat 9. Aquest diagrama al log és una de les millors eines de diagnòstic de Spring Boot: diu exactament quins beans formen el cicle i on estan definits.
Ara la solució amb esdeveniments. L'esdeveniment, un record al paquet de lloguers:
package com.ciclourbana.lloguers;
import java.time.Instant;
/** Esdeveniment de domini: s'ha iniciat un lloguer a la xarxa. */
public record LloguerIniciat(String matricula, Long estacioId, Instant moment) {
public LloguerIniciat(String matricula, Long estacioId) {
this(matricula, estacioId, Instant.now());
}
}L'emissor:
package com.ciclourbana.lloguers;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class LloguerService {
private final ApplicationEventPublisher publicador;
// Ja no injecta EstacioService: el cicle desapareix
public LloguerService(ApplicationEventPublisher publicador) {
this.publicador = publicador;
}
public void iniciar(String matricula, Long estacioId) {
// ... validacions i lògica del lloguer ...
publicador.publishEvent(new LloguerIniciat(matricula, estacioId));
}
}El receptor:
package com.ciclourbana.estacions;
import com.ciclourbana.lloguers.LloguerIniciat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
public class EstacioService {
private static final Logger log = LoggerFactory.getLogger(EstacioService.class);
private final EstacioRepositori estacioRepositori;
// Sense LloguerService: dependència unidireccional
public EstacioService(EstacioRepositori estacioRepositori) {
this.estacioRepositori = estacioRepositori;
}
@EventListener
public void enIniciarseUnLloguer(LloguerIniciat esdeveniment) {
log.info("Ancoratge alliberat a l'estació {} per la bicicleta {}",
esdeveniment.estacioId(), esdeveniment.matricula());
// Al mòdul 4 això actualitzarà el recompte persistit
}
// ... resta de mètodes de la lliçó anterior
}Comentari: ApplicationEventPublisher és un bean que Spring proporciona sempre; injectar-lo no crea dependència amb ningú. El resultat és que LloguerService no sap qui reacciona als seus esdeveniments: podrien ser zero, un o cinc oients. L'acoblament passa de bidireccional a inexistent.
Dues precaucions que convé conèixer des d'ara: els @EventListener són síncrons per defecte (s'executen al mateix fil i dins de la mateixa transacció que l'emissor); si l'oient llança una excepció, la propaga a l'emissor. Fer-los asíncrons amb @Async es tracta a la lliçó 07-03.
Conclusió
La injecció de dependències ha deixat de ser una anotació que es copia. Saps que IoC és el principi —el framework crida el teu codi— i que DI és la seva aplicació a la construcció d'objectes. Has vist en un exemple concret els quatre problemes que causa un new dins d'una classe i com desapareixen en rebre el col·laborador per constructor. Coneixes els tres estils d'injecció i els cinc arguments pels quals només el constructor és defensable, amb la testabilitat i la pressió de disseny al capdavant. Saps com resol Spring un punt d'injecció: per tipus primer, amb @Qualifier per damunt de @Primary, i pel nom només com a últim recurs. Saps injectar totes les implementacions d'una interfície en un Map o una List ordenada amb @Order, un patró que fa extensible el sistema sense tocar el codi que el consumeix. Saps expressar una dependència opcional amb Optional, @Nullable o ObjectProvider. I saps que una dependència circular no es pedaça amb @Lazy: es redissenya extraient allò comú o invertint la direcció amb esdeveniments.
CicloUrbana ja té el seu primer sistema extensible: la interfície CalculadoraTarifa amb TarifaEstandard (marcada @Primary) i TarifaEstudiant, el SelectorTarifa que les indexa per nom de negoci i un LloguerService que orquestra validacions ordenades. Els imports continuen escrits a foc en constants; a la lliçó 02-05 els traurem a la configuració.
Abans d'això queda una pregunta que hem fregat diverses vegades sense respondre. Hem dit que Spring crea una instància de cada bean i que per això EstacioRepositoriEnMemoria utilitza un ConcurrentHashMap. Per què només una? Pot haver-n'hi més? Quant viu cada bean i què passa exactament entre que s'instancia i queda disponible? La lliçó següent, Àmbit i Cicle de Vida dels Beans, respon tot això: els àmbits disponibles, el perill de l'estat mutable en un singleton, el cicle de vida complet amb @PostConstruct i @PreDestroy, la inicialització mandrosa i el punt d'extensió —BeanPostProcessor— sobre el qual es recolza bona part de la màgia aparent de Spring.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
