Spring Boot Binder API support for @Value Annotations

試著忘記壹切 提交于 2021-02-11 13:12:40

问题


I am using the Spring Boot Binder API in an EnvironmentPostProcessor (i.e. before the actual application context(s) is (are) refreshed) to bind a custom ConfigurationProperty object.

I want users to have to specify exactly one mandatory property in application.yml: com.acme.kafka.service-instance-name: <user-provided value>.

Given that, I will be able to derive the other (required but not mandatory to be put in by the user) properties:

com:
  acme:
    kafka:
      username: <can be fetched from VCAP_SERVICES, or specified explicitly>
      password: <can be fetched from VCAP_SERVICES, or specified explicitly>
      brokers:  <can be fetched from VCAP_SERVICES, or specified explicitly>
      token-endpoint: <can be fetched from VCAP_SERVICES, or specified explicitly>
      token-validity: <can be fetched from VCAP_SERVICES, or specified explicitly>

So in the simplest case for a user, the application.yml should contain only:

com:
  acme:
    kafka:
      service-instance-name: myKafkaInstance

I created a custom ConfigurationProperty for that, which looks as follows:

package com.acme.kafka;

import java.net.URL;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.List;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.validation.annotation.Validated;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@Validated
@ConfigurationProperties(prefix = AcmeKafkaConfigurationProperties.PREFIX)
public class AcmeKafkaConfigurationProperties {
  public static final String PREFIX = "com.acme.kafka";

  static final String USERNAME_FROM_VCAP_SERVICES       = "${vcap.services.${com.acme.kafka.service-instance-name}.credentials.username:}";
  static final String PASSWORD_FROM_VCAP_SERVICES       = "${vcap.services.${com.acme.kafka.service-instance-name}.credentials.password:}";
  static final String BROKERS_FROM_VCAP_SERVICES        = "${vcap.services.${com.acme.kafka.service-instance-name}.credentials.cluster.brokers:}";
  static final String TOKEN_ENDPOINT_FROM_VCAP_SERVICES = "${vcap.services.${com.acme.kafka.service-instance-name}.credentials.urls.token:}";

  @NotBlank(message = "Acme Kafka service instance name must not be blank.")
  private String serviceInstanceName;

  @NotBlank(message = "Username must not be blank. Make sure it is either specified explicitly or available from VCAP_SERVICES environment.")
  @Value(USERNAME_FROM_VCAP_SERVICES)
  private String username;

  @NotBlank(message = "Password must not be blank. Make sure it is either specified explicitly or available from VCAP_SERVICES environment.")
  @Value(PASSWORD_FROM_VCAP_SERVICES)
  private String password;

  @NotEmpty(message = "Brokers must not be empty. Make sure it is either specified explicitly or available from VCAP_SERVICES environment.")
  @Value(BROKERS_FROM_VCAP_SERVICES)
  private List<@NotBlank String> brokers;

  @NotNull(message = "Token endpoint URL must not be null and must be a valid URL. Make sure it is either specified explicitly or available from VCAP_SERVICES environment.")
  @Value(TOKEN_ENDPOINT_FROM_VCAP_SERVICES)
  private URL tokenEndpoint;

  @NotNull(message = "Token validity must not be null and a value given in seconds (1s), minutes (2m), hours (3h), or days (365d).")
  @DurationUnit(ChronoUnit.DAYS)
  private Duration tokenValidity = Duration.ofDays(3650);

  private String springCloudStreamBinderName;

  private boolean autoCreateTruststore;

  private String sslTruststoreLocation;

  private String sslTruststorePassword;
}

Notice that the AcmeKafkaConfigurationProperties uses @Value annotations for some properties that (if not explicitly configured in application.yml) should be filled with values from the CF VCAP_SERVICES environment. These properties (since they are required) are also annotated with validation annotations, to check they are properly filled.

After the Spring Boot application context has been refreshed, the above code works like a charm: 1. The AcmeKafkaConfigurationProperties instance is created 2. First the values of VCAP_SERVICES are bound, then (if explicitly specified) these are overridden by what's in application.yml 3. Then validation kicks in, and evth. just works.

