问题:最近需要完成一个文件上传的业务功能,使用的框架是SpringCloud,业务代码写好后测试,发现接收到的文件,所有中文都变成了"?",在业务代码中打好断点后发现是在请求从Feign转发向server时出现了乱码。
定位问题:FormHttpMessageCoverter在springMVC5.0以下的版本虽然有deaault_charset,并且初始是UTF-8,但实际使用的编码是定死的US-ASCII!!!是Spring的大坑!!!
解决办法:
1.将springMVC的核心包升至5.x版本即可,参考https://blog.csdn.net/schzrj/article/details/81147656,这位博主说升级spring-web包就行了,在maven中心库查询后发现,spring-web是MVC的核心包,对其他包都有依赖,要保持相同等级,所以就是要将MVC全部升级,请大家谨慎选择。
2.重写FormHttpMessageCoverter,详见下方,解决问题的详细过程。
定位问题的详细过程:
1.在出问题的业务代码处追踪过去,Feign转发请求时首先进入了Encoder,公司的项目在这里重写了一个Encoder,重写是为了改变converters的default_charset,在这里对之后流程产生的结果就是给MultipartFile统一的header:"multipart/form-data"。
2.最关键的一步来了,文件需要通过流的形式传输,那么在Encoder中,就有一个通过辨识header,分发进合适的converter的过程:
private final List<HttpMessageConverter<?>> converters = new RestTemplate().getMessageConverters();
private static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private void encodeRequest(Object value, HttpHeaders requestHeaders, RequestTemplate template) throws EncodeException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
HttpOutputMessage dummyRequest = new HttpOutputMessageImpl(outputStream, requestHeaders);
try {
Class<?> requestType = value.getClass();
MediaType requestContentType = requestHeaders.getContentType();
// 重点在这里!!!
for (HttpMessageConverter<?> messageConverter : converters) {
if (messageConverter.canWrite(requestType, requestContentType)) {
((HttpMessageConverter<Object>) messageConverter).write(
value, requestContentType, dummyRequest);
break;
}
}
} catch (IOException ex) {
throw new EncodeException("Cannot encode request.", ex);
}
HttpHeaders headers = dummyRequest.getHeaders();
if (headers != null) {
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
template.header(entry.getKey(), entry.getValue());
}
}
/*
we should use a template output stream... this will cause issues if files are too big,
since the whole request will be in memory.
*/
template.body(outputStream.toByteArray(), DEFAULT_CHARSET);
}
第一点提到,现在的header中是"content-type":"multipart/form-data",这里的converter的canwrite就是通过content-type判断的。此时分发的converter是AllEncompassingFormHttpMessageConverter。里面并没有实际业务代码,只有一些属性供canwrite方法判断,继承自FormHttpMessageConverter,这是实际发挥效果的converter。
3.一步追踪,发现在FormHttpMessageConverter,将file转成了一个HttpEntity,然后将该Entity转入ResourceHttpMessageConverter写成了一个流,并且组装进MultipartHttpOutputMessage中,这个OutputMessage由FormHttpMessageConverter持有,然后写入ResourceHttpMessageConverter中自己的流,准备转发给server,此时代码如下:
protected void writeContent(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
try {
InputStream in = resource.getInputStream();
try {
StreamUtils.copy(in, outputMessage.getBody());
}
catch (NullPointerException ex) {
// ignore, see SPR-13620
}
finally {
try {
in.close();
}
catch (Throwable ex) {
// ignore, see SPR-12999
}
}
}
catch (FileNotFoundException ex) {
// ignore, see SPR-12999
}
}
注意上方代码,resource中的inputstream是输出目标,outputMessage是输入源,这里的流程是取出message中的流,再写入输出目标,问题就出现在这个getBody中,如下代码:
private static class MultipartHttpOutputMessage implements HttpOutputMessage {
private final OutputStream outputStream;
private final HttpHeaders headers = new HttpHeaders();
private boolean headersWritten = false;
public MultipartHttpOutputMessage(OutputStream outputStream) {
this.outputStream = outputStream;
}
@Override
public HttpHeaders getHeaders() {
return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers);
}
@Override
public OutputStream getBody() throws IOException {
writeHeaders();
return this.outputStream;
}
private void writeHeaders() throws IOException {
if (!this.headersWritten) {
for (Map.Entry<String, List<String>> entry : this.headers.entrySet()) {
byte[] headerName = getAsciiBytes(entry.getKey());
for (String headerValueString : entry.getValue()) {
byte[] headerValue = getAsciiBytes(headerValueString);
this.outputStream.write(headerName);
this.outputStream.write(':');
this.outputStream.write(' ');
this.outputStream.write(headerValue);
writeNewLine(this.outputStream);
}
}
writeNewLine(this.outputStream);
this.headersWritten = true;
}
}
private byte[] getAsciiBytes(String name) {
try {
// 这里是重点!!!!!
return name.getBytes("US-ASCII");
}
catch (UnsupportedEncodingException ex) {
// Should not happen - US-ASCII is always supported.
throw new IllegalStateException(ex);
}
}
}
终于找到了问题所在,这里被写死了,US-ASCII不能表示中文,所以写好的流中文全部变成了?。
解决问题的详细过程:
先捋清整个问题的流程,首先Feign转发文件至Encoder,Encoder为multipartfile赋上header,并且根据这个header筛选出合适的converter。此处合适的converter是AllEncompassingFormHttpMessageConverter,这是FormHttpMessageConverter的一个子类,但未重写任何方法,只初始化了一些值以支持converter的筛选,在FormHttpMessageConverter中将文件包成一个HTTPentity转发向ResourceHttpMessageConverter,在该converter中转成输出流,发给server层。问题就出在转成输出流的准备阶段。
再捋捋解决问题的思路,首先起最终作用的MultipartHttpOutputMessage需要重写,这是一个FormHttpMessageConverter的私有静态类,所以无法直接继承,我们需要重写formconverter。第二点,formconverter内部属性内部方法都为私有,只有一个write方法为public,经过查找调用轨迹发现,以write方法为入口,在这个类中调用私有方法,所以无法通过继承覆盖重写,只能全部重写。第三点,converter默认有7个,而决定调用哪一个是在其子类AllEncompassingFormHttpMessageConverter中,相当于是个入口,我们无法直接修改,所以必须重写该子类。
重写流程如下:
1.新建一个类,命名为MyFormHttpMessageConverter,将formconverter的所有内容直接加进来(只贴入了MultipartHttpOutputMessage部分的代码),将ASC改为UTF-8。(在这里我依然直接写死,实际上可以将重写的类继承于AbstractHttpMessageConverter,然后UTF-8处改为charset变量,这样就可以在config中使用set更改charset了,更加灵活)
public class MyFormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> {
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private static class MultipartHttpOutputMessage implements HttpOutputMessage {
private final OutputStream outputStream;
private final HttpHeaders headers = new HttpHeaders();
private boolean headersWritten = false;
public MultipartHttpOutputMessage(OutputStream outputStream) {
this.outputStream = outputStream;
}
@Override
public HttpHeaders getHeaders() {
return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers);
}
@Override
public OutputStream getBody() throws IOException {
writeHeaders();
return this.outputStream;
}
private void writeHeaders() throws IOException {
if (!this.headersWritten) {
for (Map.Entry<String, List<String>> entry : this.headers.entrySet()) {
byte[] headerName = getAsciiBytes(entry.getKey());
for (String headerValueString : entry.getValue()) {
byte[] headerValue = getAsciiBytes(headerValueString);
this.outputStream.write(headerName);
this.outputStream.write(':');
this.outputStream.write(' ');
this.outputStream.write(headerValue);
writeNewLine(this.outputStream);
}
}
writeNewLine(this.outputStream);
this.headersWritten = true;
}
}
private byte[] getAsciiBytes(String name) {
try {
return name.getBytes(DEFAULT_CHARSET);
}catch (NullPointerException ex) {
// Should not happen - US-ASCII is always supported.
throw new IllegalStateException(ex);
}
}
}
}
2.新建一个类,命名为MyAllEncompassingFormHttpMessageConverter,除了命名以外,其余内容与AllEncompassingFormHttpMessageConverter相同,只是继承自MyFormHttpMessageConverter。
3.这一步会根据个人代码不同有不同情况,我的encoder中的converters是New出来的,就像如下:
private final List<HttpMessageConverter<?>> converters = new RestTemplate().getMessageConverters();
那么在自己写的configration中修改是肯定没作用的。所以就在我的encoder构造方法里进行添加
public SpringMultipartEncoder() {
multipartHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
jsonHeaders.setContentType(MediaType.APPLICATION_JSON);
int i = 0;
for(HttpMessageConverter<?> messageConvert : converters){
if(messageConvert instanceof AllEncompassingFormHttpMessageConverter){
//解決FormHttpMessageConverter 中getAsciiBytes(String name) 方法造成上传文件名乱码
converters.set(i, new MyAllEncompassingFormHttpMessageConverter());
}
i++;
}
}
这里采用set是担心converters的位置会不会有影响....
而其他情况下,如下代码即可:
//扩展MessageConvert
//JsonMessageConverter 默认编码格式UTF-8, StringMessageConvert 默认编码格式为ISO8859-1,更改为UTF-8
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
int i = 0;
for(HttpMessageConverter<?> messageConvert : converters){
if(messageConvert instanceof StringHttpMessageConverter){
((StringHttpMessageConverter)messageConvert).setDefaultCharset(Charset.forName("UTF-8"));
}else if(messageConvert instanceof AllEncompassingFormHttpMessageConverter){
//解決FormHttpMessageConverter 中getAsciiBytes(String name) 方法造成上传文件名乱码
converters.set(i, new MyAllEncompassingFormHttpMessageConverter());
}
i++;
}
}
代码结束,在converters筛选处打好断点,跟着代码走,发现走进了我们自己重写的代码,并且转码正常,解决!
总结:
1.这次定位问题给我长了个教训,断点调试也要科学的断点调试,分析bug可能发生的地方,然后逐行仔细跟踪才行,不能全靠看风水定位。
2.spring的一些代码风格印象深刻,public类本身只留一个public方法作为入口,其余方法都为私有,子类继承自该public类,子类私有,子类不重写任何方法,形成一个作为入口使用的的类适配器,通过子类调用父类入口,再调用父类的私有方法。有利有弊,这保证了代码的安全性,但对重写极其不友善。