Spring Boot Starter – Tworzenie Własnego Custom Starter

TL;DR: Tworzenie własnego Spring Boot Starter pozwala enkapsulować konfigurację bibliotek i narzędzi do ponownego użycia. Kluczowe elementy: @Configuration z @ConditionalOn adnotacjami, @EnableConfigurationProperties, META-INF/spring.factories i odpowiednia struktura projektu. Pozwala na plug-and-play approach w aplikacjach Spring Boot.

Dlaczego warto tworzyć własne Spring Boot Startery?

W enterprise środowiskach często używamy tych samych bibliotek i konfiguracji w wielu projektach. Bez własnych starterów programiści muszą kopiować te same konfiguracje, co prowadzi do duplikacji kodu i błędów. Spring Boot Starter rozwiązuje ten problem poprzez enkapsulację konfiguracji w reuzywalnym module.

Własny starter pozwala zespołom na standaryzację sposobu integracji z zewnętrznymi systemami, bazami danych czy narzędziami monitoringu. Zamiast dokumentować „jak skonfigurować bibliotekę X”, po prostu dodajesz dependency i wszystko działa out-of-the-box.

Co się nauczysz:

  • Jak stworzyć strukturę Spring Boot Starter od podstaw
  • Implementację auto-konfiguracji z warunkami
  • Zarządzanie properties i zewnętrzną konfiguracją
  • Best practices dla API design starterów
  • Testowanie i publikowanie własnych starterów
Wymagania wstępne: Dobra znajomość Spring Framework i Spring Boot 2.x, doświadczenie z Maven/Gradle, podstawy auto-konfiguracji Spring Boot oraz adnotacji warunkowych.

Anatomia Spring Boot Starter

Spring Boot Starter – moduł który automatycznie konfiguruje komponenty Spring gdy zostanie dodany do classpath. Zawiera dependencies, auto-konfigurację i domyślne ustawienia.

Typowy starter składa się z:

**Auto-Configuration** – klasy `@Configuration` z warunkami określającymi kiedy konfiguracja ma być aktywna

**Properties** – klasy `@ConfigurationProperties` do externalizacji konfiguracji

**META-INF/spring.factories** – plik rejestrujący auto-konfigurację w Spring Boot

**Dependencies** – transitive dependencies potrzebne do działania

Tworzenie Custom Starter – Praktyczny Przykład

Stworzymy starter dla integracji z zewnętrznym API klientów. Nazwijmy go `customer-api-spring-boot-starter`.

1. Struktura projektu

customer-api-spring-boot-starter/
├── pom.xml
├── src/main/java/
│   └── com/company/starter/customerapi/
│       ├── CustomerApiAutoConfiguration.java
│       ├── CustomerApiProperties.java
│       ├── CustomerApiClient.java
│       └── CustomerApiHealthIndicator.java
├── src/main/resources/
│   └── META-INF/
│       └── spring.factories
└── src/test/java/
    └── com/company/starter/customerapi/
        └── CustomerApiAutoConfigurationTest.java

2. Pom.xml – Dependencies



    4.0.0
    
    com.company.starter
    customer-api-spring-boot-starter
    1.0.0
    jar
    
    Customer API Spring Boot Starter
    Spring Boot Starter for Customer API integration
    
    
        1.8
        2.1.1.RELEASE
    
    
    
        
        
            org.springframework.boot
            spring-boot-autoconfigure
            ${spring-boot.version}
        
        
        
        
            org.springframework.boot
            spring-boot-configuration-processor
            ${spring-boot.version}
            true
        
        
        
        
            org.springframework
            spring-web
            5.1.3.RELEASE
        
        
        
        
            com.fasterxml.jackson.core
            jackson-databind
            2.9.7
        
        
        
        
            org.springframework.boot
            spring-boot-actuator
            ${spring-boot.version}
            true
        
        
        
        
            org.springframework.boot
            spring-boot-starter-test
            ${spring-boot.version}
            test
        
    

