Python学习笔记-12常用内建模块(上)

该笔记记录的是学习廖雪峰Python3教程的过程,摘录了一些重点,重新编排内容,并加入了更丰富的代码示例和对学习过程中所遇到问题的理解。

本章内容安排如下:

Python之所以自称 “batteries included”,就是因为内置了许多非常有用的模块,无需额外安装和配置,即可直接使用。

本章将介绍一些常用的内建模块。

datetime

datetime 是Python处理日期和时间的标准库。

获取当前日期和时间

我们先看如何获取当前日期和时间:

1
2
3
4
5
6
>>> from datetime import datetime
>>> now = datetime.now() # 获取当前datetime
>>> print(now)
2015-05-18 16:28:07.198690
>>> print(type(now))
<class 'datetime.datetime'>

注意到,datetime 模块包含一个 datetime类,我们可以通过 from datetime import datetime 来导入 datetime 类。如果仅使用 import datetime 导入,则必须引用全名 datetime.datetime 才能使用这个类。datetime.now() 可以返回当前日期和时间,其类型是 datetime.datetime


获取指定日期和时间

要指定某个日期和时间,我们直接用参数构造一个 datetime 类实例:

1
2
3
4
>>> from datetime import datetime
>>> dt = datetime(2015, 4, 19, 12, 20) # 用指定日期时间创建datetime
>>> print(dt)
2015-04-19 12:20:00

datetime转换为timestamp

在计算机中,时间实际上是用数字表示的。我们把1970年1月1日 00:00:00 UTC+00:00时区的时刻称为新纪元时间(epoch time),记为数字0(1970年以前的时间timestamp为负数),当前时间就是相对于epoch time的秒数,称为timestamp

你可以认为:

1
timestamp = 0 # 等价于1970-1-1 00:00:00 UTC+0:00

对应的北京时间是:

1
timestamp = 0 # 等价于1970-1-1 08:00:00 UTC+8:00

也即epoch time是北京时间1970年1月1日的早上8点钟。

可见timestamp的值与时区毫无关系,无论在哪个时区,同一时刻timestamp的值都相同,实际时间只需要再按时区推算就可以了。这就是为什么计算机存储的当前时间是以timestamp表示的,因为全球各地的计算机在任意时刻的timestamp都是完全相同的(假定时间已校准)。

把一个 datetime 类型转换为timestamp只需要简单调用 timestamp() 方法:

1
2
3
4
>>> from datetime import datetime
>>> dt = datetime(2015, 4, 19, 12, 20) # 用指定日期时间创建datetime
>>> dt.timestamp() # 把datetime转换为timestamp
1429417200.0

注意Python的timestamp是一个浮点数。如果有小数位,小数位表示毫秒数

某些编程语言(如Java和JavaScript)的timestamp使用整数表示毫秒数,这种情况下只需要把timestamp除以1000就得到Python的浮点表示方法。


timestamp转换为datetime

要把timestamp转换为 datetime,使用 datetime 提供的 fromtimestamp() 方法:

1
2
3
4
>>> from datetime import datetime
>>> t = 1429417200.0
>>> print(datetime.fromtimestamp(t))
2015-04-19 12:20:00

注意到timestamp是一个浮点数,它没有时区的概念,而datetime是有时区的。上述转换是在timestamp和本地时间做转换(本地时间是指当前操作系统设定的时区)。例如北京时区是东8区,则本地时间:

1
2015-04-19 12:20:00

实际上就是UTC+8:00时区的时间,也即:

1
2015-04-19 12:20:00 UTC+8:00

而此刻的格林威治标准时间与北京时间差了8小时,也就是UTC+0:00时区的时间应该是:

1
2015-04-19 04:20:00 UTC+0:00

timestamp也可以直接被转换到UTC标准时区的时间:

1
2
3
4
5
6
>>> from datetime import datetime
>>> t = 1429417200.0
>>> print(datetime.fromtimestamp(t)) # 本地时间
2015-04-19 12:20:00
>>> print(datetime.utcfromtimestamp(t)) # UTC时间
2015-04-19 04:20:00

