Des de la primera lliçó anem repetint que Spring Boot "detecta el que hi ha al classpath i registra els beans apropiats". Afegim spring-boot-starter-web al pom.xml i apareixen Tomcat, Jackson, el DispatcherServlet i una vintena de peces més sense escriure ni una sola línia de configuració. Ho hem acceptat com una caixa negra el mòdul sencer. Ara l'obrim del tot. Veurem què fa exactament @EnableAutoConfiguration, on és escrita la llista de candidats, com es decideix condició a condició què es registra i què no, per què n'hi ha prou amb declarar el teu propi bean perquè Spring Boot s'aparti, i com llegir l'informe d'autoconfiguració per respondre la pregunta més frustrant del desenvolupador de Spring: «per què no s'ha creat el meu bean?». I acabarem construint un starter propi, ciclourbana-tarifes-spring-boot-starter, empaquetant el sistema de tarifes de Ribalta perquè una altra aplicació el pugui utilitzar només declarant una dependència.
Contingut
- Què fa realment
@EnableAutoConfiguration - El fitxer
AutoConfiguration.imports - L'
AutoConfigurationImportSelectorpas a pas - Les anotacions condicionals
@ConditionalOnMissingBean: «defineix el teu bean i Spring s'aparta»- Llegir una autoconfiguració real de Spring Boot
- L'ordre entre autoconfiguracions
- L'informe d'autoconfiguració
- Excloure autoconfiguracions
- Crear un starter propi
- Provar l'starter amb
ApplicationContextRunner - Errors Comuns i Consells
- Exercicis
- Què fa realment
@EnableAutoConfiguration
@EnableAutoConfigurationA la lliçó 02-01 vam desmuntar @SpringBootApplication en tres anotacions i en vam deixar una de pendent. La seva declaració real és:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class) // <-- aquí hi ha tot
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
Class<?>[] exclude() default {};
String[] excludeName() default {};
}Només hi ha dues peces:
@AutoConfigurationPackageregistra el paquet de la classe anotada (com.ciclourbana) com a "paquet d'autoconfiguració". Altres autoconfiguracions el consulten per saber on han de buscar: és així com Spring Data JPA trobarà les nostres entitats al mòdul 4 sense que li diguem on són.@Import(AutoConfigurationImportSelector.class)és el motor.@Importés una anotació de Spring Framework que afegeix classes de configuració al context; quan el que importes és unImportSelector, Spring li pregunta en temps d'arrencada quines classes ha d'importar. És a dir: la llista no està escrita, es calcula.
La conclusió important: l'autoconfiguració no és un mecanisme especial ni privilegiat. És @Import amb una classe que decideix dinàmicament què importar. Tota la resta és la lògica d'aquesta decisió.
- El fitxer
AutoConfiguration.imports
AutoConfiguration.importsD'on treu el selector la llista de candidats? D'un fitxer de text pla que cada jar pot aportar:
Obre'l al teu propi projecte. És dins del jar spring-boot-autoconfigure:
unzip -p ~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/3.4.1/spring-boot-autoconfigure-3.4.1.jar \
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports" | head -20org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration org.springframework.boot.autoconfigure.aop.AopAutoConfiguration org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration ...
Compta quantes n'hi ha:
unzip -p ~/.m2/repository/org/springframework/boot/spring-boot-autoconfigure/3.4.1/spring-boot-autoconfigure-3.4.1.jar \
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports" | wc -lCent cinquanta-vuit classes candidates. Això és tot el "misteri" de l'autoconfiguració: una llista de noms de classe en un fitxer de text. El que fa que només unes poques s'apliquin a CicloUrbana són les condicions que veurem a l'apartat 4.
L'antecessor: spring.factories
Fins a Spring Boot 2.7, la llista vivia a META-INF/spring.factories, un fitxer de propietats que servia per a moltes coses alhora:
# Format antic (Spring Boot <= 2.7). Ja NO s'utilitza per a autoconfiguració.
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfigurationspring.factories (≤ 2.7) |
AutoConfiguration.imports (≥ 2.7, obligatori a 3.x) |
|
|---|---|---|
| Ubicació | META-INF/spring.factories |
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports |
| Format | Propietats amb \ de continuació |
Un nom de classe per línia |
| Propens a errors | Sí (les barres invertides) | No |
| Cost de lectura | Alt: es processa tot el fitxer | Menor |
| Vigència per a autoconfiguració | Eliminat a Spring Boot 3 | L'actual |
spring.factories continua existint per a altres punts d'extensió (ApplicationListener, EnvironmentPostProcessor...), però no per a autoconfiguració. Si migres un starter antic a Boot 3 i no canvies el fitxer, les teves autoconfiguracions simplement no s'apliquen, sense cap error. És un dels paranys més habituals de la migració.
- L'
AutoConfigurationImportSelector pas a pas
AutoConfigurationImportSelector pas a pasAquest és l'algorisme complet, de l'arrencada als beans registrats:
flowchart TD
A["@EnableAutoConfiguration"] --> B["AutoConfigurationImportSelector"]
B --> C["1. Llegir TOTS els fitxers<br/>AutoConfiguration.imports del classpath<br/>(158 classes a spring-boot-autoconfigure<br/>+ les de cada starter propi)"]
C --> D["2. Eliminar duplicats"]
D --> E["3. Treure les excloses<br/>exclude=, spring.autoconfigure.exclude"]
E --> F["4. Aplicar els AutoConfigurationImportFilter<br/>OnClassCondition: descarta ràpid<br/>el que no té les seves classes"]
F --> G["5. Ordenar<br/>@AutoConfigureOrder,<br/>@AutoConfigureBefore/After"]
G --> H["6. Registrar com a<br/>classes de configuració"]
H --> I["7. Avaluar les condicions<br/>de cada classe i de cada @Bean"]
I --> J{"Es compleixen?"}
J -- Sí --> K["Beans registrats"]
J -- No --> L["Descartada:<br/>apareix a Negative matches"]
El pas 4 mereix una nota de rendiment. Avaluar les condicions de 158 classes seria lent si calgués carregar cadascuna. Spring Boot ho evita amb dues optimitzacions: els AutoConfigurationImportFilter (en particular OnClassCondition) descarten candidats llegint únicament les metadades precalculades a META-INF/spring-autoconfigure-metadata.properties, sense carregar les classes; i el filtratge es reparteix en diversos fils. Per això una aplicació Spring Boot arrenca en dos segons i no pas en vint.
- Les anotacions condicionals
Una classe d'autoconfiguració porta anotacions que expressen sota quines condicions s'ha d'aplicar. Aquestes són les que veuràs un cop i un altre:
| Anotació | S'aplica si... | Exemple real |
|---|---|---|
@ConditionalOnClass |
La classe és al classpath | @ConditionalOnClass(DispatcherServlet.class) |
@ConditionalOnMissingClass |
La classe no hi és | @ConditionalOnMissingClass("com.altre.Motor") |
@ConditionalOnBean |
Ja existeix un bean d'aquest tipus | @ConditionalOnBean(DataSource.class) |
@ConditionalOnMissingBean |
No existeix un bean d'aquest tipus | @ConditionalOnMissingBean(ObjectMapper.class) |
@ConditionalOnProperty |
Una propietat té cert valor | @ConditionalOnProperty(name = "ciclourbana.tarifes.enabled", havingValue = "true") |
@ConditionalOnWebApplication |
És una aplicació web | @ConditionalOnWebApplication(type = SERVLET) |
@ConditionalOnNotWebApplication |
No és una aplicació web | Tasques per lots |
@ConditionalOnResource |
Existeix un recurs | @ConditionalOnResource(resources = "classpath:tarifes.json") |
@ConditionalOnExpression |
Una expressió SpEL és certa | @ConditionalOnExpression("${ciclourbana.xarxa.capacitat-minima:8} > 4") |
@ConditionalOnJava |
La versió de Java compleix | @ConditionalOnJava(JavaVersion.TWENTY_ONE) |
@ConditionalOnSingleCandidate |
N'hi ha exactament un, o un @Primary |
@ConditionalOnSingleCandidate(DataSource.class) |
Totes deriven de @Conditional, una anotació de Spring Framework que accepta una implementació de la interfície Condition:
public interface Condition {
boolean matches(ConditionContext context, AnnotatedTypeMetadata metadades);
}En pots escriure una de teva. Per exemple, una condició per a CicloUrbana que només activi un bean si la xarxa té configurada com a mínim una estació destacada:
package com.ciclourbana.comu;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
/** Es compleix si hi ha com a mínim una estació destacada configurada. */
public class HiHaEstacionsDestacades implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadades) {
String valor = context.getEnvironment()
.getProperty("ciclourbana.xarxa.estacions-destacades");
return valor != null && !valor.isBlank();
}
}@Bean
@Conditional(HiHaEstacionsDestacades.class)
public PanellDestacades panellDestacades(XarxaProperties xarxa) {
return new PanellDestacades(xarxa.estacionsDestacades());
}Un matís important sobre @ConditionalOnProperty, que és la més utilitzada en configuració d'aplicacions:
@ConditionalOnProperty(
prefix = "ciclourbana.tarifes",
name = "enabled",
havingValue = "true",
matchIfMissing = true) // si la propietat NO hi és, es considera complertamatchIfMissing = true és el que permet que una funcionalitat estigui activa per defecte i es pugui desactivar explícitament. Sense ell, caldria declarar la propietat perquè funcionés res.
@ConditionalOnMissingBean: «defineix el teu bean i Spring s'aparta»
@ConditionalOnMissingBean: «defineix el teu bean i Spring s'aparta»De totes les condicionals, aquesta és la que defineix la filosofia de Spring Boot i val la pena entendre-la a fons.
@Bean
@ConditionalOnMissingBean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
return builder.createXmlMapper(false).build();
}Llegeix-ho així: «si l'usuari no ha definit el seu propi ObjectMapper, jo en poso un de raonable; si l'ha definit, callo». Aquesta és exactament la promesa de Spring Boot: valors per defecte assenyats que mai no et bloquegen.
És el que passa quan declares el teu propi bean d'un tipus autoconfigurat:
package com.ciclourbana.comu;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class ConfiguracioJson {
/**
* ObjectMapper propi de CicloUrbana. En existir aquest bean,
* JacksonAutoConfiguration NO registrarà el seu.
*/
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.findAndRegisterModules();
}
}A partir d'aquest moment, l'ObjectMapper de l'autoconfiguració desapareix de l'informe d'arrencada i passa a la secció Negative matches amb el motiu: "found beans of type ObjectMapper".
L'ordre importa, i molt
Hi ha una subtilesa crítica: @ConditionalOnMissingBean s'avalua en el moment en què es processa aquesta classe de configuració, no pas al final de l'arrencada. I les autoconfiguracions es processen després de les teves classes, precisament perquè els teus beans ja estiguin registrats quan s'avaluïn. Aquest ordre és deliberat i és el que fa que el mecanisme funcioni.
D'aquí se'n segueix una regla d'or: @ConditionalOnMissingBean és per a autoconfiguracions, no pas per al codi de la teva aplicació. Si la utilitzes entre dues de les teves pròpies classes de configuració, el resultat depèn de l'ordre de processament, que no controles, i obtindràs un comportament aparentment aleatori.
Variants
// Per tipus (l'habitual; sense arguments utilitza el tipus de retorn del mètode)
@ConditionalOnMissingBean(CalculadoraTarifa.class)
// Per nom de bean
@ConditionalOnMissingBean(name = "calculadoraTarifaPersonalitzada")
// Per anotació present en algun bean
@ConditionalOnMissingBean(annotation = ServeiXarxa.class)
// Ignorant certs tipus en comprovar
@ConditionalOnMissingBean(value = CalculadoraTarifa.class, ignored = TarifaProves.class)
- Llegir una autoconfiguració real de Spring Boot
La millor manera d'entendre el mecanisme és llegir una classe real. Prenguem una versió resumida de JacksonAutoConfiguration, responsable que el nostre endpoint /api/v1/estacions retorni JSON:
package org.springframework.boot.autoconfigure.jackson;
@AutoConfiguration // 1
@ConditionalOnClass(ObjectMapper.class) // 2
public class JacksonAutoConfiguration {
@Configuration(proxyBeanMethods = false) // 3
@ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
static class JacksonObjectMapperConfiguration {
@Bean
@Primary // 4
@ConditionalOnMissingBean // 5
ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
return builder.createXmlMapper(false).build();
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
static class JacksonObjectMapperBuilderConfiguration {
@Bean
@ConditionalOnMissingBean
Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder(
ApplicationContext context,
List<Jackson2ObjectMapperBuilderCustomizer> personalitzadors) { // 6
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.applicationContext(context);
personalitzadors.forEach(p -> p.customize(builder));
return builder;
}
}
}Punt per punt:
@AutoConfiguration(Spring Boot 3) substitueix l'antic@Configuration+@AutoConfigureAfter. És una meta-anotació que ja inclou@Configuration(proxyBeanMethods = false)i accepta els atributsbefore,afteribeforeName/afterName.@ConditionalOnClass(ObjectMapper.class): si Jackson no és al classpath, tota la classe es descarta de cop. Aquí hi ha la resposta a "Spring Boot detecta el que hi ha al classpath": és literalment aquesta anotació.- Classes internes de configuració: permeten agrupar beans amb condicions diferents dins d'una mateixa autoconfiguració.
@Primary: si l'usuari defineix un altreObjectMapperamb un altre nom, el de Spring Boot continua sent el preferit per a les injeccions sense qualificar.@ConditionalOnMissingBean: la cortesia de Spring Boot.- El patró customizer: en lloc d'obligar-te a redefinir el bean sencer per canviar un detall, l'autoconfiguració recull tots els beans
Jackson2ObjectMapperBuilderCustomizerdel context i els aplica.
Aquest darrer punt és un patró molt útil que pots aprofitar avui mateix. Perquè CicloUrbana serialitzi les dates en format ISO sense substituir l'ObjectMapper complet:
package com.ciclourbana.comu;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class ConfiguracioJson {
/**
* Ajusta l'ObjectMapper autoconfigurat sense reemplaçar-lo:
* conservem tots els valors per defecte de Spring Boot.
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer personalitzadorJson() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
}
}Regla pràctica: quan vulguis canviar un detall d'alguna cosa autoconfigurada, busca primer si existeix una interfície *Customizer. Reemplaçar el bean sencer és l'opció nuclear i et deixa fora de totes les millores futures de Spring Boot.
- L'ordre entre autoconfiguracions
Algunes autoconfiguracions depenen del resultat d'unes altres. JpaRepositoriesAutoConfiguration necessita que ja existeixi un DataSource, així que s'ha d'avaluar després de DataSourceAutoConfiguration. Tres anotacions ho controlen:
| Anotació | Efecte |
|---|---|
@AutoConfigureAfter(X.class) |
S'avalua després de X |
@AutoConfigureBefore(X.class) |
S'avalua abans de X |
@AutoConfigureOrder(n) |
Prioritat numèrica; menor valor, abans |
A Spring Boot 3 s'expressen com a atributs de @AutoConfiguration:
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
public class PersistenciaPropiaAutoConfiguration { }És fonamental entendre per què importa l'ordre: la condició @ConditionalOnBean(DataSource.class) només es compleix si el DataSource ja està registrat quan s'avalua. Si la teva autoconfiguració s'avalués abans, la condició fallaria i el teu bean no es crearia mai, sense cap missatge d'error. Aquest és l'origen del 90 % dels "la meva autoconfiguració no funciona".
D'aquí la regla: @ConditionalOnBean gairebé sempre necessita un after que l'acompanyi.
Un advertiment addicional: @AutoConfigureAfter ordena únicament entre autoconfiguracions. Les classes de configuració de la teva aplicació sempre es processen abans que totes elles, i aquest ordre no es pot alterar (ni cal: és justament el que fa funcionar @ConditionalOnMissingBean).
- L'informe d'autoconfiguració
Quan un bean no apareix i no saps per què, aquesta és l'eina. Arrenca CicloUrbana amb --debug:
O, de manera equivalent:
Obtindràs un informe amb aquesta estructura:
============================
CONDITIONS EVALUATION REPORT
============================
Positive matches:
-----------------
DispatcherServletAutoConfiguration matched:
- @ConditionalOnClass found required class
'org.springframework.web.servlet.DispatcherServlet' (OnClassCondition)
- found 'session' scope (OnWebApplicationCondition)
JacksonAutoConfiguration#jacksonObjectMapper matched:
- @ConditionalOnMissingBean (types: com.fasterxml.jackson.databind.ObjectMapper;
SearchStrategy: all) did not find any beans (OnBeanCondition)
Negative matches:
-----------------
DataSourceAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class
'javax.sql.DataSource' (OnClassCondition)
SecurityAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class
'org.springframework.security.authentication.DefaultAuthenticationEventPublisher'
(OnClassCondition)
Exclusions:
-----------
None
Unconditional classes:
----------------------
org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfigurationCom llegir-lo:
| Secció | Què conté | Quan la mires |
|---|---|---|
| Positive matches | Autoconfiguracions aplicades, amb la condició que es va complir | Per confirmar que alguna cosa s'ha activat i saber per què |
| Negative matches | Descartades, amb la condició que va fallar | La més útil: diu per què no tens el teu bean |
| Exclusions | Excloses explícitament | En depurar una exclusió |
| Unconditional classes | S'apliquen sempre, sense condicions | Poques vegades |
Un flux de diagnòstic que funciona:
flowchart TD
A["El meu bean no existeix"] --> B["Arrencar amb --debug"]
B --> C["Buscar l'autoconfiguració<br/>a Negative matches"]
C --> D{"Hi apareix?"}
D -- Sí --> E["Llegir 'Did not match':<br/>diu la condició exacta que va fallar"]
E --> F1["OnClassCondition:<br/>falta una dependència<br/>→ revisa el pom.xml"]
E --> F2["OnBeanCondition:<br/>ja hi ha un bean, o en falta<br/>un del qual depèn<br/>→ revisa l'ordre"]
E --> F3["OnPropertyCondition:<br/>falta o no coincideix<br/>una propietat"]
D -- No --> G{"És a Exclusions?"}
G -- Sí --> H["Treu l'exclusió"]
G -- No --> I["La classe no és a cap<br/>AutoConfiguration.imports:<br/>falta la dependència sencera?"]
En lloc de --debug, que és molt verbós, pots activar només l'informe:
logging:
level:
org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLogger: DEBUGI hi ha una alternativa encara millor quan Actuator estigui disponible (lliçó 07-01): l'endpoint /actuator/conditions retorna el mateix informe en JSON, filtrable i consultable en calent.
- Excloure autoconfiguracions
De vegades vols desactivar una autoconfiguració: perquè no la necessites, perquè interfereix, o perquè vols configurar aquesta part manualment. Hi ha tres formes.
A l'anotació
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
SecurityAutoConfiguration.class
})
public class CicloUrbanaApplication { }Per nom (si la classe no és al classpath en compilació)
@SpringBootApplication(excludeName = {
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"
})
public class CicloUrbanaApplication { }Per propietat (la més flexible)
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration| Forma | Avantatge | Inconvenient |
|---|---|---|
exclude a l'anotació |
Amb seguretat de tipus; el compilador la valida | Fixa al codi, igual a tots els entorns |
excludeName |
Funciona sense la classe al classpath | Cadena de text sense validar |
spring.autoconfigure.exclude |
Configurable per entorn o perfil | Un error d'escriptura falla l'arrencada |
Un cas real i freqüent: has afegit spring-boot-starter-data-jpa per preparar-te per al mòdul 4 però encara no tens base de dades. DataSourceAutoConfiguration intenta crear un DataSource, no troba URL i l'arrencada falla amb "Failed to configure a DataSource". La solució temporal és excloure-la:
spring:
autoconfigure:
exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfigurationAmb un advertiment: excloure és un pedaç. Sol ser millor treure la dependència fins que la necessitis, o —el que és correcte en aquest cas— configurar una base de dades en memòria H2, que és el que farem a la lliçó 04-02.
- Crear un starter propi
Arribem a la part pràctica. Empaquetarem el sistema de tarifes de Ribalta com un starter reutilitzable, de manera que una altra aplicació municipal (la de patinets, per exemple) el pugui utilitzar declarant una sola dependència.
La convenció de noms
| Tipus d'starter | Convenció | Exemple |
|---|---|---|
| Oficial de Spring Boot | spring-boot-starter-* |
spring-boot-starter-web |
| De tercers | *-spring-boot-starter |
ciclourbana-tarifes-spring-boot-starter |
El prefix spring-boot-starter- està reservat per als starters oficials. Un starter de tercers posa el seu nom al davant. La convenció anàloga per al mòdul d'autoconfiguració és *-spring-boot-autoconfigure.
En un starter seriós se separen dos artefactes:
flowchart LR
A["ciclourbana-tarifes-spring-boot-autoconfigure<br/>El codi: autoconfiguració,<br/>properties, servei"] --> B["ciclourbana-tarifes-spring-boot-starter<br/>Només un pom.xml amb dependències"]
B --> C["Aplicació municipal<br/>declara UNA dependència"]
L'starter és un artefacte sense codi: només un pom.xml que agrupa l'autoconfiguració i les dependències que aquesta necessita. Per a aquest exercici els unirem en un únic mòdul, que és l'habitual en projectes petits, però convé conèixer la separació.
El pom.xml de l'starter
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.1</version>
<relativePath/>
</parent>
<groupId>com.ciclourbana</groupId>
<artifactId>ciclourbana-tarifes-spring-boot-starter</artifactId>
<version>1.0.0</version>
<name>CicloUrbana Tarifes Starter</name>
<description>Càlcul de tarifes per a xarxes municipals de bicicleta compartida</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Nucli: contenidor, Environment, @ConfigurationProperties.
NO utilitzem spring-boot-starter-web: un starter no ha d'imposar
el tipus d'aplicació a qui el consumeix. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Validació de les propietats de l'starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Genera les metadades per a l'autocompletat de l'IDE -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- COMPTE: sense spring-boot-maven-plugin.
Un starter és una LLIBRERIA, no una aplicació executable:
no s'ha d'empaquetar com a fat jar. -->
</plugins>
</build>
</project>Els dos comentaris del final són els errors més habituals en crear un starter: dependre de spring-boot-starter-web (imposant Tomcat a qui només volia calcular tarifes) i deixar el spring-boot-maven-plugin, que genera un fat jar del qual no es poden importar classes.
Les propietats de l'starter
package com.ciclourbana.tarifes;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.Map;
/**
* Configuració del càlcul de tarifes d'una xarxa de bicicleta compartida.
* Prefix: ciclourbana.tarifes
*/
@Validated
@ConfigurationProperties(prefix = "ciclourbana.tarifes")
public record TarifesProperties(
/** Activa o desactiva el càlcul de tarifes de l'starter. */
@DefaultValue("true") boolean enabled,
/** Moneda en què s'expressen els imports (codi ISO 4217). */
@DefaultValue("EUR") String moneda,
/** Perfils de tarifa, indexats per l'identificador del tipus d'usuari. */
@NotEmpty(message = "Cal definir com a mínim un perfil de tarifa")
Map<String, @Valid PerfilTarifa> perTipusUsuari
) {
/** Condicions econòmiques d'un tipus d'usuari. */
public record PerfilTarifa(
/** Import fix de desbloqueig. */
@NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal desbloqueig,
/** Import per minut d'ús. */
@NotNull @DecimalMin("0.00") BigDecimal preuMinut,
/** Minuts inicials sense cost. */
@Min(0) @DefaultValue("0") int minutsGratis
) { }
}El servei que aporta l'starter
package com.ciclourbana.tarifes;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.util.Set;
/**
* Calcula l'import d'un lloguer segons el perfil de tarifa configurat.
* És una classe POJO: no porta @Service ni cap anotació de Spring,
* perquè qui la registra com a bean és l'autoconfiguració.
*/
public class CalculadoraTarifes {
private final TarifesProperties propietats;
public CalculadoraTarifes(TarifesProperties propietats) {
this.propietats = propietats;
}
public Set<String> tipusDisponibles() {
return propietats.perTipusUsuari().keySet();
}
public String moneda() {
return propietats.moneda();
}
public BigDecimal calcular(String tipusUsuari, Duration durada) {
TarifesProperties.PerfilTarifa perfil =
propietats.perTipusUsuari().get(tipusUsuari);
if (perfil == null) {
throw new IllegalArgumentException("Tipus d'usuari desconegut: " + tipusUsuari
+ ". Disponibles: " + tipusDisponibles());
}
long facturables = Math.max(0, durada.toMinutes() - perfil.minutsGratis());
return perfil.desbloqueig()
.add(perfil.preuMinut().multiply(BigDecimal.valueOf(facturables)))
.setScale(2, RoundingMode.HALF_UP);
}
}Fixa't en el detall: la classe no porta anotacions de Spring. És una decisió deliberada. Una classe de llibreria anotada amb @Service només funcionaria si l'usuari escaneja el nostre paquet, i això no ho controlem. Qui la converteix en bean és l'autoconfiguració.
La classe d'autoconfiguració
package com.ciclourbana.tarifes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
/**
* Autoconfiguració de l'starter de tarifes de CicloUrbana.
*
* Registra una CalculadoraTarifes si:
* - la classe és al classpath,
* - la propietat ciclourbana.tarifes.enabled no és false,
* - i l'aplicació no ha definit ja la seva pròpia CalculadoraTarifes.
*/
@AutoConfiguration
@ConditionalOnClass(CalculadoraTarifes.class)
@ConditionalOnProperty(
prefix = "ciclourbana.tarifes",
name = "enabled",
havingValue = "true",
matchIfMissing = true) // actiu per defecte
@EnableConfigurationProperties(TarifesProperties.class) // aquí NO hi ha scan de l'usuari
public class TarifesAutoConfiguration {
private static final Logger log = LoggerFactory.getLogger(TarifesAutoConfiguration.class);
@Bean
@ConditionalOnMissingBean // l'aplicació la pot substituir
public CalculadoraTarifes calculadoraTarifes(TarifesProperties propietats) {
log.info("Starter de tarifes actiu: {} perfils configurats ({})",
propietats.perTipusUsuari().size(),
propietats.perTipusUsuari().keySet());
return new CalculadoraTarifes(propietats);
}
}Les quatre anotacions, i per què hi és cadascuna:
| Anotació | Per què hi és |
|---|---|
@AutoConfiguration |
És una autoconfiguració, no pas una @Configuration normal. Porta proxyBeanMethods = false. |
@ConditionalOnClass |
Comprovació defensiva: si el jar de l'starter no és sencer, no s'aplica. |
@ConditionalOnProperty(matchIfMissing = true) |
Actiu per defecte, desactivable amb ciclourbana.tarifes.enabled=false. |
@EnableConfigurationProperties |
Imprescindible: en un starter no hi ha @ConfigurationPropertiesScan de l'usuari que registri les nostres propietats. |
@ConditionalOnMissingBean |
L'aplicació pot aportar la seva pròpia calculadora i guanyar. |
El fitxer .imports
Sense aquest fitxer, res del que hem fet abans s'aplica. És el pas que més s'oblida:
Amb una sola línia:
Sense extensió, sense comes, sense barres invertides: un nom de classe complet per línia. Compte amb la ruta: el directori és META-INF/spring/ i el nom del fitxer és llarg i té punts. Un error d'escriptura no produeix cap error: simplement l'starter no fa res, que és la pitjor manera de fallar.
Valors per defecte de l'starter
Un starter hauria de funcionar sense cap configuració. Afegeix un fitxer de valors per defecte:
# src/main/resources/ciclourbana-tarifes-defaults.yaml
ciclourbana:
tarifes:
enabled: true
moneda: EUR
per-tipus-usuari:
estandard:
desbloqueig: 0.50
preu-minut: 0.12
minuts-gratis: 0I a l'autoconfiguració, importa'l amb @PropertySource o —més idiomàtic a Boot 3— declara els valors per defecte amb @DefaultValue al record, que és el que ja hem fet.
Utilitzar-lo des de CicloUrbana
<!-- Al pom.xml de ciclourbana -->
<dependency>
<groupId>com.ciclourbana</groupId>
<artifactId>ciclourbana-tarifes-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency># application.yaml de l'aplicació
ciclourbana:
tarifes:
moneda: EUR
per-tipus-usuari:
estandard:
desbloqueig: 0.50
preu-minut: 0.12
estudiant:
preu-minut: 0.08
minuts-gratis: 15
jubilat:
preu-minut: 0.05
minuts-gratis: 30I ja està disponible per injectar, sense @ComponentScan, sense @Import, sense res:
package com.ciclourbana.lloguers;
import com.ciclourbana.tarifes.CalculadoraTarifes;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
@Service
public class LloguerService {
private final CalculadoraTarifes calculadora; // ve de l'starter
public LloguerService(CalculadoraTarifes calculadora) {
this.calculadora = calculadora;
}
public BigDecimal finalitzar(String matricula, String tipusUsuari, Duration durada) {
return calculadora.calcular(tipusUsuari, durada);
}
}c.c.tarifes.TarifesAutoConfiguration : Starter de tarifes actiu: 3 perfils
configurats ([estandard, estudiant, jubilat])Això és exactament el que passa quan afegeixes spring-boot-starter-web. Ja no és màgia.
- Provar l'starter amb
ApplicationContextRunner
ApplicationContextRunnerUn starter té una particularitat en provar-lo: l'interessant no és només que el bean funcioni, sinó sota quines condicions apareix i sota quines no. Aixecar un context complet amb @SpringBootTest per a cada combinació seria lentíssim.
ApplicationContextRunner resol exactament això: crea contextos mínims, en memòria, configurables al vol, en mil·lisegons.
package com.ciclourbana.tarifes;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.math.BigDecimal;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class TarifesAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(TarifesAutoConfiguration.class));
@Test
void registraLaCalculadoraAmbLaConfiguracioMinima() {
runner.withPropertyValues(
"ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12")
.run(context -> {
assertThat(context).hasSingleBean(CalculadoraTarifes.class);
assertThat(context).hasSingleBean(TarifesProperties.class);
CalculadoraTarifes calculadora = context.getBean(CalculadoraTarifes.class);
// 30 min * 0,12 = 3,60 (desbloqueig 0.00 per defecte)
assertThat(calculadora.calcular("estandard", Duration.ofMinutes(30)))
.isEqualByComparingTo(new BigDecimal("3.60"));
});
}
@Test
void noRegistraResSiEstaDesactivat() {
runner.withPropertyValues(
"ciclourbana.tarifes.enabled=false",
"ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12")
.run(context -> assertThat(context).doesNotHaveBean(CalculadoraTarifes.class));
}
@Test
void laAplicacioPotAportarLaSevaPropiaCalculadora() {
runner.withUserConfiguration(ConfiguracioPropia.class)
.withPropertyValues(
"ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12")
.run(context -> {
assertThat(context).hasSingleBean(CalculadoraTarifes.class);
// Guanya la de l'usuari: @ConditionalOnMissingBean s'aparta
assertThat(context.getBean(CalculadoraTarifes.class))
.isInstanceOf(CalculadoraTarifesGratuita.class);
});
}
@Test
void fallaSiNoHiHaCapPerfilDeTarifa() {
runner.run(context -> assertThat(context)
.hasFailed()
.getFailure()
.hasMessageContaining("Cal definir com a mínim un perfil de tarifa"));
}
@Test
void respectaLaMonedaConfigurada() {
runner.withPropertyValues(
"ciclourbana.tarifes.moneda=USD",
"ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.15")
.run(context -> assertThat(
context.getBean(CalculadoraTarifes.class).moneda()).isEqualTo("USD"));
}
// --- Configuració de suport per al tercer test ---
@Configuration(proxyBeanMethods = false)
static class ConfiguracioPropia {
@Bean
CalculadoraTarifes calculadoraTarifes() {
return new CalculadoraTarifesGratuita();
}
}
/** Implementació de prova: la xarxa municipal en jornada de portes obertes. */
static class CalculadoraTarifesGratuita extends CalculadoraTarifes {
CalculadoraTarifesGratuita() {
super(new TarifesProperties(true, "EUR",
java.util.Map.of("estandard", new TarifesProperties.PerfilTarifa(
BigDecimal.ZERO, BigDecimal.ZERO, 0))));
}
@Override
public BigDecimal calcular(String tipusUsuari, Duration durada) {
return BigDecimal.ZERO;
}
}
}Els mètodes que més utilitzaràs:
| Mètode | Per a què |
|---|---|
withConfiguration(AutoConfigurations.of(...)) |
Afegeix les autoconfiguracions a provar |
withUserConfiguration(...) |
Simula beans definits per l'aplicació usuària |
withPropertyValues("clau=valor") |
Fixa propietats per a aquest context |
withClassLoader(new FilteredClassLoader(X.class)) |
Simula que una classe no és al classpath |
withBean(Tipus.class, proveïdor) |
Registra un bean concret |
run(context -> { ... }) |
Arrenca i executa les asseveracions |
El FilteredClassLoader mereix atenció: és la manera de provar @ConditionalOnClass sense tocar el pom.xml.
@Test
void noAplicaSiFaltaLaClasseDelStarter() {
runner.withClassLoader(new FilteredClassLoader(CalculadoraTarifes.class))
.run(context -> assertThat(context).doesNotHaveBean(CalculadoraTarifes.class));
}I les asseveracions sobre el context, que vénen d'AssertJ integrat amb Spring Boot:
assertThat(context).hasSingleBean(CalculadoraTarifes.class);
assertThat(context).doesNotHaveBean(CalculadoraTarifes.class);
assertThat(context).getBean("calculadoraTarifes").isNotNull();
assertThat(context).hasFailed();
assertThat(context).getFailure().hasMessageContaining("...");Aquestes cinc proves s'executen en menys d'un segon en total, perquè cada context conté tres beans i cap servidor. El mòdul 6 cobreix les proves en profunditat; aquí ApplicationContextRunner apareix perquè és l'eina específica per al que estem construint.
Errors Comuns i Consells
Oblidar el fitxer AutoConfiguration.imports. L'starter compila, s'instal·la, es declara com a dependència... i no fa absolutament res, sense ni un sol missatge d'error. És l'error número u. Verifica sempre la ruta completa: src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.
Utilitzar spring.factories a Spring Boot 3. Va ser eliminat per a autoconfiguració. Mateix símptoma silenciós.
Posar @Component o @Service a les classes d'un starter. Només funcionen si l'usuari escaneja el teu paquet, cosa que no controles ni has d'exigir. Les classes d'un starter són POJO; les registra l'autoconfiguració.
Oblidar @EnableConfigurationProperties a l'autoconfiguració. En una aplicació, @ConfigurationPropertiesScan ho cobreix tot. En un starter no hi ha aquest escaneig: sense aquesta anotació, les teves propietats no són un bean i l'autoconfiguració falla amb NoSuchBeanDefinitionException.
Deixar el spring-boot-maven-plugin al pom.xml de l'starter. Genera un fat jar reempaquetat les classes del qual són a BOOT-INF/classes/ i no són importables com a llibreria. Un starter és una llibreria.
Dependre de spring-boot-starter-web des d'un starter. Imposes Tomcat i tota la pila web a qui només volia la teva funcionalitat. Depèn del mínim (spring-boot-starter) i utilitza @ConditionalOnClass per a les capacitats opcionals.
Utilitzar @ConditionalOnBean sense @AutoConfigureAfter. La condició s'avalua abans que existeixi el bean esperat, falla i la teva autoconfiguració es descarta en silenci. Van sempre juntes.
Utilitzar @ConditionalOnMissingBean al codi de l'aplicació. Depèn d'un ordre de processament que no controles. És una eina per a autoconfiguracions.
Excloure autoconfiguracions a la lleugera. Abans d'excloure, mira l'informe --debug i entén per què s'està aplicant. Sovint el problema real és una dependència sobrant al pom.xml.
Consell: quan alguna cosa no funciona, arrenca amb --debug abans de buscar a internet. L'informe d'avaluació de condicions respon la majoria de les preguntes en trenta segons, i amb la raó exacta.
Consell: busca un *Customizer abans de reemplaçar un bean autoconfigurat. Substituir el bean sencer et deixa fora de les millores futures de Spring Boot i de les integracions que en depenen.
Consell: inclou el spring-boot-configuration-processor al teu starter. Qui l'utilitzi tindrà autocompletat i documentació de les teves propietats a l'IDE. És la diferència entre un starter agradable i un que obliga a llegir el codi font.
Consell: prova el teu starter amb ApplicationContextRunner des del primer dia. Les condicions són lògica, i la lògica sense proves es trenca. Cinc proves d'un segon t'estalvien hores de depuració a l'aplicació consumidora.
Exercicis
Exercici 1: llegir l'informe d'autoconfiguració
Arrenca CicloUrbana amb --debug i respon, citant la línia concreta de l'informe: (a) per què es va aplicar DispatcherServletAutoConfiguration?; (b) per què no es va aplicar DataSourceAutoConfiguration?; (c) quantes autoconfiguracions apareixen a Positive matches i quantes a Negative matches? Després defineix el teu propi bean ObjectMapper i comprova que JacksonAutoConfiguration#jacksonObjectMapper canvia de secció.
Exercici 2: una autoconfiguració condicional dins de l'aplicació
Sense sortir del projecte ciclourbana, crea una classe AuditoriaAutoConfiguration amb el seu fitxer .imports a src/main/resources, que registri un bean RegistreAuditoria només si: la propietat ciclourbana.auditoria.enabled és true, l'aplicació és de tipus servlet, i no existeix ja un bean d'aquest tipus. Comprova amb --debug que apareix a Positive matches en activar-la i a Negative matches en desactivar-la.
Exercici 3: l'starter complet, provat
Crea el mòdul ciclourbana-tarifes-spring-boot-starter amb tot el de l'apartat 10: TarifesProperties validat, CalculadoraTarifes, TarifesAutoConfiguration i el fitxer .imports. Afegeix una capacitat nova: un recàrrec configurable (ciclourbana.tarifes.recarrec-exces) que s'apliqui als minuts que superin ciclourbana.tarifes.durada-maxima. Escriu com a mínim cinc proves amb ApplicationContextRunner que cobreixin: configuració mínima, desactivació, substitució per l'usuari, fallada de validació i el nou recàrrec.
Solucions
Solució 1
(a) DispatcherServletAutoConfiguration es va aplicar perquè:
DispatcherServletAutoConfiguration matched:
- @ConditionalOnClass found required class
'org.springframework.web.servlet.DispatcherServlet' (OnClassCondition)
- found 'session' scope (OnWebApplicationCondition)La classe DispatcherServlet és al classpath perquè spring-boot-starter-web l'aporta, i l'aplicació és de tipus servlet.
(b) DataSourceAutoConfiguration no es va aplicar perquè:
DataSourceAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class
'javax.sql.DataSource' (OnClassCondition)Encara no tenim spring-boot-starter-data-jpa ni cap driver JDBC. Això canviarà al mòdul 4.
(c) Per comptar:
awk '/^Positive matches:/,/^Negative matches:/' /tmp/arrencada.log | grep -c " matched:"
awk '/^Negative matches:/,/^Exclusions:/' /tmp/arrencada.log | grep -c "^ [A-Z].*:$"En un CicloUrbana amb només spring-boot-starter-web obtindràs de l'ordre de 25-30 coincidències positives i unes 120 de negatives. La xifra concreta depèn de la versió de Spring Boot, però la proporció és sempre la mateixa: la immensa majoria de les autoconfiguracions no s'apliquen. Aquest és justament el disseny: 158 candidates, i només s'activen les que tenen sentit per a les teves dependències.
I en definir l'ObjectMapper propi:
package com.ciclourbana.comu;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class ConfiguracioJson {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper().registerModule(new JavaTimeModule());
}
}L'entrada es mou a Negative matches:
JacksonAutoConfiguration#jacksonObjectMapper:
Did not match:
- @ConditionalOnMissingBean (types: com.fasterxml.jackson.databind.ObjectMapper;
SearchStrategy: all) found beans of type
'com.fasterxml.jackson.databind.ObjectMapper' objectMapper (OnBeanCondition)Comentari: el missatge diu literalment "found beans of type ... objectMapper". És @ConditionalOnMissingBean funcionant: Spring Boot va veure el teu bean i es va apartar. Consell: en aquest cas concret, substituir l'ObjectMapper sencer és mala idea —perds tota la configuració de Spring Boot, inclosos els mòduls detectats automàticament. El correcte és el Jackson2ObjectMapperBuilderCustomizer de l'apartat 6.
Solució 2
package com.ciclourbana.auditoria;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
/** Registre simple d'accions sobre la xarxa. POJO, sense anotacions. */
public class RegistreAuditoria {
private static final Logger log = LoggerFactory.getLogger(RegistreAuditoria.class);
private final String desti;
public RegistreAuditoria(String desti) {
this.desti = desti;
}
public void registrar(String accio, String detall) {
log.info("[AUDITORIA -> {}] {} | {} | {}", desti, Instant.now(), accio, detall);
}
}package com.ciclourbana.auditoria;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
@AutoConfiguration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "ciclourbana.auditoria",
name = "enabled",
havingValue = "true") // sense matchIfMissing: desactivat per defecte
public class AuditoriaAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RegistreAuditoria registreAuditoria(Environment entorn) {
return new RegistreAuditoria(
entorn.getProperty("ciclourbana.auditoria.desti", "consola"));
}
}# src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports com.ciclourbana.auditoria.AuditoriaAutoConfiguration
Amb l'auditoria activada:
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --debug --ciclourbana.auditoria.enabled=true \
| grep -A 4 "AuditoriaAutoConfiguration" AuditoriaAutoConfiguration matched:
- @ConditionalOnProperty (ciclourbana.auditoria.enabled=true) matched (OnPropertyCondition)
- found 'session' scope (OnWebApplicationCondition)
AuditoriaAutoConfiguration#registreAuditoria matched:
- @ConditionalOnMissingBean (types: com.ciclourbana.auditoria.RegistreAuditoria;
SearchStrategy: all) did not find any beans (OnBeanCondition)I sense activar-la:
AuditoriaAutoConfiguration:
Did not match:
- @ConditionalOnProperty (ciclourbana.auditoria.enabled) did not find
property 'enabled' (OnPropertyCondition)Comentari: el detall interessant és que una autoconfiguració dins de la mateixa aplicació funciona exactament igual que una d'un starter extern. És un patró útil per a funcionalitats opcionals dins d'un monòlit.
Error freqüent: crear el fitxer .imports a src/main/java en lloc de src/main/resources. Maven no el copia al jar i l'autoconfiguració desapareix sense avisar. Consell: verifica sempre amb unzip -l target/*.jar | grep imports que el fitxer ha arribat a l'artefacte.
Solució 3
Les propietats ampliades amb el recàrrec:
package com.ciclourbana.tarifes;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DurationMin;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Map;
@Validated
@ConfigurationProperties(prefix = "ciclourbana.tarifes")
public record TarifesProperties(
/** Activa o desactiva el càlcul de tarifes de l'starter. */
@DefaultValue("true") boolean enabled,
/** Moneda dels imports (codi ISO 4217). */
@DefaultValue("EUR") String moneda,
/** Durada a partir de la qual s'aplica el recàrrec per excés. */
@NotNull @DurationMin(minutes = 5) @DefaultValue("2h") Duration duradaMaxima,
/** Import addicional per cada minut que excedeixi la durada màxima. */
@NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal recarrecExces,
/** Perfils de tarifa per tipus d'usuari. */
@NotEmpty(message = "Cal definir com a mínim un perfil de tarifa")
Map<String, @Valid PerfilTarifa> perTipusUsuari
) {
public record PerfilTarifa(
@NotNull @PositiveOrZero @DefaultValue("0.00") BigDecimal desbloqueig,
@NotNull @DecimalMin("0.00") BigDecimal preuMinut,
@Min(0) @DefaultValue("0") int minutsGratis
) { }
}La calculadora amb el recàrrec:
package com.ciclourbana.tarifes;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.util.Set;
public class CalculadoraTarifes {
private final TarifesProperties propietats;
public CalculadoraTarifes(TarifesProperties propietats) {
this.propietats = propietats;
}
public Set<String> tipusDisponibles() {
return propietats.perTipusUsuari().keySet();
}
public String moneda() {
return propietats.moneda();
}
public BigDecimal calcular(String tipusUsuari, Duration durada) {
TarifesProperties.PerfilTarifa perfil = propietats.perTipusUsuari().get(tipusUsuari);
if (perfil == null) {
throw new IllegalArgumentException("Tipus d'usuari desconegut: " + tipusUsuari
+ ". Disponibles: " + tipusDisponibles());
}
long minuts = durada.toMinutes();
long facturables = Math.max(0, minuts - perfil.minutsGratis());
BigDecimal importTotal = perfil.desbloqueig()
.add(perfil.preuMinut().multiply(BigDecimal.valueOf(facturables)));
// Recàrrec per excés sobre la durada màxima
long minutsExces = Math.max(0, minuts - propietats.duradaMaxima().toMinutes());
if (minutsExces > 0) {
importTotal = importTotal.add(
propietats.recarrecExces().multiply(BigDecimal.valueOf(minutsExces)));
}
return importTotal.setScale(2, RoundingMode.HALF_UP);
}
}I les proves:
package com.ciclourbana.tarifes;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class TarifesAutoConfigurationTest {
private static final String[] CONFIG_MINIMA = {
"ciclourbana.tarifes.per-tipus-usuari.estandard.desbloqueig=0.50",
"ciclourbana.tarifes.per-tipus-usuari.estandard.preu-minut=0.12"
};
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(TarifesAutoConfiguration.class));
@Test
void registraLaCalculadoraAmbLaConfiguracioMinima() {
runner.withPropertyValues(CONFIG_MINIMA).run(context -> {
assertThat(context).hasSingleBean(CalculadoraTarifes.class);
// 0,50 + 30 * 0,12 = 4,10
assertThat(context.getBean(CalculadoraTarifes.class)
.calcular("estandard", Duration.ofMinutes(30)))
.isEqualByComparingTo(new BigDecimal("4.10"));
});
}
@Test
void noRegistraResSiEstaDesactivat() {
runner.withPropertyValues(CONFIG_MINIMA)
.withPropertyValues("ciclourbana.tarifes.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(CalculadoraTarifes.class));
}
@Test
void laAplicacioPotAportarLaSevaPropiaCalculadora() {
runner.withPropertyValues(CONFIG_MINIMA)
.withUserConfiguration(ConfiguracioPropia.class)
.run(context -> {
assertThat(context).hasSingleBean(CalculadoraTarifes.class);
assertThat(context.getBean(CalculadoraTarifes.class)
.calcular("estandard", Duration.ofHours(5)))
.isEqualByComparingTo(BigDecimal.ZERO);
});
}
@Test
void fallaSiNoHiHaCapPerfilDeTarifa() {
runner.run(context -> assertThat(context)
.hasFailed()
.getFailure()
.hasMessageContaining("Cal definir com a mínim un perfil de tarifa"));
}
@Test
void aplicaElRecarrecPerExces() {
runner.withPropertyValues(CONFIG_MINIMA)
.withPropertyValues(
"ciclourbana.tarifes.durada-maxima=2h",
"ciclourbana.tarifes.recarrec-exces=0.30")
.run(context -> {
CalculadoraTarifes calculadora = context.getBean(CalculadoraTarifes.class);
// 90 min: per sota del màxim, sense recàrrec
// 0,50 + 90 * 0,12 = 11,30
assertThat(calculadora.calcular("estandard", Duration.ofMinutes(90)))
.isEqualByComparingTo(new BigDecimal("11.30"));
// 150 min: 30 minuts d'excés
// 0,50 + 150 * 0,12 + 30 * 0,30 = 0,50 + 18,00 + 9,00 = 27,50
assertThat(calculadora.calcular("estandard", Duration.ofMinutes(150)))
.isEqualByComparingTo(new BigDecimal("27.50"));
});
}
@Test
void noAplicaSiFaltaLaClasseDelStarter() {
runner.withPropertyValues(CONFIG_MINIMA)
.withClassLoader(new FilteredClassLoader(CalculadoraTarifes.class))
.run(context -> assertThat(context)
.doesNotHaveBean(CalculadoraTarifes.class));
}
@Configuration(proxyBeanMethods = false)
static class ConfiguracioPropia {
@Bean
CalculadoraTarifes calculadoraTarifes() {
TarifesProperties gratis = new TarifesProperties(
true, "EUR", Duration.ofHours(24), BigDecimal.ZERO,
Map.of("estandard", new TarifesProperties.PerfilTarifa(
BigDecimal.ZERO, BigDecimal.ZERO, 0)));
return new CalculadoraTarifes(gratis);
}
}
}Comentari: sis proves que aixequen sis contextos diferents en menys d'un segon d'execució efectiva. Compara-ho amb el que costaria fer el mateix amb @SpringBootTest, que arrencaria Tomcat sis vegades.
Detall sobre les asseveracions: s'utilitza isEqualByComparingTo i no pas isEqualTo. A BigDecimal, new BigDecimal("4.10").equals(new BigDecimal("4.1")) és false, perquè equals compara també l'escala. És un error clàssic que produeix fallades de prova desconcertants; isEqualByComparingTo utilitza compareTo i compara només el valor numèric.
Consell final sobre el disseny de l'starter: fixa't que el recàrrec s'ha afegit sense trencar ningú. recarrecExces té @DefaultValue("0.00") i duradaMaxima té @DefaultValue("2h"), així que una aplicació que ja utilitzava la versió 1.0.0 continua obtenint exactament els mateixos imports després d'actualitzar. Aquesta és la disciplina que fa usable un starter: tota propietat nova arriba amb un valor per defecte que preserva el comportament anterior.
Conclusió
Amb això es tanca el mòdul 2 i, amb ell, la caixa negra del contenidor. Saps que @EnableAutoConfiguration és simplement @Import d'un ImportSelector que calcula quines configuracions cal carregar, i que la llista de candidates no és màgia sinó un fitxer de text —META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports— amb 158 noms de classe, un per línia, substitut del spring.factories que Spring Boot 3 va eliminar. Coneixes el recorregut complet de l'AutoConfigurationImportSelector, incloses les optimitzacions que fan que avaluar 158 candidates costi mil·lisegons. Domines les anotacions condicionals i entens per què @ConditionalOnMissingBean és el cor de la filosofia de Spring Boot: valors per defecte assenyats que s'aparten tan bon punt tu prens el control. Has llegit una autoconfiguració real de Spring Boot línia a línia i has descobert el patró customizer, que gairebé sempre és millor que reemplaçar un bean sencer. Saps per què @ConditionalOnBean necessita companyia de @AutoConfigureAfter. I sobretot saps depurar: arrencar amb --debug, anar a Negative matches i llegir la condició exacta que va fallar, que és la resposta a la pregunta més frustrant del desenvolupador de Spring. Per acabar, has construït un starter complet, ciclourbana-tarifes-spring-boot-starter, amb la seva autoconfiguració condicional, les seves propietats tipades i validades, el seu fitxer .imports i sis proves amb ApplicationContextRunner que verifiquen no només que el bean funciona, sinó sota quines condicions apareix i sota quines s'aparta.
Mira enrere un moment. En començar el mòdul, CicloUrbana era una classe principal, un record, un controlador i un magatzem en memòria que ho feia tot, amb anotacions copiades per imitació. Ara té capes ben separades —EstacioController, EstacioService, EstacioRepositori amb la seva implementació en memòria—, un sistema de tarifes extensible per configuració, una memòria cau amb cicle de vida gestionat, propietats tipades i validades que fan fallar l'arrencada si algú configura un preu negatiu, i un starter propi publicable. I no queda ni una sola anotació al projecte que no sàpigues explicar.
Ha arribat el moment de tornar a la superfície. El mòdul 3, Construint Serveis Web RESTful, surt del contenidor i entra a l'API que veuran els ciutadans de Ribalta: què significa REST de debò i què no, com dissenyar els recursos i les URL de CicloUrbana, com escriure controladors complets amb @GetMapping, @PostMapping, @PutMapping i @DeleteMapping, com rebre i validar dades d'entrada, com separar les entitats dels DTO que s'exposen a l'exterior, com convertir una excepció en una resposta HTTP correcta i ben formada, i com documentar-ho tot amb OpenAPI perquè altres equips s'hi puguin integrar. El nostre únic endpoint, GET /api/v1/estacions, està a punt de convertir-se en una API completa.
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
