Modern Java Engineering
Chapitre 7
Chapitre 07 : Spring Boot
Chapitre 07 : Spring Boot
Cours : Spring Boot
1. Auto-configuration et Starters
1.1 Principe
Spring Boot automatise la configuration via :
- Starters : dépendances pré-configurées
- Auto-configuration : @ConditionalOnClass, @ConditionalOnMissingBean
- application.yml : propriétés externalisées
1.2 Starters Essentiels
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- Tomcat + Spring MVC + Jackson + Validation -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<!-- Hibernate + HikariCP + Spring Data JPA -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<!-- JUnit 5 + Mockito + AssertJ + Hamcrest -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<!-- Health, Metrics, Info, Env endpoints -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<!-- Hibernate Validator, Bean Validation -->
</dependency>
1.3 Auto-configuration
// Voir ce qui est configuré automatiquement
// Ajouter dans application.yml :
// debug: true
// Ou utiliser le endpoint /actuator/conditions
// Désactiver une auto-configuration
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
HibernateJpaAutoConfiguration.class
})
public class Application { }
// Condition personnalisée
@Configuration
@ConditionalOnClass(name = "org.postgresql.Driver")
@ConditionalOnProperty(name = "app.datasource.type", havingValue = "postgres")
public class PostgresDataSourceConfig { }
2. @SpringBootApplication
// Équivalent à :
// @Configuration
// @EnableAutoConfiguration
// @ComponentScan
@SpringBootApplication
public class Application {
public static void main(String[] args) {
// Lance Spring Boot
SpringApplication.run(Application.class, args);
}
}
// Customisation
@SpringBootApplication
public class CustomApp {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CustomApp.class);
app.setDefaultProperties(Map.of("server.port", "8081"));
app.setBannerMode(Banner.Mode.OFF);
app.setAdditionalProfiles("dev");
app.run(args);
}
}
// ApplicationRunner (au démarrage)
@Component
public class StartupRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
System.out.println("Application démarrée avec: " + Arrays.toString(args.getSourceArgs()));
}
}
// CommandLineRunner
@Component
@Order(1)
public class DataInitializer implements CommandLineRunner {
@Override
public void run(String... args) {
System.out.println("Initialisation des données");
}
}
3. Configuration
3.1 application.yml
# application.yml (recommandé)
server:
port: 8080
servlet:
context-path: /api
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: user
password: ${DB_PASSWORD} # Variable d'environnement
hikari:
maximum-pool-size: 10
minimum-idle: 2
jpa:
hibernate:
ddl-auto: validate
show-sql: false
open-in-view: false # Important: désactiver en prod
jackson:
serialization:
write-dates-as-timestamps: false
default-property-inclusion: non_null
app:
name: Modern Java Engineering
version: 1.0.0
features:
caching: true
analytics: false
supported-locales:
- fr
- en
- es
logging:
level:
com.example: DEBUG
org.springframework: WARN
org.hibernate: ERROR
3.2 Profils
# application-dev.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
jpa:
show-sql: true
devtools:
livereload:
enabled: true
# application-prod.yml
spring:
datasource:
url: jdbc:postgresql://prod-server:5432/proddb
hikari:
maximum-pool-size: 50
jpa:
show-sql: false
3.3 @ConfigurationProperties
// application.yml :
// app:
// name: MyApp
// features:
// caching: true
@ConfigurationProperties(prefix = "app")
@Component
public class AppProperties {
private String name;
private String version;
private Features features = new Features();
private List<String> supportedLocales = new ArrayList<>();
// getters/setters
public static class Features {
private boolean caching;
private boolean analytics;
// getters/setters
}
}
// Activer @ConfigurationPropertiesScan sur la configuration
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application { }
4. Actuator
4.1 Configuration
# Exposer tous les endpoints
management:
endpoints:
web:
exposure:
include: "*"
exclude: "shutdown"
endpoint:
health:
show-details: always
info:
enabled: true
metrics:
enabled: true
metrics:
export:
prometheus:
enabled: true
4.2 Endpoints
| Endpoint | Description |
|---|---|
/actuator/health | Santé de l'application |
/actuator/info | Informations custom |
/actuator/metrics | Métriques JVM, système |
/actuator/env | Propriétés d'environnement |
/actuator/beans | Tous les beans Spring |
/actuator/conditions | Conditions d'auto-configuration |
/actuator/mappings | Endpoints HTTP |
/actuator/loggers | Configuration des logs |
/actuator/threaddump | Thread dump |
/actuator/heapdump | Heap dump |
/actuator/prometheus | Métriques Prometheus |
4.3 Health Indicators
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection conn = dataSource.getConnection()) {
if (conn.isValid(5)) {
return Health.up()
.withDetail("database", "PostgreSQL")
.withDetail("status", "CONNECTED")
.build();
}
return Health.down()
.withDetail("database", "PostgreSQL")
.withDetail("status", "NOT RESPONDING")
.build();
} catch (SQLException e) {
return Health.down(e)
.withDetail("database", "PostgreSQL")
.build();
}
}
}
// Info endpoint
@Component
public class AppInfoContributor implements InfoContributor {
@Override
public void contribute(Info.Builder builder) {
builder
.withDetail("app", Map.of(
"name", "Modern Java Engineering",
"version", "1.0.0",
"java-version", Runtime.version().toString()
))
.withDetail("build", Map.of(
"timestamp", Instant.now().toString()
));
}
}
4.4 Métriques Custom
@Component
public class OrderMetrics {
private final MeterRegistry meterRegistry;
private final Counter orderCounter;
private final Timer orderTimer;
public OrderMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.orderCounter = Counter.builder("orders.created")
.description("Nombre de commandes créées")
.register(meterRegistry);
this.orderTimer = Timer.builder("orders.processing")
.description("Temps de traitement des commandes")
.register(meterRegistry);
}
public void recordOrder() {
orderCounter.increment();
}
public <T> T measureOrder(Runnable runnable) {
return orderTimer.record(runnable);
}
}
5. REST Controllers
5.1 CRUD Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<UserDTO>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(userService.findAll(page, size));
}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
UserDTO created = userService.create(request);
return ResponseEntity.created(URI.create("/api/users/" + created.getId()))
.body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserDTO> updateUser(@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
// DTOs
public record UserDTO(Long id, String name, String email, LocalDateTime createdAt) {}
public record CreateUserRequest(@NotBlank String name, @Email String email) {}
public record UpdateUserRequest(@NotBlank String name) {}
5.2 Query Parameters
@RestController
@RequestMapping("/api/search")
public class SearchController {
@GetMapping("/users")
public ResponseEntity<List<UserDTO>> searchUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name,asc") String[] sort) {
Sort sorting = Sort.by(Arrays.stream(sort)
.map(s -> s.split(","))
.map(arr -> new Sort.Order(
Sort.Direction.fromString(arr[1]), arr[0]))
.toList());
return ResponseEntity.ok(userService.search(name, fromDate,
PageRequest.of(page, size, sorting)));
}
}
5.3 Response Status
@RestController
public class StatusController {
@GetMapping("/ping")
@ResponseStatus(HttpStatus.OK)
public Map<String, String> ping() {
return Map.of("status", "OK", "timestamp", Instant.now().toString());
}
@PostMapping("/items")
@ResponseStatus(HttpStatus.CREATED)
public Item createItem(@Valid @RequestBody Item item) {
return itemService.create(item);
}
}
6. Validation
6.1 Bean Validation
// Request DTO
public record CreateProductRequest(
@NotBlank(message = "Le nom est obligatoire")
@Size(min = 2, max = 100)
String name,
@NotNull
@Positive(message = "Le prix doit être positif")
@DecimalMin("0.01")
BigDecimal price,
@Min(0)
@Max(100000)
int stock,
@Email(regexp = ".+@.+\\..+", message = "Format email invalide")
String supplierEmail,
@Past
LocalDate manufactureDate,
@Future
LocalDate expiryDate,
@Pattern(regexp = "^[A-Z]{2}\\d{4}$", message = "Format: AA1234")
String productCode
) {}
// Validation groupe
public interface Create {}
public interface Update {}
public record ProductDTO(
@Null(groups = Create.class)
@NotNull(groups = Update.class)
Long id,
@NotBlank(groups = {Create.class, Update.class})
String name
) {}
// Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
@PostMapping
public ResponseEntity<ProductDTO> create(
@Validated(Create.class) @RequestBody ProductDTO dto) {
return ResponseEntity.status(201).body(service.create(dto));
}
@PutMapping("/{id}")
public ResponseEntity<ProductDTO> update(
@PathVariable Long id,
@Validated(Update.class) @RequestBody ProductDTO dto) {
return ResponseEntity.ok(service.update(id, dto));
}
}
6.2 Validation Custom
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email déjà utilisé";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return !userRepository.existsByEmail(email);
}
}
// Utilisation
public record CreateUserRequest(
@NotBlank @Email @UniqueEmail
String email
) {}
7. Error Handling
7.1 @ControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(ValidationException ex) {
return new ErrorResponse("VALIDATION_ERROR", ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
return new ErrorResponse("VALIDATION_ERROR", "Erreur de validation", fieldErrors);
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
return new ErrorResponse("FORBIDDEN", "Accès refusé");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex) {
log.error("Erreur non gérée: {}", ex.getMessage(), ex);
return new ErrorResponse("INTERNAL_ERROR", "Erreur interne du serveur");
}
// DTO
public record ErrorResponse(String code, String message, List<FieldError> errors) {
public ErrorResponse(String code, String message) {
this(code, message, List.of());
}
}
public record FieldError(String field, String message) {}
}
7.2 Exception personnalisée
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private final String resourceName;
private final Object resourceId;
public ResourceNotFoundException(String resourceName, Object resourceId) {
super("%s non trouvé avec l'identifiant: %s".formatted(resourceName, resourceId));
this.resourceName = resourceName;
this.resourceId = resourceId;
}
public String getResourceName() { return resourceName; }
public Object getResourceId() { return resourceId; }
}
8. Testing
8.1 Unit Test
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldFindUserById() {
// Given
var user = new User(1L, "Alice", "alice@test.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// When
var result = userService.findById(1L);
// Then
assertThat(result.name()).isEqualTo("Alice");
assertThat(result.email()).isEqualTo("alice@test.com");
verify(userRepository).findById(1L);
}
@Test
void shouldThrowWhenUserNotFound() {
when(userRepository.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findById(999L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("999");
}
}
8.2 @WebMvcTest
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUser() throws Exception {
var user = new UserDTO(1L, "Alice", "alice@test.com", LocalDateTime.now());
when(userService.findById(1L)).thenReturn(user);
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@test.com"))
.andExpect(jsonPath("$.id").value(1));
}
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
when(userService.findById(999L)).thenThrow(new ResourceNotFoundException("User", 999L));
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("NOT_FOUND"));
}
@Test
void shouldCreateUser() throws Exception {
var request = new CreateUserRequest("Bob", "bob@test.com");
var created = new UserDTO(2L, "Bob", "bob@test.com", LocalDateTime.now());
when(userService.create(any())).thenReturn(created);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Bob", "email": "bob@test.com"}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.name").value("Bob"));
}
}
8.3 @DataJpaTest
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByEmail() {
var user = new User("Alice", "alice@test.com");
entityManager.persistAndFlush(user);
var found = userRepository.findByEmail("alice@test.com");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Alice");
}
@Test
void shouldReturnEmptyForUnknownEmail() {
var found = userRepository.findByEmail("unknown@test.com");
assertThat(found).isEmpty();
}
}
8.4 @SpringBootTest
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ApplicationIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void fullUserLifecycle() {
// Create
var createResponse = restTemplate.postForEntity("/api/users",
new CreateUserRequest("Alice", "alice@test.com"),
UserDTO.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var created = createResponse.getBody();
// Read
var getResponse = restTemplate.getForEntity("/api/users/" + created.id(), UserDTO.class);
assertThat(getResponse.getBody().name()).isEqualTo("Alice");
// Delete
restTemplate.delete("/api/users/" + created.id());
// Verify
assertThat(userRepository.findById(created.id())).isEmpty();
}
}
8.5 @TestConfiguration
@SpringBootTest
class NotificationServiceTest {
@Autowired
private NotificationService notificationService;
@TestConfiguration
static class TestConfig {
@Bean
@Primary
public EmailService mockEmailService() {
return mock(EmailService.class);
}
}
@Test
void shouldSendNotification() {
notificationService.send("test@test.com", "Message");
// Vérification
}
}
9. Résumé
| Concept | Outil |
|---|---|
| Dépendances | Starters |
| Configuration | application.yml + @ConfigurationProperties |
| API REST | @RestController |
| Validation | @Valid + Bean Validation |
| Erreurs | @ControllerAdvice |
| Monitoring | Actuator |
| Tests | @WebMvcTest, @DataJpaTest |
| Profils | application-{profile}.yml |