javaspring-bootconfigurationproperties

Construct @ConfigurationProperties programmatically


I need to load a pretty complex object from application properties. The problem is, that I have to operate inside an ApplicationListener<ApplicationPreparedEvent>, aka the application is not fully loaded yet. So I guess, any annotation based approach is off reach.

Is there a way to get the same functionality in a programmatic way? I have access to the ConfigurableEnvironment and all the properties are already loaded.

What I tried so far:

Super hacky. It kinda works, except one detail: arrays coming from different PropertySources get "merged" instead of the expected behaviour, where the array with the higher precedence completely overrides the lower precedence ones. I need this to work as expected since I have profile-specific application-xxx.yaml files that are expected to override the default arrays from application.yaml. The expected array-overriding behaviour is described here: Combine list from multiple spring-boot YAML files

So, seems like my hacky solution implements some @ConfiruationProperties features, but not others. Feels like I am reinventing the wheel, and doing so badly.

Is there a solution where I get the proper functionality of @ConfigurationProperties in my ApplicationListener?


Solution

  • Spring Boot exposes org.springframework.boot.context.properties.bind.Binder, which you can use like this:

    import org.springframework.boot.context.event.ApplicationPreparedEvent;
    import org.springframework.boot.context.properties.bind.Binder;
    import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
    import org.springframework.context.ApplicationListener;
    import org.springframework.core.env.ConfigurableEnvironment;
    
    public class MyApplicationPreparedListener implements ApplicationListener<ApplicationPreparedEvent> {
    
        @Override
        public void onApplicationEvent(ApplicationPreparedEvent event) {
            ConfigurableEnvironment env = event.getApplicationContext().getEnvironment();
            Binder binder = new Binder(ConfigurationPropertySources.get(env));
    
            MyComplexConfig config = binder.bind("my.config", MyComplexConfig.class)
                    .orElseThrow(() -> new IllegalStateException("Could not bind config"));
    
            System.out.println("Loaded config:");
            System.out.println("  name: " + config.getName());
            System.out.println("  servers: " + config.getServers());
            System.out.println("  timeout: " + config.getNested().getTimeout());
            System.out.println("  labels: " + config.getNested().getLabels());
        }
    }
    

    Lets take a configuration properties file and two YAMLs like this:

    import lombok.Getter;
    import lombok.Setter;
    
    import java.util.List;
    import java.util.Map;
    
    @Getter
    @Setter
    public class MyComplexConfig {
        private String name;
        private List<String> servers;
        private NestedConfig nested;
    
        @Getter
        @Setter
        public static class NestedConfig {
            private int timeout;
            private Map<String, String> labels;
        }
    }
    

    application.yaml

    my:
      config:
        name: DefaultConfig
        servers:
          - default1.server.com
          - default2.server.com
        nested:
          timeout: 30
          labels:
            env: prod
            region: us-east
    

    application-dev.yaml

    my:
      config:
        name: DevConfig
        servers:
          - dev1.server.local
        nested:
          timeout: 10
          labels:
            env: dev
            debug: true
    

    SpringBootApplication class:

    import com.example.MyApplicationPreparedListener;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.builder.SpringApplicationBuilder;
    
    @SpringBootApplication
    public class ExampleApplication {
    
        public static void main(String[] args) {
            new SpringApplicationBuilder(ExampleApplication.class)
                    .profiles("dev") // remove it to run with default profile
                    .listeners(new MyApplicationPreparedListener()) 
                    .run(args);
        }
    }
    
    

    Output for default profile:

    Loaded config:
      name: DefaultConfig
      servers: [default1.server.com, default2.server.com]
      timeout: 30
      labels: {env=prod, region=us-east}
    

    Output for dev profile:

    Loaded config:
      name: DevConfig
      servers: [dev1.server.local]
      timeout: 10
      labels: {env=dev, debug=true, region=us-east}