str转换为datetime

很多时候,用户输入的日期和时间是字符串,要处理日期和时间,首先必须把 str 转换为 datetime。转换方法是通过 datetime.strptime() 实现的:

1
2
3
4
>>> from datetime import datetime
>>> cday = datetime.strptime('2015-6-1 18:19:59', '%Y-%m-%d %H:%M:%S')
>>> print(cday)
2015-06-01 18:19:59

字符串 '%Y-%m-%d %H:%M:%S' 规定了日期和时间部分的格式。当然也可以根据实际需要进行修改,详细的说明请参考Python文档。注意,转换后的 datetime 是不带时区信息的


datetime转换为str

如果已经有了 datetime 对象,要把它格式化为字符串显示给用户,就需要转换为 str,转换方法是通过 strftime() 实现的:

1
2
3
4
>>> from datetime import datetime
>>> now = datetime.now()
>>> print(now.strftime('%a, %b %d %H:%M'))
Mon, May 05 16:28

datetime加减

对日期和时间进行加减实际上就是把 datetime 往后或往前计算,得到新的 datetime。加减可以直接用 +- 运算符,不过需要导入 timedelta 这个类:

1
2
3
4
5
6
7
8
9
10
>>> from datetime import datetime, timedelta
>>> now = datetime.now()
>>> now
datetime.datetime(2015, 5, 18, 16, 57, 3, 540997)
>>> now + timedelta(hours=10)
datetime.datetime(2015, 5, 19, 2, 57, 3, 540997)
>>> now - timedelta(days=1)
datetime.datetime(2015, 5, 17, 16, 57, 3, 540997)
>>> now + timedelta(days=2, hours=12)
datetime.datetime(2015, 5, 21, 4, 57, 3, 540997)

可见,使用 timedelta 你可以很容易地算出前几天和后几天的时刻。

我们也可以使用 timedelta 很方便地计算出两个日期之间的差:

1
2
3
4
>>> f = datetime(2016,8,19)
>>> p = datetime(2017,1,16)
>>> print(p-f)
150 days, 0:00:00

本地时间转换为UTC时间

本地时间是指系统设定时区的时间,例如北京时间是UTC+8:00时区的时间,而UTC时间指UTC+0:00时区的时间datetime 类型有一个时区属性 tzinfo,但是默认为 None,所以无法区分这个 datetime 到底是哪个时区,除非强行给 datetime 设置一个时区:

1
2
3
4
5
6
7
8
9
10
>>> from datetime import datetime, timedelta, timezone
>>> tz_utc_8 = timezone(timedelta(hours=8)) # 创建时区UTC+8:00
>>> now = datetime.now()
>>> now
datetime.datetime(2015, 5, 18, 17, 2, 10, 871012)
>>> dt = now.replace(tzinfo=tz_utc_8) # 强制设置为UTC+8:00
>>> dt
datetime.datetime(2015, 5, 18, 17, 2, 10, 871012, tzinfo=datetime.timezone(datetime.timedelta(0, 28800)))
>>> print(dt)
2015-05-18 17:02:10.871012+08:00

如果系统时区恰好是UTC+8:00,那么上述代码就是正确的,否则,不应该强制设置为时区信息。


时区转换

我们可以先通过 utcnow() 拿到当前的UTC时间,再转换为任意时区的时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 拿到UTC时间,并强制设置时区为UTC+0:00:
>>> utc_dt = datetime.utcnow().replace(tzinfo=timezone.utc)
>>> print(utc_dt)
2015-05-18 09:05:12.377316+00:00
# astimezone()将转换时区为北京时间:
>>> bj_dt = utc_dt.astimezone(timezone(timedelta(hours=8)))
>>> print(bj_dt)
2015-05-18 17:05:12.377316+08:00
# astimezone()将utc_dt转换时区为东京时间:
>>> tokyo_dt = utc_dt.astimezone(timezone(timedelta(hours=9)))
>>> print(tokyo_dt)
2015-05-18 18:05:12.377316+09:00
# astimezone()将bj_dt转换时区为东京时间:
>>> tokyo_dt2 = bj_dt.astimezone(timezone(timedelta(hours=9)))
>>> print(tokyo_dt2)
2015-05-18 18:05:12.377316+09:00

