Unicode

作者:Reuven Lerner

让我们对 Unicode 给予应有的肯定:这是一项杰出的发明,使我们星球上数百万甚至数十亿人的生活更加轻松。与此同时,处理 Unicode 以及之前的各种编码系统可能是一种极其痛苦和令人沮丧的体验。近几天我一直在处理一些与 Unicode 相关的挫折,所以我认为现在可能是重新审视每个现代软件开发人员,尤其是每个 Web 开发人员都应该理解的主题的好时机。

如果您不知道 Unicode 是什么,或者它如何影响您,请考虑以下情况:在 C 语言和较旧版本的 Python 和 Ruby 等语言中,字符串只不过是一堆字节。它没有任何规律;您可以将任何您想要的数据读入字符串,并且语言会对此没问题。例如,如果我启动 iPython(它使用 Python 2.7),我可以将 JPEG 图像读入字符串


s = open('Downloads/test.jpg').read()

大多数时候,您使用字符串不是为了保存 JPEG 图像,而是为了保存文本。如果您的文本都是英文,那么您很幸运,因为英语使用的所有字符都在 ASCII 中定义,ASCII 是一种定义了 128 个不同字符的标准,每个字符都有一个唯一的数字。因此,字符 65 是大写字母 A,空格字符是数字 32。ASCII 非常棒,并且运行良好——直到您想要开始使用英语以外的语言。

问题是大多数语言需要英语中不使用的字符,并且 ASCII 中未定义的字符。这意味着如果您想用法语、更不用说阿拉伯语或中文书写单词,您将无法使用 ASCII 来表示字符。

字母语言的解决方案是一组 ISO 标准(ISO 8859-*),它利用了 ASCII 仅使用 7 位,但数据以 8 位传输的事实。如果您可以利用所有 8 位,则可用字符的数量将增加一倍,从 128 个增加到 256 个。这对于具有已定义字母表的语言来说已经足够了。因此,西欧语言在 ISO-8859-1 中定义,希伯来语在 ISO-8859-8 中定义,依此类推。此外,这些 ISO 标准旨在使“外语”与英语混合使用成为可能。因此,您可以拥有包含英语和法语或英语和阿拉伯语的文档。ASCII 字符保留了其原始值,非 ASCII 字符在上 128 个中定义。

但是,当您想要拥有包含英语、阿拉伯语和法语的文档时会发生什么?在 ISO-8859 系列标准中,没有任何方法可以实现这一点。用于描述法语重音字符的相同数字也用于描述阿拉伯语字符。显示文本的程序负责决定将显示哪种语言,从而显示哪些字符。用俄语(ISO 8859-5)编写但在期望希伯来语(ISO 8859-8)的程序显示的文档将显示希伯来语字符,或者更确切地说,是乱码。

如果您使用非字母语言(例如中文),情况会更糟。即使您想使用上 128 个字符来书写中文,您也只能从使用该语言所需的字符的一小部分中进行选择。显然,还需要其他的东西,事实上,中国人(以及日本人)发明了自己的计算机文本存储系统,这些系统与 ASCII 完全不兼容。

Unicode 的设计是为了解决所有这些问题。简而言之,它为每个人类设计的字符提供自己的唯一编号,或“代码点”。这样做消除了与显示文本相关的歧义。只要程序支持 Unicode,它就不需要知道正在使用的语言系列。英语、法语、阿拉伯语和俄语都可以共存于同一页面上,字符之间没有任何干扰。此外,Unicode 支持非常多的代码点,允许中文和日文字符与字母字符共存。

编码

到目前为止,一切都很好。但是,切换到这个新系统提出了两个问题。首先,如何将这些单独的代码点(唯一标识人类创建的几乎每个字符)转换为字节?其次,对于不是用 Unicode 编写的现有文档会发生什么?

一方面,这些问题的答案相对简单明了。另一方面,答案导致了与使用 Unicode 相关的许多挫折感——不是因为 Unicode 本身不好或困难,而是因为不同的现有编码与基于 Unicode 的系统的混合可能会令人沮丧。

第一个问题,如何使用字节对各种 Unicode 字符进行编码,有多种答案。如果您使用的是支持 Unicode 的语言,您就不能再将字符视为等同于字节。相反,一个字符可能是一个字节,但也可能是多个字节。例如,在 UCS-32 编码方案中,每个 Unicode 字符使用 4 个字节。这为所有已定义的 Unicode 字符提供了足够的空间,这是一件好事,但它也破坏了与 ASCII 文档的向后兼容性,并将使用 ASCII 或任何 ISO-8859 系列编写的任何内容的大小增加了四倍。