Pro tip: Używaj `true` dla dependencies które nie są wymagane do podstawowego działania, jak actuator czy specific drivers.

3. Configuration Properties

@ConfigurationProperties(prefix = "customer.api")
public class CustomerApiProperties {
    
    /**
     * Base URL for Customer API
     */
    private String baseUrl = "https://api.customers.com";
    
    /**
     * API key for authentication
     */
    private String apiKey;
    
    /**
     * Connection timeout in milliseconds
     */
    private int connectTimeout = 5000;
    
    /**
     * Read timeout in milliseconds
     */
    private int readTimeout = 10000;
    
    /**
     * Enable retry mechanism
     */
    private boolean retryEnabled = true;
    
    /**
     * Maximum number of retry attempts
     */
    private int maxRetries = 3;
    
    /**
     * Health check configuration
     */
    private HealthCheck healthCheck = new HealthCheck();
    
    public static class HealthCheck {
        /**
         * Enable health check endpoint
         */
        private boolean enabled = true;
        
        /**
         * Health check endpoint path
         */
        private String endpoint = "/health";
        
        // getters and setters
        public boolean isEnabled() { return enabled; }
        public void setEnabled(boolean enabled) { this.enabled = enabled; }
        public String getEndpoint() { return endpoint; }
        public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
    }
    
    // getters and setters
    public String getBaseUrl() { return baseUrl; }
    public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
    
    public String getApiKey() { return apiKey; }
    public void setApiKey(String apiKey) { this.apiKey = apiKey; }
    
    public int getConnectTimeout() { return connectTimeout; }
    public void setConnectTimeout(int connectTimeout) { this.connectTimeout = connectTimeout; }
    
    public int getReadTimeout() { return readTimeout; }
    public void setReadTimeout(int readTimeout) { this.readTimeout = readTimeout; }
    
    public boolean isRetryEnabled() { return retryEnabled; }
    public void setRetryEnabled(boolean retryEnabled) { this.retryEnabled = retryEnabled; }
    
    public int getMaxRetries() { return maxRetries; }
    public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; }
    
    public HealthCheck getHealthCheck() { return healthCheck; }
    public void setHealthCheck(HealthCheck healthCheck) { this.healthCheck = healthCheck; }
}

4. API Client Implementation

public class CustomerApiClient {
    
    private final RestTemplate restTemplate;
    private final CustomerApiProperties properties;
    private final ObjectMapper objectMapper;
    
    public CustomerApiClient(CustomerApiProperties properties) {
        this.properties = properties;
        this.objectMapper = new ObjectMapper();
        this.restTemplate = createRestTemplate();
    }
    
    private RestTemplate createRestTemplate() {
        RestTemplate template = new RestTemplate();
        
        // Configure timeouts
        HttpComponentsClientHttpRequestFactory factory = 
            new HttpComponentsClientHttpRequestFactory();
        factory.setConnectTimeout(properties.getConnectTimeout());
        factory.setReadTimeout(properties.getReadTimeout());
        template.setRequestFactory(factory);
        
        // Add API key interceptor
        template.getInterceptors().add((request, body, execution) -> {
            request.getHeaders().add("X-API-Key", properties.getApiKey());
            return execution.execute(request, body);
        });
        
        return template;
    }
    
    public Customer getCustomer(Long customerId) {
        String url = properties.getBaseUrl() + "/customers/" + customerId;
        
        try {
            ResponseEntity response = restTemplate.getForEntity(url, Customer.class);
            return response.getBody();
        } catch (Exception e) {
            if (properties.isRetryEnabled()) {
                return retryGetCustomer(customerId, 0);
            }
            throw new CustomerApiException("Failed to get customer: " + customerId, e);
        }
    }
    