However, since I need the AcmeKafkaConfigurationProperties already in an EnvironmentPostProcessor (where the context has not been refreshed yet), I am doing this:

@Component
public class AcmeKafkaEnvironmentPostprocessor implements EnvironmentPostProcessor, ApplicationListener<ApplicationPreparedEvent>, Ordered {

  private AcmeKafkaConfigurationProperties acmeKafkaProps;
  private PropertySourcesPlaceholdersResolver resolver;

  @Override
  public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {

    // Put defaults that should be read from VCAPS if not specified on Environment as last property source.
    // This is a workaround for the fact that Spring Boot's Binder does not allow to resolve
    // @Value annotations against vcap.services environment.
    HashMap<String, Object> map = new HashMap<>();
    map.put("com.acme.kafka.username",       AcmeKafkaConfigurationProperties.USERNAME_FROM_VCAP_SERVICES);
    map.put("com.acme.kafka.password",       AcmeKafkaConfigurationProperties.PASSWORD_FROM_VCAP_SERVICES);
    map.put("com.acme.kafka.brokers",        AcmeKafkaConfigurationProperties.BROKERS_FROM_VCAP_SERVICES);
    map.put("com.acme.kafka.token-endpoint", AcmeKafkaConfigurationProperties.TOKEN_ENDPOINT_FROM_VCAP_SERVICES);
    environment.getPropertySources().addLast(new MapPropertySource("acmeKafkaDefaults", map));

    // For Details see this excellent blog post: 
    // https://spring.io/blog/2018/03/28/property-binding-in-spring-boot-2-0
    Iterable<ConfigurationPropertySource> sources = ConfigurationPropertySources.get(environment);

    resolver = new PropertySourcesPlaceholdersResolver(environment);

    // Just to check that values are resolved properly. Ok!
    String result = (String) resolver.resolvePlaceholders("${vcap.services.${com.acme.kafka.service-instance-name}.credentials.urls.token:}");

    Binder binder = new Binder(sources, resolver);

    Bindable<AcmeKafkaConfigurationProperties> bindable = Bindable.of(AcmeKafkaConfigurationProperties.class);

    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    SpringValidatorAdapter springValidator = new SpringValidatorAdapter(validator);

    BindResult<AcmeKafkaConfigurationProperties> bindResult = binder.bind(AcmeKafkaConfigurationProperties.PREFIX, 
                                                                         bindable,
                                                                         new ValidationBindHandler(springValidator));

    acmeKafkaProps = bindResult.get();

    System.out.println("ServiceInstanceName:    " + acmeKafkaProps.getServiceInstanceName());
    System.out.println("UserName:               " + acmeKafkaProps.getUsername());
    System.out.println("Password:               " + acmeKafkaProps.getPassword());
    System.out.println("TokenValidity:          " + acmeKafkaProps.getTokenValidity());
    System.out.println("TokenEndpoint:          " + acmeKafkaProps.getTokenEndpoint());
    System.out.println("Brokers:                " + acmeKafkaProps.getBrokers());

  }

Notice the part where I am placing a Map with defaults from VCAP_SERVICES in the environment:

    HashMap<String, Object> map = new HashMap<>();
    map.put("com.acme.kafka.username",       AcmeKafkaConfigurationProperties.USERNAME_FROM_VCAP_SERVICES);
    map.put("com.acme.kafka.password",       AcmeKafkaConfigurationProperties.PASSWORD_FROM_VCAP_SERVICES);
    map.put("com.acme.kafka.brokers",        AcmeKafkaConfigurationProperties.BROKERS_FROM_VCAP_SERVICES);
    map.put("com.acme.kafka.token-endpoint", AcmeKafkaConfigurationProperties.TOKEN_ENDPOINT_FROM_VCAP_SERVICES);
    environment.getPropertySources().addLast(new MapPropertySource("acmeKafkaDefaults", map));

This essentially puts key value-pairs of the following form on the environment:

com.acme.kafka.<propertyname> : "${vcap.services.<instancename>.credentials.<path-to-property>}"

To me, this feels like a workaround, since it is necessary, because the Binder API of Spring Boot, does not allow for the resolution of the @Value annotation. Instead, it always looks for the name of the property to bind (in this case com.acme.kafka-prefixed values) in the environment, and if it does not find them there, it concludes that he value is not set. It never considers to also check whether there is an @Value annotation that might make it necessary to lookup the bindable value by a completely different prefix, e.g. vcap.services... - essentially the placeholder given in the @Value annotation.

So, I tried creating my own BindHandler, which I understood to be a way to influence the binding process, e.g. by taking annotations into account. This is e.g. how Spring Boot Binder API supports validation annotations handling - by providing the ValidationBindHandler used in the code above.

So here is the BindHandler code I tried to use:

private class MyBindHandler implements BindHandler {

    @Override
    public <T> Bindable<T> onStart(ConfigurationPropertyName name, Bindable<T> target, BindContext context) {
      Value valueAnnotation = target.getAnnotation(Value.class);
      if(valueAnnotation == null) { // property has no @Value annotation
        return target;
      }

      String vcapServicesReference = valueAnnotation.value();

      // PropertySourcesPlaceholdersResolver resolver = new PropertySourcesPlaceholdersResolver(environment);
      // ... defined in EnvironmentPostProcessor.
      Object resolvedValue = resolver.resolvePlaceholders(vcapServicesReference);

      return target.withExistingValue((T) resolvedValue);

      //also tried this:
      //return target.withSuppliedValue(() -> {
      //  return (T) resolver.resolvePlaceholders(vcapServicesReference);
      //});
    }

    @Override
    public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
      return result;
    }

    @Override
    public Object onCreate(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
      return result;
    }

    @Override
    public Object onFailure(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Exception error)
        throws Exception {
      throw error;
    }

    @Override
    public void onFinish(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result)
        throws Exception {
    }
  }

Unfortunately, this does not work. onStart() and onFinish are the only callbacks that are currently being called in my setup, and the (manually) resolved value from the @Value annotation that I injected into the Bindable target is never considered.

Debugging the whole stack, I think the problem is this method in Binder.class (see comments inline for the issue):

private <T> Object bindObject(ConfigurationPropertyName name, Bindable<T> target, BindHandler handler,
            Context context, boolean allowRecursiveBinding) {

        // This call does not find any property `com.acme.kafka.username`...
        ConfigurationProperty property = findProperty(name, context);
        // ...and this statement evaluates to 'true', leading to an immediate 'return null'
        // which is equivalent to 'I give up, there is no value for that property I can bind': 
        if (property == null && containsNoDescendantOf(context.getSources(), name) && context.depth != 0) {
            //Here could / should (?) be a check, if the target was modified, i.e. has a 
            //value supplier or an existing value set (e.g. by my BindHandler above)
            // and if that is the case, the bound property should be returned.
            return null;
        }
        AggregateBinder<?> aggregateBinder = getAggregateBinder(target, context);
        if (aggregateBinder != null) {
            return bindAggregate(name, target, handler, context, aggregateBinder);
        }
        if (property != null) {
            try {
                return bindProperty(target, context, property);
            }
            catch (ConverterNotFoundException ex) {
                Object instance = bindDataObject(name, target, handler, context, allowRecursiveBinding);
                if (instance != null) {
                    return instance;
                }
                throw ex;
            }
        }
        return bindDataObject(name, target, handler, context, allowRecursiveBinding);
    }

My two questions therefore are:

  1. Is my workaround described above (where I dump defaults into the environment) the intended way, or indeed a workaround?

  2. How would one resolve and bind @Value annotations with Spring Boot Binder API, and would the proposed changes be an option?

Thanks!

来源:https://stackoverflow.com/questions/61405739/spring-boot-binder-api-support-for-value-annotations

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!