登录
  • 人们都希望被别人需要 却往往事与愿违
  • (相对 C 而言) 在 C++ 里, 想搬起石头砸自己的脚更为困难了。不过一旦你真这么做了, 整条腿都得报销!@Bjarne Stroustrup (C++ 之父)

一次找不到错误的巨坑的 http header 的 url 编码的 Python 3 迁移问题

编程 Benny 小土豆 9163 次浏览 8719 字 4 个评论
文章目录 [显示]

起源

近期,我把一个 Python 2 的项目迁移到了 Python 3,今早在进行最后一次测试时,发现下载的一个按钮是失效的。当时立刻就想,完蛋了,难道是遇到了跨平台的问题?那就糟糕了啊,跨平台问题都不太好处理的。

试了下原来的 Python 2 版本的还正常,那么就是这次迁移有点小问题了。

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

确定复现

经过一系列绝望的瞎猫碰死耗子的尝试,发现了当要下载的文件是第三个,就会触发这个错误,抛的异常如下:

  1. [E 180417 11:03:48 web:1590] Uncaught exception GET /apps/scenario_change/page/download?_id=5aab8287e138235c1c9fa9ea&type=task (127.0.0.1)
  2. 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'})
  3. Traceback (most recent call last):
  4. File "C:\Python36\lib\site-packages\tornado\web.py", line 1513, in _execute
  5. self.finish()
  6. File "C:\Python36\lib\site-packages\tornado\web.py", line 991, in finish
  7. self.flush(include_footers=True)
  8. File "C:\Python36\lib\site-packages\tornado\web.py", line 947, in flush
  9. start_line, self._headers, chunk, callback=callback)
  10. File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in write_headers
  11. lines.extend(l.encode('latin1') for l in header_lines)
  12. File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in
  13. lines.extend(l.encode('latin1') for l in header_lines)
  14. UnicodeEncodeError: 'latin-1' codec can't encode characters in position 45-46: ordinal not in range(256)
  15. [E 180417 11:03:48 web:1015] Cannot send error response after headers written
  16. [E 180417 11:03:48 web:1025] Failed to flush partial response
  17. Traceback (most recent call last):
  18. File "C:\Python36\lib\site-packages\tornado\web.py", line 1513, in _execute
  19. self.finish()
  20. File "C:\Python36\lib\site-packages\tornado\web.py", line 991, in finish
  21. self.flush(include_footers=True)
  22. File "C:\Python36\lib\site-packages\tornado\web.py", line 947, in flush
  23. start_line, self._headers, chunk, callback=callback)
  24. File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in write_headers
  25. lines.extend(l.encode('latin1') for l in header_lines)
  26. File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 381, in
  27. lines.extend(l.encode('latin1') for l in header_lines)
  28. UnicodeEncodeError: 'latin-1' codec can't encode characters in position 45-46: ordinal not in range(256)
  29. During handling of the above exception, another exception occurred:
  30. Traceback (most recent call last):
  31. File "C:\Python36\lib\site-packages\tornado\web.py", line 1022, in send_error
  32. self.finish()
  33. File "C:\Python36\lib\site-packages\tornado\web.py", line 992, in finish
  34. self.request.finish()
  35. File "C:\Python36\lib\site-packages\tornado\httputil.py", line 419, in finish
  36. self.connection.finish()
  37. File "C:\Python36\lib\site-packages\tornado\http1connection.py", line 448, in finish
  38. self._expected_content_remaining)
  39. tornado.httputil.HTTPOutputError: Tried to write 8015 bytes less than Content-Length
  40.  

追踪异常 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,开搜!

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

啥?逗我呢?没有?


小提示:为啥第一想法要怀疑 Content-Length 的长度设置错了呢?

很简单啦,Python 2 对 Unicode 的实现 emmm 这么说吧,在 Python 2 里,'hello'是二进制(字节流 bytes),u'hello'才是 Unicode;但是在 3 里呢,'hello'是 Unicode,b'hello'才是二进制。唉,谁叫 Python 问世(第一版发布于 1991.02)的时候 Unicode 标准(1991.10)还没出来呢。

以防你们不晓得这么个大坑,看下图:

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

以后在有人跟你说一个汉字是两个字节,你就拿这个图怼死他:不说编码就说一个汉字是几个字节就是耍流氓,说了编码那也是耍流氓

追踪异常 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(下载的文件的扩展名)我成功找到了这一行:

  1. file_name = response['data'][0]['task_name'] + '.txt'

看来是这句话负责生成文件名的,找到这句的函数定义,继续找调用者,叮咚!

一看函数名叫 download,看样子下图这里就是该排错的地方

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

解决异常 1:瞎比划

加个 Content-Length

大致看了眼,好像没啥问题。要不在这里加上个 Content-Length 试试?

  1. self.set_header("Content-Length", len(file_content.encode('utf-8')))

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

我的基础还是很牢固的,没算错,一点问题都没有,肯定没错啊。但是 Tried to write 8015 bytes less than Content-Length 这个异常还是抛了。

数据类型错了

也许是数据类型错了?说不定file_name, file_content需要字节流而不是字符串呢?这好像有点接近 Unicode 错误了。简单的encode('utf-8')一下,当然还是继续错啦。

也许是因为 header 的事情

那就把 header 都注释了看看。于是在浏览器里打开了文件……

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

那我只留 Content-Type

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