注意,上述代码中首先通过 utcnow() 方法获取到一个处于UTC时间的 datetime 实例(按照系统时间推算出的,所以如果系统时间错则得到的实例也是错的),这个实例本身没有时区信息,所以要通过 replace() 方法强制设置时区信息为UTC时区。然后利用这个带时区的 datetime,通过 astimezone() 方法就可以转换到任意时区了。注意,任何带时区(不必是UTC时区)的 datetime 都可以被正确转换到别的时区,例如上述 bj_dttokyo_dt 的转换。

小结

datetime 表示的时间需要时区信息才能确定一个特定的时间,否则只能视为本地时间。

如果要存储 datetime,最佳方法是将其转换为 timestamp 再存储,因为 timestamp 的值与时区完全无关。


练习

假设你获取了用户输入的日期和时间如 2015-1-21 9:01:30,以及一个时区信息如 UTC+5:00,均是 str,请编写一个函数将其转换为 timestamp

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# -*- coding:utf-8 -*-
import re
from datetime import datetime, timezone, timedelta
def to_timestamp(dt_str, tz_str):
tz_match = re.match(r'UTC([\+\-]\d+?):00', tz_str)
idate = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
idate = idate.replace(tzinfo=timezone(timedelta(hours=int(tz_match.group(1)))))
return idate.timestamp()
# 测试:
t1 = to_timestamp('2015-6-1 08:10:30', 'UTC+7:00')
if t1 == 1433121030.0:
print('Pass.')
else:
print('Fail.')
t2 = to_timestamp('2015-5-31 16:10:30', 'UTC-09:00')
if t2 == 1433121030.0:
print('Pass.')
else:
print('Fail.')


collections

简介

collections 是Python内建的一个集合模块,提供了许多有用的集合类。

namedtuple

我们知道 tuple 可以表示不变集合,例如,一个点的二维坐标就可以表示成:

1
>>> p = (1, 2)

但是,代码中使用 (1, 2) 的方式来写很难看出这个 tuple 是用来表示一个点的。如果我们特地为定义一个“点”类而编写代码,又有点小题大作。怎么办呢?collections 模块中的 namedtuple 可以帮到我们:

1
2
3
4
5
6
7
>>> from collections import namedtuple
>>> Point = namedtuple('Point', ['x', 'y'])
>>> p = Point(1, 2)
>>> p.x
1
>>> p.y
2

namedtuple() 是一个函数,它可以用来方便地创建一个按需定制的 tuple 类的子类,我们可以把这样得到的子类看作一种特殊的元组,可以采用访问属性的方式来引用该元组的元素,使用十分方便。

可以验证创建的 Pointtuple 的一种子类:

1
2
3
4
>>> isinstance(p, Point)
True
>>> isinstance(p, tuple)
True

类似的,如果要用坐标和半径表示一个圆,也可以用 namedtuple 来创建一个 Circle 类:

1
2
# namedtuple('名称', [属性list]):
Circle = namedtuple('Circle', ['x', 'y', 'r'])

deque

使用 list 存储数据时,按索引访问元素很快,但是插入和删除元素就很慢了,因为 list 是线性存储,数据量大的时候,插入和删除效率很低。deque 是为了更高效实现插入和删除操作而创造的双向列表,适合用于队列和栈:

1
2
3
4
5
6
>>> from collections import deque
>>> q = deque(['a', 'b', 'c'])
>>> q.append('x')
>>> q.appendleft('y')
>>> q
deque(['y', 'a', 'b', 'c', 'x'])

deque 除了实现 listappend()pop() 外,还支持 appendleft()popleft(),这样就可以非常高效地往头部添加或删除元素。


defaultdict

使用 dict 时,如果引用的Key不存在,就会抛出 KeyError。如果希望key不存在时也能返回一个默认值,可以使用 defaultdict

