Spring-boot: ConversionService is inconsistently used in Spring Boot application

Created on 24 Jun 2016  路  26Comments  路  Source: spring-projects/spring-boot

I apologize in advance for cross-posting, but I have not received useful help on Stack Overflow and I suspect that the issue I am facing is either a bug in Spring Boot or at least very poor documentation. Original context here:

http://stackoverflow.com/questions/37952166/spring-boot-test-case-doesnt-use-custom-conversion-service

Short version is, I would like to configure my ConversionService to understand new types (i.e. java.time.Duration). Per the docs, I try to wire it up with:

@Configuration
public class ConversionServiceConfiguration {
    private static final FormattingConversionService SERVICE = new DefaultFormattingConversionService();

    static {
        new DateTimeFormatterRegistrar().registerFormatters(SERVICE);
    }

    @Bean
    public static ConversionService conversionService() {
        return SERVICE;
    }
}

but it keeps being ineffective:

>  Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'AttachClientRule': Unsatisfied dependency expressed through constructor parameter 0:
> Error creating bean with name 'MyServiceConfig': Unsatisfied dependency expressed through field 'maxWatchTime': Failed to convert value of type [java.lang.String] to required type [java.time.Duration];
> nested exception is java.lang.IllegalStateException: Cannot convert value of type [java.lang.String] to required type [java.time.Duration]: no matching editors or conversion strategy found;

The BeanFactory still has a DefaultConversionService sticking around from before I set my own.

I can fix this one with one hack:

... implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        beanFactory.setConversionService(SERVICE);
    }
}

and then I get a different problem, where again the Environment doesn't have it set either!

> Caused by: java.lang.IllegalArgumentException: Cannot convert value [PT15s] from source type [String] to target type [Duration]
>   at org.springframework.core.env.PropertySourcesPropertyResolver.getProperty(PropertySourcesPropertyResolver.java:94)
>   at org.springframework.core.env.PropertySourcesPropertyResolver.getProperty(PropertySourcesPropertyResolver.java:65)
>   at org.springframework.core.env.AbstractPropertyResolver.getProperty(AbstractPropertyResolver.java:143)
>   at org.springframework.core.env.AbstractEnvironment.getProperty(AbstractEnvironment.java:546)

Time for another hack:

... implements EnvironmentAware {
    @Override
    public void setEnvironment(Environment environment) {
        ((AbstractEnvironment) environment).setConversionService(SERVICE);
    }
}

And that fixes this problem.

But how long until I find the next place one of these icky DefaultConversionService instances is lying around? How can I make Spring Boot actually use my custom one for _everything_ without having to keep diving deep into the guts and find lingering problems?

Spring 4.3.0, Spring Boot 1.4.0M3

Most helpful comment

I'm running into a similar issue with ConversionService where something as seemingly trivial as @Value("${classifications:}") Set<String> classifications; doesn't work as expected: It injects a set containing an empty string, rather than an empty set (unhelpfully they both toString to [] so I was scratching my head there for an hour...).

I tracked this down to the fact that even though PropertySourcesPropertyResolver (via AbstractPropertyResolver) has/uses a DefaultConversionService internally, that service isn't actually used by the BeanFactory when it's trying to convert that value. The bean factory seems to have no conversion service by default so falls back to some property editors that end up doing the wrong thing. In the end I used a BeanFactoryPostProcessor similar to the OP to work around this by setting a DefaultConversionService on the bean factory itself.

It would be nice if the usage of ConversionService within the configuration machinery were more obvious (i.e. using the same instance in all places as far as possible, making it easy to customize) and better documented (e.g. the fact that simply defining a conversionService bean doesn't help for these sort of issues because it gets registered way too late.)

All 26 comments

The javax.time types should be automatically detected and supported by DefaultFormattingConversionService so directly calling DateTimeFormatterRegistrar shouldn't be necessary.

Do you have an example project that you can share to show the problem? Conversion services are used in multiple places, so having some code to look at help a lot.

It's worth noting that we are explicitly trying to not use classspath scanning -- does your "automatically detected and supported" comment require that as a prerequisite? I've had so much trouble over the years with classpath scanning that we are trying to explicitly configure everything up front.

I will try to produce a self contained example next week, I ran out of time to do it this week...

@stevenschlansker No, it's not related to classpath scanning. See these lines or DefaultFormattingConversionService.

Thanks for the reference -- that seems to cover a very small subset of what https://github.com/spring-projects/spring-framework/blob/v4.3.0.RELEASE/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterRegistrar.java#L157-L194 provides. It only has TimeZone and Calendar types, not Duration and friends.

Here is a small test case showing how confusing this situation is:

https://gist.github.com/stevenschlansker/c432a6360fe8897a9cd9af6cd16e4719

You'll note that registering a custom conversionService bean works fine for @Value injection, but does _not_ work for the Environment unless you crack it open and set it manually. It also shows that the default configuration does not know about the Duration type at all.

Oh wow, my bad. You're totally right. By default the StandardEnvironment uses AbstractPropertyResolver which creates a DefaultConversionService and not a DefaultFormattingConversionService.

@jhoeller Is there any reason why AbstractPropertyResolver doesn't use the DefaultFormattingConversionService? Perhaps we should in Boot.

That's a great improvement, although still wouldn't cover the case of trying to add your own non-default converters, only built-ins.

Thinking on this a bit more, it's not going to be possible to create the ConversionService as bean _and_ use that ConversionService during bean creation. I think your best bet is to use an ApplicationContextInitializer to setup the ConversionService:

static class Initializer
        implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
        // customize conversionService
        applicationContext.getEnvironment().setConversionService(conversionService);
    }

}

