Поддержка Spring Boot Binder API для аннотаций @Value - PullRequest
0 голосов
/ 24 апреля 2020

Я использую Spring Boot Binder API в EnvironmentPostProcessor (т. Е. До обновления (обновления) фактического контекста приложения) для привязки пользовательского ConfigurationProperty объекта.

Я хочу, чтобы пользователям приходилось указывать ровно одно обязательное свойство в application.yml: com.acme.kafka.service-instance-name: <user-provided value>.

Учитывая это, я смогу получить другое (требуется, но не обязательно, чтобы оно было введено 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>

Таким образом, в простейшем случае для пользователя application.yml должен содержать только:

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

Я создал для этого пользовательский ConfigurationProperty, который выглядит следующим образом:

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;
}

Обратите внимание, что AcmeKafkaConfigurationProperties использует аннотации @Value для некоторых свойств, которые (если они явно не настроены в application.yml) должны быть заполнены значениями из CF VCAP_SERVICES окружающая обстановка. Эти свойства (поскольку они необходимы) также снабжены аннотациями для проверки правильности заполнения.

После контекст приложения Spring Boot обновлен, приведенный выше код работает как Очарование: 1. Создается экземпляр AcmeKafkaConfigurationProperties 2. Сначала привязываются значения VCAP_SERVICES, затем (если явно указано) они переопределяются тем, что в application.yml 3. Затем начинается проверка и evth. просто работает.

Однако, так как мне нужно AcmeKafkaConfigurationProperties уже в EnvironmentPostProcessor (где контекст еще не обновлен), я делаю это:

@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());

  }

Обратите внимание на часть где я помещаю карту со значениями по умолчанию от VCAP_SERVICES в среде:

    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));

Это, по сути, помещает пары значений следующей формы в среду:

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

В Мне кажется, что это обходной путь, поскольку он необходим, поскольку API-интерфейс Binder Spring Boot не позволяет разрешать аннотацию @Value. Вместо этого он всегда ищет имя свойства для привязки (в данном случае com.acme.kafka -префиксированных значений) в среде, и если он не находит их там, он приходит к выводу, что его значение не установлено. Он также никогда не проверяет, существует ли аннотация @Value, которая может привести к необходимости поиска привязываемого значения по совершенно другому префиксу, например, vcap.services... - по сути, это заполнитель, указанный в аннотации @Value.

Итак, я попытался создать свой собственный BindHandler, который, как я понял, может повлиять на процесс привязки, например, с учетом аннотаций. Это, например, то, как Spring Boot Binder API поддерживает обработку аннотаций проверки - предоставляя ValidationBindHandler, используемый в приведенном выше коде.

Итак, вот код BindHandler, который я пытался использовать:

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 {
    }
  }

К сожалению, это не работает. onStart() и onFinish - единственные обратные вызовы, которые в данный момент вызываются в моей установке, и (вручную) разрешенное значение из аннотации @Value, которую я вставил в Bindable target, никогда не рассматривается.

Отладка всего стека, я думаю, что проблема заключается в этом методе в Binder.class (см. Комментарии к проблеме):

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);
    }

Поэтому у меня два вопроса:

  1. Является ли мой обходной путь, описанный выше (куда я сбрасываю значения по умолчанию в окружающую среду) намеченным способом, или действительно обходной путь?

  2. Как можно разрешить и связать аннотации @Value с API Spring Boot Binder, и будут ли предложенные изменения возможными?

Спасибо !

...