1
2
3
4
5
6
7
>>> from collections import defaultdict
>>> dd = defaultdict(lambda: 'N/A')
>>> dd['key1'] = 'abc'
>>> dd['key1'] # key1存在
'abc'
>>> dd['key2'] # key2不存在,返回默认值
'N/A'

注意默认值是调用函数返回的,而函数在创建 defaultdict 对象时传入。除了在Key不存在时返回默认值,defaultdict 的其他行为跟 dict 是完全一样的。


OrderedDict

使用 dict 时,Key是无序的。在对 dict 做迭代时,我们无法确定Key的顺序。

如果要保持Key的顺序,可以用 OrderedDict

1
2
3
4
5
6
7
>>> from collections import OrderedDict
>>> d = dict([('a', 1), ('b', 2), ('c', 3)])
>>> d # dict的Key是无序的
{'a': 1, 'c': 3, 'b': 2}
>>> od = OrderedDict([('a', 1), ('b', 2), ('c', 3)])
>>> od # OrderedDict的Key是有序的
OrderedDict([('a', 1), ('b', 2), ('c', 3)])

注意,OrderedDict 的Key是按照插入的顺序排列的,不是Key本身排序

1
2
3
4
5
6
>>> od = OrderedDict()
>>> od['z'] = 1
>>> od['y'] = 2
>>> od['x'] = 3
>>> list(od.keys()) # 按照插入的Key的顺序返回
['z', 'y', 'x']

OrderedDict 可以实现一个FIFO(先进先出)的 dict,当容量超出限制时,优先删除最早添加的Key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from collections import OrderedDict
class LastUpdatedOrderedDict(OrderedDict):
def __init__(self, capacity):
super(LastUpdatedOrderedDict, self).__init__()
self._capacity = capacity
def __setitem__(self, key, value):
containsKey = 1 if key in self else 0
if len(self) - containsKey >= self._capacity:
last = self.popitem(last=False)
print('remove:', last)
if containsKey:
del self[key]
print('set:', (key, value))
else:
print('add:', (key, value))
OrderedDict.__setitem__(self, key, value)

解析一下上面的例子,我们实现了一个容量有限的先进先出的 dict —— LastUpdatedOrderedDict,在实例化时,__init__() 方法会被调用来初始化要创建的实例。self 参数指的便是要创建的实例本身,此外我们传入一个 capacity 参数用来表示这个 dict 的容量。需要注意,在这个 __init__() 方法中,我们使用 super(LastUpdatedOrderedDict, self).__init__() 这个语句来进行初始化,它会自动找到 LastUpdatedOrderedDict 的父类,把实例 self 转换为一个特殊的父类对象,从而可以调用父类的 __init__() 方法。self._capacity = capacity 这一句则是给实例绑定容量属性,使用 _capacity 这个变量名来和传入参数区别开来,前置单下划线表示这个属性应被视作私有属性来使用。

再看看 __setitem__() 方法,首先判断一下设置的key在 dict 里是否存在,如果存在则 containsKey 为1否则为0。接下来判断容量是否超出,如果超出则pop掉最前面的一个key-value对。然后再判断key是否存在,是则先删除掉原来的key-value对。最后调用父类的 __setitem__() 方法来完成赋值。


Counter

Counter 是一个简单的计数器,例如,统计字符出现的个数:

1
2
3
4
5
6
7
>>> from collections import Counter
>>> c = Counter()
>>> for ch in 'programming':
... c[ch] = c[ch] + 1
...
>>> c
Counter({'g': 2, 'm': 2, 'r': 2, 'a': 1, 'i': 1, 'o': 1, 'n': 1, 'p': 1})

Counter 实际上也是 dict 的一个子类,上面的结果可以看出,字符 'g'、'm'、'r' 各出现了两次,其他字符各出现了一次。


小结

collections 模块提供了一些有用的集合类,我们可以根据需要选用。



base64

简介

Base64是一种用64个字符来表示任意二进制数据的方法。

