Requests post 重复键丢失 bug 小记

图文无关, 单纯是我拍的照片

前情:

我有一段大一时候写的代码, 用于自动完成学校的经典阅读试卷, 最初版本参照 @左易方 学长的博客, 并摒弃了他字符串拼接式的提交让代码更清晰.

之后我做了更多, 我使用了 Flask 完成了一个原网页的代理, 以此公开我所修改一些内容, 大概流程就是对于每一个请求, 如果是不需要变动的, 那么使用 requests 原样转发, 如果是则按需变动.

在最终提交部分, 有一个这样的 post (的确是post, 而且是用 JavaScript 字符拼接得到的)

1
26866=94166&26866=94167&26866=94168&26866=94169&27352=95873&27352=95874&27352=95875&27352=95876

大致意思是

1
2
3
4
5
6
7
8
26866 对应了答案 94166
26866 对应了答案 94167
26866 对应了答案 94168
26866 对应了答案 94169
27352 对应了答案 95873
27352 对应了答案 95874
27352 对应了答案 95875
27352 对应了答案 95876

对于 Flask 得到的请求, 我采用如下方式转发

1
2
t = sess.post(url, request.form) // sess 是第一次访问页面时分配的
return t.content

到四月份一直相安无事. 直到今天坏了, 上面那一段提交后实际上得到的是

1
26866=94166&27352=95873

第一个重复项之后的都丢失了, 我第一反应就是处理的时候重复项因为未知原因被丢掉了, 大概看了看文档就知道如何做了

1
2
t = sess.post(url, request.form.lists()) // sess 是第一次访问页面时分配的
return t.content

问题到此解决, 但是——


但是我之前一直都好好的啊, 接下来是探秘.

requests 中请求的构造在models.pyprepare_body里, 构造非 stream 非 file body时, 使用的是self._encode_params(data), 核心代码如下

1
2
3
4
5
6
7
8
9
10
result = []
for k, vs in to_key_val_list(data):
if isinstance(vs, basestring) or not hasattr(vs, '__iter__'):
vs = [vs]
for v in vs:
if v is not None:
result.append(
(k.encode('utf-8') if isinstance(k, str) else k,
v.encode('utf-8') if isinstance(v, str) else v))
return urlencode(result, doseq=True)

result 在这里还是个list, 不用担心重复问题, 那么问题就一定是在for k, vs in to_key_val_list(data)了, to_key_val_listutils.py里的一个函数, 简单粗暴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def to_key_val_list(value):
"""Take an object and test to see if it can be represented as a
dictionary. If it can be, return a list of tuples, e.g.,

::

>>> to_key_val_list([('key', 'val')])
[('key', 'val')]
>>> to_key_val_list({'key': 'val'})
[('key', 'val')]
>>> to_key_val_list('string')
ValueError: cannot encode objects that are not 2-tuples.

:rtype: list
"""
if value is None:
return None

if isinstance(value, (str, bytes, bool, int)):
raise ValueError('cannot encode objects that are not 2-tuples')

if isinstance(value, Mapping):
value = value.items()

return list(value)

只关注最后几行, Mappingcollections里定义, isinstance(value, Mapping)在这里是真的, 那么问题就在value.items()或者list(value)中了.

此处我以前是直接传入的 Flask 中 request.form, 其类型是 werkzeug.datastructures.ImmutableMultiDict, 翻一翻代码可以发现, 它的items()方法继承自MultiDict, 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
def items(self, multi=False):
"""Return an iterator of ``(key, value)`` pairs.
:param multi: If set to `True` the iterator returned will have a pair
for each value of each key. Otherwise it will only
contain pairs for the first value of each key.
"""

for key, values in iteritems(dict, self):
if multi:
for value in values:
yield key, value
else:
yield key, values[0]

iteritems方法来自_compat.py

1
iteritems = lambda d, *args, **kwargs: iter(d.items(*args, **kwargs)) 

iteritems(dict, self)dict.items(self)(莫名像 Linux 的系统调用宏)

dict.items要求其参数具有leniter两个方法来生成新的 Dictionary view objects, (这是 Python 的鸭子类型的表现). 且MultiDictleniter实际上是继承自dict, 因此, len(MultiDict)时实际上是把MultiDict当成dict给出的值, 那么此处实际上是先把对象转成了dict, 然后才取其items的.

也就是说, 在一层层的转换过程中, 某一层把[('26866', '94166'), ('26866', '94167')]变成了{'26866': '94166'}, 然后又取items变成了[('26866', '94166')], 因此导致了重复键的其他值丢失.

另一方面, 这样一层层下来, 做了不少无用功, 而这些无用功很大程度上不是写代码的人造成的, 而是语言造成的, 也无怪乎 Python 的性能差了. 但是写起来舒服哇.


下周二就要考试了, 还花了半晚上 de 这个 bug, 啊, 滚去复习