由于这些原因,Unicode 世界中事实上的标准是 UTF-8,这是一种由著名程序员 Rob Pike 和 Ken Thompson 发明的可变长度编码方案。基本思想是,所有定义的 ASCII 字符(从 0 到 127)都保持不变。如果设置了高位(第 8 位),则表示该字符消耗一个额外的字节(即,该字符占用两个字节)。以类似的方式,后续字节使用高位来指示字符的描述尚未结束。通过这种方式,UTF-8 字符可以消耗少至一个字节(对于 ASCII 字符)或多达 6 个字节(对于真正不寻常的字符)。像中文和日语这样的语言每个字符需要 4 个字节。

UTF-8 提供了所有可能世界中最好的——ASCII 文档保持不变,字母语言使用的字节数不会过多,您可以使用 Unicode 解决歧义,并且可以表示所有 Unicode 字符。但是,它确实引入了一个新问题:字符串现在可能无效!如果您使用固定宽度的 UCS-32 系统,几乎每个字节都会指向一个有效字符。但在 UTF-8 中,可能会出现根据此编码方案无效的字节序列。

为了回到我在本文前面举的例子,假设我在 Python 3 而不是 Python 2.7 中执行以下代码


s = open('Downloads/test.jpg').read()

现在,在 Python 2.7 中,字符串只是字节的集合。如果我想使用 Unicode,我需要使用“Unicode 字符串”,str 类型的一个特殊版本,其中字符全部采用 Unicode 编码(并以 UTF-8 存储)。在 Python 3 中,默认字符串编码是 UTF-8,这意味着执行上述代码实际上会导致异常


UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in
position 0: invalid start byte

换句话说,Python 期望以 UTF-8 格式获取输入,但注意到文件开头处的字节 0xFF,这是非法的。您需要做的是告诉 Python 您想以二进制格式读取文件,方法是以“二进制读取”模式打开它


s = open('/Users/reuven/Downloads/test.jpg', mode='rb').read()

现在,鉴于您已在二进制模式下读取了文件,您将其视为字节,而不是字符串。而且,如果您询问 Python 返回的数据类型


>>> type(s)
<class 'bytes'>

换句话说,Python 不会创建非法字符串。因此,read() 不会这样做,而是返回一个字节串,它与 Python 2.x 字符串大致相同。

这涵盖了用 Unicode 编写的文件。但是,用另一种编码方案(例如 ISO-8859-5)编写的文件呢?在这种情况下,您需要将另一个参数传递给“open”,指示您应使用的编码。

Ruby 在过去几年中经历了类似的变化。Ruby 1.8 将字符串视为字节的集合,但它并没有真正考虑或关心 Unicode 和其他编码。Ruby 1.9(以及 2.0)在与 Python 类似的方向上发生了转变,因此每个字符串都具有与之关联的编码。与 Python 不同,您可以将二进制数据读入 Ruby 2.0 字符串,并且该语言可以正常工作


s = File.read('Downloads/test.jpg')

如果您询问 Ruby 返回的对象类型,它会告诉您它是一个字符串


>> s.class #=> String
>> s.encoding #=> #<Encoding:UTF-8>
>> s.valid_encoding? #=> false

但是,您可以将编码设置为其他内容


>> s.force_encoding(Encoding.find('ASCII-8BIT'))
>> s.encoding #=> #<Encoding:ASCII-8BIT>
>> s.valid_encoding? #=> true
<--pagebreak--> Web 开发和 Unicode

所有这些都很好,但它如何影响 Web 开发人员呢?同样,如果您可以神奇地拨动开关并让所有文档和计算机切换到使用 UTF-8,那么这一切都不会成为问题。但是,事实远非如此。不仅有很多文档是用非 UTF-8 格式编写的,而且还有许多计算机的编码仍然不是 UTF-8。

这意味着如果您有一个 HTML 表单并且您接受来自用户浏览器的输入,您很可能会从用户浏览器获得以其计算机正在使用的任何编码系统进行的输入。诚然,大多数现代计算机和浏览器都使用 UTF-8,但您会惊讶于存在多少旧系统。您应该尝试您的 Web 应用程序,确保即使有人以非 Unicode 系统向您发送数据,您仍然可以处理它(或优雅地处理失败)。