If that is the case, then I believe section 9.5.5 "Configuring a ConversionService" of the documentation is _extremely_ confusing.

http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle/#core-convert-Spring-config

A ConversionService is a stateless object designed to be instantiated at application startup, then shared between multiple threads. In a Spring application, you typically configure a ConversionService instance per Spring container (or ApplicationContext). That ConversionService will be picked up by Spring and then used whenever a type conversion needs to be performed by the framework. You may also inject this ConversionService into any of your beans and invoke it directly.

To register a default ConversionService with Spring, add the following bean definition with id conversionService:

This strongly implies that a conversion service registered as a bean will be used "whenever a type conversion needs to be performed by the framework", which clearly is not happening.

Indeed, that is quite confusing. It seems that the conversionService bean is applied to the BeanFactory but not the Environment.

Indeed, I wonder whether we should simply also apply it to the Environment there in finishBeanFactoryInitialization. We can consider doing so for 4.3.2, in time for Boot 1.4 GA.

@jhoeller Perhaps with AbstractApplicationContext.createEnvironment() should also change the default to DefaultFormattingConversionService so that JSR-310 dates work out of the box? I think the reason that DefaultConversionService is because AbstractPropertyResolver is in spring-core and DefaultFormattingConversionService is in spring-context.

We also need to be careful not to replace the Environment ConversionService if it has been replaced by the user already (for example using a ApplicationContextInitializer).

DefaultFormattingConversionService is not really meant to be used at that level. If we believe that some further JSR-310 conversion is due at the DefaultConversionService level, we should rather consider adding those specific converters there instead of just in DefaultFormattingConversionService.

The replacement part is where it gets tricky. We fundamentally don't know whether the Environment has been customized before. The Environment would somehow have to indicate to us whether setConversionService has been called on it.

As for further JSR-310 support at the DefaultConversionService level, note that we have convention-based support for class-contained valueOf/of/from methods there, actually aligned with JSR-310 by design. However, for the purposes above, Duration just has a parse(CharSequence) method. We could extend our convention there to find such parse methods for String input as well, which would take us a long way... probably with no specific converters to be added for JSR-310 at all.

Why is DefaultFormattingConversionService not meant to be used at that level (with the Environment created for the ApplicationContext)?

DefaultFormattingConversionService is primarily designed for input/output data binding, not for configuration purposes. That's why it has Formatter, Printer, Parser abstractions. It can nevertheless be set up as an application context's conversionService bean but it's just not a sensible default for a general application context in the core framework there, bringing in too much stuff (in particular a dependency on org.springframework.format and its whole load of formatting annotations). If you know that you're setting up a web app, configuring a DefaultFormattingConversionService as conversionService bean is a sensible enough choice though.

I'm running into a similar issue with ConversionService where something as seemingly trivial as @Value("${classifications:}") Set<String> classifications; doesn't work as expected: It injects a set containing an empty string, rather than an empty set (unhelpfully they both toString to [] so I was scratching my head there for an hour...).

I tracked this down to the fact that even though PropertySourcesPropertyResolver (via AbstractPropertyResolver) has/uses a DefaultConversionService internally, that service isn't actually used by the BeanFactory when it's trying to convert that value. The bean factory seems to have no conversion service by default so falls back to some property editors that end up doing the wrong thing. In the end I used a BeanFactoryPostProcessor similar to the OP to work around this by setting a DefaultConversionService on the bean factory itself.

