In Spring Boot, adding a custom converter by exten

2019-03-29 10:30发布

问题:

I'm trying to create a converter for a custom media-type like application/vnd.custom.hal+json. I saw this answer here, but it won't work since you don't have access to the protected constructor of AbstractHttpMessageConverter<T> (super class of MappingJackson2HttpMessageConverter). Which means that the following code does not work:

class MyCustomVndConverter extends MappingJacksonHttpMessageConverter {
    public MyCustomVndConverter (){
        super(MediaType.valueOf("application/vnd.myservice+json"));
    }
}

However, the following does work and basically just mimics what the constructor actually does anyway:

setSupportedMediaTypes(Collections.singletonList(
    MediaType.valueOf("application‌​/vnd.myservice+json")
));

So I did this for my class, and then added the converter to my existing list of converters by following Spring Boot's documentation here. My code basically looks like this:

//Defining the converter; the media-type is simply a custom media-type that is 
//still application/hal+json, i.e., JSON with some additional semantics on top 
//of what HAL already adds to JSON
public class TracksMediaTypeConverter extends MappingJackson2HttpMessageConverter {
    public TracksMediaTypeConverter() {
        setSupportedMediaTypes(Collections.singletonList(
            new MediaType("application‌​", "vnd.tracks.v1.hal+json")
        ));
    }
}

//Adding the message converter
@Configuration
@EnableSwagger
public class MyApplicationConfiguration {

    ...    
    @Bean
    public HttpMessageConverters customConverters() {
        return new HttpMessageConverters(new TracksMediaTypeConverter());
    }
}