噗 (/≧▽≦)/ 我的文件名呢,内容是正确的。

啊,原来是靠的 Content-Disposition 这个响应头来强制浏览器下载文件,指定文件名什么的啊……

解决异常 2:确定问题

终于,我确定了出错的代码在这一行

  1. self.set_header("Content-Disposition", "attachment; filename=%s" % file_name)

看样子是文件名。那就把 filename 替换成硬编码的test.txt,正常下载;再换成tes啊t.txt,一模一样的报错。

于是我果断的搜索了 Content-Disposition encoding 以及 Content-Disposition 编码,看到这么一篇说得还挺有道理的文章

  1. Content-Disposition: attachment;filename="$encoded_fname";
  2. filename*=utf-8''$encoded_fname

当然了我没看到下面那句话(读书要读全啊),直接套上了

  1. self.set_header("Content-Disposition", "attachment; filename*=utf-8''%s" % file_name)

当然继续报错啦。还是file_name数据类型不对?encode 成 UTF-8?差点用上 decode(字符串怎么可能再 decode 嘛?必然要报错的)

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

噗,这倒真是tes的t.txt这个字符串的 UTF-8 编码。

这就纠结了,难道我要把文件名搞成什么 ASCII 的?比如干脆是几个真伪随机数吧?

解决异常 3:解决

然后又找到了这么一篇例子丰富的文章,看到一半突然想起了,既然要给任意文字编码成 ASCII 字符集内的,那就用 base64 啊,但是浏览器又不懂 base64 没办法给还原回来啊……

唉??不是有个东西叫 URL 编码吗?迅速翻了翻例子丰富的文章,还真提了一句……

  1. from urllib.parse import quote
  2. self.set_header("Content-Disposition", "attachment; filename=%s" % quote('tes的t.txt'))

一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

我爱 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 源代码中发现了问题的根源。跟着我的节奏走!先看下抓包的结果吧:

Python 2 未编码
一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

Python 2 URL 编码
一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

Python 3 URL 编码
一次找不到错误的巨坑的http header的url编码的Python 3迁移问题

我们知道问题是由 set_header 引起的,所以看一下 set_header 的源代码:

  1. def set_header(self, name, value):
  2. # type: (str, _HeaderTypes) -> None
  3. """Sets the given response header name and value.
  4.  
  5. If a datetime is given, we automatically format it according to the
  6. HTTP specification. If the value is not a string, we convert it to
  7. a string. All header values are then encoded as UTF-8.
  8. """
  9. self._headers[name] = self._convert_header_value(value)

非常清楚的看到调用了一个_convert_header_value(),继续追溯

  1. def _convert_header_value(self, value):
  2. # type: (_HeaderTypes) -> str
  3.  
  4. # Convert the input value to a str. This type check is a bit
  5. # subtle: The bytes case only executes on python 3, and the
  6. # unicode case only executes on python 2, because the other
  7. # cases are covered by the first match for str.
  8. if isinstance(value, str):
  9. retval = value
  10. elif isinstance(value, bytes): # py3
  11. # Non-ascii characters in headers are not well supported,
  12. # but if you pass bytes, use latin1 so they pass through as-is.
  13. retval = value.decode('latin1')
  14. elif isinstance(value, unicode_type): # py2
  15. # TODO: This is inconsistent with the use of latin1 above,
  16. # but it's been that way for a long time. Should it change?
  17. retval = escape.utf8(value)
  18. elif isinstance(value, numbers.Integral):
  19. # return immediately since we know the converted value will be safe
  20. return str(value)
  21. elif isinstance(value, datetime.datetime):
  22. return httputil.format_timestamp(value)
  23. else:
  24. raise TypeError("Unsupported header value %r" % value)
  25. # If \n is allowed into the header, it is possible to inject
  26. # additional headers or split the request.
  27. if RequestHandler._INVALID_HEADER_CHAR_RE.search(retval):
  28. raise ValueError("Unsafe header value %r", retval)
  29. 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 行左右:

  1. if PY3:
  2. lines.extend(l.encode('latin1') for l in header_lines)
  3. else:
  4. lines.extend(header_lines)
  5.  

其中 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 的。我也很纠结,我也觉得没办法啊。


文章版权归原作者所有丨本站默认采用 CC-BY-NC-SA 4.0 协议进行授权 |
转载必须包含本声明,并以超链接形式注明原作者和本文原始地址:
https://dmesg.app/python2-3-port.html
喜欢 (16)
分享:-)
关于作者:
If you have any further questions, feel free to contact me in English or Chinese.
发表我的评论
取消评论

                     

去你妹的实名制!

  • 昵称 (必填)
  • 邮箱 (必填,不要邮件提醒可以随便写)
  • 网址 (选填)
(4) 个小伙伴在吐槽
  1. 结果不但你的提交真的三天没被理,而且
    测试失败了!
    测试失败了!!
    测试失败了!!!
    (重要的事三遍)
    布偶君 2018-04-21 10:20 回复
    • ?? 理了理了
      Benny 小土豆 2018-04-22 18:54 回复
  2. Windows 啊,那自求多福吧??
    ホロ 2018-04-17 16:53 回复
    • ? 没办法,没到能换 MacBook 的等级?
      Benny 小土豆 2018-04-17 16:56 回复
您直接访问了本站! 莫非您记住了我的域名. 厉害~ 我倍感荣幸啊 嘿嘿