It would be nice if the usage of ConversionService within the configuration machinery were more obvious (i.e. using the same instance in all places as far as possible, making it easy to customize) and better documented (e.g. the fact that simply defining a conversionService bean doesn't help for these sort of issues because it gets registered way too late.)

I am also running into a similar issue with a custom GenericConverter which is being used inside a Spring MVC controller on a @RequestParam (trying to add support for scala.Option). We are on Spring Boot 1.2.3 / Spring Framework 4.1.6 (admittedly a bit behind).

Through debugging, I've noticed that while my application is starting up, my converter is available as I would expect. However, once a request goes through to my Spring MVC controller, suddenly my converter is not there, which results in the following exception:

org.springframework.beans.ConversionNotSupportedException: Failed to convert value of type 'java.lang.String' to required type 'scala.Option'; nested exception is java.lang.IllegalStateException: Cannot convert value of type [java.lang.String] to required type [scala.Option]: no matching editors or conversion strategy found
    at org.springframework.beans.TypeConverterSupport.doConvert(TypeConverterSupport.java:74) ~[spring-beans-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.beans.TypeConverterSupport.convertIfNecessary(TypeConverterSupport.java:47) ~[spring-beans-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.validation.DataBinder.convertIfNecessary(DataBinder.java:603) ~[spring-context-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.resolveArgument(AbstractNamedValueMethodArgumentResolver.java:104) ~[spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:77) ~[spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:162) ~[spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:129) ~[spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:110) ~[spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandleMethod(RequestMappingHandlerAdapter.java:776) ~[spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:705) ~[spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:85) ~[spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:959) ~[spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:893) ~[spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:966) [spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:857) [spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:618) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:842) [spring-webmvc-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:725) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:291) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:206) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52) [tomcat-embed-websocket-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:239) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:206) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration$ApplicationContextHeaderFilter.doFilterInternal(EndpointWebMvcAutoConfiguration.java:291) [spring-boot-actuator-1.2.3.RELEASE.jar:1.2.3.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:239) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:206) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:77) [spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:239) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:206) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.springframework.boot.actuate.trace.WebRequestTraceFilter.doFilterInternal(WebRequestTraceFilter.java:102) [spring-boot-actuator-1.2.3.RELEASE.jar:1.2.3.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:239) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:206) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:85) [spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:239) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:206) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.springframework.boot.actuate.autoconfigure.MetricFilterAutoConfiguration$MetricsFilter.doFilterInternal(MetricFilterAutoConfiguration.java:90) [spring-boot-actuator-1.2.3.RELEASE.jar:1.2.3.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.6.RELEASE.jar:4.1.6.RELEASE]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:239) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:206) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:219) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:106) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:501) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:142) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:79) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:88) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:516) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1086) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:659) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.coyote.http11.Http11NioProtocol$Http11ConnectionHandler.process(Http11NioProtocol.java:223) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1558) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.run(NioEndpoint.java:1515) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) [na:1.8.0_111]
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) [na:1.8.0_111]
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-8.0.20.jar:8.0.20]
    at java.lang.Thread.run(Thread.java:745) [na:1.8.0_111]

This is the line I have my breakpoint on: https://github.com/spring-projects/spring-framework/blob/v4.1.6.RELEASE/spring-beans/src/main/java/org/springframework/beans/TypeConverterDelegate.java#L170

Note that there _is_ a DefaultFormattingConversionService available when the request goes through, it just doesn't contain my converter.

I've tried various things, including providing my Spring Application with a custom initializer (as suggested above) to no avail. I cannot seem to get access to my converter there. :rage4:

I must say, at first glance using ConversionService in some form seems really powerful and straight forward, but in practice it's highly confusing. There is virtually no documentation on customizing it in the Spring Boot docs and the Spring Framework docs seem misleading.

@kflorence You converters might be registered with the wrong conversion service. Try putting a
breakpoint on WebMvcConfigurationSupport.mvcConversionService() (that's the conversion service used by Spring MVC).

If you want to register converters with that service you can add a WebMvcConfigurer bean. Something like:

@Component
public class MyWebMvcConfigurer extends WebMvcConfigurerAdapter {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(...);
    }

}

@philwebb Thanks for the quick reply -- aha! So that's where that conversion service is coming from. Out of curiosity, why does Spring MVC always create it's own conversion service instead of using an existing one (if one exists)? Also, it would be nice if there was some way of telling Spring Boot to automatically add my converter to _this_ conversion service instead of the other one it automatically adds it to (the global default one?).

Anyways, I think this configuration will work for me, but my GenericConverter actually requires access to the conversion service internally so I will need to figure out how to provide it without getting a circular dependency :thinking:

Seems like there is some discussion of accessing the ConversionService inside of a converter here: https://jira.spring.io/browse/SPR-6415

This seems to work:

@Component
class SpringContextListener extends ApplicationListener[ContextRefreshedEvent] with WrapAsScala {
  @Inject private[converter] var conversionService: GenericConversionService = _
  @Inject private[converter] var genericConverters: java.util.Set[GenericConverter] = _

  def onApplicationEvent(event: ContextRefreshedEvent): Unit = {
    genericConverters.foreach(conversionService.addConverter)
  }
}

Hallelujah! :smiley:

What we tried to do:

@ConfigurationProperties
public class ApplicationProperties {

  private Duration timeout;

  ...

}
timout=PT1M



md5-d23c325cf6f99edaaef3c783252acb48



Duration d = props.getTimeout();

queue.offer(..., d == null ? DEFAULT_TIMEOUT : d.getSeconds(), SECONDS);

@jhoeller and @philwebb : I am solving similar issue. Spring Boot app with Java8 and JSR-303/JSR-349 Bean Validation. Input DTO object that represents request has OffsetDateTime field. This filed has to be annotated with @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) to support ISO date time.

I want to configure Spring Boot application globally and avoid to use @DateTimeFormat on each field. DefaultFormattingConversionService use DateTimeFormatterRegistrar but how can I specify ISO format? How can I configure org.springframework.format.datetime.standard.DateTimeFormatterRegistrar?

Since Spring Boot 2.0 we now have an ApplicationConversionService which we plan to register by default in Spring Boot 2.1. I'm going to close this one in favor of #12148 which should, I hope, unify the way that application conversion happens.

Was this page helpful?
0 / 5 - 0 ratings