JSON、Unicode和Perl……哇!

考虑以下代码

use Mojo::JSON;
use Cpanel::JSON::XS;
use Data::Dumper;
$Data::Dumper::Useqq = 1;

my $e_acute = "\xc3\xa9";

my $json = Mojo::JSON::encode_json([$e_acute]);
my $decoded = Cpanel::JSON::XS->new()->decode($json)->[0];
print Dumper( $json, $decoded );

你可能认为这只是一个合理的往返,只使用了两个不同的JSON库,Mojo::JSONCpanel::JSON::XS。然而,实际上,当你运行这段代码时,你会看到上面的$decode实际上是"\x{c3}\x{83}\x{c2}\x{a9}",而不是我们一开始的"\xc3\xa9"。

现在反转编码器/解码器模块

use Mojo::JSON;
use Cpanel::JSON::XS;
use Data::Dumper;
$Data::Dumper::Useqq = 1;

my $e_acute = "\xc3\xa9";

my $json = Cpanel::JSON::XS->new()->encode([$e_acute]);
my $decoded = Mojo::JSON::decode_json($json)->[0];
print Dumper( $json, $decoded );

现在$decode只是"\x{e9}"。这里发生了什么?

字符串中有什么?

为了理解上述内容,我们首先必须掌握Perl字符串是什么,从根本上来说。与C字符串不同,Perl字符串不仅仅是字节数组……但与Python 3字符串不同,Perl字符串也不是Unicode字符的数组。相反,Perl字符串是“code points”的数组,这些“code points”属于一个未定义的字符集。

特别是,与Python、JavaScript和许多其他流行的编程语言不同,Perl字符串不会区分“二进制”和“文本”。例如,如果Perl从一个二进制文件句柄中读取字节数据0xff、0xfe、0xfd和0xfc,Perl从这些4个字节创建的字符串被认为是包含4个code points,而不是4个字节,没有参考任何特定的字符集,存储在抽象的、内部使用的编码中。(实际上,Perl解释器可能会使用4个字节来存储字符串,但这将是实现细节,与解释的Perl代码无关。)

这一点必须强调:Perl不在乎——并且不想在乎——一个给定字符串的code points代表字节还是字符。(稍后将有更多讨论。)

回到JSON

在上面的例子中,我们比较了使用不同库进行编码和解码的往返。让我们进一步挖掘,只比较编码后的JSON

use Mojo::JSON;
use Cpanel::JSON::XS;
use Data::Dumper;
$Data::Dumper::Useqq = 1;

my $e_acute = "\xc3\xa9";

my $mojo_json = Mojo::JSON::encode_json([$e_acute]);
my $cp_json = Cpanel::JSON::XS->new()->encode([$e_acute]);
print Dumper( $mojo_json, $cp_json );

这将打印

$VAR1 = "[\"\303\203\302\251\"]";
$VAR2 = "[\"\x{c3}\x{a9}\"]";

(注意,Data::Dumper使用八进制转义输出一个字符串,另一个使用十六进制。这反映了Perl解释器实现的另一个细节,现在与我们无关。)

我们的输入字符串包含两个code points,0xc3和0xa9。回想一下,与这些code points没有特定的字符集相关联;它们只是数字。然而,JSON是纯Unicode的——最新的标准明确要求UTF-8编码。因此,我们需要将这些“无字符集”的code points转换为UTF-8才能编码成JSON。但是如何做到这一点呢?

严格来说,我们做不到。这就像试图将5“货币单位”转换为美元:我们需要知道实际来源的货币(比特币?欧元?)才能得到答案。同样,在Perl中,为了用UTF-8表达我们存储的“code points”,我们需要知道这些code points代表什么字符。例如,你的Perl字符串可能存储code point 142……但是哪个字符是那个?Perl不知道,也不关心。没有定义的字符集,code point只是一个数字。

为了解决这个问题,我们的JSON库对字符串的code points代表什么做出了合理的——尽管不一定正确的——假设。

Mojo::JSON假设我们的两个原始code points是Unicode。这意味着Mojo::JSON认为我们给了它字符U+00C3(Ã)和U+00A9(©)。编码后的JSON中从2个code points扩展到4个的原因是Mojo::JSON将我们的code points编码为UTF-8:U+00C3变为Perl code points 0303(0xc3)和0203(0x83),而U+00A9变为0302(0xc2)和0251(0xa9)。

Cpanel::JSON::XS 做出了不同的假设,适合不同的解释:这个编码器假设我们的2个原始代码点代表最终JSON中应该包含的任何字符字节。与Mojo::JSON不同,它没有关于期望编码的假设,这允许调用者完全控制编码。

