中文乱码不再是难题,轻松搞定文件上传!
背景
相信大家都碰到过上传文件或者下载文件,中文乱码的事情,希望看完本篇对你会有帮助。
故事还得从这次使用postman及apifox,上传文件到nodejs的服务时,nodejs服务这边收到的文件名中,中文部分是乱码的说起,如下图所示
之前从浏览器上传中文名称文件到后端,文件名展示是正常的,然后我怕浏览器也有问题了,毕竟项目依赖可能会升级,导致结果发生变化,于是用浏览器表单上传同样的文件看看,发现通过浏览器上传中文名是没有乱码的,如下图所示
这是怎么回事?不过想到这样的问题,肯定有很多人碰到过,所以先谷歌看看
确实有很多人碰到这个问题,逐个点进去看,发现解决方案如下
方案1: 编码问题导致的乱码,所以在重新编码即可,如下所示
方案2: 给上传文件的插件传入编码参数,如下图所示
方案3: 在header头中指定编码
等等还有一些方式,有的场景,可能按照上面的方案试下来问题解决了,但是我试完了上面提到的方法,都没有解决,于是看了下nodejs服务这边上传问题,大概是怎么处理的,并记录下来,方便后面排查问题
分析
依赖关系分析
粗略看了下不论是用的koa、express这样的库,还是nestjs等这样框架,看了一圈之后,发现nodejs里面处理文件上传的,差不多都会依赖multer这个包
multer这个包内上传文件的部分其实依赖的又是busboy这个包,来处理问题
而busboy是有两个不同的使用版本,1.0版本相对于0.x版本是有breakchange的
到这里我们已经知道,上传文件是busboy这个包处理的,而busboy这个包目前有两个不同的大版本1.x与0.x,出现中文乱码,肯定是这个包的问题
于是我分别看了下这两个版本,针对上传文件会有什么不同
busboy版本分析
首先看下我项目中正常使用的版本0.2.14
0.2.14版本浏览器上传
通过断点查看,文件名是从header头中的content-disposition中取出来的,如下图所示,此时的文件名中文部分看起来是乱码
解析content-disposition里面的参数之后,如下图所示
在讲文件名filename解码一次
也就是将filename从binary解码成utf8,最后可以看到中文名正常展示,没有乱码
0.2.14版本postman等工具上传
与浏览器不同的是,header头中的content-disposition中,filename有两个,如下图所示
解析content-disposition里面的参数之后,如下图所示
断点会发现解析下标2的之后,filename是正常的中文名,但是解析下标3之后,中文名就乱码了,如下图所示
原因是因为filename*对应的文件名本身就已经是中文了,然后又被解码了,即
filename = decodeText('这是一个测试abc.xlsx', 'binary', 'utf8'); // 成乱码了
最终控制台输出如下图所示
这时候,使用谷歌中的解决方案,Buffer.from(filename, 'latin1').toString('utf8')
会发现转不成中文了,同时busboy也不支持defParamCharset参数,最终没有找到简单的解决方法
接着看busboy1.x的版本,这里以1.6.0为例
1.6.0版本浏览器上传
从header内的content-disposition获取文件名
可以看到获取到filename中文是乱码的,与0.2.14中是一致的
这时候,我们自己在代码中,通过转码处理Buffer.from(filename, 'latin1').toString('utf8')
,中文就会正常输出了,如下图所示
还有一种传入defParamCharset: 'utf-8' 这样就会默认使用utf8来解码乱码的中文字符,这样获取到的filename也是正常的中文字符了
1.6.0版本postman等工具上传
与浏览器不同的是,header头中的content-disposition中,filename有两个,如下图所示
这里解析出来的内容与0.2.4版本一致
但是这里的取filename的方式,发生了变化,不再是通过遍历的方式获取,而是直接通过filename获取,且filename*的优先级高于filename
最终控制台展示正常,如下图所示
可以看到使用postman上传文件,文件名中的中文不是乱码的,而是正确的中文名,从上面可以看到header['content-disposition']中有两个filename,一个是filename="è¿æ¯ä¸ä¸ªæµè¯abc.xlsx"; 另一个是filename*=UTF-8''%E8%BF%99%E6%98%AF%E4%B8%80%E4%B8%AA%E6%B5%8B%E8%AF%95abc.xlsx
补充
上面验证完之后,可能会有一些问题:
- 为什么content-disposition收到的filename字段中中文字符是乱码?
- 为什么使用Buffer.from(filename, 'latin1').toString('utf8'),在1.x版本可以将乱码还原成中文字符,而在0.x版本中却不行
- 为什么header['content-disposition']会有两个filename字段?
为什么content-disposition收到的filename字段中,中文字符是乱码?
因为http协议规定,http头中携带的字符只能是ISO-8859-1字符集中的字符,所以中文被转码了,而ISO-8859-1是单字节编码,自身不能显示中文,若要显示中文,必须和其他能显示中文的编码配合,如“GBK”,“UTF-8"。
简单来说,要将中文转成http协议允许的编码,也就是ISO-8859-1,用nodejs代码来编码就是,先将中文转成能够编码中文的utf8,然后再将编码后的uft8字符在做ISO-8859-1编码
// 注意nodejs中latin1 or binary代表的就是ISO-8859-1编码
Buffer.from('这是一个测试abc.xlsx', 'utf8').toString('binary') // 返回编码结果
Buffer.from('这是一个测试abc.xlsx', 'utf8').toString('latin1')
中文编码之后的结果如下所示
然后在通过抓包工具,对比浏览器专递的最终编码字符是不是一样的,从当前给的案例看,是一样的
为什么使用Buffer.from(filename, 'latin1').toString('utf8'),在1.x版本可以将乱码还原成中文字符,而在0.x版本中却不行
原因是因为在1.x中filename本身就是latin1编码,而lantin1就是ISO-8859-1的别名,所以能够将中文又解码回来,过程如下所示
const filename = Buffer.from('这是一个测试abc.xlsx', 'utf8').toString('binary') // http传递的编码
// 将http传递的编码,在解码成utf8
Buffer.from(filename, 'latin1').toString('utf-8') // 返回正确的中文名
而在0.x是因为,直接将中文先通过binary编码,然后在通过utf8编码,这样是解码不回来的
const filename = Buffer.from('这是一个测试abc.xlsx', 'binary').toString('utf8')
Buffer.from(filename, 'utf8').toString('binary') // 解码不回来的
为什么header['content-disposition']会有两个filename字段?
为了国际化考虑,允许通过filename来指定ISO-8859-1之外的字符,当不支持filename的则会忽略,具体可以查看how-to-encode-the-filename-parameter-of-content-disposition-header-in-http,部分截图如下所示
所以这里可以总结,浏览器上传文件时,只带了一个filename字段在content-disposition字段中,而postman这类工具,带了filename还带了filename*
解决
从上面了解busboy不同的版本上传情况不同之后,那么解决该问题的时候,我们就需要根据实际项目中的版本进行解决,下面是具体的解决方案
针对busboy1.x版本
busboy1.x版本下中文乱码如下
- 浏览器上传文件,中文文件名默认是会乱码的
- postman等工具上传文件,中文文件名默认是会正常展示
所以最简单的解决方法,就是传入defParamCharset: 'utf-8',或者自己在代码里面接着转码一次,需要做一个判断如下所示,这样就可以解决所有上传场景
if (!/[^\u0000-\u00ff]/.test(value.originalname)) {
value.originalname = Buffer.from(value.originalname, 'latin1').toString(
'utf8',
);
}
这也就是网上看到的大部分的解决方案,这种方案适合busboy1.x版本的
针对busboy0.x版本
busboy0.x版本下
- 浏览器上传文件,中文文件名默认显示正常
- postman等工具上传文件,中文文件名默认是会乱码的
因为0.x与1.x解析文件名filename的方式不一样,0.x是遍历的方式,所以filename*会覆盖filename
而1.x不是遍历,而是优先获取filename*
那么针对0.x怎么解决中文乱码呢?
1、可以升级对应的multer依赖、这里需要注意的是升级multer需要升级到可能会遇到esm模块与cjs模块混用的错误,确保multer依赖的busboy是1.x版本
2、当然也可以不用中间件,直接升级busboy,然后在项目内自己直接使用busboy,如下所示
@Post('/file')
async file(@Ctx() ctx: any): Promise<any> {
const bb = busboy({ headers: ctx.req.headers, defParamCharset: 'utf-8' });
await new Promise((resolve, reject) => {
bb.on('file', (name, file, info) => {
const { filename, encoding, mimeType } = info;
console.log(
`File [${name}]: filename: %j, encoding: %j, mimeType: %j`,
filename,
encoding,
mimeType
);
const saveTo = path.join(process.cwd(), filename);
file.pipe(fs.createWriteStream(saveTo));
});
bb.on('field', (name, val, info) => {
console.log(`Field [${name}]: value: %j`, val);
});
bb.on('close', () => {
console.log('Done parsing form!');
resolve(1)
});
ctx.req.pipe(bb);
})
return 'hello world file'
}
3、可以patch busboy包,在获取filename那里加一行代码
diff --git a/node_modules/busboy/lib/types/multipart.js b/node_modules/busboy/lib/types/multipart.js
index b6d8e8b..d415020 100644
--- a/node_modules/busboy/lib/types/multipart.js
+++ b/node_modules/busboy/lib/types/multipart.js
@@ -156,6 +156,7 @@ function Multipart(boy, cfg) {
if (RE_NAME.test(parsed[i][0])) {
fieldname = decodeText(parsed[i][1], 'binary', 'utf8');
} else if (RE_FILENAME.test(parsed[i][0])) {
+ if (filename !== undefined) continue;
filename = decodeText(parsed[i][1], 'binary', 'utf8');
if (!preservePath)
filename = basename(filename);
总结
上传文件,中文名称乱码,主要还是中文编码的问题以及依赖的依赖问题,当某个底层依赖出现问题的时候,在项目内修改是成本比较高的,所以应该尽可能的降低这种依赖的依赖场景
其次浏览器上传文件与postman等工具上传文件在header字段中的content-disposition属性有差异,会出现filename*的情况
最后用一个表格总结上传文件中文乱码情况
版本 | 默认是否正常展示中文名 | 解决方法 |
---|---|---|
0.x 浏览器上传 | ✅ | |
0.x postman等上传 | ❌ | 升级到1.x or patch busboy代码 |
1.x 浏览器上传 | ❌ | 设置defParamCharset: 'utf-8' |
1.x postman等上传 | ✅ |
最终解决方案,推荐使用busboy 1.x版本,并且设置defParamCharset: 'utf-8'
转载自:https://juejin.cn/post/7366177423775449088