用记事本打开exe、jpg、pdf这些文件时,我们都会看到一大堆乱码,因为二进制文件包含很多无法显示和打印的字符,所以,如果要让记事本这样的文本处理软件能处理二进制数据,就需要一个二进制到字符串的转换方法。Base64是一种最常见的二进制编码方法。


原理

Base64的原理很简单,首先,它使用总共64个字符进行编码(26个大小写字母+10个数字+加号+左斜杠):

1
['A', 'B', 'C', ... 'a', 'b', 'c', ... '0', '1', ... '+', '/']

然后,对二进制数据进行处理时,每3个字节一组,就得到 3x8=24 bit,重新划分为4组,每组正好6个bit,有 2^6=64 种取值,对应64个字符的一个,:

base64-encode

这样我们就可以把二进制数据的3个字节使用4个字符来表示,这就是Base64编码。采用Base64编码会把3字节的二进制数据编码为4字节(4个字符所以是4字节)的文本数据,长度会增加33%。但好处是编码后的文本数据可以在邮件正文、网页等直接显示,而不会出现乱码的情况。

如果要编码的二进制数据不是3的倍数,最后多出1个或2个字节怎么办呢? Base64编码采用 \x00 字节在末尾补充到3个字节,在编码的末尾会使用 = 号表示补了多少字节。解码的时候,会自动去掉用于补足的 \x00 字节。


Python中的实现方式

Python内置的 base64 模块可以直接进行base64的编解码:

1
2
3
4
5
>>> import base64
>>> base64.b64encode(b'binary\x00string')
b'YmluYXJ5AHN0cmluZw=='
>>> base64.b64decode(b'YmluYXJ5AHN0cmluZw==')
b'binary\x00string'

上面的代码中我们使用了 b 把字符串转为二进制,然后传入 b64encode() 函数进行编码,字符串长度为13个字节(注意 \x00 是一个字符,占1字节)。可以看到编码后所得字符串20个字节,并且使用两个 = 号表明编码过程中,由于13无法整除3,所以末尾补充了两个 \x00注意,= 号是算在20个字节(15÷3×4=20 bit)里面的,而不是额外放在编码后的字符串后面。

由于标准的Base64编码后可能出现字符 +/,在URL中就不能直接作为参数,所以又有一种 “url safe” 的base64编码,把字符 +/ 分别替换成 -_

1
2
3
4
5
6
>>> base64.b64encode(b'i\xb7\x1d\xfb\xef\xff')
b'abcd++//'
>>> base64.urlsafe_b64encode(b'i\xb7\x1d\xfb\xef\xff')
b'abcd--__'
>>> base64.urlsafe_b64decode('abcd--__')
b'i\xb7\x1d\xfb\xef\xff'

我们也可以自定义64个字符的排列顺序,也即自定义Base64编码,不过,通常情况下没有必要这样做。注意,Base64仅仅是一种通过查表进行编码的方法,不能用于加密,即使使用自定义的编码表也不行(依然能很容易被破解)。Base64适用于小段内容的编码,比如数字证书签名、Cookie的内容等。由于 = 字符也可能出现在Base64编码中,但 = 用在URL、Cookie里面会造成歧义,所以,很多人会在Base64编码后把 = 去掉:

1
2
3
4
# 标准Base64:
'abcd' -> 'YWJjZA=='
# 自动去掉=:
'abcd' -> 'YWJjZA'

那么去掉 = 后解码要怎样完成呢?不用担心,因为Base64是把3个字节变为4个字节,所以,Base64编码的长度一定是4的倍数,解码时我们只需要在末尾加上足够的 = 把Base64字符串的长度变回4的倍数,就可以正常解码了。


小结

Base64是一种把任意二进制转换为文本字符串的编码方法,常用于在URL、Cookie、网页中传输少量二进制数据。


练习

