原始代码(未处理含中文和空格的文件名)

@RestController
public class FileController {
    @RequestMapping(value = "/download", method = {RequestMethod.GET})
    public void download(HttpServletResponse response) throws IOException {
        String filename = "中国 ABC.txt";
        response.setHeader("Content-Disposition", "attachment; filename=" + filename);

        response.setCharacterEncoding("UTF-8");
        byte[] b = "你好,世界。Hello, world.".getBytes("UTF-8");
        response.setContentLength(b.length);

        response.getOutputStream().write(b);
        response.getOutputStream().flush();
    }
}
这段代码的关键行是:
response.setHeader("Content-Disposition", "attachment; filename=" + filename);

在这里既没有考虑文件名中含有中文的情况,也没有考虑空格的情况。

如果文件名是 test.txt,那倒没问题。但当文件名是 中国.txt,则各个浏览器都出了问题,下载文件名变成了 __.txt。而一旦文件名中有了空格,变成 中国 ABC.txt,则各个浏览器的表现五花八门:

Chrome:download.sql (这是什么?)

Firefox:__

Internet Explorer:__ ABC.txt

处理空格
先解决空格问题。方法很简单,只要在响应头 Content-Disposition 的 filename 指令值前后加上英文双引号 " 即可:

response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");

这样处理后:

Chrome:download.sql (真的无语)

Firefox:__ ABC.txt

Internet Explorer:__ ABC.txt

按照 HTTP 协议的规范,filename 指令值前后是必须加英文双引号 " 的,也就是 filename=“abc.txt” 的形式。但是大多数开发者不太喜欢阅读规范文档,而喜欢看帖子抄答案,“遂致谤书流于后世”,不规范的写法层出不穷。
处理中文
按照网上很多文章的做法,我们先把原文件名字符串用 UTF-8 编码成字节数组,再用 ISO-8859-1 解码回到字符串:

filename = new String(filename.getBytes("UTF-8"), "ISO-8859-1");
response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");

这样处理后:
Chrome:中国 ABC.txt

Firefox:中国 ABC.txt

Internet Explorer:涓浗 ABC.txt (IE 总是拖后腿啊)

于是看到网上还有一种方法:

filename = java.net.URLEncoder.encode(filename,"UTF-8").replace("+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");

注意上面代码中 URLEncoder.encode() 方法会把空格转换成 + 号,但这样浏览器会认为文件名中就是含有一个 + 号。所以需要再用 replace(“+”, “%20”) 把 + 号转换成 %20,这个 %20 才是浏览器 JavaScript 对于 URL 编码中空格的转义表示。

不过这样处理后,Firefox 又不行了,真是按下葫芦起了瓢:

Chrome:中国 ABC.txt

Firefox:%E4%B8%AD%E5%9B%BD%20ABC.txt

Internet Explorer:中国 ABC.txt

对于以上两难境地,网上多数文章的推荐方式是读取请求头 User-Agent 的值,判断里面是否含有 Firefox 字样,然后对 Firefox 浏览器做分支专门处理。

不过笔者不太喜欢这种“分情况讨论”的代码,生怕以后再跳出一种新的浏览器来,又要针对性地写代码。所以在这里介绍一种新的处理方式。

放弃 filename 指令,改用 filename* 指令
改用这种写法:

filename = java.net.URLEncoder.encode(filename,"UTF-8").replace("+", "%20");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + filename);
结果令人满意,三种浏览器都提示了正确的下载文件名 中国 ABC.txt。

解释
根据 HTTP 协议(RFC2626)的默认规则,请求或响应头中的指令(parameter)值只能使用 ISO-8859-1 字符集。但是 ISO-8859-1 是单字节编码的,对西欧语言支持还比较好,但对中日韩等国的语言就无能为力了。

为了解决上述问题,在 RFC2231 规范中规定了当一个指令名字后面跟了一个 * 号时,表示指令值中提供了字符集和语言信息。其格式为: 字符集'语言'使用前述字符集编码后的字符串 。注意其中有两个单引号 ',其作用是栏位之间的分隔符,都是必须存在的。即使 字符集 和 语言 栏为空白,也必须写上单引号 ' 作为分隔符。

举个例子:

Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20fun
这表示 title 这个参数值是用 us-ascii 字符集编码的,语言是 en-us,编码后的字符串是 This%20is%20fun。解码后的原字符串是 This is fun。

具体见 RFC2231 文档的这一小节: https://tools.ietf.org/html/rfc2231#section-4

所以,

Content-Disposition: attachment; filename*=UTF-8''%E4%B8%AD%E5%9B%BD%20ABC.txt
就表示 filename 参数值是用 UTF-8(这里用小写 utf-8 也可以)编码的,语言不限,编码后的文件名是 %E4%B8%AD%E5%9B%BD%20ABC.txt。解码后的原文件名就是 中国 ABC.txt 了。