我最近遇到的另一个问题不是直接来自用户输入,而是用户正在上传的文件。我的 Web 应用程序以 UTF-8 运行,一切似乎都在顺利进行——直到并非如此。问题在于应用程序的一部分涉及人们上传文本文件。我会将文件内容读入字符串,然后将该字符串存储在数据库中。不幸的是,应用程序会引发异常,因为来自世界各地、使用不同语言和使用许多不同编码的人员提供的文本文件通常与 UTF-8 不兼容。一种解决方案是尝试识别上传文件的编码。在我的特定情况下,我能够捕获异常并将其报告给用户,指示仅接受 UTF-8 格式的文件。这样的错误消息是否足以满足您的应用程序取决于您正在做什么。

是的,这引出了我的下一个重点,即数据库。我工作的所有主要关系数据库和 NoSQL 数据库都支持 UTF-8 作为默认设置。例如,PostgreSQL 为每个数据库提供一个编码,指示将在文本列中使用的编码。好消息是,这确保了数据库中存储的所有文本都将是有效的 UTF-8,或者您使用的任何其他编码。坏消息(在某种程度上)是,如果您想在同一列中存储二进制数据和文本数据,您将不得不找到另一种解决方案。二进制数据(例如 JPEG 文件的内容)无法存储在文本列中,因为它不是合法的 UTF-8。相反,您需要将此类信息存储在二进制 BYTEA 列中,该列接受任何字节序列,并且不尝试确保其有效性。幸运的是,我使用的驱动程序了解 TEXT 列和 BYTEA 列之间的区别,并使用适当的数据类型返回结果。

但是,请注意编码和排序规则之间存在差异。编码是指 UTF-8(或任何其他字符集)转换为一系列字节的方式。排序规则是指文本的排序方式,因此是与语言相关的。考虑到对 100 个单词的列表进行排序在英语、西班牙语和法语中会有不同的结果,您就会明白您的应用程序的需求(和用户)将在很大程度上决定您选择使用哪种排序规则(如果有)。

结论

大约十年前,我参与了一个需要 Unicode 的多语言站点,我决定使用它,这与项目中其他人员造成了很大的摩擦,因为他们没有支持 UTF-8 的编辑器。

今天的情况已大不相同。几乎每件与 Web 相关的软件都支持 Unicode,从操作系统和语言到数据库和浏览器。但是,大量非 Unicode 计算机、程序和文件要求您将它们放在心上并能够使用它们。此外,处理二进制文件和数据意味着您需要摆脱“一切都可以是字符串”的思维模式,因为现代字符串对其允许您存储的数据很挑剔。

理解 Unicode 对于了解现代 Web 应用程序的工作原理至关重要。一旦您确保您的应用程序使用了正确的方法并在正确的位置检查了数据,它就可以与来自世界各地的用户完美配合。

资源

一般来说,字符集,特别是 Unicode,可能需要很长时间才能理解。对该主题的最佳介绍之一是 O'Reilly 出版的书籍《Java 国际化》的第一章,该书于 2001 年出版,由 Andy Deitsch 和 David Czarnecki 撰写。本书首先描述了许多不同的书写系统,然后才详细介绍这对 Unicode 的意义。

有关 Python 中 Unicode 支持的更多信息,请查看 Python 2.7.4 的“HOWTO”文档,网址为 https://docs.pythonlang.cn/2/howto/unicode 或 Python 3.x 的文档,网址为 https://docs.pythonlang.cn/3/howto/unicode。字符串中的 Unicode 支持是 Python 3 中的主要更改之一,因此请务必阅读有关您正在使用的版本的信息。

有关 Ruby 1.9.x(实际上与 Ruby 2.0 相同)中 Unicode 支持的信息,我推荐 Peter Cooper 的“Ruby 1.9 Walkthrough”,这是一个很长(但非常出色!)的截屏视频。他花费大量时间演示 Ruby 1.8 和 1.9 之间的差异,其中包含有关编码和字符串的大量详细信息。更多信息请访问 https://cooperpress.com/19walkthrough

GNU recode 程序允许您在字符集和编码之间移动文档,网址为 http://directory.fsf.org/wiki/Recode。当我在处理与 Unicode 相关的站点时,Recode 是我工具包的重要组成部分。

Reuven M. Lerner 是一位资深的 Web 开发人员,提供 Python、Git、PostgreSQL 和数据科学方面的培训和咨询服务。他撰写了两本编程电子书(《Practice Makes Python》和《Practice Makes Regexp》),并为程序员发布免费的每周新闻通讯,网址为 http://lerner.co.il/newsletter。Reuven 的 Twitter 账号是 @reuvenmlerner,与妻子和三个孩子住在以色列的莫迪因。

加载 Disqus 评论