Spring @ResponseBody и UTF-8

Written by elwood

С недавних пор я отказался от хитрых способов для реализации ajax-запросов (таких, как DWR, к примеру), отдав предпочтение обычным спринговым обработчикам, помеченным аннотацией @ResponseBody. Настроил конвертеры для json и xml, добавив их в список конвертеров AnnotationMethodHandlerAdapter. Но, как оказалось, настроил я не всё.

Дело в том, что обычно если мне нужно было получить кусок разметки при помощи ajax, я не выставлял @ResponseBody (поскольку это был “обычный” запрос), а в методе возвращал название view. Spring брал по названию view нужный jsp (хотя у меня не напрямую jsp, а через Tiles, что не суть важно). Однако мне вдруг понадобилось напрямую вернуть String. И как оказалось, конвертер StringHttpMessageConverter упрямо перекодирует возвращаемую мной строку в ISO-8859-1, свою дефолтную кодировку. Причем ни выставленные Accepts и Accept-Charset заголовки, ни указанное в @RequestMapping‘e свойство produce=”text/html;charset=UTF-8″, ни сконфигурированные в конвертере supportedMediaTypes не влияли на результат.

Полез дебагером внутрь конвертера, где выяснил, что действительно, это всё не влияет на определение нужной кодировки. А влияет лишь переданный в конвертер MediaType, который как правило содержал только mime-type, а кодировка была установлена в null. После чего конвертер, видя что ему пришел null, брал свою дефолтную кодировку и применял её к строке. Причем эту дефолтную кодировку нельзя задать ни в конструкторе конвертера, ни с помощью сеттера. Пришлось пойти на крайние меры – сделать форк конвертера в свой класс, добавить возможность задания дефолтной кодировки и заменить стандартный конвертер на свой в конфиге AnnotationMethodHandlerAdapter‘a.

Код:

/**
 * User: igor.kostromin
 * Date: 22.08.12
 * Time: 12:11
 *
 * Differs from StringHttpMessageConverter only in default charset specifying feature.
 */
public final class ConfigurableStringHttpMessageConverter extends AbstractHttpMessageConverter<String> {
 
    private Charset defaultCharset;
 
    public Charset getDefaultCharset() {
        return defaultCharset;
    }
 
	private final List<Charset> availableCharsets;
 
	private boolean writeAcceptCharset = true;
 
	public ConfigurableStringHttpMessageConverter() {
		super(new MediaType("text", "plain", StringHttpMessageConverter.DEFAULT_CHARSET), MediaType.ALL);
        defaultCharset = StringHttpMessageConverter.DEFAULT_CHARSET;
		this.availableCharsets = new ArrayList<Charset>(Charset.availableCharsets().values());
	}
 
    public ConfigurableStringHttpMessageConverter(String charsetName) {
        super(new MediaType("text", "plain", Charset.forName(charsetName)), MediaType.ALL);
        defaultCharset = Charset.forName(charsetName);
		this.availableCharsets = new ArrayList<Charset>(Charset.availableCharsets().values());
    }
 
	/**
	 * Indicates whether the {@code Accept-Charset} should be written to any outgoing request.
	 * <p>Default is {@code true}.
	 */
	public void setWriteAcceptCharset(boolean writeAcceptCharset) {
		this.writeAcceptCharset = writeAcceptCharset;
	}
 
	@Override
	public boolean supports(Class<?> clazz) {
		return String.class.equals(clazz);
	}
 
	@Override
	protected String readInternal(Class clazz, HttpInputMessage inputMessage) throws IOException {
		Charset charset = getContentTypeCharset(inputMessage.getHeaders().getContentType());
		return FileCopyUtils.copyToString(new InputStreamReader(inputMessage.getBody(), charset));
	}
 
	@Override
	protected Long getContentLength(String s, MediaType contentType) {
		Charset charset = getContentTypeCharset(contentType);
		try {
			return (long) s.getBytes(charset.name()).length;
		}
		catch (UnsupportedEncodingException ex) {
			// should not occur
			throw new InternalError(ex.getMessage());
		}
	}
 
	@Override
	protected void writeInternal(String s, HttpOutputMessage outputMessage) throws IOException {
		if (writeAcceptCharset) {
			outputMessage.getHeaders().setAcceptCharset(getAcceptedCharsets());
		}
		Charset charset = getContentTypeCharset(outputMessage.getHeaders().getContentType());
		FileCopyUtils.copy(s, new OutputStreamWriter(outputMessage.getBody(), charset));
	}
 
	/**
	 * Return the list of supported {@link Charset}.
	 *
	 * <p>By default, returns {@link Charset#availableCharsets()}. Can be overridden in subclasses.
	 *
	 * @return the list of accepted charsets
	 */
	protected List<Charset> getAcceptedCharsets() {
		return this.availableCharsets;
	}
 
	private Charset getContentTypeCharset(MediaType contentType) {
		if (contentType != null && contentType.getCharSet() != null) {
			return contentType.getCharSet();
		}
		else {
			return defaultCharset;
		}
	}
}

Использование:

<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter">
        <property name="messageConverters">
            <util:list>
                <bean class="ru.dz.mvk.util.ConfigurableStringHttpMessageConverter">
                    <constructor-arg index="0" value="UTF-8"/>
                </bean>
                <bean class="org.springframework.http.converter.FormHttpMessageConverter"/>
                <bean class="org.springframework.http.converter.ByteArrayHttpMessageConverter"/>
                <bean class="org.springframework.http.converter.xml.SourceHttpMessageConverter"/>
                <bean class="org.springframework.http.converter.BufferedImageHttpMessageConverter"/>
                <!-- json converter (for application/json multimedia type) -->
                <bean class="org.springframework.http.converter.json.MappingJacksonHttpMessageConverter"/>
                <!-- xml converter (for application/xhtml+xml)-->
                <bean class="org.springframework.http.converter.xml.MarshallingHttpMessageConverter">
                    <property name="marshaller" ref="xstreamMarshaller"/>
                    <property name="unmarshaller" ref="xstreamMarshaller"/>
                </bean>
            </util:list>
        </property>
</bean>