基本改进、集合和本地名称
使用集合,而不是列表,并且测试唯一性要快得多;集合成员资格测试需要与集合大小无关的恒定时间,而列表需要 O(N) 线性时间。使用集合推导一次生成一系列键,以避免在循环中查找和调用set.add() 方法;适当随机,较大的密钥产生重复的机会非常小。
因为这是在一个紧密的循环中完成的,所以值得您尽可能优化所有名称查找:
import secrets
import numpy as np
from functools import partial
def produce_amount_keys(amount_of_keys, _randint=np.random.randint):
keys = set()
pickchar = partial(secrets.choice, string.ascii_uppercase + string.digits)
while len(keys) < amount_of_keys:
keys |= {''.join([pickchar() for _ in range(_randint(12, 20))]) for _ in range(amount_of_keys - len(keys))}
return keys
_randint 关键字参数将 np.random.randint 名称绑定到函数中的局部变量,这比全局变量引用起来更快,尤其是在涉及属性查找时。
pickchar() 部分避免了在模块或更多本地人上查找属性;它是一个包含所有引用的单个可调用对象,因此执行速度更快,尤其是在循环中完成时。
while 循环仅在产生重复项时才会继续迭代。如果没有重复,我们会在单个集合推导中生成足够的键来填充剩余部分。
第一次改进的时间
100 件,差别不大:
>>> timeit('p(100)', 'from __main__ import produce_amount_keys_list as p', number=1000)
8.720592894009314
>>> timeit('p(100)', 'from __main__ import produce_amount_keys_set as p', number=1000)
7.680242831003852
但是当您开始扩大规模时,您会注意到针对列表的 O(N) 成员资格测试成本确实拖累了您的版本:
>>> timeit('p(10000)', 'from __main__ import produce_amount_keys_list as p', number=10)
15.46253142200294
>>> timeit('p(10000)', 'from __main__ import produce_amount_keys_set as p', number=10)
8.047800761007238
我的版本已经几乎是 10k 项的两倍; 40k 个项目可以在大约 32 秒内运行 10 次:
>>> timeit('p(40000)', 'from __main__ import produce_amount_keys_list as p', number=10)
138.84072386901244
>>> timeit('p(40000)', 'from __main__ import produce_amount_keys_set as p', number=10)
32.40720253501786
列表版本耗时 2 多分钟,超过十倍。
Numpy 的 random.choice 函数,密码强度不高
您可以通过放弃secrets 模块并改用np.random.choice() 来加快速度;然而,这不会产生加密级别的随机性,但是选择随机字符的速度是原来的两倍:
def produce_amount_keys(amount_of_keys, _randint=np.random.randint):
keys = set()
pickchar = partial(
np.random.choice,
np.array(list(string.ascii_uppercase + string.digits)))
while len(keys) < amount_of_keys:
keys |= {''.join([pickchar() for _ in range(_randint(12, 20))]) for _ in range(amount_of_keys - len(keys))}
return keys
这有很大的不同,现在可以在 16 秒内生成 10 次 40k 密钥:
>>> timeit('p(40000)', 'from __main__ import produce_amount_keys_npchoice as p', number=10)
15.632006907981122
使用 itertools 模块和生成器进一步调整
我们还可以从itertools 模块Recipes 部分中获取unique_everseen() function 来处理唯一性,然后使用无限生成器和itertools.islice() function 将结果限制为只是我们想要的数字:
# additional imports
from itertools import islice, repeat
# assumption: unique_everseen defined or imported
def produce_amount_keys(amount_of_keys):
pickchar = partial(
np.random.choice,
np.array(list(string.ascii_uppercase + string.digits)))
def gen_keys(_range=range, _randint=np.random.randint):
while True:
yield ''.join([pickchar() for _ in _range(_randint(12, 20))])
return list(islice(unique_everseen(gen_keys()), amount_of_keys))
这还是稍微快了一点,但只是稍微快了一点:
>>> timeit('p(40000)', 'from __main__ import produce_amount_keys_itertools as p', number=10)
14.698191125993617
os.urandom() 字节和产生字符串的不同方法
接下来,我们可以继续 Adam Barnes's ideas 使用 UUID4(基本上只是 os.urandom() 的包装)和 Base64。但是通过对 Base64 进行大小写折叠并用随机选择的字符替换 2 个字符,他的方法严重限制了这些字符串中的熵(您不会产生所有可能的唯一值,仅使用 (256 ** 15) / (36 ** 20) == 的 20 个字符的字符串每 99437 位熵中有 1 个!)。
Base64 编码同时使用大小写字符和数字,还添加- 和/ 字符(或+ 和_ 用于URL 安全变体) .对于仅大写字母和数字,您必须将输出大写并将这两个额外的字符映射到其他随机字符,这个过程会从os.urandom() 提供的随机数据中丢弃大量熵。除了使用 Base64,您还可以使用 Base32 编码,它使用大写字母和数字 2 到 8,因此生成的字符串具有 32 ** n 的可能性与 36 ** n 的可能性。但是,这可以比上述尝试进一步加快速度:
import os
import base64
import math
def produce_amount_keys(amount_of_keys):
def gen_keys(_urandom=os.urandom, _encode=base64.b32encode, _randint=np.random.randint):
# (count / math.log(256, 32)), rounded up, gives us the number of bytes
# needed to produce *at least* count encoded characters
factor = math.log(256, 32)
input_length = [None] * 12 + [math.ceil(l / factor) for l in range(12, 20)]
while True:
count = _randint(12, 20)
yield _encode(_urandom(input_length[count]))[:count].decode('ascii')
return list(islice(unique_everseen(gen_keys()), amount_of_keys))
这真的快:
>>> timeit('p(40000)', 'from __main__ import produce_amount_keys_b32 as p', number=10)
4.572628145979252
40k 键,10 次,仅需 4 秒多一点。所以大约快 75 倍;使用os.urandom() 作为来源的速度是不可否认的。
这是,再次加密强大; os.urandom() 生成用于加密的字节。另一方面,我们将可能产生的字符串数量减少了 90% 以上(((36 ** 20) - (32 ** 20)) / (36 ** 20) * 100 是 90.5),我们不再使用 0、1、8 和 9输出。
所以也许我们应该使用urandom() 技巧来生成正确的Base36 编码;我们必须生成自己的b36encode() 函数:
import string
import math
def b36encode(b,
_range=range, _ceil=math.ceil, _log=math.log, _fb=int.from_bytes, _len=len, _b=bytes,
_c=(string.ascii_uppercase + string.digits).encode()):
"""Encode a bytes value to Base36 (uppercase ASCII and digits)
This isn't too friendly on memory because we convert the whole bytes
object to an int, but for smaller inputs this should be fine.
"""
b_int = _fb(b, 'big')
length = _len(b) and _ceil(_log((256 ** _len(b)) - 1, 36))
return _b(_c[(b_int // 36 ** i) % 36] for i in _range(length - 1, -1, -1))
并使用它:
def produce_amount_keys(amount_of_keys):
def gen_keys(_urandom=os.urandom, _encode=b36encode, _randint=np.random.randint):
# (count / math.log(256, 36)), rounded up, gives us the number of bytes
# needed to produce *at least* count encoded characters
factor = math.log(256, 36)
input_length = [None] * 12 + [math.ceil(l / factor) for l in range(12, 20)]
while True:
count = _randint(12, 20)
yield _encode(_urandom(input_length[count]))[-count:].decode('ascii')
return list(islice(unique_everseen(gen_keys()), amount_of_keys))
这相当快,而且最重要的是产生了 36 个大写字母和数字的全部范围:
>>> timeit('p(40000)', 'from __main__ import produce_amount_keys_b36 as p', number=10)
8.099918447987875
当然,base32 版本的速度几乎是这个版本的两倍(这要归功于使用表格的高效 Python 实现),但使用自定义 Base36 编码器的速度仍然是非加密安全 numpy.random.choice() 版本的两倍。
但是,使用os.urandom() 会再次产生偏差;我们必须产生比 12 到 19 个 base36“数字”所需的更多位的熵。例如,对于 17 位数字,我们不能使用字节产生 36 ** 17 个不同的值,只能产生最接近的 256 ** 11 个字节,这大约是高了 1.08 倍,所以我们最终会产生偏差朝向A、B,以及在较小程度上C(感谢Stefan Pochmann 指出这一点)。
选择(36 ** length)以下的整数并将整数映射到base36
因此,我们需要采用一种安全的随机方法,该方法可以为我们提供在0(包括)和36 ** (desired length)(不包括)之间均匀分布的值。然后我们可以将数字直接映射到所需的字符串。
首先,将整数映射到字符串;已对以下内容进行了调整,以最快地生成输出字符串:
def b36number(n, length, _range=range, _c=string.ascii_uppercase + string.digits):
"""Convert an integer to Base36 (uppercase ASCII and digits)"""
chars = [_c[0]] * length
while n:
length -= 1
chars[length] = _c[n % 36]
n //= 36
return ''.join(chars)
接下来,我们需要一种快速且加密安全的方法来在一个范围内挑选我们的号码。您仍然可以为此使用os.urandom(),但是您必须将字节屏蔽到最大位数,然后循环直到您的实际值低于限制。这实际上已经由secrets.randbelow() function 实现。在 Python 版本 random.SystemRandom().randrange(),它使用完全相同的方法,并带有一些额外的包装,以支持大于 0 的下限和步长。
使用secrets.randbelow()函数变为:
import secrets
def produce_amount_keys(amount_of_keys):
def gen_keys(_below=secrets.randbelow, _encode=b36number, _randint=np.random.randint):
limit = [None] * 12 + [36 ** l for l in range(12, 20)]
while True:
count = _randint(12, 20)
yield _encode(_below(limit[count]), count)
return list(islice(unique_everseen(gen_keys()), amount_of_keys))
然后这非常接近(可能有偏见的)base64 解决方案:
>>> timeit('p(40000)', 'from __main__ import produce_amount_keys_below as p', number=10)
5.135716405988205
这几乎与 Base32 方法一样快,但可以生成全范围的键!