(这种灵活性允许编码器的调用者选择,例如,使用UTF-16而不是UTF-8来编码JSON。在最新的JSON规范之前,这更有意义,该规范要求在封闭系统之外使用UTF-8。)

同样的行为差异也适用于我们的两个解码函数。它们也面临一个“无法解决的”问题,这是编码的反面。它们的解决方案与编码器相似。

use Mojo::JSON;
use Data::Dumper;
$Data::Dumper::Useqq = 1;

my $from_mojo = "[\"\303\203\302\251\"]";
my $from_cp = "[\"\x{c3}\x{a9}\"]";

$from_mojo = Mojo::JSON::decode_json($from_mojo)->[0];
$from_cp = Mojo::JSON::decode_json($from_cp)->[0];
print Dumper( $from_mojo, $from_cp );

这将打印

$VAR1 = "\x{c3}\x{a9}";
$VAR2 = "\x{e9}";

回想一下,Mojo::JSON的编码器将其输入解释为Unicode,其输出代码点代表UTF-8的字节。上面你会看到,它的解码器做的是相反的事情:它将其输入解释为UTF-8的字节,并输出被认为是Unicode的代码点。这意味着如果输入包含任何大于127(0x7f)的代码点,输出的代码点数量将小于输入的数量,因为UTF-8将它们表示为多个字节。

至于Cpanel::JSON::XS

use Mojo::JSON;
use Data::Dumper;
$Data::Dumper::Useqq = 1;

my $from_mojo = "[\"\303\203\302\251\"]";
my $from_cp = "[\"\x{c3}\x{a9}\"]";

$from_mojo = Cpanel::JSON::XS->new()->decode($from_mojo)->[0];
$from_cp = Cpanel::JSON::XS->new()->decode($from_cp)->[0];
print Dumper( $from_mojo, $from_cp );

这给出

$VAR1 = "\x{c3}\x{83}\x{c2}\x{a9}";
$VAR2 = "\x{c3}\x{a9}";

decode() 方法,就像 encode() 一样,假设调用者将手动处理编码,因此只是简单地复制代码点。

附带说明:UTF-8的假设

Mojo::JSON编码到UTF-8的行为有先例:Perl本身!

你可能遇到过类似这种情况

> perl -e'print "\x{100}"'
Wide character in print at -e line 1.
Ā

对于0-255的代码点,Perl将其作为八位字节输出代码点,但当你要求输出超过255的代码点时,显然这是不行的。在这种情况下,Perl假设你想使用UTF-8,但会抛出“宽字符”警告来提示你注意到你遗漏了某些东西——在这种情况下,你忽略了将代码点256编码为字节。

滥用系统

Cpanel::JSON::XS的 encode() 允许使用非标准的JSON:字面二进制数据。考虑以下

perl -MCpanel::JSON::XS -e'print Cpanel::JSON::XS->new()->encode(["\xff"])'

… 将输出5个字节:[",0xff,"]。这是无效的JSON,因为没有任何Unicode编码(更不用说UTF-8)将字符编码为单个0xff字节。只有理解这种“字面二进制”JSON变体的特殊解码器才会将其按预期解析。这种依赖于自定义操作模式的做法削弱了JSON作为广泛支持的标准的有用性——这最初可能看起来不错,但如果你应用程序的规模扩大,很容易出现问题。

需要序列化包含任意八位字节的字符串(即二进制)的应用程序应在JSON编码之前对字符串应用二级编码(例如,Base64)。或者,更好的是,选择一个对二进制友好的编码,如CBOR

关于帘子后面的那个标志…

如果你将我们的两个编码方法的输出传递给Devel::Peek,你会看到Mojo::JSON的输出是这样的

SV = PV(0x7fdc27802f30) at 0x7fdc27e59c58
  REFCNT = 1
  FLAGS = (POK,pPOK)
  PV = 0x7fdc28826350 "[\"\303\203\302\251\"]"\0
  CUR = 8
  LEN = 34

… 而Cpanel::JSON::XS的输出是这样的

SV = PV(0x7fc0cd004d30) at 0x7fc0cd016228
  REFCNT = 1
  FLAGS = (POK,pPOK,UTF8)
  PV = 0x7fc0cce2ef60 "[\"\303\203\302\251\"]"\0 [UTF8 "["\x{c3}\x{a9}"]"]
  CUR = 8
  LEN = 34

注意后者中的UTF8标志。这告诉我们Perl内部存储字符串代码点使用UTF-8编码。这就是为什么我们之前看到Data::Dumper使用八进制转义来编码Mojo::JSON的输出,而Cpanel::JSON::XS使用十六进制的原因:Data::Dumper识别UTF8标志并根据它渲染输出。

