现象
前端文件上传,后端获取文件名中文乱码。
比如前段上传 上传文件.zip
;后端接收到 ä¸ä¼ æ件.zip
。
前后端的文件编码都是 UTF-8 格式,使用 chrome 浏览器测试。
这个问题的复现也比较奇怪,本地测试正常,服务器运营就会出现。
一度怀疑是系统的默认编码问题。
后端
最基本的 UTF-8 请求格式过滤配置:
@Bean
public FilterRegistrationBean filterRegistrationBean() {
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
characterEncodingFilter.setForceEncoding(true);
characterEncodingFilter.setEncoding("UTF-8");
registrationBean.setFilter(characterEncodingFilter);
return registrationBean;
}
文件名称的获取:
@PostMapping(value = "/upload")
@ResponseBody
public BaseResp upload(HttpServletRequest request,
HttpServletResponse response) throws IOException, ServletException {
if (request instanceof StandardMultipartHttpServletRequest) {
StandardMultipartHttpServletRequest sm = (StandardMultipartHttpServletRequest) request;
MultipartFile multipartFile = sm.getFile("file");
String filename = multipartFile.getOriginalFilename();
//filename 乱码
return RespUtil.success(filename);
} else {
return RespUtil.fail("文件上传失败");
}
}
前端
<input style="display:none"
accept="image/gif,image/jpeg,image/jpg,image/png"
type="file" id="imageUpload" ref="imageUpload">
对应的 js:
var input = this.$refs.imageUpload
input.addEventListener("change", () => {
var formData = new FormData()
formData.append("file", input.files[0])
var config = {
headers: {
"Content-Type": "multipart/form-data"
}
};
//axios 中的 this 并不指向 vue
var _this = this;
axios.post("/o/upload/upload", formData, config).then((response) => {
if (response.data.respCode === '0000') {
var filePath = response.data.respMessage;
var link = '![图片说明](' + filePath + ')'
// 更新内容
_this.article.content += '\n' + link;
// 上面双向绑定应该是无效的
_this.simplemde.value(_this.article.content);
// 清空文件内容,避免相同的文件无法监听到 change
input.value = '';
} else {
ELEMENT.Message.error(response.data.respMessage);
}
}).catch(function (error) {
ELEMENT.Message.error("请求失败");
console.log(error);
});
})
解决方案
先说最终的解决方案,直接修改了获取原始文件名的方式:
String value = new String("我是中文乱码".getBytes("ISO-8859-1"),"UTF-8");
解决步骤
1-前端指定编码
一开始怀疑是前端没有指定编码导致,于是调整 header 参数:
var config = {
headers: {
"Content-Type": "multipart/form-data"
}
};
调整为:
var config = {
headers: {
"Content-Type": "multipart/form-data;charset=UTF-8"
}
};
发现问题依然存在。
2-后端指定编码
于是去查了一圈,有的说设置一下缺失的编码即可解决。
public BaseResp upload(HttpServletRequest request,
HttpServletResponse response) throws IOException, ServletException {
String encoding = request.getCharacterEncoding();
if(StringUtil.isEmpty(encoding)) {
request.setCharacterEncoding("UTF-8");
}
// 原来的逻辑
}
日志发现 request.getCharacterEncoding()
一开始确实是 null,但是设置之后依然无效。
3-对文件名进行编码
然后就怀疑是不是中文名称没有转成 UTF-8 导致的,于是修改名称的获取方式:
StandardMultipartHttpServletRequest sm = (StandardMultipartHttpServletRequest) request;
MultipartFile multipartFile = sm.getFile("file");
String filename = multipartFile.getOriginalFilename();
// 转码处理
filename = new String(filename.getBytes(), "UTF-8");
很不幸,依然是乱码。
4-移除强制转换
然后怀疑是不是强转 utf-8 导致的,于是做了如下调整:
@Bean
public FilterRegistrationBean filterRegistrationBean() {
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
// 注释掉强制换换
//characterEncodingFilter.setForceEncoding(true);
characterEncodingFilter.setEncoding("UTF-8");
registrationBean.setFilter(characterEncodingFilter);
return registrationBean;
}
注释掉编码设置。
public BaseResp upload(HttpServletRequest request,
HttpServletResponse response) throws IOException, ServletException {
//String encoding = request.getCharacterEncoding();
//if(StringUtil.isEmpty(encoding)) {
// request.setCharacterEncoding("UTF-8");
//}
}
发现依然无效。
5-正确的方式
最后找到了正确的方式,如下:
String value = new String("我是中文乱码".getBytes("ISO-8859-1"),"UTF-8");
至于原因,是因为看了一篇对于乱码形式介绍的文章。
比如前段上传 上传文件.zip
;后端接收到 ä¸ä¼ æ件.zip
。
而且大部分中文都是这种形式,就去查一查有没有规律,还真找到了。
乱码知识回顾
ps: 内容较长,可以跳过。
字符编码理论简述
本文主要是围绕Web开发中涉及到的中文编码这一常见问题展开,包括了对字符编码基础理论的简述以及常见几种编码标准的介绍。
其中包括:ASCII、ISO8859-1、Unicode、GBK。
下面先对这些字符编码集进行简单的介绍。
1.1 ASCII
ASCII也就是美国信息交换标准码,采用单字节编码方案,但是编码只用了后七位字节,表示范围0-127共128个字符。ASCII码相对于其它编码也是最早出现的。
从上世纪60年代提出开始,到1986年最终定型。
为什么选择7位编码?
ASCII在最初设计的时候需要至少能表示64个码元:包括26个字母+10个数字+图形标示+控制字符,如果用6bit编码,可扩展部分没有了,所以至少需要7bit。
那么8bit呢?最终也被标准委员会否定,原因很简单:满足编码需求的前提下,最小化传输开销。
1.2 ISO8859-1
ISO-8859-1也被称为Latin1,使用单字节8bit编码,可以表示256个西欧字符。
其隶属于ISO8859标准的一部分,还有ISO8859-2、ISO8859-3等等。
每一种编码都对应一个地区的字符集。
比如:ISO8859-1表示西欧字符,ISO-8859-16表示中欧字符集,等等。
1.3 Unicode
不管是ASCII还是ISO8859-1,其编码范围都是有局限的。
而Unicode标准的目标就是消除传统编码的局限性。
这里的局限性一方面指编码范围的局限性:比如ASCII只能表示128个字符。
还有编码兼容性方面的局限性:比如ISO8859代表的一系列编码字符集虽然可以表示大部分国家地区的字符,但是彼此的兼容性做的不好。
Unicode的目标就如同其名称的含义一样:“实现字符编码统一”
Unicode标准的实现方案有如下三种:UTF-8、UTF-16和UTF-32.
UTF-8是变长编码,使用1到4个字节。
UTF-8在设计时考虑到向前兼容,所以其前128个字符和ASCII完全一样,也就是说,所有ASCII同时也都符合UTF-8编码格式。
其格式如下:
0xxxxxxx
110xxxxx 10xxxxxx
1110xxxx 10xxxxxx 10xxxxxx
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
字节首部为0的话,也就是前面说的ASCII了。
此外,字节首部连续1的个数就代表了该字符编码后所占的字节数。目前全世界的网页编码绝大多数使用的就是UTF-8,占比接近90%。
UTF-16也是变长编码,但其最初是固定16-bit宽度的定长编码,主要因为Unicode涵盖的字符太多了。两字节更本不够用!
UTF-32是32-bit定长编码,优点:定长编码在处理效率上相对于变长编码要高,此外,可通过索引访问任意字符是其另一大优势;缺点也很明显:32bit太浪费了!存储效率太低!
big-endian和little-endian?
在多字节编码标准中可能会遇到这样的问题:假如一个字符用两个字节表示,那么当读取这个字符的时候,哪个字节表示高有效位?哪个表示低有效位呢?这就涉及到字节的存储顺序问题。
在Unicode中UTF-16和UTF-32都会面临这个问题。通常用BOM(Byte Order Mark)来进行区分。BOM用一个”U+FEFF”来表示,这个值在Unicode中是没有对应字符的。不仅可以用其来指定字节顺序,还可以表示字节流的编码方式。
System.out.println("len1:" + "a".getBytes("UTF16").length); //len1:4
System.out.println("len2:" + "aa".getBytes("UTF16").length); //len2:6
为什么是4和6,不应该是2和4吗?。
输出编码后的字节序列可以发现,起始的两个字节都是:”fe ff”。
Java的char类型用什么编码格式?
Java语言规范规定了Java的char类型使用的是UTF-16。这就是为什么Java的char占用两个字节的原因。
此外,Java标准库实现的对char与String的序列化规定使用UTF-8。
Java的Class文件中的字符串常量与符号名字也都规定用UTF-8编码。
这大概是当时设计者为了平衡运行时的时间效率(采用定长编码的UTF-16,当然,在设计java的时候UTF-16还是定长的)与外部存储的空间效率(采用变长的UTF-8编码)而做的取舍。
1.4 GBK
GBK 是用于对简体中文进行编码。每个字符用两字节表示,同时兼容GB2312标准。
可能发生的中文乱码
这一小节介绍软件开发中常见的中文编码乱码问题,在下面示例中:对于给定的一个包含中文的字符串”你好Java”,看一下都会出现哪些乱码问题。
2.1 中文变问号,如:?????
"你好Java" ------> "??Java"
这种情况一般是由于中文字符经ISO8859-1编码造成的。
下面是编码的具体过程:
原字符串:”你好Java”
你 好 J a v a
4f60 597d 4a 61 76 61
经ISO8859-1编码后:
你 好 J a v a
3f 3f 4a 61 76 61
编码后字符串:”??Java”
String str = "你好Java";
System.out.println(byteToHexString(str.getBytes(CHARSET_ISO88591))); //3f 3f 4a 61 76 61
System.out.println(new String(str.getBytes(CHARSET_ISO88591))); //??Java
我们知道ISO8859-1是单字节编码,而对于汉字已经超出ISO8859-1的编码范围,会被转化为”3f”,我们查表可知,”3f”对应的字符正是”?”。
中文变问号的乱码情况是非常常见的,大部分开源软件的默认编码设置成了ISO8859-1,这点需要格外注意。
2.2 中文变奇怪字符,如:ä½ å¥½ 或者 ÄãºÃ
"你好Java" ------> "ä½ å¥½Java"
原字符串:”你好Java”
你 好 J a v a
4f60 597d 4a 61 76 61
经UTF-8编码后,一个中文用三个字节表示:
你 | 好 | J| a| v| a
---|---|---|---|---|---|---|---
e4 bd a0 | e5 a5 bd | 4a| 61| 76| 61
乱码原因:UTF8编码或GBK编码,再由ISO8859-1解码。
对照ISO8859-1编码表后发现:e4 bd a0分别对应三个字符:”ä½ “,e5 a5 bd分别对应三个字符”好”,
2.3 中文变“复杂中文”如:浣犲ソ
下面依然是”你好Java”经过UTF-8编码后对应的字节序列:
你 | 好 | J| a| v| a
---|---|---|---|---|---|---|---
e4 bd a0 | e5 a5 bd | 4a| 61| 76| 61
在GBK表中查找:e4 bd对应字符:”浣”,a0 e5对应字符:”犲”,a5 bd对应字符:”ソ”
同理,如果GBK编码的中文用UTF-8来解码的话,同样会出现乱码问题。
2.4 中文变成一堆黑色菱形+问号,如:�����
首先问号+黑色菱形的字符是Unicode中的”REPLACEMENT CHARACTER”,该字符的主要作用是用来表示不识别的字符。
所以产生乱码的原因可能有很多,下面通过原字符串:”你好Java”,重现一种乱码方式:
原字符串:String str = "你好Java"
你 | 好 | J| a| v| a
---|---|---|---|---|---
4f60 | 597d | 4a| 61| 76| 61
UTF-16编码后
fe ff 4f 60 59 7d 0 4a 0 61 0 76 0 61
其中”fe ff”就是字节流起始的BOM标识符。”fe ff”在Unicode标准中属于”noncharacters”,只用于内部使用。
所以,在输出该字节序列的时候,没有该码元对应的字符,对于不识别字符,就会用��替代。
结合自身的问题
结合我们遇到的中文乱码问题,很明显对应的是:UTF8编码或GBK编码,再由ISO8859-1解码。
知道原因,最直接的方式就是反过来:ISO8859-1 获取 bytes,然后转换为 UTF-8,也就是开始的答案:
String value = new String("我是中文乱码".getBytes("ISO-8859-1"),"UTF-8");
当然,问题的解决方案是多样的,这里汇总一下,便于后续使用和学习。
乱码解决方案
编写字符编码过滤器
@WebFilter(urlPatterns = "/*",filterName = "CharacterEncodingFilter")
public class CharacterEncodingFilter implements Filter{
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
filterChain.doFilter(request , response);
}
@Override
public void destroy() {
}
}
然后启动类上加上@ServletComponentScan。@WebFilter是servlet3.0才有的注解。
当然这个过滤器你还可以这么写
public class CharacterEncodingFilter implements Filter{
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
filterChain.doFilter(request , response);
}
@Override
public void destroy() {
}
}
写个bean配置类,如下
@Bean
public FilterRegistrationBean registerAuthFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new CharacterEncodingFilter();
registration.addUrlPatterns("/*");
registration.setName("CharacterEncodingFilter");
registration.setOrder(1);
return registration;
}
ps: 其实这个就是我们前面看到的,springboot 有内置的过滤器。
等价于
@Configuration
public class UtfConfig {
@Bean
public FilterRegistrationBean filterRegistrationBean() {
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
characterEncodingFilter.setForceEncoding(true);
characterEncodingFilter.setEncoding("utf-8");
registrationBean.setFilter(characterEncodingFilter);
return registrationBean;
}
}
在 application.yml 指定编码格式为utf-8
如果出现乱码问题,这种方式解决的可能性不大,但可以尝试一下。
spring:
http:
encoding:
charset: utf-8
enabled: true
force: true
server:
tomcat:
uri-encoding: UTF-8
结合源码会发现,其实 springboot 默认就是 UTF-8。
@ConfigurationProperties(
prefix = "spring.http.encoding"
)
public class HttpEncodingProperties {
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Charset charset;
private Boolean force;
//xxx others
}
这个也建议和 maven 一起使用:
- pom.xml
设置项目本身的编码为 UTF-8
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
使用 java 配置写一个字符编码配置类
/**
* 中文乱码解决
*/
@Configuration
public class CharsetConfig extends WebMvcConfigurerAdapter {
@Bean
public HttpMessageConverter<String> responseBodyConverter() {
StringHttpMessageConverter converter = new StringHttpMessageConverter(
Charset.forName("UTF-8"));
return converter;
}
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
super.configureMessageConverters(converters);
converters.add(responseBodyConverter());
}
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false);
}
}
StringHttpMessageConverter 是一个请求和响应信息的编码转换器,通过源码我们发现默认编码ISO-8859-1,不是UTF-8,所以我们只要通过上述配置将请求字符串转为UTF-8 即可
public class StringHttpMessageConverter extends AbstractHttpMessageConverter<String> {
public static final Charset DEFAULT_CHARSET = Charset.forName("ISO-8859-1");
private volatile List<Charset> availableCharsets;
private boolean writeAcceptCharset;
//xxx
}
写到这里,我感觉中文乱码的问题很可能是出在这里。
前端实际上传递的是 UTF-8,但是被默认的 ISO-8859-1
编码执行了强转,所以又乱码了。
为什么请求的编码为空呢?
这里会有第二个疑问,为什么 String encoding = request.getCharacterEncoding();
是空的呢?
实际上,这也正是 springboot 默认使用 ISO-8859-1
,导致乱码的罪魁祸首。
网上很多说是 IE 浏览器不会将页面上指定的编码写入http header发送给客户端,不过验证问题时使用的是 chrome。
所以,这个问题暂时存疑。
TODO
参考资料
https://bbs.csdn.net/topics/392401406
CharacterEncodingFilter在SpringBoot中的配置
SpringBoot 解决乱码问题:通过 spring.http.encoding.charset 指定返回 UTF-8 编码