为什么 (number).toString(32) 的结果与其他 Base32 编码器实现不同?

Why is the result from (number).toString(32) different from other Base32 encoder implementations?

背景

我想使用 Douglas Crockford 的 Base32 实现对我在 Web 应用程序中创建的随机整数进行编码,如下所述 URL https://www.crockford.com/base32.html。我曾计划自己构建编码器作为学习练习,但 'lower level' 细节为我打开了一点潘多拉魔盒。

问题

  1. 使用我尝试过的 Base32 实现对 "12345" 进行编码(例如 https://github.com/agnoster/base32-js)导致 "64t36d1n".
  2. 使用 (12345).toString(32) 编码相同的数字会得到 c1p,这对我来说更有意义,因为它更短(这是我的目标)。

我的直觉是不同之处在于操作字符串而不是数字。然而,检查我尝试过的实现代码显示,无论如何,它们使用类似于 byte.charCodeAt(0) 的东西将字符串转换为整数,所以天真告诉我这是相同的。

我会使用 2.,除了我想控制字母表(例如省略 U、I 等)这一事实。如果有知情人士可以帮助我指明正确的方向并帮助我提高对该主题的理解,我将不胜感激。非常感谢。

混淆可能源于这样一个事实,即当您说“base 32”时,您可能指的是两种不同的东西(尽管密切相关)。

事情 1:数字基数

表示数字的结构方式,定义单个“数字”有多少个不同的符号选项。我们人类通常使用以 10 为基数的 10 个不同符号 (0-9) 来表示我们的数字,但您也可以使用以 2 为基数的二进制(仅使用符号 0 和 1),以 8 为基数的八进制(使用符号 0-7 ) 等等。参见 base/radix on Wikipedia。对于大于 10 的碱基,您通常使用字母,从第 11 个符号 A 开始。例如,十六进制(基数 16)使用符号 0-9 和 A-F。

因为除了 0-9 的 10 个符号之外,我们只有 26 个不同的字母可以使用,所以在大多数情况下,只定义了最多为 36 进制的表示。如果你尝试 运行 12345..toString(40) 你会因为这个原因得到 RangeError: toString() radix argument must be between 2 and 36

现在,以这种方式(使用符号 0-9 和 A-V)表示 32 进制数 12345 将得到 C1P,因为 C 的值为 13,1 有值 1 和 P 的值为 25,而 13 * 32^2 + 1 * 32^1 + 25 * 32^0 等于 12345。这只是一种使用 32 个不同的符号而不是10,我不会这样称它为“编码”。

如果基数大于 10,这将导致 比常规基数 10 更短1(或等长)表示.

事情 2:baseN 编码

术语“baseN”编码如“base64”(最著名的此类编码)表示八位位组流的编码(使用“字母表”(一组允许的符号)的 8 位二进制 2 数据的字节流,其中 N 指定字母表有多少个符号3。这用于在不允许使用字节中所有可能值的介质上存储或传输任何八位字节流(无论其内容如何)(例如,诸如电子邮件之类的介质只能包含文本,或 URL 不允许某些特殊字符,例如 /? 因为它们具有语义等 - 甚至是一张纸,因为符号0O 以及 Il1 不能 可靠地 使用而不会有危险人类回读时它们之间的混淆)。

现在是标记与第一个“事物”的关系的部分:转换的工作方式可以想象为将输入字节变成一个巨大的数字,并更改其基数但使用编码定义的字母表,不一定是数字后跟字母。 here.

可以找到很好的视觉解释

“将输入字节变成一个巨大的数字”部分是您提到的 charCodeAt 发挥作用的地方:例如,我可以将字符串 ABC 变成数字 4276803,这在以十六进制表示形式查看字节时变得更加明显,因为一个字节可以有 256 个值,这个范围恰好适合两个十六进制“数字”的精确范围 (0x00-0xFF4)。 ABC中的三个字节5的十六进制值分别为0x65、0x66和0x67,如果我把它们并排放在一起可以看成一个大数0x656667 = 4276803.

与第一个“事物”的另一个重叠是,在密码学中,非常大的数字发挥作用,而且通常这些数字也使用 base32、base58 或 base64 等机制进行编码,但除非编程语言 and/or 处理器有一个适合大数字的数据 type/register,此时数字已经再次表示为某种八位字节流(与我刚才描述的相反,尽管有时字节顺序相反) .

当然这只是概念上它是如何完成的,否则一旦我们谈论编码不是 3 个字节而是 3,000,000 个字节,算法将不得不处理巨大的数字。实际上,使用涉及位移等的巧妙方法可以顺序地在任何长度的数据上实现相同的结果。

因为您习惯看到的“字符串”(暂时忽略 Unicode)可以粗略地与以 256 为基数表示的字节数字进行比较(一个符号代表 256 中可能的每个值)一个字节),这意味着任何这样的 baseN 编码将使输出 longer 因为新的“radix”低于 256。请注意将 12345 放入 base32 算法将意味着 string 12345 在我上面的解释中可以被视为数字 211295614005(或 0x3132333435)。这样看,64t36d1n(你从base32得到的)肯定比10进制的211295614005短,所以这一切又有意义了。

重要说明:如果您的输入数据由于其长度而无法在没有填充的情况下准确映射到其新表示,则此解释并不完全正确。例如,一个 3 字节长的数据块占用 3*8=24 位,并且每个符号使用 6 位的 base64 表示很容易实现,因为这些符号中正好有四个也会占用 4*6=24 位。但是一个 4 字节长的数据块占用 4*8=32 位,因此需要 5.333... base64 中的符号 (5.333...*6=32)。为了“填充”剩余数据 space,使用了某种填充6,因此我们可以将其四舍五入到 6 个符号。通常,填充被添加到输入数据的 end,这就是现实与我上面的“改变大数基数”概念不同的地方,因为在数学中你会期望 leading 零作为填充。


道格拉斯·克罗克福德的 base32

解决您最初的问题:

Douglas Crockford 的 base32 算法实际上是为 numbers 设计的,但是使用修改后的字母表,它不会像程序员习惯的那样将八位字节流作为输入。所以它更像是上述两件事的中间地带。你是对的 toString(32) 达到了你需要的一半,但是你必须在基数 32(0-9,A-V,不区分大小写)和 Crockford 的(0- 9 和 A-Z 但没有 I、O 和 U,不区分大小写,解码时将 I 映射到 1,将 O 映射到 0)。

来回替换这些东西已经足够复杂了,我想自己从头开始编写算法而不是依赖 toString.

会更干净(也更有教育意义)

(此外,Crockford 在末尾提出了一个额外的“检查符号”,这超出了此处的解释范围。)


脚注:

1: 这是假设整数。如果你有分数,那么事情就大不相同了,因为你可以在新基数中获得循环小数,而这些数字在旧基数中没有循环小数,反之亦然。例如,基数 32 中的 0.1 是 0.36CPJ6CPJ6CPJ6CPJ...这是一个 无限长 数字(在该特定表示中)。

2: 这里的“二进制”一词不是指以基数2表示,而是指“任何一种可以使用全范围的数据”每字节 0-255 的值,不限于表示 ASCII 范围 32-126 中人类可读文本的值。

3: 请注意,仅从 N 无法推断出字母表到底是什么,只有多长时间。众所周知的编码具有普遍接受的关于使用哪种字母表的约定,例如 base64 和 base58(后者通常用于加密货币地址,顺便说一下,它的字母表甚至不按字母顺序排列)。请注意,即使对于 base64,也有像 base64url 这样的变体,它会稍微改变字母表。其他如 base32 还没有一个普遍接受的字母表,这就是为什么你链接的网站提到“这是 a base32 编码而不是 the base32 编码”- 值得注意的是它 与 Crockford 的字母表相同。

4: 前缀0x通常用于表示以下符号将被解释为以 16 为基数的数字(十六进制) ) 而不是基数 10.

5: 我在这里谈论的是字节,因为这是 baseN 算法所使用的,但实际上字符串是基于 个字符 而不是字节,并且它们还可能包含数值超过 255 的 Unicode 字符,因此不再适合单个字节。通常,字符串首先使用 UTF-8 之类的字符编码编码为字节,然后对这些字节执行 baseN 编码。

6: base64 使用 = 作为填充,并保留使用了多少个填充字符的信息,相同数量的 = 字符也附加到输出(= 不在 base64 的字母表中)。