    private Customer retryGetCustomer(Long customerId, int attempt) {
        if (attempt >= properties.getMaxRetries()) {
            throw new CustomerApiException("Max retries exceeded for customer: " + customerId);
        }
        
        try {
            Thread.sleep(1000 * (attempt + 1)); // Exponential backoff
            return getCustomer(customerId);
        } catch (Exception e) {
            return retryGetCustomer(customerId, attempt + 1);
        }
    }
    
    public List searchCustomers(String query) {
        String url = properties.getBaseUrl() + "/customers/search?q=" + query;
        
        try {
            ResponseEntity response = restTemplate.getForEntity(url, Customer[].class);
            return Arrays.asList(response.getBody());
        } catch (Exception e) {
            throw new CustomerApiException("Failed to search customers: " + query, e);
        }
    }
    
    public boolean isHealthy() {
        try {
            String healthUrl = properties.getBaseUrl() + properties.getHealthCheck().getEndpoint();
            ResponseEntity response = restTemplate.getForEntity(healthUrl, String.class);
            return response.getStatusCode().is2xxSuccessful();
        } catch (Exception e) {
            return false;
        }
    }
}

5. Auto-Configuration

@Configuration
@EnableConfigurationProperties(CustomerApiProperties.class)
@ConditionalOnClass({RestTemplate.class, CustomerApiClient.class})
@ConditionalOnProperty(prefix = "customer.api", name = "enabled", havingValue = "true", matchIfMissing = true)
public class CustomerApiAutoConfiguration {
    
    private final CustomerApiProperties properties;
    
    public CustomerApiAutoConfiguration(CustomerApiProperties properties) {
        this.properties = properties;
    }
    
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "customer.api", name = "api-key")
    public CustomerApiClient customerApiClient() {
        return new CustomerApiClient(properties);
    }
    
    @Bean
    @ConditionalOnClass(HealthIndicator.class)
    @ConditionalOnBean(CustomerApiClient.class)
    @ConditionalOnProperty(prefix = "customer.api.health-check", name = "enabled", havingValue = "true", matchIfMissing = true)
    public CustomerApiHealthIndicator customerApiHealthIndicator(CustomerApiClient client) {
        return new CustomerApiHealthIndicator(client);
    }
    
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnBean(CustomerApiClient.class)
    public CustomerService customerService(CustomerApiClient client) {
        return new CustomerService(client);
    }
}
Pułapka: Pamiętaj o `@ConditionalOnProperty` dla wymaganych properties jak API key. Bez tego starter może się uruchomić z błędną konfiguracją.

6. Health Indicator

public class CustomerApiHealthIndicator implements HealthIndicator {
    
    private final CustomerApiClient customerApiClient;
    
    public CustomerApiHealthIndicator(CustomerApiClient customerApiClient) {
        this.customerApiClient = customerApiClient;
    }
    
    @Override
    public Health health() {
        try {
            boolean isHealthy = customerApiClient.isHealthy();
            
            if (isHealthy) {
                return Health.up()
                    .withDetail("status", "Customer API is accessible")
                    .withDetail("timestamp", Instant.now())
                    .build();
            } else {
                return Health.down()
                    .withDetail("status", "Customer API is not responding")
                    .withDetail("timestamp", Instant.now())
                    .build();
            }
        } catch (Exception e) {
            return Health.down()
                .withDetail("status", "Customer API health check failed")
                .withDetail("error", e.getMessage())
                .withDetail("timestamp", Instant.now())
                .build();
        }
    }
}

7. META-INF/spring.factories

# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.company.starter.customerapi.CustomerApiAutoConfiguration
Plik `spring.factories` to kluczowy element – bez niego Spring Boot nie znajdzie Twojej auto-konfiguracji. Upewnij się, że ścieżka do klasy jest poprawna.

Testowanie Auto-Configuration

@RunWith(SpringRunner.class)
@SpringBootTest
public class CustomerApiAutoConfigurationTest {
    
    @Autowired(required = false)
    private CustomerApiClient customerApiClient;
    
    @Test
    public void contextLoads() {
        // Test passes if context loads without errors
    }
    
