起源
近期,我把一个 Python 2 的项目迁移到了 Python 3,今早在进行最后一次测试时,发现下载的一个按钮是失效的。当时立刻就想,完蛋了,难道是遇到了跨平台的问题?那就糟糕了啊,跨平台问题都不太好处理的。
试了下原来的 Python 2 版本的还正常,那么就是这次迁移有点小问题了。
确定复现
经过一系列绝望的瞎猫碰死耗子的尝试,发现了当要下载的文件是第三个,就会触发这个错误,抛的异常如下:
- [E 180417 11:03:48 web:1590] Uncaught exception GET /apps/scenario_change/page/download?_id=5aab8287e138235c1c9fa9ea&type=task (127.0.0.1)
- HTTPServerRequest(protocol='http', host='127.0.0.1:9118', method='GET', uri='/apps/scenario_change/page/download?_id=5aab8287e138235c1c9fa9ea&type=task', version='HTTP/1.1', remote_ip='127.0.0.1', headers={'Host': '127.0.0.1:9118', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'User-Agent': 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.11; rv:54.0) Gecko/20100101 Firefox/54.0', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 'Dnt': '1', 'Referer': 'http://127.0.0.1:9118/apps/scenario_change/task_management.html', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'})
- Traceback (most recent call last):
- File "C:\Python36\lib\site-packages\tornado\web.py", line 1513, in _execute
- self.finish()
- File "C:\Python36\lib\site-packages\tornado\web.py", line 991, in finish
- self.flush(include_footers=True)
- File "C:\Python36\lib\site-packages\tornado\web.py", line 947, in flush
- start_line, self._headers, chunk, callback=callback)
- File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in write_headers
- lines.extend(l.encode('latin1') for l in header_lines)
- File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in
- lines.extend(l.encode('latin1') for l in header_lines)
- UnicodeEncodeError: 'latin-1' codec can't encode characters in position 45-46: ordinal not in range(256)
- [E 180417 11:03:48 web:1015] Cannot send error response after headers written
- [E 180417 11:03:48 web:1025] Failed to flush partial response
- Traceback (most recent call last):
- File "C:\Python36\lib\site-packages\tornado\web.py", line 1513, in _execute
- self.finish()
- File "C:\Python36\lib\site-packages\tornado\web.py", line 991, in finish
- self.flush(include_footers=True)
- File "C:\Python36\lib\site-packages\tornado\web.py", line 947, in flush
- start_line, self._headers, chunk, callback=callback)
- File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in write_headers
- lines.extend(l.encode('latin1') for l in header_lines)
- File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in
- lines.extend(l.encode('latin1') for l in header_lines)
- UnicodeEncodeError: 'latin-1' codec can't encode characters in position 45-46: ordinal not in range(256)
- During handling of the above exception, another exception occurred:
- Traceback (most recent call last):
- File "C:\Python36\lib\site-packages\tornado\web.py", line 1022, in send_error
- self.finish()
- File "C:\Python36\lib\site-packages\tornado\web.py", line 992, in finish
- self.request.finish()
- File "C:\Python36\lib\site-packages\tornado\httputil.py", line 419, in finish
- self.connection.finish()
- File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 448, in finish
- self._expected_content_remaining)
- tornado.httputil.HTTPOutputError: Tried to write 8015 bytes less than Content-Length
追踪异常 1:白高兴
按照常理讲,咱代码出了异常,咱就定位到报错的最后一行(most recent call last 嘛,最后一次调用),看下异常是什么知道个大概,然后跳过去,该回溯的回溯,管它是高级的打断点开 debug 还是低级的一顿 print 的,确定问题根源然后修改。
看一眼这个异常,Tried to write 8015 bytes less than Content-Length,看样子好像是指定了一个 Content-Length 但是实际上发送的文件却比这个短 8015 字节。莫非是迁移到 3 的过程中对 Unicode 与 bytes 应用了错误的len()
导致抛异常?
那简单了,看一眼是不是设错了 headers 就好了啊,Edit - Find - Find in path,限定文件类型为 py,开搜!
啥?逗我呢?没有?
小提示:为啥第一想法要怀疑 Content-Length 的长度设置错了呢?
很简单啦,Python 2 对 Unicode 的实现 emmm 这么说吧,在 Python 2 里,'hello'
是二进制(字节流 bytes),u'hello'
才是 Unicode;但是在 3 里呢,'hello'
是 Unicode,b'hello'
才是二进制。唉,谁叫 Python 问世(第一版发布于 1991.02)的时候 Unicode 标准(1991.10)还没出来呢。
以防你们不晓得这么个大坑,看下图:
以后在有人跟你说一个汉字是两个字节,你就拿这个图怼死他:不说编码就说一个汉字是几个字节就是耍流氓,说了编码那也是耍流氓。
追踪异常 2:找到根源
有点扫兴,竟然没有 Content-Length,那是因为啥呢?那就继续看着报错回溯呗。扫了一眼,啥,都是 tornado 的错,不是我的错?难道我要怀疑编译器解释器和久负盛名的 tornado……?
扩展阅读:
cookies 的时间戳,C 语言标准 for 循环for(int i=0;i++;i<10)
,mian,ture,全角符号,以及<link href
......
又仔细看了一眼,发现这么一段话:During handling of the above exception, another exception occurred:
处理上面这个异常的时候,又来了个这么异常。
啊原来我刚刚看的是后来的异常啊,那我得看第一个异常的尾巴:
UnicodeEncodeError: 'latin-1' codec can't encode characters in position 45-46: ordinal not in range(256)
多少年了,无数的 Python 程序员都曾经被 UnicodeEncodeError 与 UnicodeDecodeError 坑的说不出话来。
可是依旧没有我的代码啊。
终于,通过搜索. txt(下载的文件的扩展名)我成功找到了这一行:
- file_name = response['data'][0]['task_name'] + '.txt'
看来是这句话负责生成文件名的,找到这句的函数定义,继续找调用者,叮咚!
一看函数名叫 download,看样子下图这里就是该排错的地方
解决异常 1:瞎比划
加个 Content-Length
大致看了眼,好像没啥问题。要不在这里加上个 Content-Length 试试?
- self.set_header("Content-Length", len(file_content.encode('utf-8')))
我的基础还是很牢固的,没算错,一点问题都没有,肯定没错啊。但是 Tried to write 8015 bytes less than Content-Length 这个异常还是抛了。
数据类型错了
也许是数据类型错了?说不定file_name
, file_content
需要字节流而不是字符串呢?这好像有点接近 Unicode 错误了。简单的encode('utf-8')
一下,当然还是继续错啦。
也许是因为 header 的事情
那就把 header 都注释了看看。于是在浏览器里打开了文件……
那我只留 Content-Type
噗 (/≧▽≦)/ 我的文件名呢,内容是正确的。
啊,原来是靠的 Content-Disposition 这个响应头来强制浏览器下载文件,指定文件名什么的啊……
解决异常 2:确定问题
终于,我确定了出错的代码在这一行
- self.set_header("Content-Disposition", "attachment; filename=%s" % file_name)
看样子是文件名。那就把 filename 替换成硬编码的test.txt
,正常下载;再换成tes啊t.txt
,一模一样的报错。
于是我果断的搜索了 Content-Disposition encoding 以及 Content-Disposition 编码,看到这么一篇说得还挺有道理的文章:
- Content-Disposition: attachment;filename="$encoded_fname";
- filename*=utf-8''$encoded_fname
当然了我没看到下面那句话(读书要读全啊),直接套上了
- self.set_header("Content-Disposition", "attachment; filename*=utf-8''%s" % file_name)
当然继续报错啦。还是file_name
数据类型不对?encode 成 UTF-8?差点用上 decode(字符串怎么可能再 decode 嘛?必然要报错的)
噗,这倒真是tes的t.txt
这个字符串的 UTF-8 编码。
这就纠结了,难道我要把文件名搞成什么 ASCII 的?比如干脆是几个真伪随机数吧?
解决异常 3:解决
然后又找到了这么一篇例子丰富的文章,看到一半突然想起了,既然要给任意文字编码成 ASCII 字符集内的,那就用 base64 啊,但是浏览器又不懂 base64 没办法给还原回来啊……
唉??不是有个东西叫 URL 编码吗?迅速翻了翻例子丰富的文章,还真提了一句……
- from urllib.parse import quote
- self.set_header("Content-Disposition", "attachment; filename=%s" % quote('tes的t.txt'))
我爱 emoji,emoji 也爱我。??? 典型的傲娇受。
??? ??? ??? ??? ??? ??? ???
其实第一篇文章提了一句的:其中, $encoded_fname
指的是将 UTF-8 编码的原始文件名按照 RFC 3986 进行百分号编码(percent encoding)后得到的。
百分号编码就是 URL 编码啦……
总结
这个小问题其实隐藏的还是很深的,而且奇特的是在 Python 2 没有问题但是到了 3 就冒出来了。
光看报错基本上没什么线索,只能靠对业务的熟悉摸索找到对应的代码,仔细排查最终确定导致出错的代码…… 然后解决之。
就我这水平啊,还是别怀疑编译器解释器,也别怀疑久负盛名的库了,绝对是我错了。
You tell me I'm wrong, then you'd better prove you're right. ——M.J.
后续
经过一小段时间的阅读源代码,在 tornado 源代码中发现了问题的根源。跟着我的节奏走!先看下抓包的结果吧:
我们知道问题是由 set_header 引起的,所以看一下 set_header 的源代码:
- def set_header(self, name, value):
- # type: (str, _HeaderTypes) -> None
- """Sets the given response header name and value.
- If a datetime is given, we automatically format it according to the
- HTTP specification. If the value is not a string, we convert it to
- a string. All header values are then encoded as UTF-8.
- """
- self._headers[name] = self._convert_header_value(value)
非常清楚的看到调用了一个_convert_header_value()
,继续追溯
- def _convert_header_value(self, value):
- # type: (_HeaderTypes) -> str
- # Convert the input value to a str. This type check is a bit
- # subtle: The bytes case only executes on python 3, and the
- # unicode case only executes on python 2, because the other
- # cases are covered by the first match for str.
- if isinstance(value, str):
- retval = value
- elif isinstance(value, bytes): # py3
- # Non-ascii characters in headers are not well supported,
- # but if you pass bytes, use latin1 so they pass through as-is.
- retval = value.decode('latin1')
- elif isinstance(value, unicode_type): # py2
- # TODO: This is inconsistent with the use of latin1 above,
- # but it's been that way for a long time. Should it change?
- retval = escape.utf8(value)
- elif isinstance(value, numbers.Integral):
- # return immediately since we know the converted value will be safe
- return str(value)
- elif isinstance(value, datetime.datetime):
- return httputil.format_timestamp(value)
- else:
- raise TypeError("Unsupported header value %r" % value)
- # If \n is allowed into the header, it is possible to inject
- # additional headers or split the request.
- if RequestHandler._INVALID_HEADER_CHAR_RE.search(retval):
- raise ValueError("Unsafe header value %r", retval)
- return retval
这段代码有点冗长,但实际上只做了一件事情,将传递过来的 value 转换为字符串,更进一步,在 Python 2 中,如果传过来的是'hello'
,那么直接返回;如果类型是u'hello'
, 那么escape.utf8(value)
。在 Python 3 中如果是'hello'
那么直接返回,如果是'hello你'.encode('utf-8')
,那么用 latin1 解码。
最终这段代码会保证返回的类型是对应 Python 2/3 的 str 类型。
但其实这段代码并没有什么错,不过确实写的有点难懂。
之后继续追溯到write_headers()
,在 http1connection.py 的第 380 行左右:
- if PY3:
- lines.extend(l.encode('latin1') for l in header_lines)
- else:
- lines.extend(header_lines)
其中 header_lines 是一个生成器对象,包含了所有的 headers,其类型为 str(对应 2/3 的类型)
那么错误就显然易见了,在 Python 3 中对'hello'.encode('latin1')
是没有任何问题的,但是'hello和'.encode('latin1')
肯定会报 UnicodeEncodeError 啊,这也印证了我最开始发现的编码错误。
但是在 Python 2,直接就走了下面 extend 进去了,自然也没问题了。
但是如果我们调用 set_header 时传递的参数是'hell你'.encode('utf-8')
,那么在_convert_header_value
中会被用 latin1 解码,在这里又使用 latin1 编码,又回到了 utf-8 编码之后的形态,所以自然会出现对应文件名的 UTF-8 编码,也印证了上面下载出来的乱码文件。
所以,如果要修复这个问题,要么我们保证只传递过来 ASCII 的文件名(进行 URL 编码),这样用 latin1 编码就不会错了。要么直接把这俩 latin1 改成 utf-8。
不过根据 RFC7230 section 3.2.4:
Historically, HTTP has allowed field content with text in the
ISO-8859-1 charset [ISO-8859-1], supporting other charsets only
through use of [RFC2047] encoding. In practice, most HTTP header
field values use only a subset of the US-ASCII charset [USASCII].
Newly defined header fields SHOULD limit their field values to
US-ASCII octets. A recipient SHOULD treat other octets in field
content (obs-text) as opaque data.
这么做其实是有悖标准的,但是 Python 2 的版本直接不管不顾发出去了啊,实际上 Python 2 版本的应该拒绝这种 headers 的。我也很纠结,我也觉得没办法啊。
测试失败了!
测试失败了!!
测试失败了!!!
(重要的事三遍)