As per the documentation, this should work. But what I noticed is that this has the effect of replacing the existing MappingJackson2HttpMessageCoverter, which handles application/json;charset=UTF-8 and application/*+json;charset=UTF-8.

I verified this by attaching a debugger to my app and stepping through breakpoints inside Spring's AbstractMessageCoverterMethodProcessor.java class. There, the private field messageConverters contains the list of converters that have been registered. Normally, i.e., if I do not try to add my converter, I see the following coverters:

  • MappingJackson2HttpMessageCoverter for application/hal+json (I'm assuming this is added by Spring HATEOAS, which I am using)
  • ByteArrayHttpMessageConverter
  • StringHttpMessageConverter
  • ResourceHttpMessageConverter
  • SourceHttpMessageConverter
  • AllEncompassingFormHttpMessageConverter
  • MappingJackson2HttpMessageConverter for application/json;charset=UTF-8 and application/*+json;charset=UTF-8
  • Jaxb2RootElementHttpMessageConverter

When I add my custom media type, the second instance of MappingJackson2HttpMessageConverter gets replaced. That is, the list now looks like this:

  • MappingJackson2HttpMessageConverter for application/hal+json (I'm assuming this is added by Spring HATEOAS, which I am using)
  • ByteArrayHttpMessageConverter
  • StringHttpMessageConverter
  • ResourceHttpMessageConverter
  • SourceHttpMessageConverter
  • AllEncompassingFormHttpMessageConverter
  • MappingJackson2HttpMessageConverter for application/vnd.tracks.v1.hal+json (the existing one has been replaced)
  • Jaxb2RootElementHttpMessageConverter

I'm not entirely sure why this is happening. I stepped through the code and the only thing that really happens is that the no-args constructor of MappingJackson2HttpMessageConverter is called (as it should be), which initially sets the supported media-types to application/json;charset=UTF-8 and application/*+json;charset=UTF-8. After that, the list gets overwritten with the media-type that I provide.

What I cannot understand is why adding this media type should replace the existing instance of MappingJackson2HttpMessageConverter that handles regular JSON. Is there some strange magic that is going on that does this?

Currently I have a workaround, but I don't like it very much since it's not that elegant and it involves duplication of code already in MappingJackson2HttpMessageConverter.

I created the following class (only changes from the regular MappingJackson2HttpMessageConverter are shown):

public abstract class ExtensibleMappingJackson2HttpMessageConverter<T> extends AbstractHttpMessageConverter<T> implements GenericHttpMessageConverter<T> {

    //These constructors are not available in `MappingJackson2HttpMessageConverter`, so
    //I provided them here just for convenience.    

    /**
     * Construct an {@code AbstractHttpMessageConverter} with no supported media types.
     * @see #setSupportedMediaTypes
     */
    protected ExtensibleMappingJackson2HttpMessageConverter() {
    }

    /**
     * Construct an {@code ExtensibleMappingJackson2HttpMessageConverter} with one supported media type.
     * @param supportedMediaType the supported media type
     */
    protected ExtensibleMappingJackson2HttpMessageConverter(MediaType supportedMediaType) {
        setSupportedMediaTypes(Collections.singletonList(supportedMediaType));
    }

    /**
     * Construct an {@code ExtensibleMappingJackson2HttpMessageConverter} with multiple supported media type.
     * @param supportedMediaTypes the supported media types
     */
    protected ExtensibleMappingJackson2HttpMessageConverter(MediaType... supportedMediaTypes) {
        setSupportedMediaTypes(Arrays.asList(supportedMediaTypes));
    }

    ...

    //These return Object in MappingJackson2HttpMessageConverter because it extends
    //AbstractHttpMessageConverter<Object>. Now these simply return an instance of
    //the generic type. 

    @Override
    protected T readInternal(Class<? extends T> clazz, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {

        JavaType javaType = getJavaType(clazz, null);
        return readJavaType(javaType, inputMessage);
    }

    @Override
    public T read(Type type, Class<?> contextClass, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {

        JavaType javaType = getJavaType(type, contextClass);
        return readJavaType(javaType, inputMessage);
    }

    private T readJavaType(JavaType javaType, HttpInputMessage inputMessage) {
        try {
            return this.objectMapper.readValue(inputMessage.getBody(), javaType);
        }
        catch (IOException ex) {
            throw new HttpMessageNotReadableException("Could not read JSON: " + ex.getMessage(), ex);
        }
    }

    ...

}

I then use this class as follows:

public class TracksMediaTypeConverter extends ExtensibleMappingJackson2HttpMessageConverter<Tracks> {
    public TracksMediaTypeConverter() {
        super(new MediaType("application", "application/vnd.tracks.v1.hal+json"));
    }
}

The registration of the converter in the configuration class is the same as before. With these changes, the existing instance of MappingJackson2HttpMessageConverter is not overwritten and everything works as I would expect.

So to boil everything down, I have two questions:

  • Why is the existing converter being overwritten when I extend MappingJackson2HttpMessageConverter?
  • What is the right way to create a custom media-type converter that represents a semantic media-type that is still basically JSON (and therefore can be serialized and deserialized by MappingJackson2HttpMessageConverter?

回答1:

Fixed in the latest version

Not sure when this was fixed, but as of 1.1.8.RELEASE, this problem no-longer exists since it is using ClassUtils.isAssignableValue. Leaving the original answer here just for information.


There seem to be multiple issues at play here, so I'm going to summarize my findings as the answer. I still don't really have a solution for what I'm trying to do, but I'm going to talk to the Spring Boot folks to see if what's happening is intended or not.

Why is the existing converter being overwritten when I extend MappingJackson2HttpMessageConverter?

This applies to version 1.1.4.RELEASE of Spring Boot; I haven't checked other versions. The constructor of the HttpMessageConverters class is as follows:

/**
 * Create a new {@link HttpMessageConverters} instance with the specified additional
 * converters.
 * @param additionalConverters additional converters to be added. New converters will
 * be added to the front of the list, overrides will replace existing items without
 * changing the order. The {@link #getConverters()} methods can be used for further
 * converter manipulation.
 */
public HttpMessageConverters(Collection<HttpMessageConverter<?>> additionalConverters) {
    List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>();
    List<HttpMessageConverter<?>> defaultConverters = getDefaultConverters();
    for (HttpMessageConverter<?> converter : additionalConverters) {
        int defaultConverterIndex = indexOfItemClass(defaultConverters, converter);
        if (defaultConverterIndex == -1) {
            converters.add(converter);
        }
        else {
            defaultConverters.set(defaultConverterIndex, converter);
        }
    }
    converters.addAll(defaultConverters);
    this.converters = Collections.unmodifiableList(converters);
}

Inside the for loop. Notice that it determines an index in the list by calling the indexOfItemClass method. That method looks like this:

private <E> int indexOfItemClass(List<E> list, E item) {
    Class<? extends Object> itemClass = item.getClass();
    for (int i = 0; i < list.size(); i++) {
        if (list.get(i).getClass().isAssignableFrom(itemClass)) {
            return i;
        }
    }
    return -1;
}

Since my class extends MappingJackson2HttpMessageConverter the if statement returns true. This means that in the constructor, we have a valid index. Spring Boot then replaces the existing instance with the new one, which is exactly what I am seeing.

Is this desirable behavior?

I don't know. It doesn't seem to be and seems very strange to me.

Is this called out explicitly in Spring Boot documentation anywhere?

Sort of. See here. It says:

Any HttpMessageConverter bean that is present in the context will be added to the list of converters. You can also override default converters that way.

However, overriding a converter simply because it is a subtype of an existing one doesn't seem like helpful behavior.

How does Spring HATEOAS get around this Spring Boot issue?

Spring HATEOAS' lifecycle is separate from Spring Boot. Spring HATEOAS registers its handler for the application/hal+json media-type in the HyperMediaSupportBeanDefinitionRegistrar class. The relevant method is:

private List<HttpMessageConverter<?>> potentiallyRegisterModule(List<HttpMessageConverter<?>> converters) {

    for (HttpMessageConverter<?> converter : converters) {
        if (converter instanceof MappingJackson2HttpMessageConverter) {
            MappingJackson2HttpMessageConverter halConverterCandidate = (MappingJackson2HttpMessageConverter) converter;
            ObjectMapper objectMapper = halConverterCandidate.getObjectMapper();
            if (Jackson2HalModule.isAlreadyRegisteredIn(objectMapper)) {
                return converters;
            }
        }
    }

    CurieProvider curieProvider = getCurieProvider(beanFactory);
    RelProvider relProvider = beanFactory.getBean(DELEGATING_REL_PROVIDER_BEAN_NAME, RelProvider.class);
    ObjectMapper halObjectMapper = beanFactory.getBean(HAL_OBJECT_MAPPER_BEAN_NAME, ObjectMapper.class);

    halObjectMapper.registerModule(new Jackson2HalModule());
    halObjectMapper.setHandlerInstantiator(new Jackson2HalModule.HalHandlerInstantiator(relProvider, curieProvider));

    MappingJackson2HttpMessageConverter halConverter = new MappingJackson2HttpMessageConverter();
    halConverter.setSupportedMediaTypes(Arrays.asList(HAL_JSON)); //HAL_JSON is just a MediaType instance for application/hal+json
    halConverter.setObjectMapper(halObjectMapper);

    List<HttpMessageConverter<?>> result = new ArrayList<HttpMessageConverter<?>>(converters.size());
    result.add(halConverter);
    result.addAll(converters);
    return result;
}

The converters argument is passed-in via this snippet from the postProcessBeforeInitialization method from the same class. Relevant snippet is:

if (bean instanceof RequestMappingHandlerAdapter) {
    RequestMappingHandlerAdapter adapter = (RequestMappingHandlerAdapter) bean;
    adapter.setMessageConverters(potentiallyRegisterModule(adapter.getMessageConverters()));
}

What is the right way to create a custom media-type converter that represents a semantic media-type that is still basically JSON (and therefore can be serialized and deserialized by MappingJackson2HttpMessageConverter?

I'm not sure. Sub-classing ExtensibleMappingJackson2HttpMessageConverter<T> (shown in the question) works for the time being. Another option would perhaps be to create a private instance of MappingJackson2HttpMessageConverter inside your custom converter, and simply delegate to that. Either way, I am going to open an issue with the Spring Boot project and get some feedback from them. I'll then update with answer with any new information.



回答2:

Spring boot docs explicitly states that adding a custom MappingJackson2HttpMessageConverter replaces the default value.

From docs:

Finally, if you provide any @Beans of type MappingJackson2HttpMessageConverter then they will replace the default value in the MVC configuration. Also, a convenience bean is provided of type HttpMessageConverters (always available if you use the default MVC configuration) which has some useful methods to access the default and user-enhanced message converters.