请写一个能兼容去掉 = 的base64编码字符串的解码函数:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# -*- coding: utf-8 -*-
import base64
def safe_base64_decode(s):
remainder = len(s) % 4
if remainder == 0:
return base64.b64decode(s)
else:
return base64.b64decode(s+remainder*b'=')
# 测试:
assert b'abcd' == safe_base64_decode(b'YWJjZA=='), safe_base64_decode('YWJjZA==')
assert b'abcd' == safe_base64_decode(b'YWJjZA'), safe_base64_decode('YWJjZA')
print('Pass')


struct

为何需要struct

准确地讲,Python没有专门处理字节的数据类型。但由于 b'str' 可以用来表示字节,所以在Python中可以认为 字节数组=二进制str。而在C语言中,我们可以很方便地用 structunion 等库来处理字节以及字节和int,float的转换。

假设我们要把一个32位无符号整数转换为字节(4个bytes)。在Python中,得这么写:

1
2
3
4
5
6
7
8
>>> n = 10240099
>>> b1 = (n & 0xff000000) >> 24
>>> b2 = (n & 0xff0000) >> 16
>>> b3 = (n & 0xff00) >> 8
>>> b4 = n & 0xff
>>> bs = bytes([b1, b2, b3, b4])
>>> bs
b'\x00\x9c@c'

稍微解析一下,十进制数 10240099 转换为十六进制是 0x009c4063,这里使用与运算和右移来拆分出这个32位无符号整数的每一个byte(注意得到的不是字节数组而是一个整数),其中 b1=0, b2=156, b3=64, b4=99,将这四个整数放入一个列表中传入 bytes() 函数,就能得到字节数组了。注意 bs 中是有4个字节的,len(bs) 的值为4。由于对应整数64的十六进制数 0x40 属于ASCII码范围,所以用字符 @ 表示,而对应整数99的十六进制数 0x63 则对应字符 c

可以看到,这样要逐个byte来拆分,再进行转换实在是非常麻烦。如果要把浮点数转换为字节就无能为力了。幸好,Python提供了一个 struct 模块来解决 bytes 和其他二进制数据类型的转换问题。


struct的用法

struct 模块的 pack 函数把任意数据类型变成 bytes

1
2
3
>>> import struct
>>> struct.pack('>I', 10240099)
b'\x00\x9c@c'

pack() 的第一个参数是处理指令,这里 '>I' 的意思分为两部分:

  • 字节顺序:> 表示的是使用大端序作为字节顺序。
  • 数据类型:I 表示的是要转换一个32位无符号整数。可以有多个数据类型从而转换出多个数。

第二个参数要注意和处理指令一致。

unpack() 函数与 pack() 相反,它是把 bytes 转换为相应的数据类型:

1
2
>>> struct.unpack('>IH', b'\xf0\xf0\xf0\xf0\x80\x80')
(4042322160, 32896)

这里的 >IH 表示这个字节数组用的是大端序,并且要依次转换出一个32位无符号整数和16位无符号整数。

struct 模块定义的数据类型可以参考Python官方文档。关于大端序(big-endian,BE)和小端序(little-endian,LE)的区别可以看看这篇博文,讲解得很清晰。


使用struct分析bmp文件

Windows的位图文件(.bmp)是一种非常简单的文件格式,我们可以使用 struct 来分析一下。

首先找到一个bmp文件,没有的话用Windows自带的【画图】画一个即可。读入它的前30个字节来分析:

1
>>> s = b'\x42\x4d\x38\x8c\x0a\x00\x00\x00\x00\x00\x36\x00\x00\x00\x28\x00\x00\x00\x80\x02\x00\x00\x68\x01\x00\x00\x01\x00\x18\x00'

BMP格式采用小端序的方式存储数据,文件头的结构按顺序如下:

  • 两个字节:’BM’表示Windows位图,’BA’表示OS/2位图;
  • 一个32位无符号整数:表示位图大小;
  • 一个32位无符号整数:保留位,始终为0;
  • 一个32位无符号整数:实际图像的偏移量;
  • 一个32位无符号整数:Header的字节数;
  • 一个32位无符号整数:图像宽度(单位为像素);
  • 一个32位无符号整数:图像高度(单位为像素);
  • 一个16位无符号整数:始终为1;
  • 一个16位无符号整数:颜色数。