不过,正如perldoc perlunifaq 所明确指出的,UTF8标志不是供Perl代码消费的。Perl应用程序应将字符串视为简单的代码点序列,而无需考虑Perl解释器如何在内存中存储这些字符串。

话虽如此,在有限的环境下,通过将带UTF8标志的字符串视为“字符字符串”,而将不带UTF8标志的字符串视为“字节字符串”,可以模仿像Python和JavaScript这样的语言中字符串类型的区别——实际上,包括两个我自己写的在内的多个 序列化器 CPAN上,确实如此。但这并不是使用Perl字符串的支持模型,任何依赖于它的代码在不同的Perl版本中可能会有不同的表现。小心行事!

寻求和平

JSON和Perl并不匹配。例如,Perl缺少明确区分数字和字符串的类型,可能会导致JSON使用错误的类型来存储一个值或另一个值。Perl缺少原生布尔类型也会产生类似的效果。

然而,上述编码问题特别有害,因为要适应这些问题,需要很好地理解所有上述内容。大多数开发者可以轻松地处理类似于{"age": "9"}的内容,因为将"9"(字符串)转换为9(数字)是常见的。但有多少人看到"é"会想,“啊!我必须将这些字符的代码点视为字节,然后将这些字节解码为UTF-8!”当然,有些人会这样做——也许甚至很多人——但可能比那些可以轻松地将"9"转换为9的人要少。

二进制友好的编码,如CBOR,可以减轻这个问题,因为无论解码Perl源数据的什么都能更容易地识别出需要从二进制解码。当然,不知道字节和编码的人会很快学会!从根本上说,即使CBOR也并不完全适合Perl的“纯代码点”字符串模型,因为CBOR在二进制和文本字符串之间有很强的区分,而Perl没有。

最终,Perl的数据模型,尽管它为我们提供了很多便利,但与其他许多语言的通信仍然是一个挑战。我们能做的最好的就是预见这些问题,并在出现时解决它们。

后记:JSON的替代品

在我的经验中,JSON无法存储任意字节字符串是其最大的缺点,但还有其他原因让我经常避免使用JSON。

  • 它无法存储注释,并且禁止使用尾随逗号,这使得它在人类维护的数据结构中变得尴尬。

  • 它的\uXXXX转义只支持Unicode的BMP中的字符;要存储emoji或其他非BMP字符,你必须直接编码为UTF-8或在\uXXXX转义中指示UTF-16代理对(这是什么意思?)。

  • 与二进制格式相比,它效率较低。

TOML是一种适合人类维护的数据结构的良好序列化格式。它是按行分隔的,当然!——允许注释,并且任何Unicode代码点都可以用简单的十六进制表示。TOML相当新,其规范仍在变化;但无论如何,它已经支撑了许多知名软件项目,如Rust的Cargo包管理器和Hugo——这是本站所使用的!CPAN托管几个 TOML实现

上述的CBOR在JSON的效率上进行了改进,并允许存储二进制字符串。与JSON编码器必须将数字字符串化并对所有字符串进行转义相比,CBOR直接存储数字,并在字符串前加上它们的长度前缀,从而消除了转义这些字符串的需要。这大大简化了编码和解码。与TOML和YAML一样,CPAN托管了多个 CBOR 实现。(完全坦白:其中两个是我自己编写的。)

Sereal 是一种优秀的 JSON 替代品,它提供了 CBOR 的多数优点,甚至可以序列化像正则表达式这样的“Perl 特定”项目。这使得它非常适合 Perl 之间的进程间通信(IPC)。参考实现是 CPAN 上的 Sereal 发行版。不过,Sereal 在 Perl 之外的支持并不像 CBOR 那样好,所以如果您需要与非 Perl 代码通信,Sereal 可能不适合您。

YAML 是另一种人类可以轻松维护的格式。与 TOML 不同,YAML 支持二进制字符串;事实上,它足够灵活,在很多情况下可以替代 Data::Dumper。CPAN 包含多个实现 YAML 的库,如 YAML::XSYAML::PPYAML::Old

感谢阅读!

标签

费利佩·加斯珀

费利佩·加斯珀自 2000 年代初开始编写 Perl,目前定期为 cPanel, L.L.C. 提供咨询服务。他还是一名经验丰富的合唱指挥、歌手和风琴演奏家。在业余时间,他喜欢看电影、戏剧、“沙发语言学”和游戏。

浏览他们的文章

反馈

如果这篇文章有什么问题,请通过在 GitHub 上打开一个问题或拉取请求来帮助我们。