在这篇文章中,我们将讨论并演示如何使用Ruby将UUID编码为URL友好表示。这篇文章并不假设之前有任何关于UUID的知识。相反,我们将首先讨论UUID到底是什么。我们看一下我们喜欢使用UUID而不是传统的递增整数的所有原因。
你可以期待一些二进制数学,并在你的工具带中加入一个简单而有效的编码算法。
什么是UUID
UUID(Universally Unique IDentifier)或GUID(Globally Unique IDentifier)是一种数据类型,可以取代数据库表中ID列常用的整数数据类型。UUID必须遵守RFC 4122规范所规定的格式。
UUID由32个十六进制数字组成,由连字符分隔成五组。第一组是8位数字,然后是3组4位数字,最后一组是12位数字。各组数字并不传达任何重要的意义。我们只关心整个字符串的唯一性。
产生UUIDs的算法有5种变化,旨在使同一系统产生相同序列的可能性非常小。这种唯一性保证允许分布式系统的不同部分生成标识符,而不需要由中央注册机构检查其唯一性。
一个UUID看起来像这样:4302cfd8-a080-437d-b870-28730dc67498 。注意我们前面描述的分组。这个UUID的例子显示,一个十六进制数字的范围是:0-9 或a-f 。由于每个十六进制数字可以用4位表示,我们可以看到一个UUID需要128位(32 x 4 = 128)。
在Ruby中生成一个UUID
我们可以通过使用securerandom库在ruby中生成一个UUID。与其谈论UUID,不如我们花点时间来生成一个UUID?要在ruby中生成一个UUID,我们可以遵循以下步骤。
-
打开IRB,在你的shell/终端输入
irb。 -
一旦IRB被打开,你将首先需要要求securerandom。
-
然后你可以通过执行以下命令来生成一个UUID。
$ irb >> require 'securerandom' >> SecureRandom.uuid
生成的UUID将看起来类似于这样:4302cfd8-a080-437d-b870-28730dc67498 。
为什么我们要使用UUID
使用UUID有很多优点,也有很多缺点。在本节中,我们将列出使用UUIDs而不是常用的增量整数的原因。
改善隐私
按顺序排列的整数ID可以为好奇的旁观者提供我们可能不想分享的信息。一个例子是当一个用户注册时,他们注意到他们是第100号用户。 新注册的用户可以相当肯定还有99个用户。
组织通常不会与公众分享这些信息,特别是不会与他们的竞争对手分享。作为软件工程师,我们应该清楚地知道我们所分享的信息,我们的工作就是权衡各种选择。
UUID不是连续的,因此它不会泄露任何关于我们的用户群或数据库表规模的信息。
提高安全性
当一个恶意的用户意识到在我们的应用程序中存储有比如说1000个用户资料。他们就可以利用这些信息在我们的应用程序的安全方面寻找漏洞。
当我们使用用户提供的信息来访问数据库中的记录时,就会产生不安全的直接对象引用漏洞。例如,考虑以下情况。
一个用户确定他们可以通过发帖到users/6/update_email 来更新他们的电子邮件地址。假设这个应用程序没有正确地检查用户的权限,并且错误地接受用户的输入来更新数据库记录。然后一个恶意的用户发帖到users/9/update_email ,并更新了不知情的用户的电子邮件地址。然后,这个恶意用户可以获得该账户的访问权,而他们所要做的就是猜测一个不是他们自己的数据库ID号的数字。
这个安全漏洞可以而且必须通过确保我们正确地将权限应用于数据库事务来解决。此外,如果我们使用UUIDs来代替,那么猜测一个用户的ID就会困难得多。因此,我们可以看到使用UUID的安全好处,它没有可猜测的或连续的顺序。
改善用户体验
前端客户可以在分布式系统中生成具有保证唯一性的UUID。现在,前端可以在运行中生成新的记录,而不需要先将记录持久化到数据库中。
这意味着前端客户端可以在离线时工作并生成记录。将数据保存到服务器或API可以成为一个后台任务,这可能会导致一个更敏捷的用户体验。我们将能够摆脱一些加载或等待屏幕,这也是一个用户体验的胜利。
更容易拥有多个数据库
当你知道所有数据库中使用的ID都是唯一的时候,跨数据库的分片就容易多了。数据可以从一个数据库移到另一个数据库,或者合并到一起,而不会有任何ID冲突。
无限规模
2018年,Basecamp经历了一次故障,因为他们的跟踪表的ID列被设置为整数而不是大整数。整数数据类型在2147483647处用完。 大整数可以一直到9223372036854775807。然而UUID与整数或大整数相比,可以被认为是无限大的。
当我们预计要存储大量的记录时,UUID是一个不错的选择。
如何制作友好的UUID URLs
UUIDs有很多优点,但也有缺点。一个缺点是,有些人可能会认为UUID太长太难看了,不适合用在URL中。为了克服UUIDs的这个消极方面,我们可以把它编码成一个更友好的URL表示。
我们需要记住,所有的数据都可以用比特来表示,在这种情况下,我们可以用128比特来表示一个UUID。接下来我们需要找到其他的URL安全方式来表示128位。
URL安全字符
RFC 3986规范详细地解释了URL的语法。请随意看看,但为了我们的目的,我们将看看它对URL中使用的安全字符是怎么说的。
该规范在提到URL中允许使用的字符时,使用了非保留字符这一术语。以下字符是允许的。
- 大写字母和小写字母字符
A-Z and a-z - 小数位
0-9 - 连字符
- - 句号
. - 下划线
_ - 斜体字
~
顺便提一下,你有没有想过,为什么有些URL有百分号(%)?该规范确实允许未保留的字符被编码为各自的百分比编码的US-ASCII格式。
编码UUID
现在我们有了一个目标**(使UUID的URL友好**)和约束条件**(只允许URL安全的字符**),我们可以继续。我们的方法是对UUID进行编码或转换,使其可以用一个较短的字符串表示。
我们将采取的转换或编码UUID的步骤是。
- 定义我们的编码字母表
- 删除分组连字符。
- 将UUID转换为二进制表示。
- 将二进制转换为整数(基数10)。
- 使用整数来引用我们编码字母表中的字符。
- 返回编码后的字符串。
1.定义我们的编码字母表
正如我们已经看到的,UUIDs由十六进制数字组成。可以说,十六进制的字母表里有16个字符。我们需要一个更大的字母表来更紧凑地编码相同数量的数据。
我们定义的字母表将代表我们用来编码UUID的字符集。如果我们的字母表只包含字母a b c ,那么我们将只能用这些字母来编码数据。
我们可以使用Base64编码方案,而不是想出我们自己的URL安全字母表。 Base64是一种编码方案,以ASCII字符串格式表示比特。Base64经常被用来对要包含在URL中的数据进行编码。
有一个Ruby gem可以将整数转换为Base62字符。转换后的字符串就代表了原来的整数。
让我们不要重新发明轮子。相反,我们可以从前面的人那里获得灵感。
前面提到的Base62并不是打错了。在URL中使用Base64字符的一个问题是,它们可能包括+ 和/ 字符。这些都不是URL安全的字符,所以我们可以决定创建我们自己的编码方案,从Base64字符集衍生出来。
Base62是当你去掉这两个不安全的字符后剩下的东西。另一种方法是用- (连字符)替换+ (加号),用_ (下划线)替换/ (正斜杠)。这样,我们的字母表中仍然有64个字符。
让我们不要过于数学化,但考虑一下为什么这个字母表能够更紧凑地表示数据。每个十六进制数字最多需要4位。 64个字符集中的每个字符将需要6位。这意味着我们的字母表中的每个字符将代表2个,也就是1.5倍,比十六进制数字所能代表的比特多。所有这些都意味着我们的字母表需要比十六进制少33%的字符来表示相同数量的数据。
我们的字母表中的每个字符都有一个索引,从0开始一直到63。类似于数组的工作方式,我们将使用索引号来引用字母表中的字符。
# our alphabet
ALPHABET = %w[0 1 2 3 4 5 6 7 8 9
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
a b c d e f g h i j k l m n o p q r s t u v w x y z
- _].freeze
2.移除分组的连字符
我们已经知道UUID的分组是标准化的,这意味着我们不需要对连字符进行编码。如果我们需要解码成UUID的形式,我们可以很容易地将分组连字符加回到它们所属的位置。
在我们对UUID进行编码之前,去掉连字符可以再节省4个字符。
在Ruby中,我们有很多方法可以做到这一点,这里有一个简单的方法。
uuid = "bf39f02b-caa2-47e3-887b-b1a9a5849092"
result = uuid.split('-').join
# >> "bf39f02bcaa247e3887bb1a9a5849092"
3.转换为二进制
我们不能直接从十六进制映射到我们的Base64字母表。相反,我们应该将十六进制的数字转换为二进制表示。
我们的工作假设是,我们希望以后能够解码成UUID形式。为了使解码成为可能,我们的算法要求我们用相同数量的二进制数字表示每个十六进制数字。这意味着,0 将由0000 表示,即使它可以由一个0 的二进制数字表示。这种二进制编码被称为二进制编码的十进制(BCD)。
# convert to BCD
result = "70a879c66dc34c4cb4640a5549618d7f"
result = result.chars.map { |c| c.hex.to_s(2).rjust(4,'0') }.join
# >> "0111000010101000011110011100011001101101110000110100110001001100101101...
4.转换为基数10的整数
让我们花点时间考虑一下整个十六进制组,看看它们的BCD值。
%w[0 1 2 3 4 5 6 7 8 9 A B C D E F].map { |c| c.hex.to_s(2).rjust(4, '0') }
>
"0000", # 0
"0001", # 1
"0010", # 2
"0011", # 3
"0100", # 4
"0101", # 5
"0110", # 6
"0111", # 7
"1000", # 8
"1001", # 9
"1010", # A
"1011", # B
"1100", # C
"1101", # D
"1110", # E
"1111"] # F
想象一下,我们取32个十六进制数字,把它们连成一个128位的长二进制序列。现在我们要找到一种方法,从这个二进制序列中引用我们字母表中的字符。我们需要将二进制序列中的1和0转换成0到63之间的整数。
要表示一个0到63之间的数字,我们需要6个二进制数字。如果你拿128除以6,你会发现我们得到21.3。如果我们有132个二进制数字,那么我们就能把它们正好分成22组。
既然我们使用的是二进制,那么只要在数字的前面(左边)加几个零就没有问题了。这就像十进制数字一样,无论你在数字的左边加上多少个零,0008仍然是8。
因此,我们的下一步是用四个额外的零来填充我们的二进制序列,然后我们把整个序列分成6个二进制数字的组。
# add zero padding
result = "11011111001001110001100110"
# >> "000011011111001001110001100110"
result = result.prepend("0000")
# >> "000011011111001001110001100110"
#group binary digits
result = result.scan(/.{6}/)
# >> ["000011", "011111", "001001", "110001", "100110"]
5.转换为编码字母
每组6个二进制数字代表0到63之间的一个整数。现在,我们可以非常容易地将二进制数字转换成它们的整数(基数为10),并使用该值来引用我们编码字母表中的一个字符。
# convert to base 10 integers
result = ["000011", "011111", "001001", "110001", "100110"]
result = result.map { |x| x.to_i(2) }
# >> [3, 31, 9, 49, 38]
# retrieve alphabet characters
result = result.map { |x| ALPHABET[x] }
# >> ["3", "V", "9", "n", "c"]
6.返回编码后的字符串
剩下的就是将字母表中的字符连接起来,并将编码后的字符串返回给我们的客户端调用者。
result = ["3", "V", "9", "n", "c"]
result.join
# >> "3V9nc"
我们现在可以把这个编码值保存到数据库中,就像我们用lug从表中检索记录一样使用它。把它保存到数据库意味着我们只需要计算一次编码值。我们也不需要担心编码值的唯一性,因为我们已经知道UUID是唯一的。
如果我们不把编码值保存到数据库中,那么在我们能够利用它之前,我们就需要对它进行解码。没有一个放之四海而皆准的解决方案,但考虑保存和计算编码值的性能影响是很重要的。
解码回UUID
完全可以将一个字符串解码成其原始的UUID形式。为了简洁起见,我将在下面以Ruby脚本的形式介绍这个算法。本质上,它只是对我们编码UUID的六个步骤进行了反转。
# "70a879c6-6dc3-4c4c-b464-0a5549618d7f"
encoded = "1mg7d6RSDCJBHa2bL9OOr_"
# 1. Split string
result = encoded.split('')
# >> ["1","m","g","7","d","6","R","S","D","C","J","B","H","a","2","b","L","9","O","O","r","_"]
# 2. Convert to base 10 integer values
result = result.map { |x| ALPHABET.index(x) }
# >> [1,48,42,7,39,6,27,28,13,12,19,11,17,36,2,37,21,9,24,24,53,63]
# 3. Convert to 6 bit binaries
result = result.map {|x| x.to_s(2).rjust(6, '0').rjust(6, '0') }
# ["000001",
# "110000",
# "101010",
# "000111",
# "100111",
# "000110",
# "011011",
# "011100",
# "001101",
# "001100",
# "010011",
# "001011",
# "010001",
# "100100",
# "000010",
# "100101",
# "010101",
# "001001",
# "011000",
# "011000",
# "110101",
# "111111"]
# 4. Join binaries
result = result.join
# >> "000001110000101010000111100111000110011011011100001101001100010011001011010001100100000010100101010101001001011000011000110101111111"
# 5. Group into BCD
result = result.scan(/.{4}/)
# ["0000",
# "0111",
# "0000",
# "1010",
# "1000",
# "0111",
# "1001",
# "1100",
# "0110",
# "0110",
# "1101",
# "1100",
# "0011",
# "0100",
# "1100",
# "0100",
# "1100",
# "1011",
# "0100",
# "0110",
# "0100",
# "0000",
# "1010",
# "0101",
# "0101",
# "0100",
# "1001",
# "0110",
# "0001",
# "1000",
# "1101",
# "0111",
# "1111"]
# 6. Remove 4 leading zeros
# shift removes first item in result array
result.shift
# 7. Convert BCD to hexadecimal
result = result.map { |x| x.to_i(2).to_s(16) }
# ["7",
# "0",
# "a",
# "8",
# "7",
# "9",
# "c",
# "6",
# "6",
# "d",
# "c",
# "3",
# "4",
# "c",
# "4",
# "c",
# "b",
# "4",
# "6",
# "4",
# "0",
# "a",
# "5",
# "5",
# "4",
# "9",
# "6",
# "1",
# "8",
# "d",
# "7",
# "f"]
# 8. Join and add hyphens
result = result.join.unpack('a8a4a4a4a12').join('-')
# "70a879c6-6dc3-4c4c-b464-0a5549618d7f"
进一步考虑
一个不同的编码方案
一个Base64编码的字符串很难输入,更难发音。我们可以考虑一种不同的编码方案,可能会更方便用户使用。Base32正是这样一种编码方案。
Base32编码方案没有Base64那么紧凑,需要26个字符来编码一个32个字符的UUID。使用这种编码方案会产生对URL安全的字符串,而且也更方便用户使用。
使用Ruby的unpack
我还没有尝试过,但我相信通过使用unpack,我们能够将算法简化很多。例如,我们可以使用unpack对UUID进行Base64编码,然后使用gsub来替换+ 和/ 字符。
不可逆的短语
我们并不总是需要能够逆转编码过程。相反,如果我们对短语塞最感兴趣,那么我们可以使用编码后的字符串的一个子串。只要我们保存子串,我们就可以像在其他情况下使用蛞蝓一样使用它。
我们可以先用一个4个字符的子串。在我们保存子串之前,我们需要确保它是唯一的。当我们确认了子串的唯一性之后,我们才会保存生成的子串,也就是slug。然后我们逐渐增加子串的长度,直到我们能够确认它是唯一的。
这种方法会起作用,因为我们知道,在最坏的情况下,我们需要使用全长的编码字符串,因为它总是唯一的。做多个唯一性检查会对性能产生影响,我们需要注意。
FriendlyUUID也采取了一种类似但无状态的方法,在运行中计算截断的UUID。
总结
我们还可以采取许多其他方法。而且你不需要在Github上找得太远就能找到许多其他的方法。这篇文章的目的是向你展示不使用gem也可以做到这一点,但主要是为了在UUID方面获得一些乐趣。因此,我希望你有一些乐趣,如果这是你对UUIDs的第一次介绍,那么我希望它是有内容的。
你可以看看这些有趣的方法,这些方法是由其他与我们有类似目标的人采取的。(披露一下,这些方法并不都是在Ruby中进行的)