所以,组合起来用unpack读取:

1
2
>>> struct.unpack('<ccIIIIIIHH', s)
(b'B', b'M', 691256, 0, 54, 40, 640, 360, 1, 24)

这里的 c 表示对应的数据类型是一个字符。结果显示,b'B'b'M' 说明是一张Windows位图,位图大小为640x360,颜色数为24。


小结

尽管Python不适合编写底层操作字节流的代码,但在对性能要求不高的地方,利用 struct 操作能够更加方便。


练习

请编写一个bmpinfo.py,可以检查任意文件是否是Windows位图文件,如果是,打印出图片大小和颜色数。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#-*- coding: utf-8 -*-
import sys
import struct
def readBmpFile(file):
f = open(file, 'rb')
bs = f.read()
f.close()
return bs[0:30]
def checkBmp(info):
ts = struct.unpack('<ccIIIIIIHH', info)
if ts[0] == b'B' and ts[1] == b'M':
print('图片大小:%d * %d' % (ts[6], ts[7]))
print('颜色数:%d' % ts[9])
else:
print('非位图文件')
if __name__=='__main__':
if len(sys.argv) == 2:
info = readBmpFile(sys.argv[1])
checkBmp(info)
else:
info = input('Please input the file name: ')
checkBmp(info)


hashlib

摘要算法简介

Python的 hashlib 模块提供了常见的摘要算法,如MD5,SHA1等等。

什么是摘要算法呢?摘要算法又称哈希算法、散列算法。它通过一个摘要函数(也称哈希函数),把任意长度的数据转换为一个固定长度的数据串(称为摘要(digest),通常表示为由16进制数字组成的字符串)。

摘要函数应当是一个单向函数,也即计算摘要容易,但通过摘要反推原始数据却非常困难。并且即使仅对原始数据做一个bit的修改也会导致计算出的摘要完全不同。


Python实现

以常见的摘要算法MD5为例,计算一个字符串的MD5值:

1
2
3
4
5
import hashlib
md5 = hashlib.md5()
md5.update('how to use md5 in python hashlib?'.encode('utf-8'))
print(md5.hexdigest())

计算结果如下:

1
d26a53750bc40b38b65a520292f69306

如果数据量很大,我们可以多次调用 update() 来传入新数据。只要保证输入一致,那么最后计算的结果一定都是一样的:

1
2
3
4
5
6
import hashlib
md5 = hashlib.md5()
md5.update('how to use md5 in '.encode('utf-8'))
md5.update('python hashlib?'.encode('utf-8'))
print(md5.hexdigest())

但只要改动一个字母,计算的结果就会完全不同。

MD5是最常见的摘要算法,速度很快,所得摘要长度为128 bit,通常用一个32位的16进制字符串表示。

另一种常见的摘要算法是SHA1,调用SHA1和调用MD5完全类似:

1
2
3
4
5
6
import hashlib
sha1 = hashlib.sha1()
sha1.update('how to use sha1 in '.encode('utf-8'))
sha1.update('python hashlib?'.encode('utf-8'))
print(sha1.hexdigest())

SHA1所得摘要长度为160 bit,通常用一个40位的16进制字符串表示。

比SHA1更安全的算法是SHA256和SHA512,不过越安全的算法不仅越慢,而且摘要的长度也更长。

有没有可能两个不同的数据通过某个摘要算法得到了相同的摘要?完全有可能。因为任何摘要算法都是把无限多的数据集合映射到一个有限的集合中。这种情况称为碰撞(collasion)


摘要算法应用

摘要算法能应用到什么地方?举个常用例子:

任何允许用户登录的网站都会存储用户登录的用户名和口令。如何存储用户名和口令呢?方法是存到数据库表中:

name password
michael 123456
bob abc999
alice alice2008

如果以明文保存用户口令,一旦数据库泄露,所有用户的口令就会落入黑客的手里。此外,网站运维人员是可以访问数据库的,如果他们心有不轨,那么也会很容易地获取到所有用户的口令。