    @Test
    @TestPropertySource(properties = {
        "customer.api.api-key=test-key",
        "customer.api.base-url=http://localhost:8080"
    })
    public void shouldCreateCustomerApiClientWhenPropertiesProvided() {
        assertThat(customerApiClient).isNotNull();
    }
    
    @Test
    @TestPropertySource(properties = {
        "customer.api.enabled=false"
    })
    public void shouldNotCreateCustomerApiClientWhenDisabled() {
        assertThat(customerApiClient).isNull();
    }
}

@TestConfiguration
public class CustomerApiAutoConfigurationTestConfiguration {
    
    @Bean
    @Primary
    public CustomerApiClient mockCustomerApiClient() {
        return Mockito.mock(CustomerApiClient.class);
    }
}

Używanie Custom Starter

1. Dodanie dependency


    com.company.starter
    customer-api-spring-boot-starter
    1.0.0

2. Konfiguracja w application.yml

customer:
  api:
    api-key: ${CUSTOMER_API_KEY}
    base-url: https://prod-api.customers.com
    connect-timeout: 3000
    read-timeout: 15000
    retry-enabled: true
    max-retries: 5
    health-check:
      enabled: true
      endpoint: /status

3. Użycie w aplikacji

@RestController
public class CustomerController {
    
    private final CustomerService customerService;
    
    public CustomerController(CustomerService customerService) {
        this.customerService = customerService;
    }
    
    @GetMapping("/customers/{id}")
    public Customer getCustomer(@PathVariable Long id) {
        return customerService.getCustomer(id);
    }
    
    @GetMapping("/customers/search")
    public List searchCustomers(@RequestParam String query) {
        return customerService.searchCustomers(query);
    }
}
Jaka jest różnica między starter a običajną biblioteką?

Starter automatycznie konfiguruje beans i dependencies gdy zostanie dodany do classpath. Zwykła biblioteka wymaga manualnej konfiguracji. Starter = biblioteka + auto-konfiguracja + sensible defaults.

Kiedy powinienem stworzyć własny starter?

Gdy ta sama konfiguracja jest potrzebna w wielu projektach, gdy integrujesz się z external service często używanym w firmie, lub gdy chcesz standaryzować sposób używania konkretnej biblioteki.

Czy starter może mieć własne dependencies?

Tak, starter może definiować transitive dependencies. Używaj `true` dla dependencies które nie są zawsze potrzebne. Uważaj na konflikty wersji.

Jak debugować problemy z auto-konfiguracją?

Użyj `–debug` flag lub `logging.level.org.springframework.boot.autoconfigure=DEBUG`. Sprawdź warunki w `@ConditionalOn*` adnotacjach i czy wymagane properties są ustawione.

Czy mogę nadpisać beans z starter?

Tak, używaj `@ConditionalOnMissingBean` w auto-konfiguracji. Dzięki temu użytkownicy mogą definiować własne implementacje które będą miały pierwszeństwo nad starter beans.

Jak versioning starterów w enterprise?

Używaj semantic versioning. Major version dla breaking changes, minor dla nowych features, patch dla bugfixes. Dokumentuj breaking changes i migration guides.

Co z performance i startup time?

Używaj lazy initialization gdzie to możliwe, minimalizuj liczbę beans tworzonych podczas startup, używaj `@ConditionalOn*` żeby unikać niepotrzebnych konfiguracji.

🚀 Zadanie dla Ciebie

Stwórz starter dla integracji z Redis cache. Dodaj auto-konfigurację RedisTemplate, health indicator, metrics collector i configuration properties dla connection pool. Uwzględnij profile-specific configuration (dev/prod) i fallback gdy Redis nie jest dostępny. Napisz comprehensive testy i dokumentację.

Przydatne zasoby:

Jakie własne startery stworzyłeś w swoich projektach? Które biblioteki najczęściej konfigurujesz i mogłyby skorzystać z własnego starter? Podziel się doświadczeniami w komentarzach!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry