【踩坑经验】记一次 Webflux 乱码问题

6,715 阅读2分钟

背景

  • Spring Boot 升级 2.2.4 后,有一个变化在于当返回 json 时content-typeapplication/json;charset=UTF-8变为了application/json;
  • 而这个则会造成默认编码不为 UTF-8 的应用出现乱码问题

原因

  • 那么是为什么呢?根因在于 Jackson2CodecSupportDEFAULT_MIME_TYPES 去除了 Charset 选项,所以输出就没有 Charset
  • 那么 Spring 是怎么确定 Content-Type 的呢?能不能在中途加上呢?
  • 分析调用链路,可以发现是在 EncoderHttpMessageWriter 做了 updateContentType
  • 那么怎么 update 的呢?
  • 可见,是首先从 MessageHeader 里拿了下,没拿到,则用默认的,如果默认没有,则用 AbstractMessageWriterResultHandler 选择的最好的一个 MediaType
  • 那么默认的是什么呢?
  • 就是 Encoder 的第一个 MimeType ,对于 Json 默认的 Encode 即为 Jackson2JsonEncoder 因此 Jackson2CodecSupport 去除的 charset 会影响到真实请求输出

解法

  • 由上可见,解法很简单,有两种,一为添加自定义 Encoder,自定义 Encoder 的 default mime Type 中加上 charset 即可。
  • 不过这种方法成本太高,自定义 Encoder 还要完善测试什么,比较麻烦
  • 因此第二种,在 Response Header 中加上 Content-Type 成为首选,这个也比较简单,加一个 WebFilter 即可
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

/**
 * @author Lambda.J
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ContentTypeFilter implements WebFilter {
	@Override
	public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
		exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
		return chain.filter(exchange);
	}
}
  • 加完果真,正常的输出就有了 Charset,但测试一跑,发现异常链路还是有问题,没有 charset,这又是为什么呢?
  • 追踪源码发现,原来是 RequestMappingHandlerAdapter 异常处理时会清除所有 Content 相关头,而此时已走完 filter 链,要想改写,只能复写 HandlerResultHandler
  • 复写 HandlerResultHandler 过于麻烦,而且对于每种输出都要复写,成本很高
  • 所以没想到什么好方法,就采用了一个 Hack 的方法,通过反射改写 Jackson2CodecSupportDEFAULT_MIME_TYPES
// 添加到 SpringBootApplication 启动类中
static {
		// utf8的兜底方案,webfilter 在 resultHandler 之前,出现错误时会删掉 content-type,所以异常链路需要这边兜底
		addCharsetToJsonContentType();
	}
public static void addCharsetToJsonContentType() {
		Field defaultMimeTypeField = null;
		try {
			defaultMimeTypeField = Jackson2CodecSupport.class.getDeclaredField("DEFAULT_MIME_TYPES");
			defaultMimeTypeField.setAccessible(true);
			Field modifiersField = Field.class.getDeclaredField("modifiers");
			modifiersField.setAccessible(true);
			modifiersField.setInt(defaultMimeTypeField, defaultMimeTypeField.getModifiers() & ~Modifier.FINAL);
			defaultMimeTypeField.set(null,
					Collections.unmodifiableList(Arrays.asList(new MimeType("application", "json", Charsets.UTF_8),
							new MimeType("application", "*+json", Charsets.UTF_8))));
			defaultMimeTypeField.setAccessible(false);
			modifiersField.setInt(defaultMimeTypeField, defaultMimeTypeField.getModifiers() | Modifier.FINAL);
			System.out.println("Already Add Charset To Jackson");
		}
		catch (NoSuchFieldException | IllegalAccessException e) {
			e.printStackTrace();
		}
	}

关联知识 - WebFlux 下请求处理链路

  • 由 Spring 文档可知,WebFLux 下最核心的为HttpHandlerWebHandler,网络框架(netty,tomcat或者其他)收到请求先交由HttpHandler再由其调用最终的WebHandler
  • 那么 HttpHandelr 是如何构建的呢?
  • 可见,其首先拿到最终的 webHandler 和 filter 链构造了一个decorated,然后又包装了exceptionHandlers,最终变为一个HttpWebHandlerAdapter直接对接网络框架
  • 当网络框架响应 channel 状态变化时,则会调用 HttpWebHandlerAdapter 的 handler 方法,然后依次责任链调用
  • 具体如上图,可见当走到RequestMappingHandlerAdapterhandleException时,早已走完了 filter 链,因此我们的 ContentTypeFilter 就失效了

参考资料