正确的保存口令的方式是不存储用户的明文口令,而是存储用户口令的摘要,比如MD5:

username password
michael e10adc3949ba59abbe56e057f20f883e
bob 878ef96e86145580c38c87f0410ad153
alice 99b1c2188db85afee403b1536010c2c9

当用户登录时,首先计算用户输入的明文口令的MD5,然后和数据库存储的MD5对比,如果一致,说明口令输入正确,如果不一致,口令肯定错误。


练习一

根据用户输入的口令计算出对应的MD5值,并验证是否与数据库中保存的一致

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#-*- coding: utf-8 -*-
import sys
import hashlib
db = {
'michael': 'e10adc3949ba59abbe56e057f20f883e',
'bob': '878ef96e86145580c38c87f0410ad153',
'alice': '99b1c2188db85afee403b1536010c2c9'
}
def calc_md5(password):
md5 = hashlib.md5()
md5.update(password.encode('utf-8'))
return md5.hexdigest()
def login(user, password):
digest = calc_md5(password)
if db[user] == digest:
return True
else:
return False
if __name__=='__main__':
user = input('请输入用户名:')
password = input('请输入密码:')
if login(user, password):
print('密码正确,登录成功。')
else:
print('密码错误,登录失败。')

采用MD5存储口令是否就一定安全呢?也不一定。假设你是一个黑客,已经拿到了存储MD5口令的数据库,如何通过MD5反推用户的明文口令呢?暴力破解费事费力,真正的黑客不会这么干。

考虑这么个情况,很多用户喜欢用123456,888888,password这些简单的口令。于是,黑客可以事先计算出这些常用口令的MD5值,得到一个反推表:

1
2
3
'e10adc3949ba59abbe56e057f20f883e': '123456'
'21218cca77804d2ba1922c33e0151105': '888888'
'5f4dcc3b5aa765d61d8327deb882cf99': 'password'

这样,无需破解,只需要对比数据库的MD5,黑客就获得了使用常用口令的用户账号。


加盐

对于用户来讲,当然不应该使用过于简单的口令。但是,我们能否在程序设计上对简单口令加强保护呢?

由于常用口令的MD5值很容易被计算出来,为了加强保护,我们可以对原始口令添加一个复杂字符串(俗称“盐”),这样再计算MD5值就不容易被黑客蒙中了,这种方法俗称“加盐”

1
2
3
4
5
6
def calc_md5(password):
md5 = hashlib.md5()
md5.update(password.encode('utf-8'))
md5.update('the-Salt'.encode('utf-8'))
return md5.hexdigest()

经过加盐处理的MD5口令,只要盐不被黑客知道,即使用户使用简单口令,也很难通过MD5被反推出来。

但是如果有两个用户都使用了相同的简单口令比如123456,数据库中就会存储两条相同的MD5值,这说明这两个用户的口令是一样的。有没有办法让使用相同口令的用户存储不同的MD5呢?如果假定用户无法修改登录名,就可以通过把登录名作为“盐”的一部分来计算MD5,从而实现相同口令的用户有不同的MD5值。


练习二

根据用户输入的登录名和口令模拟用户注册,计算更安全的MD5:

1
2
3
4
5
6
7
8
9
10
db = {}
def register(username, password):
db[username] = calc_md5(password + username + 'the-Salt')
def calc_md5(password):
md5 = hashlib.md5()
md5.update(password.encode('utf-8'))
return md5.hexdigest()

根据修改后的MD5算法实现用户登录的验证:

1
2
3
4
5
6
7
def login(user, password):
digest = calc_md5(password + username + 'the-Salt')
if db[user] == digest:
return True
else:
return False

小结

摘要算法在很多地方都有广泛的应用。要注意摘要算法不是加密算法,不能用于加密(因为无法通过摘要反推明文),只能用于防篡改,但是它的单向计算特性决定了可以在不存储明文口令的情况下验证用户口令。



坚持技术分享,您的支持将鼓励我继续创作!