1. Preface

When developing Spring Boot applications, you can inject Bean into the Spring IoC container based on conditions. For example, if a configuration property exists in the configuration file, then the Bean is injected.

Spring Boot

The red part of the diagram means that the class tagged with @Configuration can only be injected into Spring IoC if ali.pay.v1.app-id is present in the environment configuration of Spring.

The @ConditionalOnProperty in this case is one of the conditional annotation family. It has many more conditional annotations to meet various scenarios.

ConditionalOnProperty

There are actually a lot more than just the ones in the screenshot, they are also implemented in other frameworks in the Spring family.

This is a bit off topic, today is not about the usage of these conditional control annotations, just that I found a problem that could not be solved using the conditional annotation @ConditionalOnProperty.

2. Scenarios where the configuration file has a Map structure

Here is a configuration file.

1
2
3
4
5
6
7
8
app:
 v1:
  foo:
    name: felord.cn
    description: 码农小胖哥
  bar:
    name: ooxx.cn
    description: xxxxxx

Corresponding configuration classes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
@ConfigurationProperties("app")
public class AppProperties {
    /**
     *  
     */
    private Map<String, V1> v1 = new HashMap<>();

    /**
     *  
     *
     * @author felord.cn
     * @since 1.0.0.RELEASE
     */
    @Data
    public static class V1 {
        /**
         * name
         */
        private String name;
        /**
         * description
         */
        private String description;

    }
}

Here comes the special feature. The yml configuration foo and bar are actually used as key in the Map to identify V1, unlike other configuration parameters this key can be defined by the user as a String, maybe foo, maybe bar, depending on the developer’s preference.

At this point you want to make a @ConditionalOnProperty determination based on app.v1.*.name (using the wildcard * for now), but it won’t work because you’re not sure of the value of *, so what do you do?

3. Solution

I spent a day poking around here, at first I thought Spring provided wildcards (app.v1.*.name) or even SpringEL expressions to get them, but after half a day of messing with them I was left with no luck.

Suddenly it occurred to me that I was looking at Spring Security OAuth2 source code that had similar logic. Anyone who has used Spring Security OAuth2 knows that Spring Security OAuth2 also requires the user to customize a key to identify their OAuth2 client. I use Gitee for example.

1
2
3
4
5
6
7
8
spring:
  security:
    oauth2:
      client:
        registration:
          gitee:
            client-id: xxxxxx
            client-secret: xxxxx

The key here is gitee, but of course it depends on your mood, even if you use zhangshan as the key.

Spring Security OAuth2 provides the relevant conditional injection ideas, and here are the core classes for their conditional injection judgments.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ClientsConfiguredCondition extends SpringBootCondition {

   private static final Bindable<Map<String, OAuth2ClientProperties.Registration>> STRING_REGISTRATION_MAP = Bindable
         .mapOf(String.class, OAuth2ClientProperties.Registration.class);

   @Override
   public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
      ConditionMessage.Builder message = ConditionMessage.forCondition("OAuth2 Clients Configured Condition");
      Map<String, OAuth2ClientProperties.Registration> registrations = getRegistrations(context.getEnvironment());
      if (!registrations.isEmpty()) {
         return ConditionOutcome.match(message.foundExactly("registered clients " + registrations.values().stream()               
                 .map(OAuth2ClientProperties.Registration::getClientId).collect(Collectors.joining(", "))));
      }
      return ConditionOutcome.noMatch(message.notAvailable("registered clients"));
   }

   private Map<String, OAuth2ClientProperties.Registration> getRegistrations(Environment environment) {
      return Binder.get(environment).bind("spring.security.oauth2.client.registration", STRING_REGISTRATION_MAP)
            .orElse(Collections.emptyMap());
   }

}

Obviously the structure of OAuth2ClientProperties is the same as the structure of AppProperties that we want to verify. So the logic above can be copied to bind the environment configuration with the uncertain key to our configuration class AppProperties. The core binding logic is this paragraph.

1
2
Binder.get(environment)
        .bind("spring.security.oauth2.client.registration", STRING_REGISTRATION_MAP);

First, a bindable data structure is declared via Bindable, where the mapOf method is called to declare a Map data binding structure. Then we extract the configuration properties starting with spring.security.oauth2.client.registration from the configuration environment interface Environment and inject them into Map through the binding specific object Binder. Now that we have access to the Map, it is entirely in our hands to determine what policy to use.

Bindable is a new data binding feature for Spring Boot 2.0, for those interested, you can get more information from spring.io.

I don’t need to tell you what to do next, so who doesn’t know how to do it? With the @Conditional annotation, you can dynamically inject beans based on the actual parameters under app.v1.

4. Summary

I spent a lot of time today solving a practical requirement using the data binding features of Spring Boot 2.0. When we get stuck in a problem, the first thing we need to do is think about whether there are similar scenarios and corresponding solutions. This also shows the importance of accumulation in general.

Reference https://felord.cn/spring-boot-bindable.html