你好!欢迎来到每年一次的博客文章!今年,我想研究一下我在工作中遇到的最奇怪的错误。为了做好准备,让我们从各种背景开始。 📚
当我们使用非标准端口编写URL时,请在:之后指定端口。使用主机名和IPv4地址,这很简单。这是一些Python代码,以显示它是多么容易。
>>> url = urllib.parse.urlparse(..." https:// fdc8:bf8b:e62c:abcd:1111:2222:3333:4444:8000" ...)...>> > url.hostname' fdc8' >>>尝试:... url.port ...除了ValueError作为错误:... print(错误)...端口无法转换为' bf8b:e62c:abcd:1111:2222:3333 :4444:8000'
由于IPv6地址使用"十六进制"格式,十六进制字段之间用:分隔:我们不能区分端口与普通字段。请注意,在上面的示例中,主机名在第一个:之后被截断,而不是在8000之前被截断。
幸运的是,URL规范认识到了这种歧义,并为我们提供了一种处理方法。 RFC 2732(URL中的文字IPv6地址格式)说
要在URL中使用原义IPv6地址,应在" ["和"]"字符。
从ipaddress导入ip_address def safe_host(host):如果是IPv6地址,请用括号将" host包围起来。"尝试:如果ip_address(host).version == 6:返回" [{}]" .format(host),除了ValueError:传递返回主机
在代码的其他地方,也调用了类似的方法,以便可以安全地插入主机名,IPv4地址和IPv6地址。
def test_safe_host_with_hostname():"""主机名应保持不变。"""断言safe_host(" node.example.com")==" node.example.com" def test_safe_host_with_ipv4_address():""" IPv4地址应保持不变。"""断言safe_host(" 192.168.0.1")==" 192.168.0.1" def test_safe_host_with_ipv6_address():""" IPv6地址应用方括号括起来。"""断言(safe_host(" fdc8:bf8b:e62c:abcd:1111:2222:3333:4444")==" [fdc8:bf8b:e62c:abcd:1111:2222:3333:4444] ")
谢天谢地,他们做了。 Python 2测试失败(不要那样看着我😒)。
in py27 in 1失败。 83秒✔确定py36 in 2。 82秒✔确定py37 in 2。 621秒✔确定py38 in 2。 524秒✔确定py39 in 2。 461秒
主机名和IPv6地址测试均失败。但是为什么它们失败了,为什么Python 3测试通过了呢? 🤔
失败表明node.example.com被方括号包围,但这仅应发生在IPv6地址上!让我们打开一个Python 2解释器以进行快速的健全性检查。
如果您像Jeff Bridges一样对结果感到困惑,请放松。我们很可能不在Bizarro世界中,其中node.example.com是有效的IPv6地址。必须对此行为做出解释。
当我们自己看到ip_address()函数的结果时,事情开始变得更加清晰。
>>>尝试:... ipaddress.ip_address(" node.example.com")...除了ValueError作为错误:...打印(错误)...' node.example.com&# 39;似乎不是IPv4或IPv6地址
Python 3知道这不是IPv6地址,所以Python 2为什么不呢?答案是两个Python版本在处理文本方面有何不同。
计算机不会像人们认为的那样对文本进行操作。它们以数字运算。这就是为什么我们要以IP地址开头的部分原因。为了用计算机表示人类可读的文本,我们必须给数字赋予含义。因此,ASCII诞生了。
ASCII是一种字符编码,这意味着它指定如何将字节解释为我们理解的文本(假设您说英语)。因此,当您的计算机看到二进制形式的01101110(十进制为110)时,您会看到n,因为这就是ASCII所表示的意思。
实际上,使用哪种编号系统都没有关系。如果指定了二进制,八进制,十进制,十六进制等,则...如果可以将其理解为正确的整数,则它将正确显示。
只是为了咯咯地笑,让我很幽默,让我们看一下node.example.com的字符编号转换。我们将省略二进制和八进制,因为它们使此表比原来更丑。
嘿,请稍等...如果您侧向倾斜头并斜视那行看上去有点像IPv6地址,不是吗?
我们必须进行验证,以便绝对确定。您仍然可以打开Python 2解释器,对吗?
>>> #将主机名中的字符转换为十六进制。 >>>主机名=" node.example.com" >>> hostname_as_hexadecimal ="" .join(hex(ord(c))[2:] for hostname中的c)>> hostname_as_hexadecimal' 6e6f64652e6578616d706c652e636f6d' >>>>> #转换" IP地址"到文本。 >>>地址= ipaddress.ip_address(主机名)>> str(地址)' 6e6f:6465:2e65:7861:6d70:6c65:2e63:6f6d' >>>>> #从该文本中删除冒号。 >>> address_without_colons = str(地址).replace(":","")>> address_without_colons' 6e6f64652e6578616d706c652e636f6d' >>>>> #比较结果,看看结果是否相等。 >>> hostname_as_hexadecimal == address_without_colons是
果然,当您将它们都煮成数字时,它们都是十六进制的一团糟。
如果我们深入研究ipaddress模块的Python 2版本的源代码,我们最终会遇到一些奇怪的问题。
#如果isinstance(address,bytes)从压缩地址构造:self._check_packed_address(address,16)bvs = _compat_bytes_to_byte_vals(address)self ._ip = _compat_int_from_byte_vals(bvs,' big')return
事实证明,在某些条件下,ipaddress模块可以从原始字节创建IPv6地址。我的假设是,它提供了这种行为,作为从离线数据中解析IP地址的便捷方法。
node.example.com是否满足那些特定条件?你敢打赌。因为我们使用的是Python 2,所以它只是字节,恰好是16个字符长。
>>> isinstance(" node.example.com",字节)True>>> #`self._check_packed_address`基本上只是检查它有多长时间。 >>> len(" node.example.com")== 16真
其余的ipaddress行表示将字节序列解释为big-endian整数。魔术最适合留给另一篇博客文章,但要点是node.example.com的十六进制解释被压缩为一个巨大的数字。
这绝对是一个很大的数字,但并不是那么大,以至于它无法容纳在IPv6地址空间中。
事实证明,如果您的解释自由,则node.example.com可以是IPv6地址!
关于数字的一句报价引自W.E.B. DuBois,但这实际上来自Harold Geneen的书《 Managing》。
掌握了数字之后,实际上,您将不再是阅读数字,而是阅读书籍时读的单词。您将阅读含义。
我可能没有读过这本书,但很可能是出于上下文的考虑,但我认为这很适合我们的情况。
如上所述,我们可以自由地将字符转换为数字并重新返回。问题的根源在于,当我们使用Python 2时,它将文本视为字节。没有更深的内在含义。字节可能是ASCII,也许是长整数,也许是bean IP地址。这些字节的解释取决于我们。
Python 2默认不区分字节和文本。实际上,字节类型只是str的别名。
为了更具体一点,请参阅Python 2如何将n视为与此原始字节序列相同。
我们的Python 2代码无法按我们希望的方式工作,因为原始字节可以具有任意含义,并且我们还没有告诉它使用我们想要的含义。
因此,现在我们知道了为什么Python 2将node.example.com解释为IPv6地址,但是为什么Python 3的行为有所不同?更重要的是,如何使两者融为一体?
在1960年代,ASCII看起来是个好主意。经过几十年的事后分析,我们知道扩展ASCII提供给我们的256个字符不足以处理世界上所有的书写系统。因此,Unicode诞生了。
有大量的博客文章,Wikipedia文章和技术文档会比我详细解释Unicode更好。如果需要,您应该阅读它们,但这是我的主旨。
Unicode是一组字符编码。 UTF-8是主要的编码.UTF-8与ASCII重叠,因此ASCII字符仍然只是一个字节。为了处理大量其他字符,多个字节可以表示单个字符。
>>> " n" .encode(" utf-8").hex()#1个字符(U + 006E),1个字节。 ' 6e' >>> "🤿" .encode(" utf-8").hex()#1个字符(U + 1F93F),4个字节。 ' f09fa4bf' >>> "悟り" .encode(" utf-8").hex()#2个字符(U + 609F,U + 308A),6个字节。 ' e6829fe3828a'
我所知道的每种编程语言都尊重原始字节和Unicode文本之间的差异,这两种数据类型之间保持严格的分隔。
在Python 3中,默认情况下会启用此严格分隔。请注意,它不认为n与原始字节序列是同一回事。
如果我们可以像Python 3一样让Python 2理解Unicode,那么我们就可以修复我们的错误。
另外,如果您想了解更多有关如何在Python中处理Unicode的信息,请查看Ned Batchelder关于实用Unicode的演讲。
Python 2实际上确实了解Unicode,但是它认为Unicode文本与" normal"是分开的。文本。在Python 2历史记录中的某个时候,unicode类型被固定在该语言的一侧,并且默认情况下未启用。很难对此感到兴奋,但是它确实成功了。至少他们知道总是一直键入unicode()是一件很麻烦的事,因此使用u前缀可以方便地实现文字语法。
这不是最好的解决方案,但确实很关键。我们立即添加了将主机名转换为Unicode的行。我们还将相同的变换应用于带方括号的行。这样,我们始终将主机名处理为Unicode,并且始终返回Unicode值。
def safe_host(host):如果主机是IPv6地址,请用括号将" host括起来;""" + host = u" {}" .format(host)尝试:if ip_address(host).version == 6:-返回" [{}]" .format(host) +返回u" [{}]" .format(host),但ValueError:通过
对我们来说幸运的是,u前缀也可以在Python 3中使用,而unicode()则不能(因为默认情况下所有文本都是Unicode,所以该类型不存在任何业务)。在Python 3中,将u视为无操作。
当我们使用unicode类型时,ipaddress模块不再尝试将node.example.com解释为字节并将这些字节转换为IP地址。我们得到了我们所期望的
>>>尝试:... ipaddress.ip_address(u" node.example.com")...除了ValueError作为错误:... print(error)... u' node.example。 com'似乎不是IPv4或IPv6地址
✔确定py27 in 1。 728秒✔确定py36 in 2。 775秒✔确定py37 in 2。 717秒✔确定py38 in 2。 674秒✔确定py39 in 2。 506秒
我在上面提到我们的解决方案不是最好的。如果有更多时间,我们该如何做呢?
这里的第一个(也是最好的)解决方案是放弃对Python 2的支持。 2020now和Python 2正式不再受支持。原始代码适用于Python3。最好的长期决策是迁移代码使其仅在Python 3上运行,并避免Python 2维护的麻烦。不幸的是,运行此代码的许多人仍然依赖于在Python 2上运行的代码,因此我们必须进行适当的过渡。
如果在短期内无法从Python 2迁移出去,那么接下来要做的就是更新我们的代码,以便它使用诸如future或6这样的兼容性层。这些库旨在使Python 2现代化,并帮助解决此类问题。
对我们来说,从亚历克西斯·金(Alexis King)的Parse翻页也不会感到伤害,也不必验证思想流派。当主机名通过用户输入进入我们的程序时,应立即将其转换为unicode类型(甚至IP地址类型),因此在整个代码中,我们不会在多个不同位置解决此问题。
最后,尽管我们的程序当前不使用英语以外的其他语言来处理任何主机名,但无论如何,最好还是以Unicode的方式进行思考。同样,2020年和https://andндекс.рф之类的国际化域名也很重要。
如果您到现在为止,请多谢阅读。将与我的同事进行的简短调试会议变成关于Python 2的危险和Unicode的价值的论文很有趣。明年再见! 😂